Strict TDD Flow
Every task in the plan follows the Red → Green → Refactor cycle. No exceptions. The spexso-tester writes tests first, the spexso-builder writes implementation to pass them.
Red / Green / Refactor / Commit
Red (Tester writes failing tests) → Green (Builder makes tests pass) → Static checks (configurable lint gate) → Refactor (Clean up, still green) → Commit (Atomic commit with message)
Execution waves — parallel RED, batch-spawned independent tasks
Tasks no longer execute one-by-one in plan order. Before the first task, the orchestrator pipes the plan's tasks ({id, files, tests, deps}) through npx spexso waves — a deterministic wave planner (lib/waves.js): every task's dependencies land in an earlier wave, and tasks inside one wave never share a source or test file. Cycles, unknown dependencies, and duplicate ids are hard errors (a planner bug), with a sequential fallback so the build still proceeds.
Within a wave (config parallel in .spexso/config.json, seeded by init):
parallel.red(defaulttrue) — all the wave's testers are spawned in one message (one Task tool call per task), so they run concurrently. Testers write only their own task's test files (the planner partitions test paths per task — a hard planning rule), so they cannot collide. RED verification then runs per task, in wave order; retries are individual.parallel.green(defaulttrue) — the wave's builders batch-spawn the same way. Parallel builders edit only their own task's files, skip project-wide checks (the orchestrator runs those once per wave) and never touch git; verification and the per-task staged commits stay sequential. Concurrent builders do share the worktree's build artifacts — if a project's build tooling trips over that, setparallel.green: falseto fall back to sequential GREEN.
Wave attempts are recorded with npx spexso attempt {id} {phase} --batch: they count toward the ceiling but do not start the task's time budget — a wave sibling may wait behind other tasks longer than its whole budget without doing any work. The clock starts at the task's first non-batch attempt (see Per-task time budget).
Because a whole wave's test files exist on disk before the first commit, commits stage per task (the task's planned files + tests), never git add -A — that would swallow a sibling's tests and break the atomic-commit-per-task invariant.
What earns a test — the clause ceiling
More tests are not more safety; past the clauses they are wall-clock paid on every run. The scope rule lives in the project rulebook (.spexso/rules/test.md §Scope) and is enforced at three points:
- Planner — every task's tests are scoped to the clauses it
Covers:. A CDS model task gets a test only for behaviour the compiler does not already prove (a@restrictrule), never a schema-validation test:cds buildIS that test. - Tester — the clause tests are the ceiling, not the floor: one test per EARS clause, plus the edge cases the spec explicitly names, plus one regression test per line of a bug report's
## Unchanged behavior. No generic happy/validation/error quartet per function. - Reviewer — a clause with no asserting test is a HIGH spec drift finding; tests no clause requires are a LOW over-test comment, the mirror of the existing over-build finding.
What the rulebook excludes outright: framework and platform behaviour (that UI5 renders XML, that the CDS compiler compiles), the presence of a control or label, the wording of messages and i18n texts (assert the error's identity — status, code, target — never its text), and anything the static-checks gate already catches.
The second half of the rule is level, which is where suite runtime actually goes: prove each clause at the cheapest level that can still fail for the right reason — plain unit for pure logic, one cds.test() file per service (each cds.test('serve') boots a server), QUnit against a stubbed model for controller wiring, OPA5 only for a genuine cross-screen journey, and wdi5 nightly in CI only — never in the RED loop or in testCommand.
For a suite written before these rules, test.md also carries the cleanup order — measure first, fix the cost without deleting anything (one cds.test() file per service, e2e out of the merge gate, parallel runner), then delete by category, and leave the cheap green rest alone.
The assertion gate on RED
A failing test is necessary but not sufficient for RED: agent-written tests are often print-observation without a single assertion (arXiv 2602.07900). After the targeted tests are confirmed failing, the orchestrator runs npx spexso red-check {task test files} (lib/red-check.js) — a deterministic, extension-aware grep that requires at least one real assertion per test file (assert.*/expect(/chai should/tap-style t.* for JS/TS, cl_abap_unit_assert for ABAP, an assert…( call otherwise) and flags files whose only "verification" is console.log/print. A non-zero exit means the tester wrote assertion-free tests: it is re-dispatched (new red attempt recorded first), same as the "tests are wrong" branch.
The static-checks gate
Between GREEN and COMMIT, the builder runs every command listed in .spexso/config.json under staticChecks (e.g. npx ui5lint {files}, npx tsc --noEmit). The {files} placeholder is substituted with the changed-files list for this task. A non-zero exit counts as a test failure — the 3-strike rule applies. Configuration is opt-in: empty or missing → the gate is silently skipped. See static checks for the full spec, recipes, and configuration flow.
Detailed TDD Sequence Per Task
waves = run "npx spexso waves" with plan.tasks as JSON # dependency waves of file-disjoint tasks
for each wave in waves: # parallel.red/green: testers, then builders, batch-spawned per wave
for each task in the wave: # verification + commit sequential per task
# RED PHASE — Tester agent
run "npx spexso attempt {task.id} red" # code-enforced counter; non-zero exit = ceiling reached -> escalate
# tester may return no-test-needed for docs/config-only tasks;
# orchestrator verifies via changed-file extensions, then skips RED+GREEN
spawn spexso-tester with:
context: task.description + task.acceptance_criteria + spec.data_model
instruction: "Write tests for this task. Tests MUST fail against current code."
output: test file(s)
# Verify red
run config.testTargetedCommand with {files} = task.test_files # never a hardcoded runner flag
assert: exit code != 0 # Tests must fail
if tests pass:
if tests faithfully cover the acceptance criteria:
# TERMINAL STATE: already implemented (brownfield). Keep tests as
# regression coverage, skip the builder, note on the issue. Never
# distort tests to force a failure.
commit tests; continue to next task
else: re-run tester (tests were wrong; new red attempt recorded first)
# GREEN PHASE — Builder agent
run "npx spexso attempt {task.id} green" # counted BEFORE every builder dispatch (ceiling: 3 total)
spawn spexso-builder with:
context: task.description + test files + CLAUDE.md patterns
instruction: "Write MINIMUM code to make these tests pass. Do not modify tests."
output: implementation file(s)
# Verify green
run config.testTargetedCommand with {files} = task.test_files # never a hardcoded runner flag
assert: exit code == 0 # Tests must pass
if targeted task tests green AND remaining failures all match
config.falseFailPatterns (e.g. "cds: command not found" — a tool this
machine lacks; the worktree has its own npm ci, so a missing module is real):
# TERMINAL STATE: environment false-fail. Proceed — CI (fresh clone,
# full deps) is the authoritative gate. Note on the issue.
continue to static checks
if tests fail for real: retry builder — "npx spexso attempt" enforces the ceiling of
3 total builder dispatches; when it exits non-zero, PAUSE — ask human for help.
The builder gets ONE implementation run per dispatch (no internal retry loop).
# REFACTOR PHASE
review implementation for duplication, naming, patterns
apply minor cleanups
run config.testTargetedCommand (+ direct neighbours if shared code was touched)
# full suite runs exactly twice per issue: /spexso-finish gate + CI
# COMMIT — stage THIS task's files only (a sibling's parallel-RED tests
# may already be on disk; git add -A would swallow them)
git add {task.files + task.test_files}
git commit -m "feat(spexso): task-{id} {task.title}"
run "npx spexso attempt reset {task.id}" # fresh attempt budget for the next task
# Context management (yolo mode only)
if mode == yolo: run /compact # Prevent context explosionTerminal states — when a gate is unsatisfiable
Two conditions are normal in SAP brownfield work and must exit the loop instead of retrying forever:
| Condition | Detection | Exit |
|---|---|---|
| RED-impossible (already implemented) | Faithful spec tests pass on first run — the behavior already exists | Keep the tests as regression coverage, skip the builder, note on the issue. Never distort tests to force a failure. |
| GREEN-impossible (environment false-fail) | The task's targeted tests are green; every remaining failure matches falseFailPatterns in .spexso/config.json (default: cds: command not found) | Proceed — CI is the authoritative gate. Note on the issue. A failure in a file the task actually changed never qualifies. |
Both scenarios are kept as permanent regression fixtures in test/fixtures/loop-repro/ and guarded by test/loop-terminal-states.test.js.
Config-driven test commands
The loop never hardcodes a test runner's flags. /spexso-setup writes two commands to .spexso/config.json during stack detection: testCommand (full suite, default npm test) and testTargetedCommand (one task's tests, {files} placeholder, default npm test -- {files}). Jest projects get npx jest {files}, node --test projects get node --test {files}, and so on — so the GREEN gate is observable on every framework, not only Jest.
Code-enforced attempt counters
The 3-strike rail is enforced by the CLI, not by prompt text. The orchestrator runs npx spexso attempt {task-id} {phase} before every tester or builder dispatch; the verb increments .spexso/attempts.json (gitignored, survives subagent respawns) and exits non-zero once the phase ceiling is hit — RED: 2 tester dispatches, GREEN: 3 builder dispatches. A non-zero exit forces the escalate branch mechanically: the orchestrator cannot miscount and cannot keep looping. npx spexso attempt reset {task-id} clears the counters after the task's commit, and /spexso-build empties the whole ledger (reset --all) before its first task, so an issue never inherits strikes from the previous issue in the same worktree (a bundle member, a reused checkout).
Why strict TDD for AI?
AI-generated code looks correct but often misses edge cases. Tests written first from the spec catch these gaps before they reach production. The tester agent doesn't see the implementation — it only sees the spec. This separation prevents the common AI failure mode of writing tests that test what the code does rather than what it should do.
Per-task time budget
Alongside the 3-strike attempt ceiling, every task has a wall-clock budget (budgets.perTaskMinutes in .spexso/config.json, default 30 — tune per project). The clock starts at the task's first recorded non-batch attempt (--batch wave attempts count toward the ceiling but leave the clock unstarted — waiting behind wave siblings is not work); once exceeded, npx spexso attempt exits non-zero and the orchestrator must pause and escalate with the latency report (npx spexso observe report) instead of grinding on. This kills spexso's classic failure mode — a small task silently consuming an hour. Both rails live in .spexso/attempts.json and are enforced in code, not prose (nicoharness adoption).