Steered by symptoms,
locked by invariants.
An offline-first Japanese learning PWA, one HTML file, aimed at JLPT N3. Its owner never wrote a spec. He reported what felt wrong in real use, the agent measured it against the data, shipped a minimal fix, and locked every fix with a test that replays the exact report. When owner and app disagreed, an instrument was built and the numbers settled it.
Owner on an iPad, agent in a Claude Code session, zetetic rules loaded. No invented content anywhere in the product.
The loop, as actually practiced
Every change followed the same five steps. No step is skippable, and the last two are what make the first three trustworthy.
A symptom, not a spec
The owner reports lived experience: “my kana never get past guru”, “the lesson quiz only offers the right answer”, “mouth + I gives naka, this is incomprehensible”. French, terse, symptom-level.
Measure before believing
The symptom is reproduced against real data: card counts, reference dictionaries, an audit of the session journal. When the measurement contradicts the report, that is said plainly, and the hunt moves to what produced the perception. There is almost always a real defect behind it, just not where it was announced.
Minimal fix, named metric
Standing rule from the owner: a change that cannot name the metric it improves has no right to exist. Commits are one concern each, staged file by file, and the message records the measured defect, not the timeline.
An invariant replays the report
Each fix lands with an executable check that replays the original complaint, values included: “the card for 中 must say au milieu, never naka”. Ten invariants guard the repo: nine static checks plus a real boot in headless Chromium with about thirty named scenario blocks.
Observable release
The app prints its version on screen and an invariant enforces that the displayed version equals the service worker build. “It is deployed” is not a sentence anyone has to trust. It is a line the owner reads on his iPad.
The gates that actually bit
Checks that never fail are decoration. These two failed for real, mid-build, and blocked a ship until the ambiguity was resolved.
The ratchet vs. the import
import : niveaux = 0, attendu 1 Ne pas livrer.
Adding an auto-open ratchet to level gating silently swallowed a scenario the import test owned. The red run forced a real distinction between a level opened by progress and a level opened by an imported save, instead of letting the ambiguity ship.
The kana that left the audio world
bascules : couper le son sur un niveau ne recalcule pas les cartes en pause
When syllable dictation was removed as pedagogically empty, the toggle test still expected paused audio cards on a kana level. The test was consciously retargeted to word dictation, in the same commit as the behavior change. Retargeted, never deleted.
The zetetic contract names three refusals: “pre-existing”, a skipped check, and a red run. All three held for the whole build.
Nothing written from memory
Standing rule number two. Every fact in the product traces to a source, and a gap in the source stays a visible gap instead of a plausible invention.
| In the product | Comes from | License |
|---|---|---|
| Kanji meanings | KANJIDIC2, the EDRDG reference dictionary | CC BY-SA 4.0 |
| Character decomposition | CJKVI IDS database (CHISE project) | GPLv2 |
| Etymology stories | makemeahanzi hints, derived from Wiktionary, translated faithfully | GPL / Arphic |
| Furigana readings | Janome morphological analyzer with the IPAdic dictionary | Apache 2.0 |
| Spacing & recall science | Cepeda 2008, Karpicke & Roediger 2008, Dumay & Gaskell 2007, Pashler 2008 | Cited in README |
A word the dictionary cannot read gets no furigana rather than a guessed one. A sign with no French gloss shows bare rather than glossed by imagination. Course material under copyright never enters the repository; the app links to the official free textbook instead of copying it.
In the owner’s words
Four sentences that summarize how it felt to lead this build.
I built the app by reporting symptoms. I described what felt wrong, the agent measured it, fixed it, then locked the fix with a test that replays my exact report.
Nothing was invented. Every kanji meaning, every etymology, every reading comes from a cited source, and a gap in the source stays a visible gap.
When I disagreed with the app, we did not argue. We built the screen that shows both measurements, and the numbers settled it.
Ten executable invariants guard the door. If one fails, the output says do not ship, and it does not ship.
The product it produced
Repository content only, no third-party course material. Progress seeded so the mastery colors show.




Single-file engine, offline-first, three-mode sync, generic shell: an imported program can declare its own language, sections and cards, so the same engine can serve Korean or Chinese without a fork.
Want your build led like this?
Symptom in, measured fix out, invariant locked, release observable. That is the engagement.