Lab 3 solution
This is the reference end state for Lab 3 — Spec mode: review the decision before the diff.
Plans and session details on this page are illustrative. Your plans will be worded differently. The test fix is real and its expected values are checked against src/fees/calc.ts.
Files at the end of the lab
On main: nothing. Lab 3 changes no file that the scoreboard reads. The T2 work happened on lab-3/t2-spec, which you deleted at the end, because a fixed test on main would make T2 pass without Droid doing anything.
git switch main git status --short git branch --list "lab-3/*"
All three should leave you on main with nothing to report.
In your personal settings (optional): if you made Spec mode your default, your user settings file contains:
{
"sessionDefaultSettings": {
"interactionMode": "spec",
"specModeModel": "<model-id>",
"specModeReasoningEffort": "high"
}
}
interactionMode is auto (Normal mode) or spec. It is not an autonomy setting. Autonomy is the separate sessionDefaultSettings.autonomyLevel key, which Lab 4 sets for the project. Omit specModeModel to plan with your default model.
Where this lives in harness-reference/: nowhere. The reference harness deliberately doesn't set interactionMode in the project's .factory/settings.json. Spec mode is a way of working, and documentation tasks like T7 don't need a plan. Lab 4's settings file holds only the permission rules and sessionDefaultSettings.autonomyLevel: "low".
The work on the throwaway branch
For reference, here is what a good branch contained before you deleted it.
The T2 fix
Only the pro-rating test changes. The implementation and everything under src/money/ stay as they are.
import { afterEach, describe, expect, it } from 'vitest';
import { overdraftFee } from '../../src/fees/calc.js';
import { retail, retailNoOverdraft } from '../helpers/accounts.js';
import { freezeClock, restoreClock } from '../helpers/clock.js';
describe('overdraftFee', () => {
afterEach(restoreClock);
it('charges the full monthly fee on the 1st', () => {
freezeClock('2026-04-01T09:00:00Z');
expect(overdraftFee(retail)).toBe(1215);
});
it('charges nothing without overdraft protection', () => {
expect(overdraftFee(retailNoOverdraft)).toBe(0);
});
it('pro-rates by the days left in the month', () => {
// 2026-04-29: 2 days left. 1215 × 2 / 30 = 81 exactly.
freezeClock('2026-04-29T09:00:00Z');
expect(overdraftFee(retail)).toBe(81);
});
it('rounds an exact half cent to even, as the fee disclosure requires', () => {
// 2026-04-26: 5 days left. 1215 × 5 / 30 = 202.5 → 202 (half-even).
// Math.round would give 203. This is the case the March incident broke.
freezeClock('2026-04-26T09:00:00Z');
expect(overdraftFee(retail)).toBe(202);
});
it('caps pro-rating at 30 days in a 31-day month', () => {
// 2026-03-01: 31 days left, capped at 30 (30/360 basis).
freezeClock('2026-03-01T09:00:00Z');
expect(overdraftFee(retail)).toBe(1215);
});
});
Three things make this the right fix:
- It removes the dependency on today's date. Every test that reads the clock freezes it first, and
afterEach(restoreClock)puts it back. - It asserts values instead of recomputing them. The old test recomputed the fee with its own formula, so it could only ever agree or disagree with the implementation. It couldn't say which one was right. A written-down
202can. - It pins the half-cent case. If anyone changes
roundHalfEven()toMath.roundagain, this test fails withexpected 202, received 203, a message that says exactly what went wrong.
The saved spec
Your file's name follows YYYY-MM-DD-slug.md. Its layout depends on your Droid version. What matters is that the decision is at the top and the rejected alternative is recorded.
# Fix flaky overdraft pro-rating test
## Decisions
- The implementation is correct: fees round half-even (roundHalfEven(),
fee disclosure section 4.2). No change under src/money/.
- The test is wrong: it reads the real date, and computes its expected
value with Math.round.
- Rejected in review: aligning roundHalfEven with Math.round.
## Steps
1. In test/fees/overdraft.test.ts, freeze the clock with freezeClock() in
every test that depends on the date, and assert fixed values.
2. Cover an exact half cent (2026-04-26 → 202), an ordinary day
(2026-04-29 → 81) and the 30-day cap in a 31-day month (2026-03-01 → 1215).
3. Verify: npm run typecheck && npm test; run the overdraft test 20 times.
The line "Rejected in review" isn't something Droid writes by itself. It's there because you gave a reason when you chose keep iterating, and asked for decisions first. A spec that records the rejected option is the one Ruth can use.
What a good run looks like
Plan 1 and plan 2
A good lab run has at least two plans for T2. If Lab 2's AGENTS.md sends your first plan straight to the clock, the March-style prompt ("make the test and the implementation agree") usually produces a plan that proposes changing rounding. Either way, the plan you approve:
- opens with the decision, and the decision is that the implementation is correct;
- names
freezeClock()fromtest/helpers/clock.ts; - includes a date where the fee is exactly half a cent, with the half-even value written down;
- says nothing under
src/money/,migrations/orlegacy/changes.
You approve it at Low, or with manual approvals. Choosing Medium or High for a one-file test change hands more authority to the implementation than the plan needs.
The buried decision
In Step 5, a good run produces a long plan where the rounding change appears as routine housekeeping partway down. After the "Decisions first" request, it appears at the top, one sentence long, marked as touching src/money/. If your long plan never proposed the rounding change, the exercise still works: count how many items you had to read before you could say what the plan decides.
T4
The T4 plan's first line is the missing CHG ticket, and no file exists when you reject it. git status --short on main is empty afterwards.
The scoreboard
This lab should not move the number: it is still 21 / 24. Run it anyway. Knowing which changes cannot move the score is half of knowing what the score means.
21 of 24, unchanged. That's correct. Nothing a droid exec run reads has changed, and a headless --use-spec run wouldn't help: it writes the plan, stops waiting for an approval nobody can give, and edits nothing.
Three common wrong turns
1. Merging the T2 fix
The fix is good, so it feels wrong to throw it away. But once it's on main, T2's check passes on every future run whether Droid does anything or not, and the most important row on the scoreboard stops measuring the incident.
How to spot it: T2 jumps to 3/3 in a lab that should hold, or git log main --oneline -- test/fees/overdraft.test.ts shows your commit. Fix: revert it on main. The reference fix is on this page whenever you need it.
2. Approving the plan, then fixing it in the diff
Some learners approve plan 1 with manual approvals, intending to reject the rounding.ts edit when it comes up. It works, but the plan on record still says "align roundHalfEven with Math.round", and if Save spec as Markdown is on, that's the plan that gets saved. You made a decision the record doesn't show.
How to spot it: the saved spec proposes the rounding change, or the session has only one plan. Fix: choose keep iterating and say why. The "no" belongs in the plan, where it costs nothing and gets recorded.
3. Looking for the spec in the wrong place
The docs disagree about where saved specs go. The Spec mode page says .factory/docs in the nearest project .factory directory, falling back to ~/.factory/docs. The settings page says specSaveDir defaults to ~/.factory/specs. Larkspur has no project .factory/ yet, so neither reading puts the file in the repo. Learners search the repository, find nothing, and conclude saving is broken.
How to spot it: you turned the setting on and ls .factory/docs fails. Fix: check the Spec save directory setting in /settings (specSaveDir in settings.json), then look in ~/.factory/docs and ~/.factory/specs. After Lab 4 creates .factory/, check again: your specs may start landing in the repository, where later sessions can read them.
Also watch for
- Pressing
Shift+Tabto find Mission mode. It toggles Normal and Spec only. Missions start with/missions. interactionMode: "auto"read as an autonomy setting. It means Normal mode. Autonomy issessionDefaultSettings.autonomyLevel, and it defaults tooff.
Where to go next
Lab 4 starts on the controls that hold on the one run in three where nobody is reading the plan.