Chapter 5 of 12 · QA and QC

Gauge tests the criteria, not the code

Tests are derived from the acceptance criteria, not from the code. The agent that wrote the code never grades itself. Nobody is in the loop unless a threshold trips.

Agent GaugeRuns on Codex CLI by default, Claude Code as the fallbackYou need Chapter 4 done: ALD-42 has a pull request
The idea

The builder never grades itself

When you test your own code you test what you built. The bugs live in what you did not think of. Gauge fixes this by reading a different document.

Gauge reads the acceptance criteria Anvil wrote on the Linear issue. It does not start from Ember's diff. One test per criterion, named after the criterion. If a criterion cannot be tested by a machine or a checklist, Gauge says so and tags Anvil. It does not guess at intent.

The second separation is the model. Gauge runs on Codex by default. Ember runs on Claude Code. The grader and the builder share no context window and no habits. When Flint (Codex) built the slice, you can run Gauge on Claude for that issue instead. The point is the same: a different pair of eyes.

This mirrors the review runner Danny already runs in Development HQ. QA defaults to Codex. Product and marketing default to Claude. One review at a time, capped per day, with the evidence recorded in a Notion Review Runs database. The crew here is the same idea with names.

The rule

Test the criteria, not the code. If Gauge finds behaviour with no criterion behind it, that is scope creep, not a feature. It goes to Anvil as a comment.

The file

gauge.md: the procedure, the thresholds, the line Bellows reads

Codex reads AGENTS.md, which you met in chapter 4. This file is the Claude Code version and the fuller statement of how Gauge works. Copy it to .claude/agents/gauge.md in your repo. Keep the two in step.

---
name: gauge
description: >-
  Gauge, QA and QC. Use to derive test cases from a Linear issue's acceptance
  criteria, write them as Playwright or Vitest tests, run them against the
  preview deployment, and file one defect per failure. Also runs the
  regression suite before a deploy. Runs on Codex by default via Bellows;
  this file is the Claude Code fallback.
tools: Read, Edit, Write, Grep, Glob, Bash, mcp__linear__*
model: sonnet
---

You are Gauge. You are skeptical. You assume the slice is broken until the
tests say otherwise. You test the acceptance criteria, not the code, and you
never mark a case passed that you did not run.

## Procedure

1. Read the issue. List every `AC-n`. If a criterion cannot be tested by a
   machine or an explicit checklist, comment `Untestable: AC-n — <why>` and
   tag Anvil. Do not guess at intent.
2. For each criterion write one test named after it, in
   `tests/e2e/ALD-<id>.spec.ts` (Playwright, against `PREVIEW_URL`) or
   `tests/unit/` (Vitest) when the criterion is pure logic.
3. Seed what the test needs through the app or the seed script, never by
   editing the database by hand.
4. Run: `PREVIEW_URL=<url> npm run test:e2e` and `npm run test`. Run the whole
   regression suite too. A change that breaks an old test is a failure.
5. For each failure, write a defect on the issue beginning `Defect:` with the
   criterion, the steps, expected, actual, and the test file and line.
6. Move the issue back to `In Progress` if anything failed. Leave it `In Test`
   and report `QA GREEN` if everything passed.

## Thresholds that stop you and ask a human

Bellows enforces these, and you should call them out yourself:

- The same test passes then fails on a rerun (flaky). Do not rerun until
  green. Report `FLAKY: <test>`.
- Fewer than 100% of criteria have a test. Report `COVERAGE: n/m`.
- This issue has failed QA three times. Report `THIRD FAIL`.

## Output that Bellows parses

A table with one row per criterion: `AC-n | PASS/FAIL/NOT RUN | test file`.
Then one of `QA GREEN`, `QA RED`, `FLAKY: ...`, `COVERAGE: n/m`, `THIRD FAIL`
on its own line.
Procedure

List the criteria. Write one test each. Seed through the app or the seed script, never by hand in the database. Run the new tests and the whole regression suite. One defect per failure. Move the issue back if anything failed.

Thresholds

Three things stop Gauge and ask a human: a flaky test, less than full coverage of the criteria, a third failed cycle. Everything else runs alone.

The last line

A table, then one status line. Bellows reads only that line: QA GREEN, QA RED, FLAKY:, COVERAGE: or THIRD FAIL. Keep it exact.

The model: and tools: fields in the front matter follow the Claude Code subagent format. Check the field names against the current docs at code.claude.com before you rely on them; the pattern is what matters.

The kickoff

Bellows hands Gauge the issue and the preview

When Flint writes APPROVE, Bellows moves the issue to In Test and fills this prompt. The double-brace placeholders are Bellows's. If you paste it by hand, replace them with ALD-42, the issue title and your PREVIEW_URL.

You are Gauge (AGENTS.md if you are Codex; .claude/agents/gauge.md if you are
Claude Code). Test Linear issue {{ISSUE_ID}} ("{{ISSUE_TITLE}}").

Preview deployment: {{PREVIEW_URL}}

1. List every acceptance criterion AC-n on the issue. If one cannot be tested
   by a machine or an explicit checklist, comment `Untestable: AC-n — why` and
   tag Anvil.
2. Write one test per criterion, named after it, in tests/e2e/ALD-{{ISSUE_ID}}.spec.ts
   (Playwright) or tests/unit/ (Vitest) when it is pure logic. Seed data
   through the app or the seed script only.
3. Run `PREVIEW_URL={{PREVIEW_URL}} npm run test:e2e` and `npm run test`,
   then the full regression suite.
4. For each failure post a Linear comment beginning `Defect:` with the
   criterion, steps, expected, actual, and the test file and line.
5. Commit the tests to the issue branch.

Report a table `AC-n | PASS/FAIL/NOT RUN | test file`, then on its own line one of:
QA GREEN · QA RED · FLAKY: <test> · COVERAGE: <n>/<m> · THIRD FAIL
{{EXTRA}}

What Gauge wrote for ALD-42

Five criteria, five tests, each named after the criterion it proves. Look for three things. The seeded account acc_014 is Riverbend Café, the café that asked for this in ticket TKT-118. AC-4 checks the driver route sheet for Tuesday East, because a note the driver cannot see solves nothing. AC-5 is not a product criterion at all. It came from Warden's threat note in chapter 6: a request carrying another tenant's session must get a 401, 403 or 404.

// tests/e2e/ALD-42.spec.ts — written by Gauge from the acceptance criteria on
// ALD-42 "Route notes on an account". One test per criterion, named after it.
// Run: PREVIEW_URL=https://alderline-crm-git-ald-42.vercel.app npm run test:e2e
import { test, expect } from '@playwright/test';

const BASE = process.env.PREVIEW_URL || 'http://localhost:4300';

test.describe('ALD-42 · Route notes on an account', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto(`${BASE}/accounts/acc_014`); // Riverbend Café, seeded
    await page.getByRole('heading', { name: 'Riverbend Café' }).waitFor();
  });

  test('AC-1 a route note field appears on the account page', async ({ page }) => {
    await expect(page.getByLabel('Route note')).toBeVisible();
  });

  test('AC-2 saving a note and reloading shows the note with author and time', async ({ page }) => {
    const text = `Back door after 7am — test ${Date.now()}`;
    await page.getByLabel('Route note').fill(text);
    await page.getByRole('button', { name: 'Save note' }).click();
    await expect(page.getByText('Saved')).toBeVisible();
    await page.reload();
    const note = page.getByTestId('route-note');
    await expect(note).toContainText(text);
    await expect(note).toContainText('Sam Lindqvist');
    await expect(note).toContainText(/\d{1,2}:\d{2}/);
  });

  test('AC-3 a note longer than 280 characters is rejected with a message', async ({ page }) => {
    await page.getByLabel('Route note').fill('x'.repeat(281));
    await page.getByRole('button', { name: 'Save note' }).click();
    await expect(page.getByRole('alert')).toContainText('280 characters');
  });

  test('AC-4 the note appears on the driver route sheet for that account', async ({ page }) => {
    await page.getByLabel('Route note').fill('Ring twice');
    await page.getByRole('button', { name: 'Save note' }).click();
    await page.goto(`${BASE}/routes/tuesday-east`);
    const row = page.getByRole('row', { name: /Riverbend Café/ });
    await expect(row).toContainText('Ring twice');
  });

  test('AC-5 a user from another tenant cannot read the note (Warden control)', async ({ request }) => {
    const res = await request.get(`${BASE}/api/accounts/acc_014/notes`, { headers: { Cookie: 'session=other-tenant-fixture' } });
    expect([401, 403, 404]).toContain(res.status());
  });
});
Why the preview and not localhost

The preview deployment is what Sam would get. Testing on your laptop tests your laptop. Vercel builds a preview for every branch; Bellows passes its URL as PREVIEW_URL. No test in this file writes to a database by hand. The route note is saved through the same form Sam uses.

The worked run

One fails. Nobody is in the room.

This is the run of Monday 14 September 2026 as Bellows started it at 4:05 pm. Bellows calls codex exec with the kickoff prompt. You can run the same command by hand from the repo root. --full-auto is the flag the kit's Bellows passes; check codex exec --help in your version for the sandbox and approval flags you want.

codex exec --full-auto "You are Gauge (AGENTS.md if you are Codex; .claude/agents/gauge.md if you are Claude Code). Test Linear issue ALD-42 (\"Route notes on an account\"). Preview deployment: https://alderline-crm-git-ald-42-route-notes.vercel.app …"
(the rest of the command is the kickoff prompt above with the placeholders filled in)
Gauge · reading ALD-42 on Linear · 5 acceptance criteria found
AC-1 a route note field appears on the account page
AC-2 saving a note and reloading shows the note with author and time
AC-3 a note longer than 280 characters is rejected with a message
AC-4 the note appears on the driver route sheet for that account
AC-5 a user from another tenant cannot read the note (Warden control)
Gauge · all five are machine-testable. No Untestable comments.
Gauge · writing tests/e2e/ALD-42.spec.ts · 5 tests, one per criterion · fixture other-tenant-fixture from the seed script
PREVIEW_URL=https://alderline-crm-git-ald-42-route-notes.vercel.app npm run test:e2e
> alderline-crm@0.4.0 test:e2e
> playwright test tests/e2e
Running 5 tests using 1 worker
✓ 1 tests/e2e/ALD-42.spec.ts:14:3 › ALD-42 · Route notes on an account › AC-1 a route note field appears on the account page (1.1s)
✓ 2 tests/e2e/ALD-42.spec.ts:18:3 › ALD-42 · Route notes on an account › AC-2 saving a note and reloading shows the note with author and time (3.6s)
✘ 3 tests/e2e/ALD-42.spec.ts:30:3 › ALD-42 · Route notes on an account › AC-3 a note longer than 280 characters is rejected with a message (5.4s)
✓ 4 tests/e2e/ALD-42.spec.ts:36:3 › ALD-42 · Route notes on an account › AC-4 the note appears on the driver route sheet for that account (2.9s)
✓ 5 tests/e2e/ALD-42.spec.ts:44:3 › ALD-42 · Route notes on an account › AC-5 a user from another tenant cannot read the note (Warden control) (0.4s)
1) tests/e2e/ALD-42.spec.ts:30:3 › ALD-42 · Route notes on an account › AC-3 a note longer than 280 characters is rejected with a message
Error: Timed out 5000ms waiting for expect(locator).toContainText(expected)
Locator: getByRole('alert')
Expected string: "280 characters"
Received: <element(s) not found>
31 | await page.getByLabel('Route note').fill('x'.repeat(281));
32 | await page.getByRole('button', { name: 'Save note' }).click();
> 33 | await expect(page.getByRole('alert')).toContainText('280 characters');
| ^
1 failed
tests/e2e/ALD-42.spec.ts:30:3 › ALD-42 · Route notes on an account › AC-3 a note longer than 280 characters is rejected with a message
4 passed (13.9s)
Gauge · AC-3 failed. Trace: POST /api/accounts/acc_014/notes returned 500 Internal Server Error. The form accepted 281 characters and the server fell over. No alert rendered.
Gauge · running the regression suite
npm run test
> alderline-crm@0.4.0 test
> vitest run
✓ src/lib/routes.test.ts (14 tests) 212ms
✓ src/lib/orders/pattern.test.ts (22 tests) 318ms
✓ src/app/api/accounts/accounts.test.ts (19 tests) 604ms
… 20 more files
Test Files 23 passed (23)
Tests 186 passed (186)
Duration 9.84s
Gauge · regression clean. Nothing old broke.
Gauge · posting Defect: comment on ALD-42 · committing the tests to ALD-42-route-notes
[ALD-42-route-notes 3f9c2a1] test(ALD-42): one Playwright test per acceptance criterion
AC-1 | PASS | tests/e2e/ALD-42.spec.ts
AC-2 | PASS | tests/e2e/ALD-42.spec.ts
AC-3 | FAIL | tests/e2e/ALD-42.spec.ts:33
AC-4 | PASS | tests/e2e/ALD-42.spec.ts
AC-5 | PASS | tests/e2e/ALD-42.spec.ts
QA RED

Here is what lands on the Linear issue. The defect names the criterion, the steps, what was expected, what happened, and the file and line of the test that proved it. Ember gets everything it needs to fix the cause. Nobody has to explain anything.

Linear · ALD-42 · comment from Gauge
G
Gauge · Alderline Crew · Mon 14 Sep 2026, 4:12 pm

Defect: AC-3 a note longer than 280 characters is rejected with a message

Criterion AC-3.

Steps 1. Open /accounts/acc_014 (Riverbend Café). 2. Paste 281 characters into Route note. 3. Click Save note.

Expected An alert containing "280 characters". The note is not saved.

Actual POST /api/accounts/acc_014/notes returns 500 Internal Server Error. No alert. The form's maxlength stops typing but not a paste, and the server has no limit of its own.

Where tests/e2e/ALD-42.spec.ts:33

AC-1PASStests/e2e/ALD-42.spec.ts
AC-2PASStests/e2e/ALD-42.spec.ts
AC-3FAILtests/e2e/ALD-42.spec.ts:33
AC-4PASStests/e2e/ALD-42.spec.ts
AC-5PASStests/e2e/ALD-42.spec.ts

QA RED

Bellows · Mon 14 Sep 2026, 4:12 pm

⚙️ Bellows ran Gauge (codex, 6.8 min). Result: QA RED

State In TestIn Progress · QA fails on this issue: 1 of 3

Bellows reads the last line. QA RED moves the issue back to In Progress and counts one failed cycle out of the three allowed. On its next tick Bellows starts Ember with the defect as the brief. Priya's phone stays quiet. It is 4:12 pm on a Monday.

claude -p "Use the ember agent. Fix the defect Gauge filed on ALD-42 (AC-3). Read the Defect: comment, fix the cause and not the symptom, keep PR #57 open, and say what you changed." --permission-mode acceptEdits
Ember · reading Defect: AC-3 on ALD-42
Ember · cause: the 280 limit lives only in the form's maxlength. A paste or a direct POST bypasses it and the insert fails on the column width.
Ember · fix: a zod schema on the server, max 280, return 400 with a message the form renders as an alert
src/lib/schemas/note.ts +7 (new) noteSchema = z.object({ text: z.string().trim().min(1).max(280, 'Keep it under 280 characters') })
src/app/api/accounts/[id]/notes/route.ts +14 −3 parse the body; on failure return 400 { error }
src/components/RouteNote.tsx +6 −1 show the 400 message as role="alert"
src/lib/schemas/note.test.ts +18 (new) 280 passes, 281 fails, whitespace is trimmed
npm run lint && npm run test
Test Files 24 passed (24)
Tests 188 passed (188)
[ALD-42-route-notes 8b41d07] fix(ALD-42): validate note length on the server, return 400 with a message
Ember · pushed at 4:52 pm. PR #57 updated. Not verified: the alert on a slow connection. Defect marked fixed in the PR body. Moving ALD-42 back to In Test so Gauge reruns on the next tick.
— Bellows tick, 5:00 pm: ALD-42 is In Test again, Gauge runs with the same kickoff —
PREVIEW_URL=https://alderline-crm-git-ald-42-route-notes.vercel.app npm run test:e2e
Running 5 tests using 1 worker
✓ 1 tests/e2e/ALD-42.spec.ts:14:3 › … › AC-1 a route note field appears on the account page (1.0s)
✓ 2 tests/e2e/ALD-42.spec.ts:18:3 › … › AC-2 saving a note and reloading shows the note with author and time (3.3s)
✓ 3 tests/e2e/ALD-42.spec.ts:30:3 › … › AC-3 a note longer than 280 characters is rejected with a message (1.7s)
✓ 4 tests/e2e/ALD-42.spec.ts:36:3 › … › AC-4 the note appears on the driver route sheet for that account (2.8s)
✓ 5 tests/e2e/ALD-42.spec.ts:44:3 › … › AC-5 a user from another tenant cannot read the note (Warden control) (0.4s)
5 passed (9.6s)
Gauge · regression: 188 passed. Posting the table. 5:06 pm.
AC-1 | PASS | tests/e2e/ALD-42.spec.ts
AC-2 | PASS | tests/e2e/ALD-42.spec.ts
AC-3 | PASS | tests/e2e/ALD-42.spec.ts
AC-4 | PASS | tests/e2e/ALD-42.spec.ts
AC-5 | PASS | tests/e2e/ALD-42.spec.ts
QA GREEN

Two cycles, thirteen minutes of Gauge's time and forty of Ember's, one bug that would have reached Sam on a busy Tuesday. The same bug shows up again in chapter 6, because Warden had asked for that server-side limit at 12:40 that day, before Ember wrote a line.

The thresholds

Three things stop Gauge and ask a human

Everything else runs alone. These three do not. Gauge writes the status line, Bellows matches it to an escalation in kit/.claude/gates.json, and Priya gets one message with the reason from that file. Chapter 7 covers the gates in full.

Gauge reportsWhenWhat Bellows doesWhy (the reason in gates.json)
FLAKY: <test>The same test passed, then failed on a rerun. Gauge stops rerunning.Escalation flaky. Asks Priya whether to quarantine the test. The issue does not move.Reruns hide bugs. A human decides whether to quarantine the test.
COVERAGE: n/mFewer than 100% of the criteria have a test. Usually one was Untestable.Asks Priya through the qa-third-fail escalation with the note "Not every criterion has a test." Anvil gets tagged to rewrite the criterion.Three cycles usually means the spec is wrong, not the code. A criterion no machine can test is the same problem earlier.
THIRD FAILThis issue has failed QA three times. maxQaFailsPerIssue is 3 in the budgets.Escalation qa-third-fail. Asks Priya. Nobody builds a fourth time until she answers.Three cycles usually means the spec is wrong, not the code.
Why reruns are banned

A flaky test is a test that knows something you do not. It might be a race in the code, a fixture two tests share, or a preview that was still warming up. Rerun it until it passes and you have taught the crew to hide bugs and call it green. Gauge is not allowed to do that. It reports FLAKY once and stops. Priya decides whether the test is quarantined with a Linear issue or the code is fixed. That decision takes her a minute and it stays visible.

End to end

How QA runs with nobody watching

Press play. Priya appears once, and only if a threshold trips. Every other edge is agent to agent.

Regression before deploy

Gauge runs the whole suite on every pass, not just the new file. A change that breaks an old test is a failure of the new slice. Bellows will not move an issue to Ready to Deploy until the last line Gauge wrote was QA GREEN, and Warden and Beacon have had their turn. By the time Priya sees the deploy gate, the tests have already run against the exact build she is approving.

Six weeks of Alderline's build, illustrative numbers from the dashboard:

0
tests written
by Gauge, from criteria
0
defects found
before anything deployed
0
issues deployed
each one QA GREEN first
0
thresholds tripped
two flaky, one third fail

Founder Q&A

What if a criterion cannot be tested?

Gauge comments Untestable: AC-n — why on the issue and tags Anvil. It does not guess and it does not skip. "Works well" is not a criterion; "saving a note and reloading shows it with author and time" is. Anvil rewrites the criterion so a machine or an explicit checklist can prove it. If the criterion is a checklist item, say the copy reads right on the first screen, Gauge runs the checklist and records each item. It never writes PASS on something it did not run.

Who fixes the test when the spec was wrong?

Anvil owns the criterion. Gauge owns the test. If Gauge misread a criterion, Ember says so in the PR under "For Flint" or in a reply to the defect, and Gauge rewrites the test. If the criterion itself was wrong, Anvil edits the issue first and Gauge follows. The THIRD FAIL threshold exists for exactly this case. When build and test loop three times the spec is usually the bug, and Priya settles it in one comment.

Can Gauge run on Claude instead of Codex?

Yes. .claude/agents/gauge.md is the fallback, and you invoke it with "Use the gauge agent." Codex is the default so the grader is on a different model from the builder. When Flint (Codex) built the slice, running Gauge on Claude keeps that separation. In the kit's Bellows the runner for QA is fixed to codex in the PLAN; change it per issue if you want the swap to be automatic.

How long does QA take?

ALD-42 took seven minutes on the first pass (4:05 to 4:12 pm) and six on the second (5:00 to 5:06 pm), most of it Playwright waiting on the preview. A slice with more criteria takes longer to write, not much longer to run. The budget in gates.json caps any run at 45 minutes. If Gauge hits that, Bellows asks Priya rather than letting it churn.

Does this replace a QA person?

It replaces the part of QA that is writing and running checks against criteria, and it does that on every issue without being asked. It does not replace judgement about what to build, and it does not replace someone using the product for real. At Alderline that person is Sam. He uses the CRM every day and what he finds goes through Ledger as a ticket. Gauge makes sure the things you already decided keep working. Sam finds the things you did not decide yet.

What you have now

An agent that reads Anvil's criteria and writes one test each, runs them against the preview, files a defect per failure with the file and line, and moves the issue back without asking you. A different model grading than building. Three thresholds that turn a problem into one message on your phone, and no reruns. The regression suite runs before every deploy.

Next: Warden. The threat note that produced AC-5, the six checks on every pull request, and what happens when a key gets pasted into a file.