Skip to content

Break work into steps

  • Lesson 6
  • 45 min
  • Module 2: Write actionable tasks

What you will be able to do

When the code is new to you, do not ask for the whole change at once. Ask for an investigation and a plan first, then one step at a time, and check each step before the next. Each piece is small enough to understand.

Before you startWhat to know and to have readyAbout 45 minutes

What you need to know

What you need on your computer

  • The project ~/projects/ticket-cleaner-assist with the brief and the bug report committed, and its environment active. python -m pytest prints 25 passed, and git status says working tree clean.
  • If the project is missing or broken, use the reset steps of Context and boundaries, then do the steps of the last two lessons again.

You hire a builder to put a new kitchen into an old house. A good builder does not start by knocking down walls. First, they look: where do the pipes run, and is that wall holding up the floor above? Then they give you a plan, room by room, with what each part costs. You agree to it. Then they do one part, and you both check it before the next one starts.

If the builder finds an old lead pipe on the way, they tell you. They do not quietly build the new kitchen around it.

The problem: everything at once, in code you do not know

Section titled “The problem: everything at once, in code you do not know”

Amira has not worked on ticket-cleaner since the Python course. In Problem and acceptance criteria, the assistant did the whole feature in one turn (transcript feature-bounded). All 8 checks passed. But to accept the change, Amira must read 5 files and 149 changed lines at once. They hold the option, the filter, the new key, the tests and the README. In code that is new to her, she cannot easily tell a good line from a strange one.

And something is missing from that turn. The new filter compares the chosen names with each ticket’s category, as clean_category() stored it. In Grace’s export, a stored category can still be LOGIN, because of Grace’s bug. Then --category login misses that ticket. The change does not say so, and its tests use small letters only. Nobody decided this: it just happened.

When the code is new to you, separate the work into four stages:

  1. Investigate. Read the code and explain how it works now. Change nothing. Many assistants have a mode for this, where they may answer but not edit files. Aider calls it ask mode. In a chat window, every turn is like this.
  2. Plan. Write the change as small, numbered steps. For each step, name the file that changes and the test that shows the step works. You read the plan and approve it, or change it.
  3. Change one step. Ask for that step only, and then stop.
  4. Verify. Run the tests and ruff, and read the step’s diff with the plan beside it. Then go back to stage 3 with the next step.

The investigation also gives you facts: statements about the current code that you can check. A fact that the change must keep can become a test before you change anything. Then the test protects it during every step.

Criterion 5 of the brief says that valid_records and rejected must not change with --category. Before you add the option, ask a smaller question: do they change with --status today?

Read build_summary() in report.py: line 29 is "valid_records": len(tickets),. tickets is the list of every valid ticket. The status filter makes a second list, selected, and only the counts after it use that list. So the answer is “no”. Now check it by running the program:

Command valid_records rejected rows selected
--status open 9 6 4
--status pending 9 6 2
--status closed 9 6 3

The first two columns stay the same; only selected changes. That is a fact about the current code. It also tells you where the new step belongs: after the status filter, next to selected, and away from valid_records.

Five boxes from left to right. data/tickets.csv: 15 rows. split_records: 9 valid tickets, 6 rejected rows. filter_by_status: open, 4 tickets, billing 2, login 1, shipping 1. A dashed green box, new step 1, keep the chosen categories: with --category billing, 2 tickets. count_by_category and average_priority: billing 2, and 2.5. Below split_records: valid_records 9 and rejected, 6 rows, are counted here, from all the tickets; the status and the categories do not change them, criterion 5. Below the new step: the investigation's test pins this, valid_records is the same for open, pending and closed.
Where step 1 of the plan goes. The whole-file counts are made before the status filter, so a category filter placed after it cannot change them.
Text description of the diagram

The diagram follows the sample file through build_summary(). data/tickets.csv has 15 rows. split_records() makes 9 valid tickets and 6 rejected rows. filter_by_status() keeps the 4 open tickets: billing 2, login 1 and shipping 1. Next is a dashed green box, the new step 1: keep the chosen categories. With –category billing, 2 tickets stay. Last, average_priority() gives 2.5, and count_by_category() gives {"billing": 2}. A note under split_records() says that this step counts valid_records 9 and rejected, 6 rows, from all the tickets. So the status and the categories do not change them. That is criterion 5. A second note says that the investigation’s test pins this: valid_records is the same for open, pending and closed.

The course team gave the same brief and the same five files to the same model, in two turns (transcript feature-plan-first). In the first turn, Aider was in ask mode, so it could not change files. The brief ended with this request:

Prompt (the end of turn 1)
Do not change any file yet. First read the code and explain, in a few lines, how a ticket's category goes from the input file to the report. Then propose a plan in small numbered steps. For each step, say which file changes and which test shows that the step works.

The assistant explained the flow, and asked for three more files: files.py, parsing.py and records.py. “I won’t change these files,” it added. Then it gave a plan of four steps:

What the assistant printed (part)
1. **Add optional category filtering to the report.**
Change: `ticket_cleaner/report.py`, `tests/test_report.py`.
…
2. **Expose the repeatable CLI option.**
Change: `ticket_cleaner/cli.py`, `tests/test_cli.py`.
…
3. **Document the option.**
Change: `README.md`.
…
4. **Verify the completed change.**

With the three new files, it confirmed the flow, and it found something:

What the assistant printed (part)
3. `clean_category()` strips surrounding spaces and maps aliases such as `Sign-in` to `login`. **Other names retain their capitalization**—for example, `LOGIN`
stays uppercase.

That is Grace’s bug, found by reading the code. The one-turn session never mentioned it.

In the second turn, the prompt was short:

Prompt (turn 2)
Do step 1 of your plan only, with its test. Then stop.

The assistant changed only report.py and tests/test_report.py: +82 −4. It said “No CLI or documentation changes are included.” Then the checks ran: 33 passed, and 1 of 8 hidden checks. That is the right result for step 1: the option --category does not exist yet, so the checks that use it must fail.

Four stages from left to right, with an arrow from the fourth back to the third labelled next step of the plan. 1, Investigate: read, change nothing. Turn 1, ask mode: explained how a category reaches the report, asked for 3 more files, found that LOGIN stays uppercase; 0 files changed. 2, Plan: small numbered steps; 4 steps, each names the file that changes and the test that shows it works; you read and approve it. 3, Change one step, and stop: turn 2, do step 1 of your plan only, with its test, then stop; 2 files, plus 82 minus 4. 4, Verify, before the next step: 33 passed; ruff format --check, 2 files would be reformatted; acceptance checks 1 of 8, no --category option yet. Below: the same brief in one shot, feature-bounded, 5 files, plus 145 minus 4 to review at once.
Investigate, plan, change one step, verify, then the next step. In the recorded session, the first turn changed nothing and found Grace's bug; the second did step 1 only.
Text description of the diagram

The diagram shows four stages in a row. An arrow goes from the fourth stage back to the third, labelled “next step of the plan”. Stage 1, investigate: read and change nothing. In turn 1, in ask mode, the assistant explained how a category reaches the report. It asked for 3 more files, and found that “LOGIN stays uppercase”. No file changed. Stage 2, plan: small numbered steps. The plan had 4 steps, and each named the file that changes and the test that shows it works. You read and approve it. Stage 3, change one step and stop. Turn 2 asked: “Do step 1 of your plan only, with its test. Then stop.” It changed 2 files, 82 lines added and 4 removed. Stage 4, verify before the next step. The tests gave 33 passed. ruff format –check said that 2 files would be reformatted. 1 of 8 acceptance checks passed, because the –category option does not exist yet. A note below says that the same brief in one shot (feature-bounded) gave 5 files, +145 −4, to review at once.

Step 1 is 86 changed lines, not 149, so Amira can read every line. One new test stops her:

tests/test_report.py (part of step 1)
def test_category_comparison_preserves_stored_labels():
tickets = [
Ticket(id="T-1", status="open", category="Billing", priority=3),
]
summary = build_summary(tickets, [], status="open", categories=[" billing "])
assert summary["selected"] == 1
assert summary["by_category"] == {"Billing": 1}

In turn 1, the assistant found that LOGIN stays in capital letters. In turn 2, it built the filter around that: it compares in small letters, but the report keeps Billing. This test then makes the split a promise: by_category must say {"Billing": 1}. When Module 4 fixes Grace’s bug, this test fails.

This is what the stages are for. The investigation found a fact. The decision about it belongs to Amira: the bug is a separate task, with its own report. So the plan says that the option does not work around it. Amira’s review of step 1 rejects this test. The change that the team accepts in Module 3 does not work around the bug either: you check that there.

You do the investigation yourself, add its test, and commit the plan.

  1. Find what build_summary() counts, and where:

    Terminal window
    grep -n "def \|valid_records\|rejected" ticket_cleaner/report.py
    Output (macOS)
    4:def filter_by_status(tickets: list[Ticket], status: str = "open") -> list[Ticket]:
    9:def count_by_category(tickets: list[Ticket]) -> dict[str, int]:
    17:def average_priority(tickets: list[Ticket]) -> float | None:
    24:def build_summary(tickets: list[Ticket], rejected: list[Rejected], status: str) -> dict:
    29: "valid_records": len(tickets),
    33: "rejected": [{"row": r.row, "problems": r.problems} for r in rejected],
  2. Check the fact with the program. Run it three times, with --status open, --status pending and --status closed. After each run, open reports/summary.json:

    Terminal window
    python -m ticket_cleaner data/tickets.csv --status pending

    Each report has "valid_records": 9 and the same six rejected rows. selected is 4, 2 and 3, as in the table above.

  3. Pin the fact in a test. Copy tests/test_report.py from the snapshot end-of-m02, then look at the diff:

    Terminal window
    cp ../nextia-learning-labs/C15/snapshots/end-of-m02/tests/test_report.py tests/
    git diff
    Output (the end)
    @@ -30,3 +30,11 @@ def test_summary_for_a_status_with_no_tickets(tickets):
    assert summary["selected"] == 0
    assert summary["by_category"] == {}
    assert summary["average_priority"] is None
    +
    +
    +def test_whole_file_counts_do_not_depend_on_the_status(tickets):
    + rejected = [Rejected(row=4, problems=["missing id"])]
    + for status in ("open", "pending", "closed"):
    + summary = build_summary(tickets, rejected, status=status)
    + assert summary["valid_records"] == 3
    + assert summary["rejected"] == [{"row": 4, "problems": ["missing id"]}]

    The diff also adds Rejected to the import at the top of the file. The test uses the file’s fixture tickets: three tickets, two open and one closed. For each status, it checks that valid_records is 3 and rejected is the same.

  4. Run the tests:

    Terminal window
    python -m pytest
    Output (the end)
    ============================== 26 passed in 0.04s ==============================
  5. Optional: check that the test can fail. In ticket_cleaner/report.py, change line 29 to "valid_records": len(selected),, and run python -m pytest tests/test_report.py:

    Output (part)
    E assert 2 == 3
    FAILED tests/test_report.py::test_whole_file_counts_do_not_depend_on_the_status
    ========================= 1 failed, 4 passed in 0.02s ==========================

    With open, the status filter keeps two of the three tickets, so the wrong code gives 2. Then put the line back with git restore ticket_cleaner/report.py.

  6. Copy the plan, and run the checks:

    Terminal window
    cp ../nextia-learning-labs/C15/snapshots/end-of-m02/docs/tasks/category-option-plan.md docs/tasks/
    Terminal window
    python -m pytest
    ruff check .
    ruff format --check .
    Output (the end of each command)
    ============================== 26 passed in 0.03s ==============================
    All checks passed!
    20 files already formatted
  7. Commit the test and the plan together:

    Terminal window
    git add tests/test_report.py docs/tasks/category-option-plan.md
    git commit -m "Plan the category option, and pin what the investigation found"
    Output
    [feature/category-option c15e9d8] Plan the category option, and pin what the investigation found
    2 files changed, 43 insertions(+), 1 deletion(-)
    create mode 100644 docs/tasks/category-option-plan.md

Here is Amira’s plan:

docs/tasks/category-option-plan.md
# Plan: the --category option
Written before any change, after reading `cli.py`, `report.py`, `parsing.py`
and the tests.
## How a category reaches the report now
1. `files.read_rows` reads each row as text.
2. `parsing.to_ticket` cleans it: `clean_category` changes the web form's
names (`Sign-in` to `login`).
3. `report.build_summary` keeps the tickets of the chosen status
(`filter_by_status`), then counts them by category (`count_by_category`).
4. `cli.main` saves the summary as JSON.
`valid_records` and `rejected` come from all the tickets, before the status is
chosen. A test now shows that (`test_whole_file_counts_do_not_depend_on_the_status`).
## Found while reading
`clean_category` keeps the capital letters of a name that is not one of the
web form's names, so `LOGIN` and `login` are two categories. That is Grace's
bug (`docs/tasks/category-names-bug.md`), a separate task. The option compares
the chosen names with the stored category, and does not work around the bug.
## Steps
| Step | Change | File | Test that shows it works |
|---|---|---|---|
| 1 | Keep the tickets of the chosen categories, after the status | `report.py` (`build_summary` gets `categories`) | `test_report.py`: one, two, none, an unknown category |
| 2 | Add `--category` (repeatable) and pass it on | `cli.py` | `test_cli.py`: the command with `--category " Billing "` |
| 3 | Add the `categories` key only when the option is used | `report.py` | `test_baseline.py` still passes; a new test for the key |
| 4 | Describe the option | `README.md`, `--help` | `test_cli.py`: `--help` shows `--category` |
After each step: `python -m pytest`, `ruff check .`, `ruff format --check .`.

It has the same shape as the assistant’s plan, with two differences. It records the finding and Amira’s decision about it. And each step has its own test, so the verify stage has something to run.

When to break the work up, and what it costs

Section titled “When to break the work up, and what it costs”

Break the work up when the code is new to you, when the change touches several files, or when a step is hard to undo. A plan also helps a reviewer: the pull request can follow it, step by step.

It costs more turns. The plan-first session made 3 model calls and cost US$0.050, and it did only step 1. The whole feature in one turn made 1 call and cost US$0.032. The larger cost is your time: you read a plan and approve each step. Prices change, and with a free tier or no assistant you pay nothing.

Do not plan a one-line fix in code that you know: the plan is longer than the change. And a plan is not a promise. If step 2 shows that step 3 is wrong, change the plan, in the file, and say why.

Common mistake

The step passes its tests and fails the format check

Symptom: after step 1, python -m pytest prints 33 passed, but ruff format --check . prints 2 files would be reformatted. This happened in the recorded session.

Cause: the assistant wrote lines longer than the project’s limit. Tests check behaviour, not style, so they cannot see this. A step that is “done” by its tests is not done by the brief, whose constraints ask for ruff format --check . to pass.

Fix: verify every step with all three checks of the plan, not only the tests. Run ruff format . and read git diff afterwards, because it changes files. The scope file says so too. Then run the three checks again before the next step.

Module practice: a feature request and a minimal bug report

Section titled “Module practice: a feature request and a minimal bug report”

The module’s practice task: write a feature request and a minimal bug report for the practice project, as Amira did. Work in a disposable copy, so that your project stays as it is.

  1. In ~/projects, copy the snapshot end-of-m02 to a new folder, ticket-cleaner-try. Make its environment, and check that python -m pytest prints 26 passed.
  2. The feature. Grace asks: “Can the report also say how many of the selected tickets are urgent?” In ticket-cleaner, priority 1 is the most urgent level: Ticket.is_urgent() returns True for it. Write docs/tasks/urgent-count.md with the five parts of a brief. Give at least three criteria with commands on data/tickets.csv and exact expected values.
  3. The bug. Grace’s team says that a new form sends the category log in, with a space. Make a minimal input of two rows, and run it. Then write docs/tasks/log-in-bug.md: the observation, the expected result, the minimal example, the file references and what is not affected. Is this Grace’s bug again?
  4. Delete the folder when you finish.
Show an example answer

The values. The open tickets of data/tickets.csv are T-1001 (priority 2), T-1003 (1), T-1005 (3) and T-1015 (2): one urgent ticket. Pending: T-1004 (2) and T-1010 (1): one. Closed: T-1002 (3), T-1006 (1) and T-1012 (3): one.

docs/tasks/urgent-count.md (example)
# Task: count the urgent tickets in the report
Request: Grace wants to see how many of the selected tickets are urgent.
Observed now: the report has no count of urgent tickets.
Expected: the report has a key "urgent": the number of selected tickets
with priority 1.
## Acceptance criteria
1. `python -m ticket_cleaner data/tickets.csv` gives "urgent": 1 (T-1003).
2. With `--status pending`, "urgent" is 1 (T-1010); with `--status closed`,
it is 1 (T-1006).
3. With no selected tickets, "urgent" is 0, not null.
4. Every other key of the report stays the same. `tests/test_baseline.py`
gets the new key in the same commit, after Grace confirms that her
spreadsheet accepts it.
## Constraints
- Change only ticket_cleaner/report.py, README.md and the tests. No new
dependency. `ruff check .` and `ruff format --check .` pass.
## Evidence of done
- `python -m pytest` passes, with new tests for criteria 1 to 3.
- The commands of criteria 1 and 2 give the values above.

Criterion 4 is the hard one. A new key changes every report, so the baseline test of Module 1 must fail. The brief says that, and names the decision that Grace must make first.

The bug. The minimal input and its result:

two-names.csv
id,status,category,priority,subject
T-1,open,login,1,One
T-2,open,log in,1,Two

python -m ticket_cleaner two-names.csv --output reports/two-names.json prints 2 rows: 2 valid, 0 rejected., and the report has:

"by_category": {
"log in": 1,
"login": 1
},
  • Expected: {"login": 2}, if Grace confirms that log in is the login team.
  • File references: ticket_cleaner/records.py: CATEGORY_ALIASES has log-in and sign-in, but not log in. ticket_cleaner/parsing.py: clean_category() looks names up there.
  • Not affected, and not Grace’s bug: log in is in small letters, so capital letters are not the cause. The name is missing from the list of web form names. Fixing Grace’s bug alone does not fix this one. Say so in the report, so that nobody merges the two.

The module’s completion check: someone else could check your task without guessing what you meant. Test your own brief with these four questions. Answer each one with yes or no:

  1. Does every criterion have a command or a test, and an exact expected result?
  2. Did you calculate the expected results before any code exists?
  3. Do the constraints name the files that may change?
  4. Could a colleague who has not read Grace’s email decide “done” or “not done” with your brief alone?

Then apply them to this brief from Tomás:

Tomás's brief
# Task: urgent tickets
Add the urgent tickets to the report. Make sure it works and is tested.
Don't break anything.
Show the answer

It fails all four questions. “Add the urgent tickets” can mean a count, a list of IDs, or a filter. “Make sure it works” has no command and no expected value. Nothing names the files, and “don’t break anything” does not say whether a new key is allowed in every report. A colleague must ask Tomás, and an assistant would decide alone, as in the vague session. The example answer of the module practice passes all four questions.

You have finished the module when you can also show these on your own project:

  • git log --oneline shows six commits on feature/category-option. The last three are Write the task brief for the category option, Write Grace's bug report with a minimal input and Plan the category option, and pin what the investigation found.

  • python -m pytest prints 26 passed, and ruff format --check . prints 20 files already formatted.

  • On macOS and Linux, this command prints nothing, because your project matches the snapshot end-of-m02:

    Terminal window
    diff -rq . ../nextia-learning-labs/C15/snapshots/end-of-m02 -x .git -x .venv -x reports -x __pycache__ -x .pytest_cache -x .ruff_cache

Reset. If your project is broken, follow the reset steps of Context and boundaries with the snapshot end-of-m02 in place of end-of-m01. The last line of python -m pytest is then 26 passed.

Check your understanding

Name the four stages for a change in code that is new to you, and say what each stage produced in the recorded session feature-plan-first.

Show the answer
  • Investigate: turn 1, in ask mode. The assistant explained how a category reaches the report, asked for three more files, and found that LOGIN stays in capital letters. No file changed.
  • Plan: four numbered steps, each with the files that change and the tests.
  • Change one step: turn 2, “Do step 1 of your plan only, with its test. Then stop.” Two files, +82 −4.
  • Verify: 33 passed, but ruff format --check . failed on 2 files. 1 of 8 hidden checks passed, which is right for step 1. The review found a test that makes Grace’s bug a promise.

Check your understanding

Why does the test test_whole_file_counts_do_not_depend_on_the_status come before any change, and what does it protect in step 1?

Show the answer

It pins a fact of the current code: valid_records and rejected describe the whole file, whatever the status. The investigation found the fact by reading line 29 of report.py and running three statuses. If it is a test before the change, it protects the fact during every step. Step 1 adds a filter next to the status filter. If the filter goes in the wrong place, and the whole-file counts start to follow it, this test fails. That would break criterion 5 of the brief.

Check your understanding

In turn 1, the assistant found that LOGIN stays uppercase. In turn 2, it wrote a test that asserts {'Billing': 1}. What should Amira do, and why is it her decision?

Show the answer

She rejects that test, and she writes the finding into the plan as a separate task: Grace’s bug, with its own report. The test makes the bug a promise, so the fix in Module 4 would break it. An assistant can find a fact, but the decision about it belongs to the person who owns the task. Is it a bug, should this change work around it, and who must agree? Here, Grace already reported it, so the option does not work around it.

Recap

This lesson

  • In code that is new to you, separate four stages: investigate, plan, change one step, verify. Then the next step.
  • The recorded investigation changed nothing and found Grace’s bug. Step 1 alone was +82 −4, small enough to read, and the review found a test that made the bug a promise.
  • An investigation gives facts. Pin a fact that the change must keep in a test, before the change: test_whole_file_counts_do_not_depend_on_the_status.
  • The finding is the assistant’s; the decision is yours.

Module 2: write actionable tasks

  • A task brief has observed and expected behaviour, inputs, constraints and evidence of done. Its acceptance criteria each have a check with an exact result.
  • Relevant context is the observation, the expected result, a minimal example, file references and interfaces, and no secrets.
  • Your project matches end-of-m02: six commits on feature/category-option and 26 passed.

Next, in Module 3, an assistant’s change for the feature arrives, and you review it line by line.

Next: Inspect the diff