T08-L03 · Write code · Level 3 Builder · 17-minute read, plus 3 hours of doing
One sentence, nine changed files
The plate-reader script from T08-L02 did its job. Paul reran it without Mira, the README worked, and within two months three groups on the floor were using it for their weekly summaries. Then the Kessler group asks for one more column: each sample as a percentage of the plate's control. Mira opens OpenCode and types one sentence: "Add a percent-of-control column." The assistant changes the script, rewrites the rounding "for consistency", renames two columns, and updates the expected-output file so that the old test passes again. The new column looks right. The three groups' tables no longer match last month's, and nobody can say which change did it.
In a company it's the quality team's monthly returns report. The head of quality asks for returns per quarter. The assistant builds calendar quarters. The company's financial year starts in April. The first quarterly report puts March's recall in the wrong quarter, and it goes to the management meeting that way.
In both cases the assistant did roughly what it was asked. The request left the important decisions open, and the assistant filled them with guesses. You are at Level 3 Builder: colleagues depend on this code, and a change that breaks it breaks their results too. This book shows you how to write those decisions down before any code, so that the assistant, your reviewer and your colleague all check the same thing.
After this you can
- Write a one-page spec for a change: numbered rules, what must not change, and a known-answer check for each rule.
- Turn the checks into tests that run with one command, before the assistant writes any code.
- Have OpenCode plan against the spec, then review its diff rule by rule and reject what the spec doesn't allow.
- Release the change as a numbered version with a changelog, a maintainer and a deputy.
- Say where your build stops and your organisation's code review and automated checks take over.
Before you start
- Books: T08-L02. You need a script in a shared Git repository with a README and a known-answer test file. The four-quadrant method is in T01-L01.
- OpenCode Desktop with the model your lab or company provides, and Git. Use made-up or public numbers in the chat; the real data stays on your computer.
- Python 3.11 or newer on your computer and your colleague's. The tests use
unittest, which comes with Python, so nobody installs anything. - The person who asked for the change, for 20 minutes, and one colleague who reruns the tests.
- About 3¼ hours: 17 minutes to read, about 3 hours to do, in two sessions.
Last verified 23 September 2026. OpenCode Desktop is a download for macOS (Apple Silicon and Intel), Windows (x64) and Linux (.deb and .rpm) from opencode.ai/download. It has two built-in agents: Build, with all tools enabled, and Plan, in which file edits and commands are set to ask, so nothing changes without your approval. Press Tab to switch.
/initcreates or updatesAGENTS.mdin the project folder, and OpenCode's documentation says to commit that file to Git./undoremoves the last message and its file changes, and needs the project to be a Git repository. The latest Python is 3.14.7 (5 August 2026); Python 3.11 gets security fixes until October 2027.
The idea in one page
At Level 2 you wrote a script and a colleague reran it. At Level 3 the script has several users, and every change risks their results. The fix is a short file, SPEC.md, written before the code. It has four parts:
| Part | What it answers | Example |
|---|---|---|
| What changes | What a user will see afterwards | "A new last column, pct_control." |
| Rules | Numbered, each one testable | "R3: If the control has no rows, stop with a message." |
| Must not change | What current users rely on | "M1: Without the new option, the output is identical to version 1.0." |
| Checks | A test file and a hand-worked answer per rule | "C3: plate_noctrl.csv stops, and no output folder is created." |
Four facts shape how you use it.
- An assistant fills every gap with a guess. Guesses look reasonable, which is why they get through. Rounding, column names, what happens with missing data, where a quarter starts: if the spec doesn't say, the code decides.
- Writing the checks finds the gaps. You can write "handle a missing control" without knowing what that means. You can't write its expected output without deciding. Most of the value of a spec comes from the moment you try to write a check and can't.
- A test the assistant can edit protects nothing. If the assistant may change the expected output, it will make a failing test pass by changing the answer. Expected files are written by hand and only by people.
- Review against rules, not taste. "I'd do this differently" starts a long conversation. "This block changes rounding, and M1 forbids it" ends one. Every changed block of code should map to a rule. A block that maps to none gets removed.
What stays yours: the rules, the expected outputs and the decision to release. The assistant writes code against them.
Worked example: one change, two teams, four quadrants
Both situations use the four-quadrant method from T01-L01. The lab one is told in full; the company one shows what changes. Names and numbers are illustrations.
Lab: a percent-of-control column
Structure it. Mira writes the request as it reached her, and then what the three groups need from it. "The Kessler group wants each sample as a percentage of the plate's positive control. A good result: they get the column, the other two groups' tables stay exactly as they are, and anyone can check both with one command."
Understand it. She spends twenty minutes with Dr Kessler's postdoc and ten with each of the other groups. Three things come out. The Kessler group names its control ctrl; the Braun group, which might want the column later, uses pos. Kessler wants the percentage of the blank-corrected control, not the raw one. And nobody has thought about a bad control. When she asks "what if the control's triplicates spread by more than 15%?", the postdoc says: "Then I wouldn't trust any of the percentages, but I'd still want to see them."
She starts SPEC.md and writes the rules. Then she writes the checks, one per rule, and on the fifth one she gets stuck. Check C5 is meant to show that nothing changes for existing users: the old test_input.csv should still give the old expected_output.csv. But the old test file has no control. Under her rule R3, "no control rows: stop", the script would refuse the file that two groups run every week.
Back to Structure it: "The column is only computed when someone asks for it with --control NAME. Without that option, version 1.1 behaves exactly like 1.0." That one line removes the conflict, lets each group name its own control, and turns M1 into something she can test byte for byte. The spec, after that loop:
# SPEC: percent of control (version 1.1)
Maintainer: Mira. Deputy: Paul. Requested by: Kessler group.
## What changes
New option --control NAME. With it, summary.csv gets a last column
pct_control: the sample's blank-corrected mean as a percentage of the
control's blank-corrected mean, one decimal, decimal comma.
## Rules
R1 The control is the sample called NAME, compared exactly as written.
R2 The control's own pct_control is 100,0.
R3 --control given but no rows with that name: stop with
"no control 'NAME' found". No output folder is created.
R4 Control CV above 15%: still compute, and add a last line
"WARNING: control CV 18,4% - check before using pct_control".
R5 Control mean after blank correction is 0 or below: stop with a message.
## Must not change
M1 Without --control, output is identical to version 1.0.
M2 Existing columns, their order, names and rounding.
M3 Standard library only. Python 3.11 or newer.
M4 Never overwrite a results folder; never skip a row.
M5 Files in tests/expected/ are written by hand. The assistant never edits them.
## Checks (known answers worked out by hand)
C1 tests/plate_ctrl.csv --control ctrl -> tests/expected/plate_ctrl.csv (R1, R2)
C2 tests/plate_ctrl.csv --control pos -> stops, no folder (R3)
C3 tests/plate_badctrl.csv --control ctrl -> warning line, CV 18,4 (R4)
C4 tests/plate_zeroctrl.csv --control ctrl -> stops, no folder (R5)
C5 test_input.csv, no option -> expected_output.csv, byte for byte (M1, M2)
Run all: python -m unittest
She works out the expected outputs on paper and in a spreadsheet: three small plates of seven to nine rows each. For plate_badctrl.csv she chooses control values that spread by about 18%, so the warning must appear, and a sample whose percentage she can check in her head.
Choose tools. OpenCode Desktop, as in T08-L02. The group knows it, Plan mode lets her check the assistant's intentions before anything changes, and the Review tab shows every changed file. The alternative is Claude Code, which works the same way from a terminal. For the tests she picks Python's built-in unittest over the more popular pytest: pytest is nicer to write, but it's one more package on Paul's Windows laptop, where he can't install anything, and M3 says standard library only.
Build it. She writes the tests herself, before any code. They're short, because each test only runs the script and compares files:
"""Known-answer tests. Run from the repository folder: python -m unittest"""
import pathlib, shutil, subprocess, sys, tempfile, unittest
HERE = pathlib.Path(__file__).parent
SCRIPT = HERE.parent / "summarise.py"
def run(data_file, *options):
"""Copy a test file to a fresh folder, run the script, return result and folder."""
folder = pathlib.Path(tempfile.mkdtemp())
shutil.copy(HERE / data_file, folder)
result = subprocess.run([sys.executable, str(SCRIPT),
str(folder / pathlib.Path(data_file).name), *options],
capture_output=True, text=True)
return result, folder
def summary(folder):
return next(folder.glob("results-*/summary.csv")).read_bytes()
class PercentOfControl(unittest.TestCase):
def test_c1_known_answer(self):
result, folder = run("plate_ctrl.csv", "--control", "ctrl")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(summary(folder), (HERE / "expected/plate_ctrl.csv").read_bytes())
def test_c2_missing_control_stops(self):
result, folder = run("plate_ctrl.csv", "--control", "pos")
self.assertNotEqual(result.returncode, 0)
self.assertIn("no control 'pos' found", result.stdout + result.stderr)
self.assertEqual(list(folder.glob("results-*")), [])
def test_c5_old_behaviour_unchanged(self):
result, folder = run("../test_input.csv")
self.assertEqual(summary(folder), (HERE.parent / "expected_output.csv").read_bytes())
C3 and C4 follow the same pattern. She runs python -m unittest and sees C5 pass and the others fail, which is right: the old behaviour still works and the new one doesn't exist yet. She commits the spec, the test files and the expected outputs before asking for any code. The commit history now shows that the answers came first.
She adds three lines to the project's AGENTS.md, the file OpenCode reads at the start of every session:
- Before any change, read SPEC.md. Every change must map to a rule (R1..R5).
- Never edit files in tests/expected/ or expected_output.csv.
- Standard library only. Don't rename or reorder existing columns.
Then she presses Tab for Plan mode and writes: "Read SPEC.md and tests/. Plan the change to summarise.py. For each step, name the rule it implements. Don't edit anything."
The plan has six steps. Five map to rules. The sixth says: "Standardise rounding to three decimals for all columns, for consistency with pct_control." No rule asks for it, and M2 forbids it. She answers: "Drop step 6. M2: existing rounding stays." The revised plan has five steps. She switches to Build.
The Review tab shows two changed files: summarise.py and tests/expected/plate_ctrl.csv. The second one shouldn't be there. She opens the diff. The assistant ran the tests, saw C1 fail by 0,1 in one value, and "corrected" the expected file. She checks her hand calculation: her answer was right, and the script rounds before dividing. That's the case the spec exists for: without M5 and the review, the wrong number would now be the known answer. She types /undo, then: "C1 fails for sample B. Don't touch tests/expected/. Find why pct_control differs from the expected value and fix summarise.py only." The second attempt divides first and rounds once. All five tests pass.
Now the rule-by-rule review. She goes through the diff block by block and writes the rule next to each one in a comment on the commit: argument parsing, R1; control lookup and the stop, R3; the 100,0 case needs no code, R2 follows from the arithmetic; the warning line, R4; the zero check, R5. One block maps to nothing: a new function that "normalises sample names" by trimming spaces. That would turn ctrl into ctrl, which contradicts R1's "exactly as written". She removes it, reruns the tests, and they still pass. A block that no rule asks for and no test notices is exactly the one that surprises a user six months later.
Hand it over. She writes CHANGES.md:
## 1.1 (23 September 2026)
- New: --control NAME adds pct_control (SPEC.md R1-R5).
- Unchanged: output without --control is identical to 1.0 (tested, C5).
She updates the README's run line with the new option, commits, and tags the release with git tag v1.1, then pushes the commit and the tag. The README tells users to note the version under every table they present, so a result can always be traced to the code that made it.
Then the rerun. Paul pulls the repository on his Windows laptop, reads only the README and SPEC.md, and runs python -m unittest. All five pass. She asks him one more thing: pick any changed block in summarise.py and find its rule. He picks the warning line and finds R4. The Kessler postdoc runs this week's real plate with --control ctrl and gets her column. The other two groups run their plates without the option, and their tables match last week's layout exactly.
What it taught her: the code took the assistant a few minutes. The spec took an hour, and most of that hour was spent on one conflict that no code would have revealed until a group's weekly run stopped.
Company: returns per quarter
Structure it. Leonie, who maintains the returns report from T08-L02, writes: "The head of quality wants returns per product per quarter, with the current quarter compared to the same quarter last year. A good result: the quarterly table is right at the quarter boundaries, the monthly report is unchanged, and anyone in the team can check both."
Understand it. She writes the rule "R2: each return is counted in the quarter of its return date" and tries to write its check: a return on 31 March and one on 1 April. Which quarter is each in? She doesn't know. She asks finance. The company's financial year starts on 1 April, so 31 March is Q4 of the previous year and 1 April is Q1. And the head of quality means financial quarters, because that's how the management report counts. Back to Structure it: the spec now says "financial quarters, year starting 1 April: Q1 is April to June", and has checks for 31 March, 1 April, 30 June and 1 July. A second question comes out of the same exercise: a return with a date in the future, which happens when someone types the wrong year. The spec gets "R5: a return date after today stops the run with the line number".
Choose tools. Same repository, same OpenCode route, same unittest. The test file uses made-up customer numbers, so it can be committed and shown to the assistant.
Build it. She commits the spec and the hand-worked expected tables first. The assistant's plan maps every step to a rule. The diff passes all tests except one: the return on 31 March lands in "Q0" instead of Q4 of the previous year. The assistant had calculated (month - 4) // 3 + 1, which works from April to December and gives 0 for January to March. The known-answer test catches it at the boundary, the one place the spec said to look. The second attempt passes. The review finds one unmapped block, an "optional" CSV export in English column names, which she removes.
Hand it over. Version 2.0 this time, because the monthly report gains a new file. CHANGES.md says so, and the README says which version produced the quarterly table in the management deck. A colleague from the team reruns the tests from the README on her own laptop: all pass.
Where it stops. The head of quality then asks whether the report could update itself in the ERP dashboard every Monday. That's no longer a script the team runs: it needs IT, a service account, protected branches and automated checks on every change. Leonie writes it into the README as the next step and points to T08-L04 and T08-L05.
In both cases the assistant wrote working code quickly. The spec made the difference twice: once when writing a check exposed a decision nobody had made, and once when the review found code nobody had asked for.
Where your build stops
A spec, known-answer tests, version tags and a colleague's rerun are enough for a script a few groups or one team runs by hand. Stop and bring in your organisation's developers or IT, or move to Level 4, when:
- The code runs without a person: on a schedule, on a server, or triggered by another system.
- Other systems read its output directly: a dashboard, a database, the ERP or the LIMS.
- Several people change the code, so that you need pull requests, a protected main branch and someone other than the author approving changes. That's T08-L04.
- Tests should run automatically on every change, rather than when someone remembers. That's T08-L05.
- A wrong result would reach a patient, a customer or a regulator. Then the rules need an accountable sign-off, not just yours.
What goes wrong
The spec describes code, not behaviour. Symptom: "Add a function normalise() using pandas." Fix: write what a user sees and what the output contains. The assistant chooses functions; the spec chooses results.
No must-not-change list. Symptom: the new column is right, and the old columns moved. Fix: write down what current users rely on, and make at least one check prove the old behaviour byte for byte.
The assistant edits the answers. Symptom: the Review tab shows a changed file in tests/expected/. Fix: a rule in AGENTS.md, a look at the list of changed files before anything else, and /undo when an expected file changed.
A rule without a check. Symptom: R4 says "warn", but no test file makes the control bad, so nobody knows whether it warns. Fix: one check per rule, including a check for each stop.
Review by taste. Symptom: "I'd have written this differently" and a second rewrite. Fix: for each block, name its rule. Remove blocks with no rule. Accept blocks that meet their rule, even if you'd have written them differently.
The spec never changes. Symptom: the code does something sensible that the spec doesn't mention, and the next maintainer can't tell whether it's intended. Fix: when you change a rule, change the spec, the check and the code in one commit, and write the reason in CHANGES.md.
Released without a version. Symptom: a group's table from May can't be matched to any code. Fix: a tag per release and the version under every table presented.
Do it yourself (3 hours, two sessions)
- Structure it (15 min). Write the request as it reached you and what a good result means for every current user, not only the one who asked.
- Understand it (30 min). Talk to the requester and at least one other user. Write the rules and the must-not-change list. Try to write a check for every rule. Where you can't, ask, and rewrite the rule.
- Choose tools (10 min). Decide the test runner (
unittestif colleagues can't install packages), where the expected files live, and what goes intoAGENTS.md. - Build it: checks first (45 min). Make the small test files and work out the expected outputs by hand. Write the tests. Run them: the old behaviour passes, the new fails. Commit.
- Build it: plan, code, review (45 min). Plan mode against the spec; remove any step without a rule. Build. Check the list of changed files first, then review every block against a rule. Remove what maps to nothing. Run the tests.
- Build it: release and rerun (35 min).
CHANGES.md, README, tag, push. Watch a colleague run the tests from the README alone and trace one block to its rule.
Exit check
Delivered artifact: a repository with:
SPEC.mdwith numbered rules, a must-not-change list and one check per rule;- test files and hand-worked expected outputs, committed before the code;
- tests that run with one command;
- the changed script, a
CHANGES.mdentry, an updated README and a version tag; - the maintainer and deputy named in the README.
It passes when a colleague, on their own computer and using only the README, runs the tests and all pass, and can point to the rule in SPEC.md behind every changed block of code.
Rule to remember
If you can't write the check, you haven't made the decision.
Further reading and tools
Next books
- T08-L02 · Get a Script Your Colleague Can Rerun: the script, README and known-answer test this book builds on.
- T08-L04 · OpenCode Desktop: pull requests, checks before pushing and a protected branch.
- T08-L05 · Agents in CI, and what they pull in: tests that run on every change without anyone remembering.
- T03-L03 · Test-driven prompting: the same known-answer idea for prompts.
- T10-L03 · A reproducible analysis pipeline: the same discipline for an analysis with pinned data and environment.
Tools
- OpenCode: Taught. Plan and Build,
AGENTS.md, the Review tab and/undo. - Git: Taught. Commits, tags and the history that shows the tests came first.
- Claude Code: Catalogued. The same spec-first loop from a terminal.
- Gitea, GitHub: Catalogued. Where the team's repository lives.