T08-L03 · Write code · Level 3 Builder · 24-minute read, plus 2½ hours of doing

One sentence, three groups

The plate-reader script from T08-L02 · Get a Script Your Colleague Can Rerun, Without Being a Developer did its job. Jonas reran it without Mia, and within two months three groups on the floor used it for their weekly summaries. Then Lena, a student in one of them, asks for one more column: each sample as a percentage of the plate's control. Typed as one sentence, "add a percent-of-control column", that request leaves a dozen decisions open. What if a plate has no control? Round before dividing, or after? What happens to the two groups that never asked for the column? An AI assistant fills every open decision with a guess, and the guesses look reasonable, which is why they get through.

In a company it's the quality team's returns report. The head of quality asks for returns per quarter. The assistant builds calendar quarters; the financial year starts in April. The first quarterly report puts March's recall in the wrong quarter.

You are at Level 3 Builder: colleagues depend on this code, so a change that breaks it breaks their results. This book shows you how to write the decisions down before any code, lock the answers so the assistant can't change them, have OpenCode write tests against them, and review its change rule by rule.

After this you can

  • Write a one-page spec, SPEC.md: numbered rules, what must not change, and a check for each rule, and have OpenCode find its gaps before any code exists.
  • Work out the expected answers yourself and lock them in opencode.json, so OpenCode can't edit them.
  • Have OpenCode write tests first, and see them fail for the right reason.
  • Make your own slash commands, /check and /spec-review, that the whole group gets with the repository.
  • Plan against the rules, build, review the change block by block, and release it as a version your colleague can check.

Before you start

Last verified 26 September 2026. OpenCode Desktop and its model Space Bunny are free. Space Bunny keeps none of your prompts but runs in the US: use made-up plates in the chat. Python and its test tool unittest are free. Details for experts at the end of this book.

The idea in one page

  • A spec is a list of decisions. SPEC.md has four parts: what changes (what a user will see), numbered rules, a must-not-change list (what current users rely on), and checks: an input file and its expected result for every rule.
  • A test is a small program that runs the script on a test file and compares the result with a file of expected results. python -m unittest runs all of them and reports each as passed or failed.
  • Writing the checks finds the gaps. You can write "handle a missing control" without deciding what that means. You can't write its expected result without deciding. In Plan mode OpenCode does this for you: write the check for every rule, and ask me where you can't.
  • The answers are yours, and locked. You work out the expected results by hand. Two lines in opencode.json stop OpenCode from editing those files, so it can't make a failing test pass by changing the answer.
  • Tests before code. OpenCode writes the tests from your checks. They fail, for one reason: the feature doesn't exist yet. You commit them before any code, so the history shows the answers came first.
  • Review by rule, not by taste. Every changed block of code should implement a rule. A block that implements none is questioned, and removed unless it only describes.
  • Your own slash commands. A small file in .opencode/commands/ turns a prompt you'd type every time into /check or /spec-review, for everyone who has the repository.

Watch it once (88 seconds)

No sound. A yellow dot points at each part while the caption explains it. Read the tour as text (opens in a new tab).

Worked example: a percent-of-control column

The lab example is told in full; the company version at the end shows what changes. Our run used OpenCode Desktop 2.0.16 and the free Space Bunny model. Your answers will be worded differently; what to look for is the same.

Step 1: Open a new session in the project. In OpenCode, click platereader-summary under Projects, then New session. If colleagues push to the repository too, first get their changes as in T19-L02 · Work on a Shared Gitea Repository From Your Computer With OpenCode.

You should see: on the new-session screen, platereader-summary and the branch label main below the message box, as in T08-L02.

Structure it

Step 2: Write down the request and what "good" means for every user. Mia writes it before she opens anything:

Request:  Lena's group wants each sample as a percentage of the plate's control (the sample ctrl).
Good:     Lena gets the column; the two other groups' tables stay exactly as they are;
          anyone can check both with one command.

The second line names users who didn't ask for anything. They are the ones a change usually breaks.

Understand it

Step 3: Draft the spec. The project folder is platereader-summary inside your OpenCode workbench folder. Save text files there as in T08-L02: in Notepad choose All files so it doesn't add .txt; in TextEdit first choose Format → Make Plain Text; press Enter after the last line. Save this as SPEC.md. It is Mia's first draft, with its gaps; yours will have some too:

# SPEC: percent of control (version 1.1)

Maintainer: Mia. Deputy: Jonas. Requested by: Lena.

## What changes

summary.csv gets a last column, pct_control: each 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 ctrl.
R2 The control's own pct_control is 100,0.
R3 No rows called ctrl: stop with "no control 'ctrl' found". No output folder.
R4 Control CV above 15 %: still compute, but warn.
R5 Control mean after blank correction is 0 or below: stop with a message.

## Must not change

M1 The existing columns: their names, order and rounding.
M2 Standard library only.
M3 Never overwrite a results folder; never skip a row.
M4 tests/expected/ and expected_output.csv are written by hand, never by the assistant.

## Checks

C1 tests/plate_ctrl.csv -> tests/expected/plate_ctrl.csv
C2 to do

Version 1.1 is the version of the script this spec describes, not of the spec.

Step 4: Make three small test plates. Create a folder tests in the project and save three files there, in the same format as test_input.csv. plate_ctrl.csv, a normal plate with a control:

well;sample;od
A1;blank;0,050
A2;blank;0,054
B1;ctrl;0,812
B2;ctrl;0,798
B3;ctrl;0,806
C1;A;0,503
C2;A;0,506
C3;A;0,506
D1;B;0,310
D2;B;0,420
D3;B;0,300

plate_badctrl.csv, a control that spreads widely, so R4 must warn:

well;sample;od
A1;blank;0,050
A2;blank;0,054
B1;ctrl;0,690
B2;ctrl;0,940
B3;ctrl;0,730
C1;A;0,503
C2;A;0,506
C3;A;0,506

plate_zeroctrl.csv, the same but with the control below the blanks, so R5 must stop:

well;sample;od
A1;blank;0,050
A2;blank;0,054
B1;ctrl;0,049
B2;ctrl;0,050
B3;ctrl;0,051
C1;A;0,503
C2;A;0,506
C3;A;0,506

Choose values like these on purpose: each file proves one rule.

Step 5: Ask Plan to write a check for every rule. Switch to Plan (Ctrl+.) and send:

Read @SPEC.md, the test files in tests/ and the current summarise.py.
For each rule R1 to R5 and M1 to M4, write the check you would use: which input file, which command, and the exact expected result.
Where you cannot write a check because the spec leaves something open, don't guess: ask me.
Do not change anything.

OpenCode's questions Q1 to Q6, cut off after Q6: test_input.csv has no ctrl row, so a literal R3 would make the weekly file stop, with options a, b and c; Q2 asks whether pct_control uses unrounded or rounded means, giving 60,1 and 38,7 against 60,2 and 38,6; Q3 to Q5 ask for the warning's wording, whether the control keeps its flag, and the message for R5

You should see: a list of checks it could write, then numbered questions. Ours asked seven. Two changed what gets built:

  • Q1: test_input.csv, the file the other groups run every week, has no control. Under R3 as written, their weekly run would stop.
  • Q2: dividing the rounded means gives 60,2 for sample A, dividing the unrounded ones 60,1. Your spreadsheet gives one of them; the spec says neither.

The others: the exact warning text, whether the control keeps its flag, the stop message for R5, whether CTRL counts as ctrl (Q6), and whether you write the expected files yourself (Q7). Nobody had made any of these decisions. That's what a spec is for.

Step 6: Answer, and let Build write the decisions in. Switch to Build and send your answers. Ours:

My answers:
Q1: (c). The column is only added when someone asks for it with --control NAME. Without the option, the output stays identical to today: test_input.csv still gives expected_output.csv byte for byte. R3 only applies with --control. No line number: "no control 'NAME' found", exit 1, no output folder.
Q2: Unrounded. Divide the unrounded means and round once, at the end.
Q3: Only the control warns. Print the line to stderr and also write it to warnings.txt in the results folder: WARNING: control ctrl CV 18,3% is above 15% - check before using pct_control
Q4: Keep flag yes. The warning is extra.
Q5: No line number: "control 'NAME' has a blank-corrected mean of 0 or below", exit 1, no output folder. The below-0 fixture is enough.
Q6: Exactly as written. CTRL is not ctrl.
Q7: Yes. I write the files in tests/expected/ myself.
Now update SPEC.md with these decisions, and add a Checks section: C1, C2, ... with the input file, the command and the expected result, and the rule each one proves. Change only SPEC.md.

Byte for byte means identical down to the last character. An option is an extra word you add to the command, here --control ctrl. Exit 1 means the script ends with an error code the tests can see. stderr is where a script prints its messages. A fixture is OpenCode's word for a test file.

Step 7: Read the spec it wrote, like code. Press Ctrl+Shift+R for the Review panel and read SPEC.md line by line.

The Review panel with SPEC.md: the new Rounding section says pct_control = ROUND_HALF_UP(mean(NAME) / mean(sample) * 100, 1), control divided by sample; on the left, OpenCode's list of judgement calls ends with I bumped the version to 1.2

Ours had two mistakes. The formula divides the control by the sample: upside down. And OpenCode bumped the version to 1.2 on its own. It also grew the spec to 150 lines of shell commands, which Jonas would never read. So we sent:

Three corrections to SPEC.md:
1. Keep version 1.1. It is the version of the script, not of the spec.
2. The Rounding line is upside down: pct_control is the sample's mean divided by the control's mean, times 100.
3. Jonas must be able to read this. Write each check as one plain line: input file, option, expected result, rule. Keep C1 to C8, drop C9 to C11. The commands go into the tests next, not into the spec.
Change only SPEC.md.

OpenCode's answer: all three corrections applied, 150 lines down to 63; version back to 1.1; formula un-inverted to mean(sample) / mean(NAME) * 100; checks C1 to C8, one plain line each

You should see: the three corrections, and eight checks that read like this:

C1 tests/plate_ctrl.csv --control ctrl -> tests/expected/plate_ctrl.csv byte for byte, no warnings.txt. R1, R2, M1.
C2 test_input.csv, no option -> expected_output.csv byte for byte, no pct_control column. M1, the column is opt-in.
C3 test_input.csv --control ctrl -> stops "no control 'ctrl' found", exit 1, no output folder. R3.
C4 tests/plate_badctrl.csv --control ctrl -> computes anyway, control row keeps flag yes, one warning to stderr and to warnings.txt. R4.
C5 tests/plate_zeroctrl.csv --control ctrl -> stops "control 'ctrl' has a blank-corrected mean of 0 or below", exit 1, no output folder. R5.
C6 tests/plate_ctrl.csv --control CTRL -> stops "no control 'CTRL' found", exit 1. R1, the name matches exactly.
C7 broken_input.csv --control ctrl -> still stops on line 8, exit 1, no output folder. M3.
C8 tests/plate_ctrl.csv twice in one day -> second run exits 1, "already exists; refusing to overwrite", first summary.csv untouched. M3.

The assistant writes the spec quickly and well, and it still makes mistakes you only catch by reading. A spec error is worse than a code error: the tests will check the wrong thing.

Step 8: Work out the answers yourself. In a spreadsheet, as in T08-L02: blank mean 0,052; subtract it; per sample, mean, CV = STDEV.S / AVERAGE × 100 (German Excel: STABW.S / MITTELWERT), and pct_control = sample mean / control mean × 100, from the unrounded means. Then create a folder expected inside tests and type two files there in Notepad or TextEdit; never save them from Excel, which changes the separators. plate_ctrl.csv:

sample;mean;cv_percent;flag;pct_control
ctrl;0,753;0,9;no;100,0
A;0,453;0,4;no;60,1
B;0,291;22,9;yes;38,7

plate_badctrl.csv:

sample;mean;cv_percent;flag;pct_control
ctrl;0,735;18,3;yes;100,0
A;0,453;0,4;no;61,7

OpenCode had already told us these numbers in step 5. Ours matched; that's a bonus, not the check. The expected files come from your own calculation.

On Windows, Notepad ends each line the Windows way (its status bar shows Windows (CRLF)), and the tests compare byte for byte. Open the terminal panel (**Ctrl+**, or /terminal) and convert your answer files, including expected_output.csv` from T08-L02, with this one line:

python -c "import pathlib; [p.write_bytes(p.read_bytes().replace(b'\r\n', b'\n')) for p in [*pathlib.Path('tests/expected').glob('*.csv'), pathlib.Path('expected_output.csv')]]"

It changes only the line endings. On a Mac, TextEdit already saves them the right way.

Step 9: Lock the answers. Save this as opencode.json in the project folder:

{
  "$schema": "https://opencode.ai/config.json",
  "permission": {
    "edit": {
      "*": "allow",
      "tests/expected/*": "deny",
      "expected_output.csv": "deny"
    }
  }
}

The first rule allows every edit; the two after it forbid edits to your answer files. Then test the lock. Send:

I have locked the answer files in opencode.json. Test the lock: try to add one empty line at the end of tests/expected/plate_ctrl.csv.
If you are blocked, tell me what blocked you, and do nothing else: don't try another way.

OpenCode's answer: Used 1 Edit, then Blocked. Nothing was changed. The attempt was rejected by the permission layer with Permission denied: edit, and it shows the opencode.json that blocked it

You should see: Blocked. Nothing was changed. It worked at once, in the same session. AGENTS.md asks the assistant not to edit a file; opencode.json stops it. The lock covers OpenCode's editing tool. A command typed into the shell could still change a file, which is why you also look at the list of changed files in every review.

Choose tools

  • OpenCode, Plan then Build, with Space Bunny Free and made-up plates.
  • Python's unittest for the tests. It comes with Python, so Jonas installs nothing.
  • opencode.json for the lock, and two custom commands for the checks you'll run again and again. Both are files in the repository, so every colleague gets them.

Build it: the checks before the code

Step 10: Have OpenCode write the tests. Still in Build, send:

Now write the tests, before any code. One test per check C1 to C8 in SPEC.md, in tests/test_control.py, using unittest from the standard library.
Each test copies its input file to a fresh temporary folder, runs summarise.py there and compares with the files in tests/expected/ or the text in SPEC.md.
Everything must run with one command from the project folder: python -m unittest
Don't touch summarise.py. Then run the tests and tell me which pass and which fail, and why.

OpenCode's answer: C3 to C8 fail; Why all seven fail: one cause, seven symptoms, summarise.py has no --control option yet, so it rejects the command line with unrecognized arguments before any of the test runs

You should see: C2 passes and seven fail, all for the same reason: the option doesn't exist yet. That's what you want. C2 passing proves the tests really run the script. Seven failing for one named reason proves they test something that isn't built. A test that passes before the code exists tests nothing. OpenCode also added an almost empty file, tests/__init__.py; without it, python -m unittest finds no tests in the tests folder and reports NO TESTS RAN.

You don't need to read the test code. You need to know that each test compares with your files, and that it fails now.

Step 11: Make a /check command. A folder name that starts with a dot is awkward to create by hand, and a Mac's Finder refuses it. So open the terminal panel and type mkdir -p .opencode/commands (it works in PowerShell, Git Bash and on a Mac). Then save this as check.md in that folder:

---
description: Run all tests and explain failures against SPEC.md
agent: plan
---
These are the results of python -m unittest:

!`python -m unittest 2>&1`

For each check C1 to C8, say pass or fail. For each failure, name the rule in @SPEC.md it protects and the likely cause, in one sentence. Do not change any file.

On a Mac, write python3 instead of python. The part between the dashes describes the command; agent: plan runs it in Plan, which doesn't edit files. The line starting with ! runs the tests when you use the command and puts their output into the message. @SPEC.md adds the spec. Type / in the message box.

The command list above the message box: /init, /review, /check Run all tests and explain failures against SPEC.md, /new, /undo, /compact, /fork, /export, /open, /terminal

You should see: /check with your description, right away. Next to it, OpenCode's own /review, which reviews uncommitted changes in general; step 18 makes one that reviews them against your rules.

Step 12: Run /check. Choose /check and press Enter twice.

The /check answer as a table: C1 fail, R1 R2 M1, --control is not an argument yet; C2 pass; C3 fail, R3; C4 fail, R4; C5 fail, R5, each with its cause in one sentence

You should see: 1 pass, 7 fail, each with the rule it protects. The agent label under the message box now says Plan: the command switched it. Switch back to Build (Ctrl+.) before the next step.

Step 13: Commit the checks before the code. Send:

Before any code, save the checks in Git.
Add __pycache__/ to .gitignore. In AGENTS.md, replace the part that says there is no test runner: the tests now run with python -m unittest, SPEC.md is the contract for every change, and the files in tests/expected/ and expected_output.csv are locked in opencode.json.
Then commit SPEC.md, tests/, opencode.json, .opencode/, .gitignore and AGENTS.md with the message "Spec, tests and hand-worked answers for percent of control". Don't push yet.

__pycache__ is a folder Python creates when it runs the tests; it doesn't belong in Git.

OpenCode's answer: the committed files with a note on each: your hand-worked answers, one test per check, the discovery marker, your /check command, .gitignore and AGENTS.md; summarise.py is not in the commit; then what changed in AGENTS.md

You should see: the commit, and summarise.py is not in the commit. From now on the history shows that the answers and tests came before the code. Your AGENTS.md said do not add a test runner unless asked, from T08-L02; now you've asked, so it must say so, or the next session follows the old rule.

Build it: plan, code, review

Step 14: Ask for a plan with a rule for every step. Switch to Plan and send:

Plan the change to summarise.py that makes all eight tests pass.
For every step, name the rule in SPEC.md it implements (R1 to R5, M1 to M4). If a step implements no rule, leave it out and tell me what you left out.
Don't change anything yet.

Plan doesn't edit your project's files, but it can run commands. Ours tried the whole change in a copy of the project in a temporary folder, then asked to reach two folders next to the project:

Permission required: Access files outside the project directory, D:/oc-book/ws19b/OpenCode/plate_ctrl/* and plate_badctrl/*, with Deny, Allow always and Allow once

If yours asks too, click Deny. In OpenCode 2.0.16 that stops the whole answer, so send the request again, shorter:

I denied that: stay inside this project folder. Write the plan now.
For every step, name the rule in SPEC.md it implements. If a step implements no rule, leave it out and tell me what you left out.

The plan: The six steps; step 1, let PlateError carry no line number, implements R3 and R5; step 2, three constants, implements R2 and R4; step 3, add_pct_control, with the code it will add: find the control by exact name, stop if it is missing or at 0 or below, warn above 15 %, divide each mean by the control's

You should see: numbered steps, each ending with Implements: and rule numbers. A step without one is your cue to ask why.

Step 15: Read what it left out. Scroll to the end of the plan.

Left out, implements no rule: the module docstring, README.md and the AGENTS.md Last verified line; Deliberately not done, would break a rule: no default for --control, no case-insensitive matching, no stripping of NAME; Two things the spec doesn't settle: a control mean of exactly 0 never reaches R5's wording

You should see: three lists. Left out is what you asked for: things no rule covers. Deliberately not done is the plan defending your rules against its own habits. Two things the spec doesn't settle are new gaps. Step 18 finds the first again and step 20 settles it. The second (a plate with two problems reports the other one first) we accepted: both stop with exit 1 and write nothing. Decide on each left-out item. We wanted the script's own description (its docstring) kept correct, and README and AGENTS.md updated at the end.

Step 16: Build it. Switch to Build and send:

Go: steps 1 to 6 in summarise.py. Also update the docstring's usage line and column list, so the file doesn't contradict itself. README and AGENTS.md come later; nothing else.
Then run python -m unittest and show me the result. Don't commit.

OpenCode's answer: Ran 8 tests, OK; All eight pass, not committed, summarise.py is modified and nothing else is; What landed: a table of the docstring and steps 1 to 6 with the rules each implements

You should see: Ran 8 tests … OK and one changed file. Ours passed all eight at the first attempt. If yours doesn't and asks to change an expected file, the lock refuses; answer that the code is wrong until proven otherwise.

Step 17: Look at the Review panel. Press Ctrl+Shift+R. The icons above the file switch between side by side and Unified diff, one column, which is easier to read.

The Review panel: Files Changed 1, summarise.py with +74 and -13 lines; the new function add_pct_control, which looks up the control by exact name, stops if it is missing or its mean is 0 or below, and builds the warning line

You should see: Files Changed 1. Look at the file list before any code: a changed file in tests/expected/, a new file you didn't ask for, or a test file changed after step 13 is the first thing to question. Close the panel.

Step 18: Review it rule by rule with /spec-review. Save a second command as .opencode/commands/spec-review.md:

---
description: Review uncommitted changes rule by rule against SPEC.md
agent: plan
---
These are the uncommitted changes:

!`git diff`

Go through the changes block by block. For each block, name the rule in @SPEC.md it implements. List every block that implements no rule, and say whether it should stay. Say whether anything in tests/expected/ or expected_output.csv changed. Do not change any file.

git diff lists every line changed since the last commit. Type /spec, choose /spec-review and press Enter twice.

Block by block: a table of blocks B1 to B11 with the rule each implements; B1 and B2, docstring lines, implement none; B3 the constants R2 and R4; B5 R3 and R5; B6 the exact-name lookup R1 and R3; B7 the zero check R5; B9 the division R2 and Rounding; B11 the --control option R1

You should see: every block with a rule, and the answer files unchanged. Ours found three blocks with no rule: all three are descriptions (docstrings), which stay. A block of code with no rule is the one that surprises a user six months later: remove it, then run /check. At the end, ours had two more findings; the one that matters is the first:

Two completeness caveats: B7 implements R5's wording but is unreachable at exactly zero, because summarise() already refuses a zero mean with sample ctrl has a mean of 0; CV undefined; both stop with exit 1 and no output folder; and B14's trailing newline in warnings.txt is not pinned by any test

At exactly 0 the script stops, as R5 requires, but with an older message. Either the code or the spec has to change; they must not disagree. We changed the spec, in step 20.

Step 19: Run it yourself. Open the terminal panel (Ctrl+**, **Cmd+ on a Mac, or /terminal) and type:

python -m unittest
python summarise.py --control ctrl tests/plate_badctrl.csv
cat tests/results-2026-09-26/summary.csv

Use today's date in the last line; on a Mac, type python3.

The terminal: Ran 8 tests, OK; the bad-control plate prints WARNING: control ctrl CV 18,3% is above 15% - check before using pct_control, writes its summary, and the summary shows ctrl 100,0 and A 61,7

You should see: OK, the warning, and the same table as your tests/expected/plate_badctrl.csv. Afterwards delete the results-… folder inside tests in File Explorer (Finder on a Mac), so a rerun today isn't refused, and close the panel.

Hand it over

Step 20: Update the spec, the changelog and the README, and commit. /spec-review left the session in Plan: switch back to Build (Ctrl+.) and send:

Get it ready for the group:
1. SPEC.md, R5: add that a mean of exactly 0 is stopped earlier by the existing message "sample 'NAME' has a mean of 0; CV undefined" (exit 1, no output folder). The spec must say what the code does.
2. CHANGES.md, new: "## 1.1 (26 September 2026)" with what's new (--control NAME adds pct_control, SPEC.md R1 to R5), what's unchanged (without --control the output is identical to 1.0, check C2), and "Requested by Lena".
3. README.md: one run line for --control ctrl, and "Check it first" now starts with python -m unittest (all tests must pass). Keep it under 15 lines.
4. AGENTS.md: fix the stale "Last verified" line.
Then commit everything with the message "Percent of control (1.1)". Don't push yet.

You should see: one commit with summarise.py, the four files and .opencode/commands/spec-review.md. Ours included the command file and said so, in case we didn't want it; it's meant for the whole group, so it belongs in the commit.

Step 21: Tag the version and push. A tag is a name for one exact state of the code. Send:

Tag this commit as v1.1 with the message "Percent of control", then push main and the tag v1.1 to origin.
If Git reports a conflict, stop: don't resolve it yourself. Show me both versions of the lines and who wrote each.

OpenCode's answer: the tag v1.1 on the remote, annotated, Tagger Mia, message Percent of control, on commit bb32d81; it sits on the commit with the code, not on the earlier one with only the tests, which stood at 1 pass and 7 fail; and moving the tag later would need a force push

You should see: the tag on the commit with the code. origin is your Gitea copy of the repository. Git doesn't send tags with a normal push, which is why the prompt names it. Never move a published tag: if something is wrong, make a new version. Step 23 does exactly that.

Step 22: Name the session. Click ⋯ next to the session title, Rename, type Percent of control (1.1), Enter. Then click the square icon at the top left (Home).

Home: the platereader-summary project with two sessions today, Percent of control (1.1) and Plate summary script

You should see: one session per change, named after the version. Next year's question, why is the control matched exactly?, starts here.

Step 23: Watch Jonas rerun it, and fix what breaks. (Jonas's screen.) Jonas clones the repository on his Windows laptop, where Git was installed with its default settings, and runs python -m unittest. Three of eight fail. Those settings convert line endings to the Windows style on the way out of Git, so his tests/expected/plate_ctrl.csv no longer matches the script's output byte for byte. We reproduced exactly this with a Windows default clone. Back on Mia's computer, send:

Jonas cloned the repository on Windows with Git's default settings, and 3 of the 8 tests fail. His copy of tests/expected/plate_ctrl.csv has Windows line endings (CRLF).
Make the repository keep the line endings exactly as they are for everyone who clones it, whatever their Git settings. Don't change any test or expected file.
Add a CHANGES.md entry "## 1.1.1 (26 September 2026)" saying what this fixes, commit with the message "Keep line endings for every clone (1.1.1)", tag it v1.1.1 with the message "Line endings", and push main and the tag. Don't move the v1.1 tag.
If Git reports a conflict, stop: don't resolve it yourself. Show me both versions of the lines and who wrote each.

OpenCode's answer: it could not watch a Windows clone go from red to green, because it was told to stay inside the project folder; What Jonas has to do: git rm --cached -r . and git reset --hard, because a plain git pull will not fix his existing copy; the tag v1.1 unmoved

You should see: a one-line file .gitattributes (* -text: never convert line endings), a new tag, and v1.1 unmoved. OpenCode said honestly what it could not verify, so we checked ourselves: a fresh Windows default clone passes all eight tests, and so does Jonas's old copy after git pull and the two commands above. Those two commands throw away anything changed in that copy, so he runs them only in a copy he hasn't edited. Or he simply clones again.

Gitea as Jonas sees it: rna-lab/platereader-summary, Releases, 2 Tags, v1.1.1 and v1.1, each with ZIP and TAR.GZ download links

A colleague without Git clicks Releases, then Tags, then ZIP next to the newest version, and runs python -m unittest in the extracted folder: we did that too, and all eight passed. Lena then runs her real plate, kept outside the folder, with --control ctrl, and the two other groups run theirs without the option and get last week's layout.

CHANGES.md on Gitea at v1.1.1: 1.1 with New, Unchanged and Requested by Lena, then 1.1.1 with Fixed: a .gitattributes holding * -text

You should see (on Jonas's computer): OK after eight tests. CHANGES.md answers what changed and why without anyone opening the code, and the version under every table Lena presents traces it to this exact code.

Company: returns per quarter

An illustration of how the same steps play out; we ran the lab version.

  • Steps 2 to 4: the request is returns per product per quarter, compared with the same quarter last year. Test rows include returns on 31 March, 1 April, 30 June and 1 July, with made-up customer numbers.
  • Step 5: Plan asks which quarter 31 March is in. Finance answers: the financial year starts on 1 April, so 31 March is Q4 of the previous year. It also asks what to do with a return date in the future; the answer becomes a rule: stop with the line number.
  • Steps 10 to 16: the tests fail first; the first build puts 31 March into "Q0", and the boundary test catches it. The second attempt passes. /spec-review finds an "optional" export with English column names that no rule asks for; it's removed.
  • Steps 20 to 23: version 1.2, because the quarterly table is new and the monthly report is unchanged. The repository is on the company's GitHub, where tags and ZIP downloads work the same way.
  • Where it stops: when the head of quality wants the report to update itself every Monday, it needs a service account, a protected main branch and automatic checks. That's T08-L04 · Use OpenCode Desktop on Your Organisation's Real Repositories and T08-L05 · Run Automatic Checks, an AI Reviewer and Releases You Can Roll Back.

Working with OpenCode day to day

  • Custom commands live in .opencode/commands/, one Markdown file each; the file name is the command. ! followed by a command in backticks runs it and inserts its output, @file inserts a file, and $ARGUMENTS inserts what you type after the command. Commit them: the whole group gets them.
  • agent: plan in a command runs it in Plan, and leaves the session in Plan. Switch back before you ask for changes.
  • opencode.json in the project is read at once and travels with the repository. It is the right place for locks the whole group needs.
  • /review is OpenCode's own review of uncommitted changes; /spec-review is yours, against your rules. Use either before every commit.
  • /fork starts a new session from an earlier message, when you want to try another way without losing this one.
  • One session per version, named after it (step 22).

Where your build stops

A spec, locked answers, tests, tags and a colleague's rerun are enough for a script a few groups run by hand. Bring in your organisation's developers or IT, or move to Level 4, when:

What goes wrong

The spec leaves a decision open. Symptom: the result differs from your spreadsheet in the last digit, or a group's weekly run suddenly stops. Fix: ask Plan to write a check for every rule and to ask where it can't (step 5). Answer every question in the spec, not only in the chat.

The assistant's spec is wrong. Symptom: a formula upside down, a version changed, a spec nobody can read. Fix: read SPEC.md in the Review panel line by line, like code (step 7). The tests will faithfully check whatever the spec says.

The answers get changed to match the code. Symptom: a file in tests/expected/ appears in the Review panel. Fix: the lock in opencode.json (step 9) and the file list before the code (step 17). If it happened anyway, for example through a shell command, ask Build to restore tests/expected/ from the last commit and show git status.

No tests run. Symptom: Ran 0 tests or NO TESTS RAN. Fix: keep tests/__init__.py, and start python -m unittest from the project folder, not from tests.

The tests pass for you and fail for a colleague. Symptom: only the byte-for-byte checks fail, only on Windows. Fix: .gitattributes with * -text (step 23); a colleague's existing copy needs a fresh clone, or git pull and the two commands in step 23.

Your own answers fail. Symptom: C1, C2 and C4 fail on your computer though the numbers look identical. Fix: your answer files have Windows line endings from Notepad; convert them with the line in step 8.

Deny ends the answer. Symptom: after Deny, the answer just stops. Fix: send the request again with stay inside this project folder (step 14).

Code with no rule. Symptom: /spec-review lists a block that implements nothing. Fix: remove it and run /check, or, if the group really needs it, add the rule and a check first.

A published version is changed. Symptom: the same tag points at different code on different days. Fix: never move a tag; make a new version with a CHANGES.md entry (step 23).

Do it yourself (2½ hours, two sessions)

  1. Structure it (10 min): step 2, naming every current user.
  2. Understand it (60 min): your draft spec and three small test files (steps 3 and 4); the gaps from Plan and the requester's answers (steps 5 and 6); read and correct the spec (step 7); your answers by hand, and the lock (steps 8 and 9).
  3. Choose tools (5 min): where the tests live, which files to lock, which commands the group needs.
  4. Build it, checks first (25 min): steps 10 to 13. Don't go on until the tests fail for one named reason and are committed.
  5. Build it, plan and code (30 min): steps 14 to 19.
  6. Hand it over (20 min): steps 20 to 22, then sit next to your colleague for step 23 and say nothing.

Exit check

What you hand in: a repository on your group's server with SPEC.md (rules, must-not-change list, one check per rule), test files and hand-worked expected results locked in opencode.json and committed before the code, tests that run with python -m unittest, the two commands in .opencode/commands/, .gitattributes, the changed script, a CHANGES.md entry, an updated README and a version tag.

It passes when your colleague, on their own computer and using only the README, gets OK from all tests, and can point to the rule in SPEC.md behind any changed block of code you show them.

Rule to remember

If you can't write the check, you haven't made the decision.

Details for experts

Checked on 26 September 2026. You don't need this to follow the book; it's for setting things up or answering a colleague.

WhatDetailsWhat it means for you
VersionsOpenCode Desktop 2.0.16, Python 3.13 on Windows 11, Git for Windows 2.55, Gitea 1.27.2.Python 3.11 or newer runs these tests.
Custom commandsMarkdown files in .opencode/commands/ (project) or ~/.config/opencode/commands/ (yours). Front matter: description, agent, model, subtask. Body: $ARGUMENTS, $1…, !`command`, @file. A file named like a built-in command replaces it.Put commands the group needs in the project; your personal ones in your config.
Permissionspermission.edit takes patterns relative to the project, * matching any characters. Other keys include bash, read, webfetch and external_directory. Most default to allow; reaching outside the project asks.Here: allow everything, deny the answer files. More in T08-L04 · Use OpenCode Desktop on Your Organisation's Real Repositories.
What the lock coversIt stops OpenCode's editing tool; the refusal reads Permission denied: edit. It doesn't inspect shell commands.Keep looking at the changed-file list, and keep git diff in /spec-review.
PlanCan't edit project files, but may run commands; ours tested the change in a copy under the system's temporary folder. Deny on a permission question ends the turn.Delete such copies when you're done (OpenCode tells you where they are).
unittestpython -m unittest searches the current folder and every subfolder that has an __init__.py. With no tests found, Python 3.12 and newer report NO TESTS RAN and fail.Keep tests/__init__.py.
Line endingsGit for Windows installs with core.autocrlf=true, which converts on checkout. .gitattributes with * -text switches conversion off for this repository, whatever the settings. A ZIP from Gitea keeps the committed endings.Any byte-for-byte check needs this.
TagsAnnotated tags (git tag -a) store who tagged, when and why. git push sends tags only when named, or with --tags. Gitea lists each tag with ZIP and TAR.GZ downloads; New Release turns one into a release with notes.Releases with notes: T19-L04 · Check Every Change Automatically, Release Versions and Tell the Lab.

Further reading and tools

Latest news