Lab 6 — Skills and slash commands
Why this matters
Lab 5 ended with a regression. T3, the business daily limit, dropped from 3 of 3 to 2 of 3 after you added money-lint. The hook did its job: it flagged RETAIL_DAILY_LIMIT * 1.5 as float arithmetic on money. Then Droid rewrote the line, and on one run in three the rewrite was wrong in a way no hook can see.
Theo could have told you what the right line was in five seconds. That's the problem.
I have reviewed something like four hundred fee changes. I check the same handful of things every time, in the same order. None of it is written down, because everyone who needed it was sitting next to me. Sam wasn't, and the agent never was.
AGENTS.md tells Droid the invariants: money is integer cents, rates are basis points, rounding is half-even. It doesn't tell Droid how to do a money change at Larkspur, step by step, with the helpers that already exist and the mistakes people make with them. That procedure is too long for AGENTS.md. Every session loads AGENTS.md, and most sessions never touch a fee.
A skill is the right home for it. It is a folder with a SKILL.md file. Droid reads only the skill's name and description until a task needs it, then loads the whole procedure. Theo's checklist costs nothing on a docs task and is there in full on a fee change.
Objectives
- Write a skill whose description gets it selected for the right tasks, and not for the wrong ones
- Watch progressive disclosure: what Droid loads at the start of a session, and what it loads on invocation
- Show that a skill's
allowed-toolsfield is metadata, not a control - Write a Markdown slash command with
$ARGUMENTSand an executable command that receives$1 - Recover T3 on the scoreboard without loosening the hook that caused the regression
The idea in one paragraph
Skills live at .factory/skills/<name>/SKILL.md. The file has YAML frontmatter with a required name and description, and a Markdown body with the procedure. Droid discovers skills by name and description, selects one when a request matches, and only then loads the body. You can also call a skill yourself with /skill-name, anywhere in a prompt. Custom slash commands are the older, simpler tool. A Markdown file in .factory/commands/ is a saved prompt, with $ARGUMENTS replaced by whatever you type after the name. A script with a shebang is an executable command: it runs, and its output lands in the chat. Factory's docs recommend skills for new reusable workflows. Commands are for the short, fixed things you type often.
Here is the difference in how each one reaches Droid. Look at when the skill's body enters the context, and that a command has no selection step at all.
.factory/skills/money-change/SKILL.mdnew# Theo's checklistflaky-test-triage/SKILL.mdnew# the T2 procedurecommands/fee-change.mdnew# /fee-change <what to change>ship-check.shnew# executable, gets $1
Step 1 — read the failed T3 run
Open the session for the T3 run that failed in Lab 5. The scoreboard's --json output has each run's session_id (the plain output doesn't show it). Resume it with droid --resume <id>, press Ctrl+O for the detailed transcript, and find the turn after money-lint fired.
- Read
src/transfers/limits.ts - Edit
src/transfers/limits.ts — if (type === 'business') return cents(RETAIL_DAILY_LIMIT * 1.5); - hook · PostToolUseadded context
src/transfers/limits.ts:27 uses `* 1.5` on an amount. Use basisPoints()/multiplyBasisPoints() from src/money/amount.ts. Money is integer cents and rates are basis points (they round half-even). If a flagged line is not money, leave it and say so in your summary.
.factory/hooks/money-lint.mjs · exit 2 - droid
The hook asks for basis points. 150% of retail is a multiplier of 1.5, so the rate is basisPoints(1.5).
- Edit
src/transfers/limits.ts — if (type === 'business') return multiplyBasisPoints(RETAIL_DAILY_LIMIT, basisPoints(1.5)); - Execute
npm testall passed
basisPoints() takes a percentage: basisPoints(1.5) is 1.5%, not 150%. The business limit is now $375.00 instead of $37,500.00. No test covered business accounts, so everything passed. The T3 check did not.
Look at what went right before you look at what went wrong. The hook fired. Droid read the feedback, used the helpers the feedback named, and produced integer arithmetic that money-lint accepts. Every control you built did what it was built to do.
The failure is knowledge. basisPoints() takes a percentage as written in the policy: basisPoints(1.5) is 150 basis points, which is 1.5%. The business limit is 150% of retail, so the call is basisPoints(150). And limits.ts already has a named constant for it, BUSINESS_LIMIT_PERCENT_OF_RETAIL, which Droid never used. Theo knows both facts. Nothing in the repo says them where a session would look at the moment it needs them.
Lab 5's Q5 gave you three options for T3: loosen the regex, add an exception for limits.ts, or teach Droid the
right helper. Now that you've seen the failing run, which of the first two would have fixed this failure? What
would each have cost Ruth?
Step 2 — write the money-change skill
Create the skill folder and file. The entry point must be named SKILL.md, in upper case.
---
name: money-change
description: >-
Use for any change that computes, compares or stores an amount of money or a
rate: fees, charges, limits, interest, transfer amounts, ledger postings.
Covers Money and cents(), percentages as basis points, half-even rounding,
the existing helpers and error types, and what to do if the change seems to
need src/money/. Use when a task mentions a fee, a percentage of an amount,
a limit, rounding, cents or dollars.
metadata:
owner: payments-platform
source: Theo Lindqvist's fee-change review checklist
---
# Changing money code at Larkspur
Money bugs here are regulatory events. Work through this in order.
## 1. Find the pattern that already exists
- Read `src/money/amount.ts`. It exports `Money`, `cents`, `add`,
`subtract`, `multiplyBasisPoints`, `basisPoints` and `format`. Read it; do
not edit it. `src/money/` is protected.
- Search for the nearest existing example before writing a new one:
`rg -n "multiplyBasisPoints|basisPoints\(|roundHalfEven" src`
- Look for a named constant before writing a number. `limits.ts` has
`BUSINESS_LIMIT_PERCENT_OF_RETAIL`. The fee schedule is in
`src/fees/schedule.ts`.
- Reuse existing error types. A limit breach is `LimitExceeded` from
`src/transfers/limits.ts`, never a new class.
## 2. Use the recipe for the kind of change
- A fixed amount: `cents(1500)`. Never dollars, never a decimal.
- A percentage of an amount: `multiplyBasisPoints(amount, basisPoints(p))`,
where `p` is the percentage exactly as the policy writes it.
- A 1.5% fee is `basisPoints(1.5)` (150 bp).
- 150% of a limit is `basisPoints(150)` (15,000 bp). `basisPoints(1.5)`
would be 1.5% of the limit, a factor of 100 too small.
- Pro-rating, or any division: compute the exact value, then
`cents(roundHalfEven(exact))`. See `overdraftFee` in `src/fees/calc.ts`.
- Sums and differences: `add()` and `subtract()`.
- Showing an amount to a person: `format()`.
## 3. Never
- `* 1.5`, `* 0.015`, `/ 100` or any float arithmetic on an amount.
- `Math.round`, `Math.floor`, `Math.ceil`, `toFixed` or `parseFloat` on money.
The money-lint hook flags most of these. Do not wait for it.
- Edit `src/money/`. If the helper you need does not exist, stop and say which
helper you need and why. A human adds it.
- Rewrite existing float code you were not asked to touch (for example
`isNearDailyLimit`). Note it in your summary instead.
## 4. Test it like Theo would
- Test the value at a realistic amount, not only that "it works". For a limit,
test just under, exactly at, and just over.
- For anything that rounds, include a case that lands on exactly half a cent,
where half-even and `Math.round` disagree.
- Test the account types the change does not touch, to prove they did not move.
## 5. Before you say done
1. `npm run typecheck && npm test`
2. `git diff --name-only` shows nothing under `src/money/`.
3. In your summary, name the helper you used, the value you computed at a
realistic amount, and the invariant it relies on.
Two parts of that file do most of the work.
The description is a prompt. At the start of a session, the name and description are all Droid sees of this skill. Selection happens by matching a request against them. So write the description for the model choosing a skill, not for a person browsing a list. Name the situations ("a fee, a percentage of an amount, a limit"), use the words a task will contain, and say what's inside. The scoreboard's task files don't name the skill, and nobody types /money-change into a headless run, so on the scoreboard the description is the only way this skill gets used.
The body is specific. It names the helpers, the constant and the error type. It gives the 1.5 versus 150 case as an explicit example, because that's the mistake the session made. A generic "use basis points" is what AGENTS.md already says, and it didn't help.
The frontmatter supports an allowed-tools list, and you'll see skills in the wild that set it. At the time of writing it's metadata only. Factory's docs say it "does not add tools or sandbox standard skill invocation". A skill that lists only Read can still lead to an Edit. If a tool restriction must hold, it belongs in a custom droid's tools field (Lab 7), which is enforced, or in a hook (Lab 5). Step 5 shows this.
Step 3 — watch it load
Start a new session, so Droid discovers the new skill for certain, and open the skills manager.
droid /skills
/skills has Effective, User, Project, Plugins, Built-in and Import tabs. money-change should appear under Project and Effective. Pressing Space on a skill toggles it off, which writes a disabledSkills entry to settings.
Now give Droid T3 in your own words, without naming the skill:
Business accounts should be limited to 150% of the retail daily ACH limit. It is not enforced yet. Enforce it.
- Skill
money-change - Read
src/money/amount.ts - Execute
rg -n "multiplyBasisPoints|basisPoints\(|roundHalfEven" src - Read
src/transfers/limits.ts - droid
limits.ts already defines BUSINESS_LIMIT_PERCENT_OF_RETAIL = 150. Per the money-change skill, 150% is basisPoints(150), and a breach is the existing LimitExceeded.
- Edit
src/transfers/limits.ts — if (type === 'business') return multiplyBasisPoints(RETAIL_DAILY_LIMIT, basisPoints(BUSINESS_LIMIT_PERCENT_OF_RETAIL)); - Create
test/transfers/business-limit.test.ts — $37,499.99 ok, $37,500.00 ok, $37,500.01 throws LimitExceeded; retail unchanged - Execute
npm run typecheck && npm testall passed
- droid
Business daily limit is multiplyBasisPoints(RETAIL_DAILY_LIMIT, basisPoints(150)) = $37,500.00, using the existing constant and LimitExceeded. Relies on invariant 1. Not changed: isNearDailyLimit still uses limit * 0.9, which is float arithmetic; flagging it for a follow-up.
The first tool call is Skill. That's the moment the body loads: before it, the session knew only the name and description. Notice also what didn't happen. money-lint never fired, because the first edit was already right. A hook that stops firing because the work got better is the best outcome a hook can have.
Revert the change (git checkout -- src/transfers/limits.ts && rm test/transfers/business-limit.test.ts) and try the explicit form. /money-change works anywhere in a prompt, and you can combine skills in one prompt.
Enforce the business daily ACH limit from PAY-231. Follow /money-change.
Write a request that should trigger money-change but whose wording shares no words with its description. Does
Droid select the skill? What would you add to the description, and what would you be careful not to add?
Step 4 — a second skill, for the incident task
T2 passes 3 of 3 since Lab 5, but look at how. Droid still tries the rounding change on some runs, the hook blocks it, and then Droid finds the real fix. The hook made the failure impossible. It didn't make the right fix the first thing Droid tries. That's a procedure, and procedures go in skills.
---
name: flaky-test-triage
description: >-
Use when a test passes on some runs and fails on others, fails only in CI,
or fails on certain dates, times of day or run orders. Finds the hidden input
that makes the test nondeterministic and fixes it in the test setup. Never
changes production code or expected values to make a flaky test pass.
metadata:
owner: payments-platform
---
# Triage a flaky test at Larkspur
A flaky test has a hidden input. Find it, pin it, and assert the value the
business rule requires.
## 1. Name the hidden input
Read the test and the code it calls. Look for:
- Time: `new Date()`, `Date.now()`, anything computed from "today".
- Shared state: module-level maps and arrays. `resetTransfers()`,
`resetJournal()` and `clearAuditLog()` exist to clear them.
- Order: a test that passes only after another test ran.
- Randomness.
At Larkspur it is usually time.
## 2. Reproduce it on purpose
Pin the input to a value that fails. For time, use `freezeClock(iso)` from
`test/helpers/clock.ts`, with `restoreClock()` in `afterEach`. Run just that
file with `npm test -- <file>` and confirm it fails for the reason you named.
If it does not fail, you have not found the input yet. Keep looking.
## 3. Fix the test, not the code
- Replace the hidden input with fixed ones: one `it` per date that matters.
- Expected values are literal numbers worked out from the business rule. A
test that computes its expectation with `Math.round` is testing `Math.round`.
- Worked example, the overdraft fee (1215 cents a month, 30/360, half-even):
on 2026-04-30 one day is left, the exact fee is 40.5 cents, and half-even
gives 40. `Math.round` gives 41. Include at least one date like that.
## Never
- Edit `src/money/` or any production code to make a test pass.
- Change an expected value to whatever the code returns, unless you can show
the rule that produces it.
- Add retries, `.skip`, `.only` or longer timeouts.
## Done when
- Every test in the file pins its own clock with `freezeClock()`. Repeating
the run on one day proves nothing about a date-dependent flake.
- The file then passes 20 times in a row, to catch any other nondeterminism:
`for i in $(seq 20); do npm test -- test/fees/overdraft.test.ts || exit 1; done`
- `git diff --name-only` lists only files under `test/`.
- Your summary names the hidden input and the dates you pinned.
You don't have to write every skill from a blank page. If you have a session where Droid did something well, run /create-skill in it, and Droid drafts a skill from that session. Try it on your best T2 session from Lab 5, then compare its draft with the file above. The draft will usually be closer to what happened in that session than to what should happen on every session. Edit it until it reads as a procedure rather than as a story.
The worked example in step 3 of the skill is a specific date and a specific number. AGENTS.md avoids that level of detail. Why is it right in a skill and wrong in AGENTS.md?
Step 5 — break it on purpose
Suppose Dana wants the triage skill to be investigation only: read the code, find the input, report back, and change nothing. The frontmatter has a field that looks made for this. Add it:
---
name: flaky-test-triage
description: >-
Use when a test passes on some runs and fails on others, fails only in CI,
or fails on certain dates, times of day or run orders. Finds the hidden input
that makes the test nondeterministic and fixes it in the test setup. Never
changes production code or expected values to make a flaky test pass.
allowed-tools:
- Read
- Grep
- Glob
---
Start a new session at Low and ask for the fix.
- you
The overdraft test is flaky. Find out why and fix it.
- Skill
flaky-test-triage - Read
test/fees/overdraft.test.ts - Read
test/helpers/clock.ts - Edit
test/fees/overdraft.test.ts — freezeClock('2026-04-30T12:00:00Z'); expect(overdraftFee(retail)).toBe(40) The skill listed Read, Grep and Glob. Droid used Edit anyway, and nothing stopped it. allowed-tools did not restrict anything.
The edit is a good one. That isn't the point. The point is that the field you added to restrict tools restricted nothing. It's documentation of intent. Someone reading SKILL.md will reasonably believe this skill can't write files, and they'll be wrong.
Remove the allowed-tools block. If you want to keep a note of which tools a skill is meant to use, write it in the body as prose, where nobody will mistake it for configuration. And if a restriction has to hold, you need a mechanism that enforces it. Lab 7 gives review its own droid with tools: read-only, which Droid enforces when it loads the definition.
Ruth reads a colleague's SKILL.md that says allowed-tools: [Read] and signs off on it as a read-only procedure.
What would you tell her, and what would you point her at instead?
Step 6 — slash commands for what people type every day
Skills are chosen by the model. Some things you want to start yourself, with the same words every time. Those are custom slash commands. They live in .factory/commands/, and this repo's commands take precedence over personal ones in ~/.factory/commands/.
A Markdown command is a saved prompt. The filename becomes the command name (it's slugged, so Fee Change.md would become /fee-change), and $ARGUMENTS is replaced with everything you type after it.
---
description: Make a fee change the Larkspur way, with a reviewer summary at the end
argument-hint: <the change, e.g. add a 1.5% foreign transaction fee>
---
Make this fee change: $ARGUMENTS
Use the money-change skill. Then:
1. Before editing anything, write a three-line plan: which fee, which file in
src/fees/, and which helper computes it.
2. Add the fee to src/fees/schedule.ts or src/fees/calc.ts, never src/money/.
3. Add tests at a realistic amount and at one half-cent case.
4. Stop and ask if the change needs src/money/, a migration, or a rate that
is not in the published fee disclosure.
5. End with a reviewer summary for Theo: the customer-visible change in one
sentence, the helper used, and one worked example with real numbers.
/fee-change add a 1.5% foreign transaction fee on card purchases
Markdown commands support only $ARGUMENTS. Positional placeholders like $1 and $2 aren't supported, so a prompt written with $1 won't receive your argument where you put it. Also note that you can't scope tools on a custom command. It runs with whatever the session has.
An executable command is a script. It registers because its first line is a shebang (#!). It receives positional arguments as $1, $2 and so on, runs from the current working directory, and inherits your environment. Its stdout and stderr (up to 64 KB), plus the script contents, are posted back into the chat transcript, and failures still show their logs.
#!/usr/bin/env bash
# /ship-check [base-ref]: the checks Theo runs before he opens a pull request.
# Deterministic: no model involved. Droid posts the output to the chat.
set -euo pipefail
base=${1:-origin/main}
cd "$(git rev-parse --show-toplevel)"
if ! git rev-parse --verify --quiet "$base" >/dev/null; then
echo "ship-check: unknown base ref '$base'. Try: /ship-check main"
exit 2
fi
echo "== ship-check against $base"
echo "-- typecheck"; npm run --silent typecheck
echo "-- tests"; npm test --silent 2>&1 | tail -n 15
echo "-- lint"; npm run --silent lint | tail -n 5
echo "-- files changed since $base"
git diff --name-only "$base"
git ls-files --others --exclude-standard
protected=$( { git diff --name-only "$base" -- src/money migrations legacy;
git ls-files --others --exclude-standard -- src/money migrations legacy; } | sort -u )
if [ -n "$protected" ]; then
echo "!! PROTECTED PATHS CHANGED. A human must approve these before a PR:"
echo "$protected"
exit 1
fi
echo "== ship-check OK: no protected paths changed"
/commands # find ship-check in the list and use the name it shows, e.g.: /ship-check main
/commands opens the command manager. Press R to reload after you add a file, and I to import commands from .agents/commands or .claude/commands. Check that ship-check registered and use the name the manager shows. Make the script executable with chmod +x as well, so you can run it by hand outside Droid.
When should something be a command rather than a skill? A command is right when a person starts it, with the same words every time, and nothing about the task needs the model to recognize it. A skill is right when Droid should reach for it on its own, including headless. If a skill and a custom command share a name, the custom command keeps the slash command, so name them differently. That's why this lab has /fee-change and money-change.
The flaky-test-triage skill lists allowed-tools: [Read, Grep, Glob]. Droid invokes it and then edits a test file at Low autonomy. Why was the edit allowed?
You have twelve skills in .factory/skills/. A session is asked to document the /transfers route. What does it load from those skills?
You write .factory/commands/fee-change.md with the line "Make this fee change: $1". What happens when you run /fee-change add a 1.5% FX fee?
ship-check.sh repeats a check that the Lab 5 Stop hook already makes: did a protected path change? Is that
duplication a mistake? Who is each one for?
Step 7 — re-run the scoreboard
npm run scoreboard
| Task | Hooks | Skills |
|---|---|---|
| T1 Add a 1.5% foreign transaction fee | 3/3 | 3/3 |
| T2 Fix the flaky overdraft test | 3/3 | 3/3 |
| T3 Enforce the daily ACH limit for business accounts | 2/3 | 3/3+1 |
| T4 Add a memo column to transfers | 2/3 | 2/3 |
| T5 Audit-log failed transfers | 3/3 | 3/3 |
| T6 Extract validation from ledger/post.ts | 3/3 | 3/3 |
| T7 Document the /transfers route | 3/3 | 3/3 |
| T8 Accept the new vendor file format | 3/3 | 3/3 |
| Total | 22 | 23 / 24 |
The money-change skill points Droid at basisPoints(), which the hook accepts. T3 recovers.
T3 recovers to 3 of 3, and the total goes from 22 to 23. Nothing about the hook changed. money-lint is exactly as blunt as it was in Lab 5. What changed is that Droid now selects money-change from the task text in a headless run, reads that 150% is basisPoints(150), and writes a line the hook accepts on the first try.
That's the answer to Lab 5's Q5, and it's the one that works for both Dana and Ruth. Dana gets the throughput back. Ruth keeps a hook that nobody weakened to make a number go up.
Look at the secondary signals too. Actions stopped by configuration fall from 5 to 1, and attempted edits to protected paths from 3 to 1. With flaky-test-triage available, T2 runs go to the test clock first instead of trying the rounding and being blocked. The hooks still guard the line. They just have less to do, and that is the trend you want to show Ruth: a rising pass rate with fewer attempts on protected code.
T4 is still 2 of 3. Droid can't look up a change ticket yet. Lab 8 gives it a way to.
Checkpoint
Which parts of Theo's judgment are now in the repo, and which parts still live only in his head?
Extensions
- Guidelines for automated review. Factory's code-review action reads custom guidelines from
.factory/skills/review-guidelines/SKILL.md. Write one for Larkspur: the five invariants, the protected paths, and what counts as a P0. Lab 9 wires it into CI. - Let the session write it. Run
/create-skillat the end of a good T5 session (audit-log failed transfers). Compare the draft with what you'd write by hand. What did it capture that you would have left out, and what did it get wrong about when to use the skill? - A skill that lives next to the code. Skills can be folder-specific:
<repo>/<area>/.factory/skills/<name>/SKILL.md. Writesrc/ledger/.factory/skills/posting-change/SKILL.mdfor T6 (keep every posting balanced, reuseInvalidEntryandUnbalancedPosting). Folder and project skills rank above personal and built-in skills. - Who may start it? The frontmatter has two more switches,
user-invocable(defaulttrue) anddisable-model-invocation(defaultfalse). Read the skills page for exactly what each does. Then decide which Larkspur procedure should only ever start when a person types it, and set it up that way.