executing-plans
executing-plans
Use when executing an implementation plan in the current session as the implementer yourself — your human partner chose inline execution, or no subagent tool is available
incremental-implementationBuilds a change as a sequence of small steps, each of which leaves the project building, passing its checks and safe to merge or revert on its own. Use when a user asks to implement a feature that spans several files or layers, says "do this step by step", "break this into small commits", "this is too big for one change", "migrate without downtime", "keep t…
Independent GitHub snapshot · Automated classification · Original documentation governs
A large change written in one pass fails in one piece: nothing can be run until everything exists, the first error could be anywhere in a thousand lines, and the reviewer gets a diff too big to read. The alternative is to cut the work into steps that each do one thing, prove it with a command, and leave the project in a state that could be merged.
This skill covers how to find the project's checks, how to cut a change into steps using the established patterns (vertical slice, expand–migrate–contract, branch by abstraction, feature flag), what to do after every step, and how to get back to a working state when a step goes wrong.
package.json scripts, Makefile, justfile, pyproject.toml or the README. Typically: build, type check, lint, tests.timeout 300 npm test). Record the result. A check that already fails is reported to the user and excluded from "green" by agreement, not fixed silently.Every step must pass five tests:
Choose the cut by the kind of change:
| Pattern | Use it when | The first step is |
|---|---|---|
| Vertical slice | A feature crosses storage, logic and interface | The thinnest path through every layer for one case, with the rest hard-coded or absent |
| Walking skeleton | A new service or pipeline | Empty stages connected end to end and running in the real environment |
| Riskiest part first | Nobody knows whether the key piece works | A time-boxed proof of that piece alone |
| Contract first | Two sides are built in parallel | The agreed schema or types, plus a stub that returns canned data |
| Expand, migrate, contract | A column, endpoint or function signature that is in use must change | Add the new form next to the old one; nothing reads it yet |
| Branch by abstraction | A component called from many places is being replaced | Put an interface in front of the old component and route callers through it |
| Preparatory refactoring | The current structure makes the change awkward | A behaviour-preserving refactor with the tests unchanged |
| Feature flag | A step must merge before the feature is complete | The flag, off by default, with its removal listed as the last step |
Ordering rules:
Show this table before starting whenever there are more than three steps, and keep it updated.
| # | Step | Proves | Check | Flag |
|---|------|--------|-------|------|
"Proves" is the observable result in plain words. "Check" is a command, or a manual action with the expected outcome.
git diff --stat, then the diff). Anything that does not belong to this step comes out.Step 2 of 5 done: export route behind EXPORT_CSV
Changed: src/server.js (+9), test/export-route.test.js (+21)
Checks: npm test -> 44 passed, 0 failed · npm run lint -> clean
Tried by hand: GET /invoices/export.csv -> 404 with the flag off, 200 text/csv with EXPORT_CSV=1
Noticed, not touched: src/server.js has no handler for HEAD requests
Next: step 3, "Export" link on the invoices page
Useful git commands for the loop:
git status --short # start from a known state
git switch -c feature/invoice-csv-export # one branch for the whole change
git add -p # stage only the hunks of this step
git commit -m "Add CSV serializer for invoices"
git stash push -u -m "step 3, failed attempt" # return to the last green state, keep the work
git revert --no-edit 4f2a9c1 # undo one committed step
git bisect start && git bisect bad && git bisect good v0.4.0
git bisect run npm test # find the step that broke a check, then: git bisect reset
Unrelated bugs, dead code and tempting clean-ups go into the "Noticed, not touched" line of the report. They are not fixed in the current step. If one of them blocks the step, it becomes a separate step placed before it.
Remove what was temporary: flags, stubs, dual writes, the old code path. Each removal is a planned step with its own check. Then run every baseline check on the final state and give the user the list of steps with their commits and anything still open.
Request: "Add CSV export to the invoices page of Ledgerline." Baseline: npm test passes (41 tests), npm run lint clean. The user wants a commit per step and allows unfinished work on main behind a flag.
| # | Step | Proves | Check | Flag |
|---|----------------------------------------------|-----------------------------------------------------|-----------------------------------|------------|
| 1 | toCsv(invoices) pure function | Quoting and number format are right | npm test (new unit test) | - |
| 2 | GET /invoices/export.csv | The file downloads for the signed-in account | npm test (route test) + curl | EXPORT_CSV |
| 3 | "Export" link on the invoices page | A user can reach it | Open /invoices with the flag on | EXPORT_CSV |
| 4 | Date-range filter passed through to export | The export matches what the page shows | Route test with from/to | EXPORT_CSV |
| 5 | Remove the flag after a week in production | Feature is on for everyone, no dead branch | grep finds no EXPORT_CSV; npm test | removed |
Step 1 has no I/O, so it is the cheapest place to settle the format:
// src/invoice-csv.js
const cell = (v) => (/[",\n]/.test(String(v)) ? `"${String(v).replaceAll('"', '""')}"` : String(v))
export function toCsv(invoices) {
const rows = invoices.map((i) => [i.number, i.customer, i.issuedOn, (i.totalCents / 100).toFixed(2)])
return [['number', 'customer', 'issued_on', 'total'], ...rows].map((r) => r.map(cell).join(',')).join('\n') + '\n'
}
Step 2 adds the route and keeps it invisible until the flag is set:
// src/server.js (inside the request handler)
if (req.method === 'GET' && req.url === '/invoices/export.csv' && flags.exportCsv) {
res.writeHead(200, { 'content-type': 'text/csv; charset=utf-8', 'content-disposition': 'attachment; filename="invoices.csv"' })
return res.end(toCsv(invoices))
}
Its tests cover both states of the flag (404 when off, the exact CSV body when on), so the dark path is verified too. The report after step 2 is the one shown in section 4. Step 4 was added after step 3, when trying the link by hand showed that the export ignored the filter the page had applied: the plan changed because a step was observable.
Request: "orders.total is a float and we get rounding errors. Move it to integer cents without downtime." The table has 2.3 million rows and two services read it.
| # | Step | Proves | Check |
|---|--------------------------------------------------------|------------------------------------------|-----------------------------------------------|
| 1 | Expand: add nullable total_cents | Old code still runs against new schema | Migration applies; full suite green |
| 2 | Write both columns on every insert and update | New rows carry both values | Test: created order has total_cents |
| 3 | Backfill old rows in batches of 5,000 | No row is missing the new value | Count of NULL total_cents is 0 |
| 4 | Read from total_cents in the API, then in the reports | Every consumer uses the new column | Mismatch count is 0; one deploy per consumer |
| 5 | Stop writing total | Nothing depends on the old column | grep for the column name; suite green |
| 6 | Contract: back up, then drop total | Schema is clean | Migration applies after a week without reads |
-- step 1
ALTER TABLE orders ADD COLUMN total_cents INTEGER;
-- step 3, repeated per id range by a script that sleeps between batches
UPDATE orders
SET total_cents = CAST(ROUND(total * 100) AS INTEGER)
WHERE total_cents IS NULL AND id BETWEEN 1 AND 5000;
-- checks for steps 3 and 4: both must return 0
SELECT COUNT(*) FROM orders WHERE total_cents IS NULL;
SELECT COUNT(*) FROM orders WHERE total_cents <> CAST(ROUND(total * 100) AS INTEGER);
-- step 6, a separate release, after a backup
ALTER TABLE orders DROP COLUMN total;
Each step is deployable alone, and until step 6 every one can be rolled back without losing data. Step 6 is the only irreversible one, which is why it waits and why it is announced to the owners of both services first.