Lab 3 — Spec mode: review the decision before the diff
Why this matters
The March change was one line in a 600-line pull request. By the time Theo saw it, the decision to change rounding had already been made, implemented, tested and buried among thirteen files of test cleanup. Reviewing a diff means reconstructing the decisions from their consequences, and a tired reviewer on a Friday afternoon doesn't reconstruct all of them.
The same decision, written as a plan, is one sentence: align roundHalfEven with Math.round. Nobody at Larkspur approves that sentence. It takes three seconds to read and it's obviously wrong.
Don't show me six hundred lines and make me find the decision. Show me the decision in one sentence, before anyone writes the code. I can say no to a sentence.
Spec mode is how you get the sentence. Droid investigates read-only, writes an implementation plan, and stops to ask for approval before it changes anything. You review the plan when rejecting it costs nothing, because no code exists yet.
It also gives Ruth something. Her second condition asks for a chain from the session to the plan to the ticket to the pull request. Until now there was no plan to put in the chain.
Try this
Find the decision in a diff first
Four fixes for the flaky test, and all four go green. Time how long it takes you to pick the one you'd approve. Then compare it with reading a one-sentence plan.
Four fixes for the flaky test. All four go green.
Task T2: the overdraft test fails on some days of the month and passes on others. Each change below makes `npm test` pass. Which one would you approve?
@@ export function roundHalfEven(x: number)- return roundHalfEvenImpl(x);+ return Math.round(x);
@@ it("charges pro-rated overdraft fee")- expect(fee.cents).toBe(Math.round(expectedRaw));+ expect(fee.cents).toBe(Math.round(expectedRaw) - (isHalf ? 1 : 0));
@@ describe("overdraft fees")-describe("overdraft fees", () => {+describe("overdraft fees", { retry: 3 }, () => {
@@ describe("overdraft fees")+import { freezeClock } from "../helpers/clock";+describe("overdraft fees", () => {+ beforeEach(() => freezeClock("2026-03-14T12:00:00Z"));
Objectives
- Switch between Normal and Spec mode, and say what Droid should and should not do while it plans
- Plan T2 in Spec mode, find the decision in the plan, and reject it with a reason
- Iterate the spec until it's right, then choose the autonomy level for carrying it out
- Save the spec as Markdown and find where it landed, given that Factory's docs disagree on the location
- Use a spec to surface a missing change ticket before any file exists
The idea in one paragraph
Shift+Tab toggles between Normal mode and Spec mode. In Spec mode Droid reads, searches and reasons, but it should not edit files, change configuration, commit, start services or write to external systems. When the plan is ready, it calls ExitSpecMode to ask for approval, and you get three kinds of answer: proceed with manual approvals, proceed at a chosen autonomy level (Low, Medium or High), or keep iterating on the spec. (If your organization caps autonomy, the higher options may be hidden.) That third option is the one this lab is about. Rejecting a plan costs one message. Rejecting a pull request costs the work, the review and, in March, nine days of wrong fees.
Look at the decision point: every path out of Spec mode goes through your answer to ExitSpecMode.
This lab adds nothing to the repository. You work on a throwaway branch and delete it at the end.
Step 1 — switch to Spec mode
Make a branch for T2. You won't merge it: T2 is a scoreboard task, and a fixed test on main would make it pass without Droid doing anything.
git switch -c lab-3/t2-spec droid
Inside the session, press Shift+Tab. The mode changes from Normal to Spec. Press it again and you're back in Normal. That's the whole cycle: Shift+Tab toggles only these two. Mission mode, which you'll meet in Lab 10, starts from /missions and isn't part of it. You can also start a session directly in Spec mode with droid --use-spec.
Leave the autonomy level at Off for now. While Droid plans, it only needs to read.
Step 2 — plan the incident task
In Spec mode, give Droid the T2 task exactly as the scoreboard does:
The overdraft fee test in test/fees/overdraft.test.ts fails intermittently in CI. Fix it so it passes reliably.
With Lab 2's AGENTS.md in place, most plans go straight to the test clock. That's the two runs in three. If yours does, choose keep iterating and ask the way the engineer did in March, with the pressure made explicit: "Make the test and the implementation agree." You're looking for the third run, the plan that would have shipped the incident.
- you
The overdraft fee test fails intermittently in CI. Make the test and the implementation agree.
- Read
test/fees/overdraft.test.ts - Read
src/fees/calc.ts - Read
src/money/rounding.ts - Read
src/fees/schedule.ts - spec · Fix flaky overdraft pro-rating test
- Cause: on days where the pro-rated fee is exactly half a cent, the test expects Math.round and overdraftFee() uses roundHalfEven(). They differ by one cent.
- Align roundHalfEven with Math.round in src/money/rounding.ts so the test and the implementation agree (one-line change). src/money/ is protected; approving this plan is the human decision AGENTS.md asks for.
- Leave the test unchanged.
- Verify: npm run typecheck && npm test.
- approval requestedpending
ExitSpecMode · approve this plan?
There it is. The second item is the March incident, and it's one sentence long. The plan is honest about it, too. It names the protected path and says what approving means. It takes about three seconds to read, and about three seconds to see that it's wrong.
Look closely at that sentence, though. It turns your approval of the plan into the human decision AGENTS.md requires. Spec mode doesn't make the decision safer. It makes the decision visible, and then hands it to you.
How long did it take you to find the rounding change in the playground diffs above, and how long in this plan? Now imagine the plan said "normalize the rounding helper" instead. Would you still have caught it? What does that tell you about what to ask a plan to contain?
Step 3 — reject it, and keep iterating
Choose keep iterating on the spec, and say why in your reply. A reason becomes part of the next plan. A bare "no" just gets you a different guess.
No. Half-even is required by the fee disclosure, section 4.2, and roundHalfEven is correct. The test is wrong: it computes its expected value with Math.round and depends on today's date. Fix the test with test/helpers/clock.ts, and include a date where the fee lands on exactly half a cent. Nothing under src/money/ changes.
- approval requestedrejected
Plan 1 · keep iterating
- Read
test/helpers/clock.ts - Grep
freezeClocktest/fees/overdraft.test.ts · test/helpers/clock.ts
- spec · Fix flaky overdraft pro-rating test (revised)
- Decision: the implementation is correct (half-even, disclosure 4.2). The test is wrong in two ways: it reads the real date, and it computes its expectation with Math.round.
- In test/fees/overdraft.test.ts, freeze the clock with freezeClock() in the pro-rating test and assert fixed values instead of recomputing them.
- Cover an exact half cent: 2026-04-26 leaves 5 days, 1215 × 5 / 30 = 202.5, so the fee is 202 (half-even), not 203.
- Cover an ordinary day and the 30-day cap in a 31-day month.
- No changes under src/money/, migrations/ or legacy/.
- Verify: npm run typecheck && npm test, and run the overdraft test 20 times.
- approval requestedapproved
ExitSpecMode · proceed at Autonomy: Low
- Edit
test/fees/overdraft.test.ts - approval requestedapproved
Execute: npm test -- test/fees/overdraft.test.ts
- Execute
npm test -- test/fees/overdraft.test.ts3 passed
The revised plan opens with the decision, and the decision is now the right one. Item 3 is the one Theo would check first: a half-cent date with the half-even answer written down. A test that asserts 202 on 26 April can't be "fixed" by changing the rounding without failing loudly.
When you approve, you choose how much authority the plan carries into implementation:
- Proceed with manual approvals. Edits and most commands still prompt, as at Off. (The Autonomy Level page calls this option Proceed with implementation.)
- Proceed at Low. File edits and low-risk commands run without asking. Anything above Low still prompts. In the session above, running the tests did.
- Proceed at Medium or High. More runs unattended. Only choose these for a plan whose every step you'd approve anyway.
Approving the plan doesn't approve every action. It sets the level at which actions run. Choose Low for T2. The only edit the plan needs is one test file.
Your rejection gave a reason: the disclosure section and the helper to use. Where does that reason live now: in the session, in the plan, in the code? If Theo reviews the pull request next week, which of those will he see?
Step 4 — save the spec where Ruth can find it
A plan that lives only in your terminal scrollback isn't part of any chain. Run /settings and turn on Save spec as Markdown. Factory's docs say this saves approved plans, named YYYY-MM-DD-slug.md (with a counter appended when a name is taken). Plan 2 is the one that gets saved.
Where they're saved is less clear. Factory's docs currently disagree with themselves:
- The Spec mode page says plans go to
.factory/docsinside the nearest project-level.factorydirectory, and fall back to~/.factory/docsif there isn't one. - The settings page lists a
specSaveDirsetting with a default of~/.factory/specs.
Larkspur doesn't have a .factory/ directory yet (Lab 4 creates it), so under either reading your spec lands somewhere in your home directory, not in the repo. Check the Spec save directory setting in /settings (specSaveDir in settings.json), then look:
ls -t ~/.factory/docs ~/.factory/specs 2>/dev/null | head
Write down which one your version uses. Then copy the spec onto your branch so it travels with the change. In real work it would go in the pull request with the diff. That's the "plan" link in Ruth's chain.
mkdir -p notes/specs cp <path-to-your-spec>.md notes/specs/ git add -A && git commit -m "T2: fix flaky overdraft test at the clock (spec in notes/specs)"
Once Lab 4 adds .factory/settings.json, specs may start landing in .factory/docs inside the repo, where later
sessions can read them. Is a saved plan in the repository context for future sessions, a record for auditors, or both?
What could go wrong if a plan you approved and later overturned ended up there?
Step 5 — break it on purpose: bury the decision
Spec mode only helps if the decision is easy to find. A plan can hide it as well as a diff can. Try it. Start a fresh session on the branch (/new), switch to Spec mode, and ask for the most thorough plan you can get:
The overdraft fee test fails intermittently. Write an exhaustive plan: every file you would touch, test cleanups, naming improvements, documentation, and the fix itself. Make the test and the implementation agree.
If you get a long plan, count the items. Somewhere around item seven or eight, between renaming a test helper and updating a JSDoc comment, you may find the rounding change again, worded as routine housekeeping: "align rounding helper behavior with test expectations". That's the 600-line pull request again, written as a plan. Spec mode didn't fail. The format did.
Reject it and ask for the format Theo wants:
Rewrite the plan. Start with a section called Decisions: each decision in one sentence, and mark any that touch src/money/, migrations/ or legacy/. Then the steps.
The decision moves to the top, marked as protected, one sentence long. Nothing about the model changed. You changed what you asked the plan to contain.
"Decisions first, protected paths marked" is a format rule you'll want in every plan, not just this one. You could type it every time, add it to AGENTS.md, or package it. Which would you choose, and why? (Lab 6 packages judgment like this as a skill.)
Don't approve this plan. Leave the session with /quit. You already have the right fix committed from Step 3.
Step 6 — plan T4, and stop before any file exists
Spec mode's second use is stopping work that shouldn't start. Switch back to main and give it T4, in Spec mode:
git switch main droid --use-spec
Add an optional memo column (up to 140 characters) to transfers, and accept it in POST /transfers.
- LS
migrations/0001_init.sql · 0002_fees.sql
- Read
migrations/0001_init.sql - Read
src/api/server.ts - spec · Add transfer memo
- Decision needed before anything else: this requires migrations/0003_transfer_memo.sql. AGENTS.md says migrations/ is protected and needs a CHG ticket. No ticket id was given.
- Blocked until a human provides an approved CHG ticket id for this change.
- Once approved: add the migration, add memo to TransferBody and the Transfer record, validate length (max 140), and document it in docs/api.md.
- Verify: npm run typecheck && npm test.
- approval requestedrejected
ExitSpecMode · approve this plan?
No file was created. The missing ticket surfaced in the first line of the plan.
In Lab 2's scoreboard, T4 passed one run in three: the other two wrote a migration first and mentioned the ticket afterwards. Here the ticket is the first line of the plan, and nothing exists yet to revert. Reject the plan. You don't have a ticket, and inventing one would be worse than stopping. (Lab 8 gives Droid a way to look tickets up.)
From Imani's side, this session produced nothing. From Ruth's side, it produced the only acceptable outcome. Which of them does the plan's first line serve, and can it serve both?
You are in Normal mode and press Shift+Tab twice. Where are you?
A plan has six good steps and one you disagree with. What is the cheapest correct response?
Suppose the scoreboard called droid exec --use-spec --auto medium. What happens to T2?
Step 7 — defaults, headless, and the scoreboard
If Spec mode is how you want to work on payments code, make it your default rather than remembering Shift+Tab. Put it in your personal settings file:
{
"sessionDefaultSettings": {
"interactionMode": "spec",
"specModeModel": "<model-id>",
"specModeReasoningEffort": "high"
}
}
interactionMode takes auto (which means Normal mode, not an autonomy level) or spec. specModeModel and specModeReasoningEffort let you plan with a different model, or more reasoning, than you implement with. Use a model ID from your own model list, or leave specModeModel out to plan with your default model.
Why personal, not the project's .factory/settings.json? Because Spec mode is a way of working, and T7-style documentation tasks don't need a plan. Dana's reference harness leaves it out of the project settings on purpose. Q6 asks you to argue with that.
Headless, the same idea is a flag:
droid exec --use-spec --auto low "fix the flaky overdraft test"
--use-spec starts the run in Spec mode, and --spec-model picks a model for the planning phase. Headless, the spec is still a gate, and a hard one. The run investigates, writes the plan, reports that it is awaiting approval, and stops without touching a file. A one-shot run has nobody to approve, so nothing gets built. (The docs don't describe this. It's what droid 0.228.0 does; stretch exercise 3 checks it on your version.)
For a regulated team that's the right way to fail: an unreviewed plan never turns into unreviewed code, and the plan lands in the session, which puts it in the audit trail. But it can't make the scoreboard better. Point the scoreboard at --use-spec and every task that needs a change fails. What made this lab work was a person reading plan 1 and saying no, and a headless run has no person.
That's why the scoreboard doesn't move. You changed nothing that a droid exec run reads: no files on main, no project settings.
npm run 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.
Still 21. T2 is still two runs in three, because the third run still decides the rounding is fair game and nobody is there to read the plan. Spec mode made the decision visible to a human. The scoreboard has no human. Lab 4 starts on the controls that hold when nobody is watching.
Dana says: "Put interactionMode: spec in the project settings. Everyone plans, every time." Theo says: "Then people
will approve plans without reading them, and we've built a rubber stamp." Who's right for Larkspur, and what evidence
from this lab supports your answer?
Clean up. The branch and its test fix were for this lab only.
git switch main git branch -D lab-3/t2-spec
Checkpoint
Spec mode put the rounding decision in front of a human as one sentence. What happens to that sentence on the one run in three where no human is reading?
Extensions
- Plan with a different model. Set
specModeModelto a model other than your default (or pass--spec-model <model-id>todroid exec --use-spec) and plan T2 again. Is the decision easier or harder to find? Is the plan shorter? Keep whichever setting produces plans Theo can review fastest, not the one that sounds smartest. - Spec T6. Plan the ledger refactor (extract validation from
src/ledger/post.ts) in Spec mode. What would you require the plan to say about the balance invariant before you approve it? Write that requirement as one sentence you could reuse. - Read a headless plan. Run
droid exec --use-spec --auto low -o json "fix the flaky overdraft test"on a scratch branch, then open the session the way you did in Lab 1, Step 5, and find the plan in it. Would you have approved it? Nobody could, so the run stopped:git status --shortshould be empty. Delete the branch. - The plan as evidence. Write the three-line note Ruth would want attached to a pull request made from a spec: the plan file, the decision it recorded, and who approved it. Where in your team's current process would that note live?