diff --git a/capstone/README.md b/capstone/README.md
index 9d48256..615bc4c 100644
--- a/capstone/README.md
+++ b/capstone/README.md
@@ -127,6 +127,17 @@ swappable part; the workflow is the durable skill*), and you just lived it inste
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** The capstone runs the whole loop on one feature.
+> To begin from a clean app, copy the snapshot into a fresh `tasks-app` and make the first commit:
+>
+> ```bash
+> mkdir -p ~/ai-workflow-course/tasks-app
+> cp -r ~/ai-workflow-course/capstone/lab/start/. ~/ai-workflow-course/tasks-app/
+> cd ~/ai-workflow-course/tasks-app && git init -b main && git add -A && git commit -m "start: capstone"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell + Python, on the `tasks-app` repo. You'll direct Claude Code (`claude`; sub
your own agent) to do the git and the edits (M4); you make the calls and verify each result.
diff --git a/capstone/lab/start/README.md b/capstone/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/capstone/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/capstone/lab/start/cli.py b/capstone/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/capstone/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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
| list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/capstone/lab/start/tasks.py b/capstone/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/capstone/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/01-the-copy-paste-problem/README.md b/modules/01-the-copy-paste-problem/README.md
index bfcf43e..ed89ccd 100644
--- a/modules/01-the-copy-paste-problem/README.md
+++ b/modules/01-the-copy-paste-problem/README.md
@@ -122,6 +122,18 @@ you already feel is the curriculum.
## 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/01-the-copy-paste-problem/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 1"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell + a tiny bit of Python (just enough to have something real to run). You will
not write Python; you'll run a small app we provide.
diff --git a/modules/01-the-copy-paste-problem/lab/start/README.md b/modules/01-the-copy-paste-problem/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/01-the-copy-paste-problem/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/01-the-copy-paste-problem/lab/start/cli.py b/modules/01-the-copy-paste-problem/lab/start/cli.py
new file mode 100644
index 0000000..36a7af8
--- /dev/null
+++ b/modules/01-the-copy-paste-problem/lab/start/cli.py
@@ -0,0 +1,56 @@
+"""Tiny command-line front end for the demo task app.
+
+Run it:
+ python cli.py add "write the lesson"
+ python cli.py list
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/01-the-copy-paste-problem/lab/start/tasks.py b/modules/01-the-copy-paste-problem/lab/start/tasks.py
new file mode 100644
index 0000000..6913314
--- /dev/null
+++ b/modules/01-the-copy-paste-problem/lab/start/tasks.py
@@ -0,0 +1,39 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ 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)
diff --git a/modules/02-version-control-as-a-safety-net/README.md b/modules/02-version-control-as-a-safety-net/README.md
index 4078474..d2639a1 100644
--- a/modules/02-version-control-as-a-safety-net/README.md
+++ b/modules/02-version-control-as-a-safety-net/README.md
@@ -132,6 +132,18 @@ Everything above is standard Git. What's *specific* to AI-assisted work:
## 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/02-version-control-as-a-safety-net/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 2"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git commands), on the `tasks-app` project from Module 1.
**You'll need:** Git installed (`git --version`; if it's missing, install from
diff --git a/modules/02-version-control-as-a-safety-net/lab/start/README.md b/modules/02-version-control-as-a-safety-net/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/02-version-control-as-a-safety-net/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/02-version-control-as-a-safety-net/lab/start/cli.py b/modules/02-version-control-as-a-safety-net/lab/start/cli.py
new file mode 100644
index 0000000..36a7af8
--- /dev/null
+++ b/modules/02-version-control-as-a-safety-net/lab/start/cli.py
@@ -0,0 +1,56 @@
+"""Tiny command-line front end for the demo task app.
+
+Run it:
+ python cli.py add "write the lesson"
+ python cli.py list
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/02-version-control-as-a-safety-net/lab/start/tasks.py b/modules/02-version-control-as-a-safety-net/lab/start/tasks.py
new file mode 100644
index 0000000..6913314
--- /dev/null
+++ b/modules/02-version-control-as-a-safety-net/lab/start/tasks.py
@@ -0,0 +1,39 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ 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)
diff --git a/modules/03-version-control-for-words/README.md b/modules/03-version-control-for-words/README.md
index 1bb7c4f..27123fe 100644
--- a/modules/03-version-control-for-words/README.md
+++ b/modules/03-version-control-for-words/README.md
@@ -193,6 +193,18 @@ Here's why this module is more than "learn Git on easy mode":
## 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/03-version-control-for-words/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 3"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git commands) plus a little markdown writing, on the `tasks-app` from
Modules 1–2. The AI stays in the **browser**; you copy its draft into the file yourself, exactly as
in Module 2.
diff --git a/modules/03-version-control-for-words/lab/start/README.md b/modules/03-version-control-for-words/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/03-version-control-for-words/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/03-version-control-for-words/lab/start/cli.py b/modules/03-version-control-for-words/lab/start/cli.py
new file mode 100644
index 0000000..7dd143c
--- /dev/null
+++ b/modules/03-version-control-for-words/lab/start/cli.py
@@ -0,0 +1,58 @@
+"""Tiny command-line front end for the demo task app.
+
+Run it:
+ python cli.py add "write the lesson"
+ python cli.py list
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/03-version-control-for-words/lab/start/tasks.py b/modules/03-version-control-for-words/lab/start/tasks.py
new file mode 100644
index 0000000..6913314
--- /dev/null
+++ b/modules/03-version-control-for-words/lab/start/tasks.py
@@ -0,0 +1,39 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ 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)
diff --git a/modules/04-getting-the-ai-out-of-the-browser/README.md b/modules/04-getting-the-ai-out-of-the-browser/README.md
index 3e1a6f8..024be38 100644
--- a/modules/04-getting-the-ai-out-of-the-browser/README.md
+++ b/modules/04-getting-the-ai-out-of-the-browser/README.md
@@ -281,6 +281,18 @@ loop and the loop is unchanged.
## 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/04-getting-the-ai-out-of-the-browser/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 4"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell + a small Python change *made by the AI, not by you*. You'll drive an agentic
tool; the tool writes the Python.
diff --git a/modules/04-getting-the-ai-out-of-the-browser/lab/start/README.md b/modules/04-getting-the-ai-out-of-the-browser/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/04-getting-the-ai-out-of-the-browser/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/04-getting-the-ai-out-of-the-browser/lab/start/cli.py b/modules/04-getting-the-ai-out-of-the-browser/lab/start/cli.py
new file mode 100644
index 0000000..7dd143c
--- /dev/null
+++ b/modules/04-getting-the-ai-out-of-the-browser/lab/start/cli.py
@@ -0,0 +1,58 @@
+"""Tiny command-line front end for the demo task app.
+
+Run it:
+ python cli.py add "write the lesson"
+ python cli.py list
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/04-getting-the-ai-out-of-the-browser/lab/start/tasks.py b/modules/04-getting-the-ai-out-of-the-browser/lab/start/tasks.py
new file mode 100644
index 0000000..6913314
--- /dev/null
+++ b/modules/04-getting-the-ai-out-of-the-browser/lab/start/tasks.py
@@ -0,0 +1,39 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ 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)
diff --git a/modules/05-commit-the-ai-config/README.md b/modules/05-commit-the-ai-config/README.md
index 3891148..e9876b0 100644
--- a/modules/05-commit-the-ai-config/README.md
+++ b/modules/05-commit-the-ai-config/README.md
@@ -195,6 +195,18 @@ Three things make this specifically an AI problem, not a generic config chore:
## 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/05-commit-the-ai-config/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 5"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell + markdown, on the `tasks-app` project from Modules 1–2. You'll use your
editor-integrated AI (Module 4) for the part where the AI obeys the file.
diff --git a/modules/05-commit-the-ai-config/lab/start/README.md b/modules/05-commit-the-ai-config/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/05-commit-the-ai-config/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/05-commit-the-ai-config/lab/start/cli.py b/modules/05-commit-the-ai-config/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/05-commit-the-ai-config/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/05-commit-the-ai-config/lab/start/tasks.py b/modules/05-commit-the-ai-config/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/05-commit-the-ai-config/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/06-branches-sandboxes-for-experiments/README.md b/modules/06-branches-sandboxes-for-experiments/README.md
index 2b5db71..936e0f2 100644
--- a/modules/06-branches-sandboxes-for-experiments/README.md
+++ b/modules/06-branches-sandboxes-for-experiments/README.md
@@ -233,6 +233,18 @@ Everything above is standard Git. Here's why it matters *more* in an AI-assisted
## 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/06-branches-sandboxes-for-experiments/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 6"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git commands), driving the `tasks-app` from Modules 1–2 with your
editor-integrated AI from Module 4.
diff --git a/modules/06-branches-sandboxes-for-experiments/lab/start/README.md b/modules/06-branches-sandboxes-for-experiments/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/06-branches-sandboxes-for-experiments/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/06-branches-sandboxes-for-experiments/lab/start/cli.py b/modules/06-branches-sandboxes-for-experiments/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/06-branches-sandboxes-for-experiments/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/06-branches-sandboxes-for-experiments/lab/start/tasks.py b/modules/06-branches-sandboxes-for-experiments/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/06-branches-sandboxes-for-experiments/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/07-worktrees-running-agents-in-parallel/README.md b/modules/07-worktrees-running-agents-in-parallel/README.md
index 6d942b2..71c517b 100644
--- a/modules/07-worktrees-running-agents-in-parallel/README.md
+++ b/modules/07-worktrees-running-agents-in-parallel/README.md
@@ -213,6 +213,18 @@ to run two agents and watch them overwrite each other's work.
## 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/07-worktrees-running-agents-in-parallel/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 7"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git commands), plus two AI edit sessions on the `tasks-app`.
In this lab you'll run **two AI sessions at the same time** on the same project (one adding a
diff --git a/modules/07-worktrees-running-agents-in-parallel/lab/start/README.md b/modules/07-worktrees-running-agents-in-parallel/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/07-worktrees-running-agents-in-parallel/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/07-worktrees-running-agents-in-parallel/lab/start/cli.py b/modules/07-worktrees-running-agents-in-parallel/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/07-worktrees-running-agents-in-parallel/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/07-worktrees-running-agents-in-parallel/lab/start/tasks.py b/modules/07-worktrees-running-agents-in-parallel/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/07-worktrees-running-agents-in-parallel/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/08-remotes-and-hosting/README.md b/modules/08-remotes-and-hosting/README.md
index be0e11b..24b33d8 100644
--- a/modules/08-remotes-and-hosting/README.md
+++ b/modules/08-remotes-and-hosting/README.md
@@ -295,6 +295,18 @@ A remote isn't only about durability. It's what the AI parts of this course run
## 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/08-remotes-and-hosting/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 8"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git commands), plus one short provided shell script. Runs on macOS, Linux,
WSL, or Git Bash on Windows. Continues the `tasks-app` repo from Module 2.
diff --git a/modules/08-remotes-and-hosting/lab/start/README.md b/modules/08-remotes-and-hosting/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/08-remotes-and-hosting/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/08-remotes-and-hosting/lab/start/cli.py b/modules/08-remotes-and-hosting/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/08-remotes-and-hosting/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/08-remotes-and-hosting/lab/start/tasks.py b/modules/08-remotes-and-hosting/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/08-remotes-and-hosting/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/09-issues-and-the-task-layer/README.md b/modules/09-issues-and-the-task-layer/README.md
index b1fdf25..2607962 100644
--- a/modules/09-issues-and-the-task-layer/README.md
+++ b/modules/09-issues-and-the-task-layer/README.md
@@ -225,6 +225,18 @@ valuable, not less.
## 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/09-issues-and-the-task-layer/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 9"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** Markdown + shell, against the `tasks-app` repo you pushed to a forge in Module 8.
You'll draft issues as Markdown locally (so you can version and reuse the format), then have your
diff --git a/modules/09-issues-and-the-task-layer/lab/start/README.md b/modules/09-issues-and-the-task-layer/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/09-issues-and-the-task-layer/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/09-issues-and-the-task-layer/lab/start/cli.py b/modules/09-issues-and-the-task-layer/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/09-issues-and-the-task-layer/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/09-issues-and-the-task-layer/lab/start/tasks.py b/modules/09-issues-and-the-task-layer/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/09-issues-and-the-task-layer/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/10-reviewing-code-you-didnt-write/README.md b/modules/10-reviewing-code-you-didnt-write/README.md
index b948e9e..f0c75e6 100644
--- a/modules/10-reviewing-code-you-didnt-write/README.md
+++ b/modules/10-reviewing-code-you-didnt-write/README.md
@@ -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.
diff --git a/modules/10-reviewing-code-you-didnt-write/lab/start/cli.py b/modules/10-reviewing-code-you-didnt-write/lab/start/cli.py
new file mode 100644
index 0000000..72aea21
--- /dev/null
+++ b/modules/10-reviewing-code-you-didnt-write/lab/start/cli.py
@@ -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 | list | done ]")
+ 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:]))
diff --git a/modules/10-reviewing-code-you-didnt-write/lab/start/tasks.py b/modules/10-reviewing-code-you-didnt-write/lab/start/tasks.py
new file mode 100644
index 0000000..dd6d93b
--- /dev/null
+++ b/modules/10-reviewing-code-you-didnt-write/lab/start/tasks.py
@@ -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)
diff --git a/modules/11-collaboration-humans-and-agents/README.md b/modules/11-collaboration-humans-and-agents/README.md
index e72859f..fd84e87 100644
--- a/modules/11-collaboration-humans-and-agents/README.md
+++ b/modules/11-collaboration-humans-and-agents/README.md
@@ -253,6 +253,18 @@ You're not learning collaboration *and then* learning to work with agents. They'
## 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/11-collaboration-humans-and-agents/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 11"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell plus your host's web UI for the issue, PR, review, and merge steps. From
Module 4 on you direct the AI to do the git work and verify the result; the only commands you type by
hand here are read-only checks like `git branch` and `git show`. You'll implement the feature with
diff --git a/modules/11-collaboration-humans-and-agents/lab/start/README.md b/modules/11-collaboration-humans-and-agents/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/11-collaboration-humans-and-agents/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/11-collaboration-humans-and-agents/lab/start/cli.py b/modules/11-collaboration-humans-and-agents/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/11-collaboration-humans-and-agents/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/11-collaboration-humans-and-agents/lab/start/tasks.py b/modules/11-collaboration-humans-and-agents/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/11-collaboration-humans-and-agents/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/12-revert-reset-and-recovery/README.md b/modules/12-revert-reset-and-recovery/README.md
index 11a482b..aa5055b 100644
--- a/modules/12-revert-reset-and-recovery/README.md
+++ b/modules/12-revert-reset-and-recovery/README.md
@@ -221,6 +221,18 @@ Recovery was always a real skill. AI raises its value on every axis:
## 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/12-revert-reset-and-recovery/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 12"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git commands), on the `tasks-app` from Modules 1–2.
You'll do the two scenarios that matter most: **revert a bad merge** that's already on `main`, then
diff --git a/modules/12-revert-reset-and-recovery/lab/start/README.md b/modules/12-revert-reset-and-recovery/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/12-revert-reset-and-recovery/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/12-revert-reset-and-recovery/lab/start/cli.py b/modules/12-revert-reset-and-recovery/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/12-revert-reset-and-recovery/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/12-revert-reset-and-recovery/lab/start/tasks.py b/modules/12-revert-reset-and-recovery/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/12-revert-reset-and-recovery/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/13-testing-in-the-ai-era/README.md b/modules/13-testing-in-the-ai-era/README.md
index c32e923..f5bf42d 100644
--- a/modules/13-testing-in-the-ai-era/README.md
+++ b/modules/13-testing-in-the-ai-era/README.md
@@ -202,6 +202,18 @@ Passing for the right reason is the skill.
## 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/13-testing-in-the-ai-era/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 13"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** Python (standard-library `unittest`), with a couple of shell commands to run the
suite. Nothing to install.
diff --git a/modules/13-testing-in-the-ai-era/lab/start/README.md b/modules/13-testing-in-the-ai-era/lab/start/README.md
new file mode 100644
index 0000000..d031a55
--- /dev/null
+++ b/modules/13-testing-in-the-ai-era/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks` (Module 13 copy)
+
+The same tiny task tracker from Modules 1 and 2, with one feature added: a `count` command backed
+by `TaskList.pending_count()`. Use this copy for the Module 13 lab so everyone starts from the same
+code, including the same latent bug.
+
+If you already have a `tasks-app` from earlier modules, you can use that instead; just make sure it
+has a `count` command (the Module 2 lab added one). The planted bug in this copy is there on purpose.
+
+## Files
+
+- `tasks.py`: core logic (`Task`, `TaskList`), now with `pending_count()`.
+- `cli.py`: command-line front end. Adds `count`.
+
+## Run it
+
+```bash
+python cli.py add "write the tests"
+python cli.py add "fix the bug"
+python cli.py done 0
+python cli.py list
+python cli.py count
+```
+
+Requires Python 3.10+. No third-party packages; tests use the standard library `unittest`.
diff --git a/modules/13-testing-in-the-ai-era/lab/start/cli.py b/modules/13-testing-in-the-ai-era/lab/start/cli.py
new file mode 100644
index 0000000..0b0df68
--- /dev/null
+++ b/modules/13-testing-in-the-ai-era/lab/start/cli.py
@@ -0,0 +1,59 @@
+"""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 count
+
+State is kept in tasks.json next to this file. Same minimal app from Modules 1 and 2, with a
+`count` command bolted on.
+"""
+
+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 | list | done | count]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{tlist.pending_count()} task(s) pending")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/13-testing-in-the-ai-era/lab/start/tasks.py b/modules/13-testing-in-the-ai-era/lab/start/tasks.py
new file mode 100644
index 0000000..82daeb0
--- /dev/null
+++ b/modules/13-testing-in-the-ai-era/lab/start/tasks.py
@@ -0,0 +1,43 @@
+"""Core task logic for the demo app.
+
+Same running example from Modules 1 and 2, carried forward. It has grown one feature since then:
+a `pending_count()` helper that the AI added to back a `count` command. The feature "works" in
+the obvious case, which is exactly the kind of code this module teaches you to verify properly.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def pending(self) -> list[Task]:
+ return [t for t in self.tasks if not t.done]
+
+ def pending_count(self) -> int:
+ # Added by the AI to support `cli.py count`. Looks right, ran fine in a quick check.
+ return len(self.tasks)
+
+ 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)
diff --git a/modules/14-continuous-integration/README.md b/modules/14-continuous-integration/README.md
index d63fced..b82dfb4 100644
--- a/modules/14-continuous-integration/README.md
+++ b/modules/14-continuous-integration/README.md
@@ -205,6 +205,18 @@ the more you need a reviewer that checks behavior instead of believing the diff.
## 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/14-continuous-integration/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 14"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** YAML (the CI config) plus the Python `tasks-app` and shell commands. You direct
the agent to place files, commit, and recover; you commit a starter workflow, watch it pass, then
break it on purpose and watch CI catch it.
diff --git a/modules/14-continuous-integration/lab/start/README.md b/modules/14-continuous-integration/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/14-continuous-integration/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/14-continuous-integration/lab/start/cli.py b/modules/14-continuous-integration/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/14-continuous-integration/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/14-continuous-integration/lab/start/tasks.py b/modules/14-continuous-integration/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/14-continuous-integration/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/15-security-scanning/README.md b/modules/15-security-scanning/README.md
index ec16244..e31763f 100644
--- a/modules/15-security-scanning/README.md
+++ b/modules/15-security-scanning/README.md
@@ -203,6 +203,15 @@ add them *despite* using AI; using AI is what moves them from "nice to have" to
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/15-security-scanning/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/15-security-scanning/lab ~/ai-workflow-course/15-security-scanning-lab
+> cd ~/ai-workflow-course/15-security-scanning-lab && git init -b main && git add -A && git commit -m "start: module 15"
+> ```
**Lab language:** shell, driving Python tooling, on the `tasks-app` from Module 1. You'll install two
scanners (both pip-installable, cross-platform), let the AI introduce all three problems, catch them,
and wire the catch into your pipeline.
diff --git a/modules/16-containers-and-reproducible-environments/README.md b/modules/16-containers-and-reproducible-environments/README.md
index 1d55a38..f85e6ee 100644
--- a/modules/16-containers-and-reproducible-environments/README.md
+++ b/modules/16-containers-and-reproducible-environments/README.md
@@ -164,6 +164,18 @@ Docker itself you may already know. What makes containers matter *more* in AI-as
## 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/16-containers-and-reproducible-environments/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 16"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Docker CLI) on the `tasks-app` from Module 1. You won't write Python; you'll
containerize and run the app you already have.
diff --git a/modules/16-containers-and-reproducible-environments/lab/start/README.md b/modules/16-containers-and-reproducible-environments/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/16-containers-and-reproducible-environments/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/16-containers-and-reproducible-environments/lab/start/cli.py b/modules/16-containers-and-reproducible-environments/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/16-containers-and-reproducible-environments/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/16-containers-and-reproducible-environments/lab/start/tasks.py b/modules/16-containers-and-reproducible-environments/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/16-containers-and-reproducible-environments/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/17-secrets-config-and-environments/README.md b/modules/17-secrets-config-and-environments/README.md
index fa7da99..8d3ea35 100644
--- a/modules/17-secrets-config-and-environments/README.md
+++ b/modules/17-secrets-config-and-environments/README.md
@@ -277,6 +277,15 @@ model will keep offering it. Concretely:
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/17-secrets-config-and-environments/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/17-secrets-config-and-environments/lab ~/ai-workflow-course/17-secrets-config-and-environments-lab
+> cd ~/ai-workflow-course/17-secrets-config-and-environments-lab && git init -b main && git add -A && git commit -m "start: module 17"
+> ```
**Lab language:** Python + shell, on a new `sync` feature for the `tasks-app` from Module 1.
You'll take a file that hardcodes a secret (the exact thing an AI hands you) and refactor it so the
diff --git a/modules/18-continuous-delivery-and-deployment/README.md b/modules/18-continuous-delivery-and-deployment/README.md
index 9c38ea4..ae18025 100644
--- a/modules/18-continuous-delivery-and-deployment/README.md
+++ b/modules/18-continuous-delivery-and-deployment/README.md
@@ -199,6 +199,15 @@ between an autonomous contributor and your users.
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/18-continuous-delivery-and-deployment/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/18-continuous-delivery-and-deployment/lab ~/ai-workflow-course/18-continuous-delivery-and-deployment-lab
+> cd ~/ai-workflow-course/18-continuous-delivery-and-deployment-lab && git init -b main && git add -A && git commit -m "start: module 18"
+> ```
**Lab language:** shell, driving the container tooling from Module 16. You'll extend the `tasks-app`
into a tiny running service, then build a deploy script that ships it locally with a health check and
automatic rollback, the whole CD motion simulated on your own machine.
diff --git a/modules/19-runners-the-compute-behind-automation/README.md b/modules/19-runners-the-compute-behind-automation/README.md
index 78de079..2973fe9 100644
--- a/modules/19-runners-the-compute-behind-automation/README.md
+++ b/modules/19-runners-the-compute-behind-automation/README.md
@@ -202,6 +202,15 @@ review reflex from Module 10 has to extend to the workflow files, not just the a
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/19-runners-the-compute-behind-automation/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/19-runners-the-compute-behind-automation/lab ~/ai-workflow-course/19-runners-the-compute-behind-automation-lab
+> cd ~/ai-workflow-course/19-runners-the-compute-behind-automation-lab && git init -b main && git add -A && git commit -m "start: module 19"
+> ```
**Lab language:** shell, plus a one-line edit to the YAML workflow from Module 14. Runs on your own
machine and your own forge, with no hosted account required for the core of it.
diff --git a/modules/20-mcp-servers-giving-the-ai-hands/README.md b/modules/20-mcp-servers-giving-the-ai-hands/README.md
index 7fcb388..808b6e6 100644
--- a/modules/20-mcp-servers-giving-the-ai-hands/README.md
+++ b/modules/20-mcp-servers-giving-the-ai-hands/README.md
@@ -225,6 +225,18 @@ That changes what matters about the integration.
## 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/20-mcp-servers-giving-the-ai-hands/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 20"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** Python (a ~15-line MCP server) plus your agentic tool's config. Runs on your own
machine, any OS.
diff --git a/modules/20-mcp-servers-giving-the-ai-hands/lab/start/README.md b/modules/20-mcp-servers-giving-the-ai-hands/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/20-mcp-servers-giving-the-ai-hands/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/20-mcp-servers-giving-the-ai-hands/lab/start/cli.py b/modules/20-mcp-servers-giving-the-ai-hands/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/20-mcp-servers-giving-the-ai-hands/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/20-mcp-servers-giving-the-ai-hands/lab/start/tasks.py b/modules/20-mcp-servers-giving-the-ai-hands/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/20-mcp-servers-giving-the-ai-hands/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/21-skills-teaching-the-ai-your-playbook/README.md b/modules/21-skills-teaching-the-ai-your-playbook/README.md
index 4fb3b47..56bc24f 100644
--- a/modules/21-skills-teaching-the-ai-your-playbook/README.md
+++ b/modules/21-skills-teaching-the-ai-your-playbook/README.md
@@ -160,6 +160,18 @@ On paper this is just "write a runbook." The AI-specific twist is what changes t
## 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/21-skills-teaching-the-ai-your-playbook/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 21"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** markdown (the skill file) plus shell and Python (the `tasks-app`). You'll write a
skill, then have your editor-integrated AI (Module 4) execute it.
diff --git a/modules/21-skills-teaching-the-ai-your-playbook/lab/start/CHANGELOG.md b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/CHANGELOG.md
new file mode 100644
index 0000000..962e042
--- /dev/null
+++ b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Changelog
+
+Newest entries on top. One line per user-visible change.
+
+## Unreleased
+
+- Add `count` command: print how many tasks are still pending.
+- Add `done ` command: mark a task complete.
+- Initial CLI: `add` and `list`.
diff --git a/modules/21-skills-teaching-the-ai-your-playbook/lab/start/cli.py b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/cli.py
new file mode 100644
index 0000000..e1eb003
--- /dev/null
+++ b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/cli.py
@@ -0,0 +1,59 @@
+"""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 count
+
+State is kept in tasks.json next to this file. The same minimal app from Module 1 onward; the
+target your "add a command" skill extends.
+"""
+
+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 | list | done | count]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{tlist.pending_count()} task(s) pending")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/21-skills-teaching-the-ai-your-playbook/lab/start/tasks.py b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/tasks.py
new file mode 100644
index 0000000..62ab400
--- /dev/null
+++ b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/tasks.py
@@ -0,0 +1,41 @@
+"""Core task logic for the demo app.
+
+The same running example from Module 1 onward, carried forward with the `pending_count()` helper
+that backs the `count` command. This is the codebase your "add a command" skill operates on.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def pending(self) -> list[Task]:
+ return [t for t in self.tasks if not t.done]
+
+ def pending_count(self) -> int:
+ return len([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)
diff --git a/modules/21-skills-teaching-the-ai-your-playbook/lab/start/test_tasks.py b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/test_tasks.py
new file mode 100644
index 0000000..44ffcc7
--- /dev/null
+++ b/modules/21-skills-teaching-the-ai-your-playbook/lab/start/test_tasks.py
@@ -0,0 +1,44 @@
+"""Test suite for the tasks-app. Run from this folder with:
+
+ python -m unittest
+
+Your "add a command" skill should ADD a test here for every new command. The point is to assert
+intended behavior, not just that nothing crashed.
+"""
+
+import unittest
+
+from tasks import TaskList
+
+
+class TestTaskBasics(unittest.TestCase):
+ def test_add_appends_a_task(self):
+ tl = TaskList()
+ tl.add("write the skill")
+ self.assertEqual(len(tl.tasks), 1)
+ self.assertEqual(tl.tasks[0].title, "write the skill")
+ self.assertFalse(tl.tasks[0].done)
+
+ def test_complete_marks_done(self):
+ tl = TaskList()
+ tl.add("a")
+ tl.complete(0)
+ self.assertTrue(tl.tasks[0].done)
+
+ def test_pending_excludes_completed(self):
+ tl = TaskList()
+ tl.add("a")
+ tl.add("b")
+ tl.complete(0)
+ self.assertEqual([t.title for t in tl.pending()], ["b"])
+
+ def test_pending_count_ignores_done(self):
+ tl = TaskList()
+ tl.add("a")
+ tl.add("b")
+ tl.complete(0)
+ self.assertEqual(tl.pending_count(), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/modules/22-securing-third-party-mcp-and-skills/README.md b/modules/22-securing-third-party-mcp-and-skills/README.md
index 28fc985..bb30868 100644
--- a/modules/22-securing-third-party-mcp-and-skills/README.md
+++ b/modules/22-securing-third-party-mcp-and-skills/README.md
@@ -195,6 +195,15 @@ skills different from any dependency you've shipped before:
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/22-securing-third-party-mcp-and-skills/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/22-securing-third-party-mcp-and-skills/lab ~/ai-workflow-course/22-securing-third-party-mcp-and-skills-lab
+> cd ~/ai-workflow-course/22-securing-third-party-mcp-and-skills-lab && git init -b main && git add -A && git commit -m "start: module 22"
+> ```
**Lab language:** shell, with a small Python file to read. You'll audit a deliberately sketchy
third-party skill, run a static red-flag scan over it, then reproduce a prompt-injection attack
against the Module 1 `tasks-app` and apply the least-privilege mitigation.
diff --git a/modules/23-working-with-existing-codebases/README.md b/modules/23-working-with-existing-codebases/README.md
index 3401d09..5c32d8c 100644
--- a/modules/23-working-with-existing-codebases/README.md
+++ b/modules/23-working-with-existing-codebases/README.md
@@ -162,6 +162,15 @@ into a revertable diff.
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/23-working-with-existing-codebases/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/23-working-with-existing-codebases/lab ~/ai-workflow-course/23-working-with-existing-codebases-lab
+> cd ~/ai-workflow-course/23-working-with-existing-codebases-lab && git init -b main && git add -A && git commit -m "start: module 23"
+> ```
**Lab language:** shell + the provided Python script (`orient.py`); you run it, you don't write it.
This lab does **not** use `tasks-app`; the entire point is a codebase you *didn't* write.
diff --git a/modules/24-assistive-agents/README.md b/modules/24-assistive-agents/README.md
index 29dda03..42abd20 100644
--- a/modules/24-assistive-agents/README.md
+++ b/modules/24-assistive-agents/README.md
@@ -167,6 +167,15 @@ occasionally wrong with no consequences.
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/24-assistive-agents/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/24-assistive-agents/lab ~/ai-workflow-course/24-assistive-agents-lab
+> cd ~/ai-workflow-course/24-assistive-agents-lab && git init -b main && git add -A && git commit -m "start: module 24"
+> ```
**Lab language:** Python (two small stdlib-only scripts) driven by Claude Code (`claude`; sub your
own agent). No `pip install`, no hosted account. The scripts do the deterministic halves (assemble
the prompt, validate and render the response, present the decision gate); the model does the one part
diff --git a/modules/25-autonomous-agents/README.md b/modules/25-autonomous-agents/README.md
index be98d47..e8000a6 100644
--- a/modules/25-autonomous-agents/README.md
+++ b/modules/25-autonomous-agents/README.md
@@ -202,6 +202,15 @@ the job is non-deterministic and persuasive**, and that changes what "automation
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/25-autonomous-agents/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/25-autonomous-agents/lab ~/ai-workflow-course/25-autonomous-agents-lab
+> cd ~/ai-workflow-course/25-autonomous-agents-lab && git init -b main && git add -A && git commit -m "start: module 25"
+> ```
**Lab language:** Python (one orchestrator script) plus a little shell and Git. It runs on your own
machine, any OS, against the `tasks-app` repo from Module 1, with no forge account or paid agent
required to complete it.
diff --git a/modules/26-orchestrating-multiple-agents/README.md b/modules/26-orchestrating-multiple-agents/README.md
index 9bc6a1f..604e881 100644
--- a/modules/26-orchestrating-multiple-agents/README.md
+++ b/modules/26-orchestrating-multiple-agents/README.md
@@ -254,6 +254,18 @@ imaginary, and that the fix was a ten-minute coordination plan you skipped.
## 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/26-orchestrating-multiple-agents/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 26"
+> ```
+>
+> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
**Lab language:** shell (Git + a couple of helper scripts) driving multiple AI edit sessions on the
`tasks-app`, integrated through PRs.
diff --git a/modules/26-orchestrating-multiple-agents/lab/start/README.md b/modules/26-orchestrating-multiple-agents/lab/start/README.md
new file mode 100644
index 0000000..d963b11
--- /dev/null
+++ b/modules/26-orchestrating-multiple-agents/lab/start/README.md
@@ -0,0 +1,25 @@
+# Demo app: `tasks`
+
+A deliberately tiny command-line task tracker. It exists to be *changed by an AI*, so it's small
+enough to read in a minute but real enough to have more than one file, which is exactly where the
+copy-paste workflow starts to hurt.
+
+This is the running example for **Module 1** (where you feel the copy-paste problem) and **Module 2**
+(where you put it under version control).
+
+## Files
+
+- `tasks.py`: the core logic (`Task`, `TaskList`).
+- `cli.py`: the command-line front end. Reads/writes `tasks.json`.
+
+## Run it
+
+```bash
+python cli.py add "read module 1"
+python cli.py add "set up my editor"
+python cli.py list
+python cli.py done 0
+python cli.py list
+```
+
+Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
diff --git a/modules/26-orchestrating-multiple-agents/lab/start/cli.py b/modules/26-orchestrating-multiple-agents/lab/start/cli.py
new file mode 100644
index 0000000..91799e9
--- /dev/null
+++ b/modules/26-orchestrating-multiple-agents/lab/start/cli.py
@@ -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
+
+State is kept in tasks.json next to this file. It's intentionally minimal; the point of this app
+is to be a realistic-but-small thing you change with an AI, not a product.
+"""
+
+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 | list | done | count | delete ]")
+ 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":
+ tlist.complete(int(argv[1]))
+ save(tlist)
+ print("updated")
+ elif command == "count":
+ print(f"{len(tlist.pending())} pending")
+ elif command == "delete":
+ tlist.remove(int(argv[1]))
+ save(tlist)
+ print("deleted")
+ else:
+ print(f"unknown command: {command}")
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/modules/26-orchestrating-multiple-agents/lab/start/tasks.py b/modules/26-orchestrating-multiple-agents/lab/start/tasks.py
new file mode 100644
index 0000000..3be57d9
--- /dev/null
+++ b/modules/26-orchestrating-multiple-agents/lab/start/tasks.py
@@ -0,0 +1,42 @@
+"""Core task logic for the demo app.
+
+Deliberately small and deliberately split across two files (this and cli.py) so that the
+copy-paste workflow has more than one place to go wrong. This is the running example used in
+Modules 1 and 2.
+"""
+
+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:
+ self.tasks[index].done = True
+
+ def remove(self, index: int) -> None:
+ del self.tasks[index]
+
+ 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)
diff --git a/modules/27-evals/README.md b/modules/27-evals/README.md
index 8301fe2..e63369f 100644
--- a/modules/27-evals/README.md
+++ b/modules/27-evals/README.md
@@ -212,6 +212,15 @@ That's the durable skill. Models are weather. The eval set is the thermometer yo
## Hands-on lab
+
+> **Starting point (this lab is skip-friendly).** This lab is self-contained and does not depend
+> on the earlier labs. Its files live in `modules/27-evals/lab/`. Copy them into a working folder
+> and make a first commit so you start clean:
+>
+> ```bash
+> cp -r ~/ai-workflow-course/modules/27-evals/lab ~/ai-workflow-course/27-evals-lab
+> cd ~/ai-workflow-course/27-evals-lab && git init -b main && git add -A && git commit -m "start: module 27"
+> ```
**Lab language:** Python + shell. You'll run a tiny eval harness, point an agent at a task, and run
a regression eval across a "model swap."