feat(labs): make every lab a self-contained, skip-friendly starting point
CI / check (pull_request) Successful in 7s

Each lab now stands on its own; no hard dependency on prior labs.
- App-based labs get a canonical tasks-app snapshot in lab/start/ (three
  baselines: v0 add/list/done; v1 +count; v2 +count/delete), assigned by where
  each module sits in the command timeline. Modules with a purpose-built app
  (M10 trap, M13 planted bug, M21) snapshot their own app; planted devices kept.
- Self-contained labs (M15/17/18/19/22/23/24/25/27, which operate on their own
  lab files) get a preamble pointing at modules/NN/lab/.
- Every module + capstone gets a "Starting point (skip-friendly)" preamble:
  copy the snapshot, git init -b main, commit -> clean status, then start.

Lets a learner skip around or recover: copy start/, commit, go. All snapshots
run; tools/check.sh passes; no em-dashes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TfzV5QvtPDz8LJS3Pu5VLT
This commit is contained in:
2026-06-23 18:24:17 -04:00
parent edf3f34336
commit 07182429c4
85 changed files with 2724 additions and 0 deletions
@@ -191,6 +191,18 @@ you couldn't do yourself.
## Hands-on lab
> **Starting point (this lab is skip-friendly).** You do not need to have done the earlier labs.
> To begin from a clean, known state, copy this module's snapshot into a fresh `tasks-app` and
> make the first commit:
>
> ```bash
> mkdir -p ~/ai-workflow-course/tasks-app
> cp -r ~/ai-workflow-course/modules/10-reviewing-code-you-didnt-write/lab/start/. ~/ai-workflow-course/tasks-app/
> cd ~/ai-workflow-course/tasks-app && git init -b main && git add -A && git commit -m "start: module 10"
> ```
>
> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell + the Python `tasks-app`. You won't write Python; you'll open a PR for a
real change, then review a diff the "AI" produced and catch the trap planted in it.
@@ -0,0 +1,62 @@
"""Tiny command-line front end for the demo task app.
Run it:
python cli.py add "write the lesson"
python cli.py list
python cli.py done 0
State is kept in tasks.json next to this file. The `done` command turns a bad index into a
clean error message and a non-zero exit code; note that behavior before you review the AI
change, so you can tell if the change quietly alters it.
"""
import json
import sys
from pathlib import Path
from tasks import Task, TaskList
STATE = Path(__file__).parent / "tasks.json"
def load() -> TaskList:
if not STATE.exists():
return TaskList()
raw = json.loads(STATE.read_text())
return TaskList(tasks=[Task(**t) for t in raw])
def save(tlist: TaskList) -> None:
STATE.write_text(json.dumps([t.__dict__ for t in tlist.tasks], indent=2))
def main(argv: list[str]) -> int:
tlist = load()
if not argv:
print("usage: python cli.py [add <title> | list | done <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
try:
tlist.complete(int(argv[1]))
except IndexError as exc:
print(f"error: {exc}")
return 1
save(tlist)
print("updated")
else:
print(f"unknown command: {command}")
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
@@ -0,0 +1,42 @@
"""Core task logic for the demo app.
Same running example as Modules 1 and 2, with one addition: `complete` now validates the
index and raises a clear error for a bad one. That explicit edge-case handling is here on
purpose; it's the kind of thing an AI "refactor" likes to quietly remove. This is the
known-good base you'll review an AI change against in Module 10.
"""
from dataclasses import dataclass, field
@dataclass
class Task:
title: str
done: bool = False
@dataclass
class TaskList:
tasks: list[Task] = field(default_factory=list)
def add(self, title: str) -> Task:
task = Task(title=title)
self.tasks.append(task)
return task
def complete(self, index: int) -> None:
if not 0 <= index < len(self.tasks):
raise IndexError(f"no task at index {index}")
self.tasks[index].done = True
def pending(self) -> list[Task]:
return [t for t in self.tasks if not t.done]
def render(self) -> str:
if not self.tasks:
return "(no tasks yet)"
lines = []
for i, task in enumerate(self.tasks):
box = "[x]" if task.done else "[ ]"
lines.append(f"{i}. {box} {task.title}")
return "\n".join(lines)