Compare commits

28 Commits

Author SHA1 Message Date
claude 562197c1e0 Inline screenshots: Unit 3 (red CI) and Capstone (green CI) (#128)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 10s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 17:19:32 -04:00
claude 019426b7d1 Inline screenshots: M12, Unit 4, Unit 5 (final terminal batch) (#127)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 10s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 16:59:28 -04:00
claude 7c5920dc1a Inline screenshots for the M11 (collaboration) post (#126)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 10s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 16:03:27 -04:00
claude 361ae4bb8d Port PR #2 from GitHub: tighten tasks-app scope note (#125)
CI / check (push) Successful in 6s
Sync course wiki / sync-wiki (push) Successful in 3s
Sync to GitHub mirror / sync (push) Successful in 11s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 15:58:03 -04:00
claude f61c46fe19 Inline screenshot for the M10 (reviewing) blog post (#124)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 8s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 15:45:48 -04:00
claude bdbb91f1d1 Inline screenshot for the M9 (issues) blog post (#123)
CI / check (push) Successful in 8s
Sync to GitHub mirror / sync (push) Successful in 9s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 15:37:00 -04:00
claude 164559fcb5 Inline screenshots for the M8 blog post (#122)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 9s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 15:16:39 -04:00
claude 585aeabd9a Inline screenshot for the M7 (worktrees) blog post (#121)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 9s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 15:01:51 -04:00
claude efc5161698 Inline terminal screenshot for the M6 blog post (#120)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 8s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 14:58:03 -04:00
claude 60989dc640 Inline terminal screenshot for the M5 blog post (#119)
CI / check (push) Successful in 8s
Sync to GitHub mirror / sync (push) Successful in 8s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 14:53:32 -04:00
claude 5c745e9748 Inline terminal screenshots for the M4 blog post (#118)
CI / check (push) Successful in 8s
Sync to GitHub mirror / sync (push) Successful in 9s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-02 14:43:53 -04:00
claude 1be4e5b56c Inline terminal screenshots for the M3 blog post (#117)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 8s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-01 10:42:53 -04:00
claude 11b1995d77 Crop inline M2 screenshots to terminal-only (#116)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 11s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-01 10:37:30 -04:00
claude c4479e1041 Inline terminal screenshots for the M2 blog post (#115)
CI / check (push) Successful in 6s
Sync to GitHub mirror / sync (push) Successful in 8s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-07-01 10:31:00 -04:00
claude dec55e4c9f GitHub social-preview image (1280x640) (#114)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 9s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 23:25:34 -04:00
claude 75d9e2b419 Featured images for the 17 blog posts + square hero (#113)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 10s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 22:54:22 -04:00
claude 125802616d Drop the bad gh-mirror/ exclude (it blocked the auto-heal) (#112)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 10s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 21:43:45 -04:00
claude c46715b811 Fix sync workflow self-include (polluted GitHub with gh-mirror/) (#111)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 9s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 21:41:13 -04:00
claude 9b6658563b Auto-sync Gitea main to GitHub mirror on every push (#110)
CI / check (push) Successful in 7s
Sync to GitHub mirror / sync (push) Successful in 10s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 21:38:40 -04:00
claude 556b5a7256 M15: change planted secret pattern + note hosted-forge push protection (#109)
Sync course wiki / sync-wiki (push) Successful in 5s
CI / check (push) Successful in 7s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 21:12:57 -04:00
claude 70d91722b7 Prep docs/syllabus/README for github.com/recklessop public mirror (#108)
Sync course wiki / sync-wiki (push) Successful in 5s
CI / check (push) Successful in 7s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-24 21:10:08 -04:00
justin 58f54ce745 Module 8: walk through GitHub PAT setup, link SSH as optional (#106) (#107)
Sync course wiki / sync-wiki (push) Successful in 4s
CI / check (push) Successful in 7s
Co-authored-by: Justin Paul <justin@jpaul.me>
Co-committed-by: Justin Paul <justin@jpaul.me>
2026-06-23 20:32:04 -04:00
claude 95e5911957 Use python3 as the canonical command name course-wide (#104) (#105)
CI / check (push) Successful in 7s
Sync course wiki / sync-wiki (push) Successful in 4s
2026-06-23 20:25:05 -04:00
claude 7f439212ac Self-contained, skip-friendly lab starting points (#103)
Sync course wiki / sync-wiki (push) Successful in 5s
CI / check (push) Successful in 6s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-23 18:24:36 -04:00
justin 74f23534c0 Module prereqs: sort Prerequisites lists numerically ascending (#102)
CI / check (push) Successful in 5s
Sync course wiki / sync-wiki (push) Successful in 5s
2026-06-23 14:06:28 -04:00
claude edf3f34336 Add CI (build+test) and tools/check.sh (#101)
CI / check (push) Successful in 5s
Co-authored-by: claude <claude@jpaul.io>
Co-committed-by: claude <claude@jpaul.io>
2026-06-23 09:48:32 -04:00
justin 8e0ae0d58a Merge pull request 'Module 6: reframe Part C around the AI silently auto-resolving conflicts' (#99) from claude/issue-97 into main
Sync course wiki / sync-wiki (push) Successful in 5s
Reviewed-on: #99
2026-06-23 09:07:34 -04:00
justin 0f8e7497a4 Module 6: reframe Part C around the AI silently auto-resolving conflicts (#97)
A current frontier editor-agent told to "merge X into Y" resolves the
conflict and completes the merge in one turn, so the learner never sees a
marker. The old Part C assumed Git would stop and ask. Rework the lab into
a three-beat sequence: witness the conflict once (agent stop-on-conflict
idiom, as in Module 26), undo it with `git merge --abort`, then let the AI
merge for real and auto-resolve while the learner does the one job still
theirs: verify with `git diff` after every merge.

Updates the matching surfaces so they tell one story: learning objective
#4, the Merge-conflicts key concept, the AI-angle bullet, the
Where-it-breaks bullet, Check-for-understanding, the blog mirror, and the
make-conflict.sh on-screen guidance (read the markers yourself first).

Closes #97

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCv6VTpBG6Zo4xR4AvUQpj
2026-06-23 09:03:40 -04:00
176 changed files with 3484 additions and 387 deletions
+25
View File
@@ -0,0 +1,25 @@
# PR + push CI for the course. Reports a commit status the claude-deck autopilot
# review gate reads, and runs the same build/test the gate runs on the merged tree:
# build = render the wiki from this tree (proves the generator works)
# test = tools/check.sh (lab compile + parse + no-slop guard + structure)
name: CI
on:
pull_request: {}
push:
branches: [main]
workflow_dispatch: {}
jobs:
check:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: build (render wiki) + test (check.sh)
shell: bash
run: |
set -euo pipefail
command -v python3 >/dev/null || { apt-get update && apt-get install -y --no-install-recommends python3 python3-pip; }
python3 -c "import yaml" 2>/dev/null || python3 -m pip install --quiet pyyaml 2>/dev/null || true
python3 tools/build_wiki.py --repo-root . --out /tmp/awc-wiki-build \
--web-base https://git.jpaul.io/justin/ai-workflow-course --branch main --host gitea
bash tools/check.sh
+85
View File
@@ -0,0 +1,85 @@
# Auto-sync this Gitea repo to its public GitHub mirror on every push to main.
#
# Design (deliberate trade-offs):
# - Push-driven from Gitea (this repo IS the source of truth); GitHub is a mirror.
# - Each sync = one snapshot commit on GitHub referencing the source Gitea SHA.
# GitHub gets a real, growing history (one commit per Gitea push that changed
# the mirrored tree); NO force-push, NO history rewrites.
# - The mirror tree is FILTERED: `blog/`, `handoff.md`, `.claude/`, `.gitea/`,
# and the usual generated junk are never copied. They are not in the GitHub
# history either (per the original "do not push these" rule).
# - If a Gitea push touches only excluded paths, the rsync produces no diff and
# the workflow exits clean (no empty commit on GitHub).
#
# Prereqs (one-time):
# - Repo secret GH_MIRROR_TOKEN holds a GitHub PAT with `repo` scope (push to
# recklessop/ai-workflow-course). Name avoids the GITHUB_ reserved prefix.
# - The GitHub mirror exists at github.com/recklessop/ai-workflow-course.
name: Sync to GitHub mirror
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: sync-github-mirror
cancel-in-progress: false # serialize; never cancel a sync mid-push
jobs:
sync:
runs-on: docker
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Sync filtered tree to GitHub
shell: bash
env:
GH_MIRROR_TOKEN: ${{ secrets.GH_MIRROR_TOKEN }}
run: |
set -euo pipefail
if [ -z "${GH_MIRROR_TOKEN:-}" ]; then
echo "::error::GH_MIRROR_TOKEN secret not set; see this workflow's header."
exit 1
fi
command -v rsync >/dev/null || { apt-get update && apt-get install -y --no-install-recommends rsync; }
GH_REPO="recklessop/ai-workflow-course"
SRC_SHA="$(git rev-parse --short HEAD)"
# Clone the GitHub mirror into a sibling working dir
GH_DIR="${RUNNER_TEMP:-/tmp}/awc-gh-mirror"; rm -rf "$GH_DIR"; git clone --depth=1 "https://x-access-token:${GH_MIRROR_TOKEN}@github.com/${GH_REPO}.git" "$GH_DIR"
# Mirror this checkout's tree into gh-mirror/ with the exclusions.
# --delete drops files removed on the source; --exclude='.git' protects
# both repos' .git dirs from rsync touching them.
# NOTE: do NOT add an --exclude for the clone target dir name; rsync's --exclude also
# protects the matching path at the DESTINATION from --delete, which would prevent
# any stray copy of that dir from ever being cleaned up. The clone living in
# $RUNNER_TEMP (outside ./) already prevents the recursive self-include.
rsync -a --delete \
--exclude='.git' \
--exclude='.gitea/' \
--exclude='.claude/' \
--exclude='blog/' \
--exclude='handoff.md' \
--exclude='__pycache__/' \
--exclude='*.pyc' \
--exclude='tasks.json' \
--exclude='.DS_Store' \
./ "$GH_DIR"/
cd "$GH_DIR"
git add -A
if git diff --cached --quiet; then
echo "no relevant changes for the mirror (source push only touched excluded paths); skipping"
exit 0
fi
git config user.name "Justin Paul"
git config user.email "justin@jpaul.me"
git commit -m "sync from gitea @ ${SRC_SHA}"
# Plain push: each sync is a fast-forward append (no rewrites). If a
# stranger pushed to GitHub main between clone and push, --force-with-lease
# would tell us; here we let it fail loudly so we notice the divergence.
git push origin HEAD:main
+25
View File
@@ -0,0 +1,25 @@
# PR + push CI for the GitHub mirror. Mirrors .gitea/workflows/ci.yml:
# build = render the wiki from this tree; test = tools/check.sh.
name: CI
on:
pull_request: {}
push:
branches: [main]
workflow_dispatch: {}
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.x'
- name: build (render wiki) + test (check.sh)
shell: bash
run: |
set -euo pipefail
python3 -m pip install --quiet pyyaml || true
python3 tools/build_wiki.py --repo-root . --out /tmp/awc-wiki-build \
--web-base https://github.com/recklessop/ai-workflow-course --branch main --host github
bash tools/check.sh
+4 -4
View File
@@ -18,7 +18,7 @@ built on a branch and merged through review, the same motion the modules teach.
## Read it as a book
The lessons render into the **[Wiki](https://git.jpaul.io/justin/ai-workflow-course/wiki)** as a
The lessons render into the **[Wiki](https://github.com/recklessop/ai-workflow-course/wiki)** as a
navigable textbook (unit-by-unit sidebar, one page per module, prev/next links). The wiki is
generated from `modules/` and kept in sync automatically; it's build output, so read it there but
**edit the lessons here in `modules/`**. See [`tools/`](tools/) for the generator and the sync
@@ -92,7 +92,6 @@ ai-workflow-course/
README.md # this file
AGENTS.md # committed AI instructions; dogfoods Module 5 (vendor-neutral name)
the-workflow-syllabus.md # the full course plan (source of truth for structure)
handoff.md # build-context notes for the authoring sessions
_TEMPLATE.md # the shape every module follows
modules/
01-the-copy-paste-problem/
@@ -112,5 +111,6 @@ ai-workflow-course/
## Status
All 27 modules and the capstone are written and reviewed. The lessons render to the
[Wiki](https://git.jpaul.io/justin/ai-workflow-course/wiki) as a textbook, kept in sync from
`modules/` by CI. Blog drafts for jpaul.me live under [`blog/`](blog/).
[Wiki](https://github.com/recklessop/ai-workflow-course/wiki) as a textbook, kept in sync from
`modules/` by CI. Each lab is skip-friendly: copy that module's `lab/start/` snapshot into a
fresh `tasks-app`, commit, and run that lab without doing the earlier ones.
@@ -52,7 +52,7 @@ Here's what to get in place. You'll use all of it for the rest of the course.
**A code editor.** Any will do, but a graphical one like VS Code is the easiest starting point; later modules build on editor-integrated AI tools, and VS Code is the path of least resistance there.
**Python 3.10 or newer.** Check with `python --version` or `python3 --version`. Whichever one prints a 3.10+ version is the command you'll use everywhere from here on. (On current macOS and default Ubuntu, it's usually `python3`; if `python` says "command not found," just read every `python` in the labs as `python3`.)
**Python 3.10 or newer.** The labs are written with `python3`, the command current macOS and default Ubuntu actually ship (they install Python only as `python3`, with no bare `python` on PATH). Check with `python3 --version`; if it prints a 3.10+ version, use `python3` everywhere from here on. If `python3` says "command not found" but `python --version` shows 3.10+ (older or some Windows setups), just read every `python3` in the labs as `python` instead.
**Your usual AI chat assistant,** open in a browser tab. Any of them. Remember: model-agnostic.
+1 -1
View File
@@ -120,7 +120,7 @@ git commit -m "Add count command"
git status # shows tasks.py as modified
git restore tasks.py # discard the change, back to your last commit, byte for byte
git diff # empty. nothing changed. you're clean.
python cli.py list # works again
python3 cli.py list # works again
```
That's it. You just recovered from a bad AI change in one command, with zero retyping and zero guesswork. Sit with how *cheap* that was for a second; that cheapness is the thing that lets you say yes to riskier AI work for the rest of the course.
+2 -2
View File
@@ -10,7 +10,7 @@ Tags: AI, developer workflow, version control, configuration, AGEN
# Commit the AI's Config, Not Just the Code
I used to start every AI coding session the same way: by giving the same little speech. "We use four-space indent. Run the tests with `python -m unittest` before you tell me it works. The logic goes in `tasks.py`, not crammed into the CLI file. And whatever you do, don't hand-edit `tasks.json`; it's generated."
I used to start every AI coding session the same way: by giving the same little speech. "We use four-space indent. Run the tests with `python3 -m unittest` before you tell me it works. The logic goes in `tasks.py`, not crammed into the CLI file. And whatever you do, don't hand-edit `tasks.json`; it's generated."
The AI would nod (figuratively), do exactly that, and we'd have a great session. Then I'd close the tab. The next morning I'd open a fresh one, and the AI had forgotten every word of it. So I'd give the speech again. And again. I was a broken record reading my own project back to a goldfish.
@@ -27,7 +27,7 @@ Different vendors look for different filenames, and honestly, the names keep cha
So what goes in it? Not a prompt, and not your README. This is a briefing for an agent that's about to edit your code. Keep it to things that actually change the AI's behavior:
- **Project conventions**: the layout and patterns this codebase actually uses. *"Core logic lives in `tasks.py`; the CLI front end is `cli.py`; state persists to `tasks.json`."*
- **Build and test commands**: the exact, copy-pasteable commands. *"Run tests with `python -m unittest`. Don't claim a change works until they pass."* That one line stops the AI from inventing a test runner you don't use.
- **Build and test commands**: the exact, copy-pasteable commands. *"Run tests with `python3 -m unittest`. Don't claim a change works until they pass."* That one line stops the AI from inventing a test runner you don't use.
- **Coding standards**: *"Standard library only, no third-party packages. Type-hint public functions."*
- **The don't-touch list**: generated files, vendored code, secrets. *"Never edit `tasks.json` by hand; it's generated."*
- **House style**: the taste calls that otherwise come back wrong every time. *"Keep functions small. Don't reformat files you aren't changing."*
+24 -26
View File
@@ -79,9 +79,9 @@ Let it edit `tasks.py` and `cli.py` freely. This is a multi-file change: exactly
```bash
git diff # read what it actually changed
python cli.py add "ship module 6" --priority high
python cli.py add "water plants" --priority low
python cli.py list # see if priorities work and sort
python3 cli.py add "ship module 6" --priority high
python3 cli.py add "water plants" --priority low
python3 cli.py list # see if priorities work and sort
git add .
git commit -m "Add task priorities (experiment)"
```
@@ -90,7 +90,7 @@ The payoff: prove the isolation. Switch back to `main` and watch the whole featu
```bash
git switch main
python cli.py list # no priorities: main is exactly as you left it
python3 cli.py list # no priorities: main is exactly as you left it
```
Sit with that for a second. Your bold change exists *only* on the branch. `main` never saw it. That's the entire point of the module in two commands.
@@ -103,7 +103,7 @@ Sit with that for a second. Your bold change exists *only* on the branch. `main`
git switch main
git merge experiment/priorities # likely a fast-forward: main slides up to the branch
git log --oneline --graph # straight line = fast-forward
python cli.py list # the feature is now on main
python3 cli.py list # the feature is now on main
git branch -d experiment/priorities # branch did its job; -d is the safe delete
```
@@ -121,41 +121,39 @@ That's it. Notice what you did *not* do: no file-by-file restore, no manual undo
This is the mental shift the module is selling. When discarding is *this* cheap, you stop being precious about what you let the AI try. Risky refactor? Branch it. Want to compare two approaches? A branch each; keep the winner, delete the loser. The branch becomes your unit of "maybe."
## Merge conflicts: when two changes collide (and the AI helps)
## Merge conflicts: when two changes collide (and the AI resolves them before you see them)
Most merges just work; Git is genuinely good at combining changes that touch *different* lines. A **conflict** only happens when two branches changed the *same* lines in different ways, and Git refuses to guess which you meant. It stops and marks the collision right inside the file:
```python
<<<<<<< HEAD
print("usage: python cli.py [add <title> | list | done <index> | purge]")
print("usage: python3 cli.py [add <title> | list | done <index> | purge]")
=======
print("usage: python cli.py [add <title> | list | done <index> | stats]")
print("usage: python3 cli.py [add <title> | list | done <index> | stats]")
>>>>>>> feature/stats
```
Read it like this. Everything from `<<<<<<< HEAD` to `=======` is **your current branch's version**. Everything from `=======` to `>>>>>>> feature/stats` is **the incoming version**. The markers are real text Git inserted into your file. Resolving means editing the file so it holds the version you want (often a blend of both, here a usage string listing *both* commands) and deleting all three marker lines.
You can manufacture exactly this in `tasks-app`: make one branch where the AI adds a `stats` command (updating the usage string), then a *separate* branch off `main` where it adds a `purge` command (also updating the usage string). Both edit the same line. Merge one into the other and Git stops cold:
Here's the twist, and it's the reason I'm not going to hand you a "read the markers, edit them out" drill and call it a skill. You can manufacture exactly this collision in `tasks-app`: make one branch where the AI adds a `stats` command (updating the usage string), then a *separate* branch off `main` where it adds a `purge` command (also updating the usage string). Both edit the same line. Then tell a current editor-agent to "merge `feature/stats` into `feature/purge`," and watch what *doesn't* happen: it doesn't stop. It reads both sides, picks the resolution, finishes the merge, and reports a clean result, all in one turn. You never see a marker. From your chair the conflict simply didn't occur.
That's the sweet spot for the AI (a small, perfectly bounded reasoning task with both sides and the surrounding code right there) and it's also the trap. So do this once, deliberately, to see the machine: ask it to stop instead of resolving.
> *"Merge `feature/stats` into `feature/purge`. If it conflicts, stop and show me the conflict; don't resolve it yet."*
Now Git pauses on the unmerged file and you can read the markers above with your own eyes. Then `git merge --abort` to rewind, and let the agent do it for real with no guard rail, the way you actually would:
> *"Merge `feature/stats` into `feature/purge`; the usage line collides, and the final version should list BOTH commands."*
It resolves silently and the merge lands. And here is the only part that's still your job, conflict or no conflict:
```bash
git merge feature/stats
git status # cli.py listed under "Unmerged paths"
git diff HEAD~1 # what the merge actually changed; confirm no markers, both commands present
python3 cli.py # run it: see the merged usage string
python3 cli.py stats && python3 cli.py purge # both actually work
```
And here's where editor-integrated AI earns its keep, because a merge conflict is *the* sweet spot for it: a small, perfectly bounded reasoning task with both sides and the surrounding code right there. Ask:
> *"`cli.py` has a merge conflict on the usage line. I want the final version to list BOTH the `stats` and `purge` commands. Resolve the conflict and remove the markers."*
It should hand back a single marker-free line. Then you settle it with Git:
```bash
git diff # check ONLY what you intended changed; no markers remain
python cli.py # run it: see the merged usage string
git add cli.py
git commit # opens an editor for the merge message; save and close
```
Once you can read those three lines of markers, conflicts stop being scary and become a five-minute chore. The syntax is identical no matter the file or the project. (And if your AI's edits didn't happen to collide (they're nondeterministic), the course ships a little `make-conflict.sh` helper that manufactures one deterministically so you can still practice.)
That `git diff` after *every* merge is the whole skill now. Not "edit the markers by hand," which the AI did for you before you could blink, but "know a conflict can happen and check the silent resolution," because a resolution that runs cleanly can still be wrong and it won't leave an error behind to warn you. (And if your AI's edits didn't happen to collide (they're nondeterministic), the course ships a little `make-conflict.sh` helper that manufactures one deterministically so you can still see the markers at least once.)
## The AI angle: why this matters *more* now
@@ -177,7 +175,7 @@ The honest limits, so you don't over-trust the sandbox:
## You're done when
You've created a branch, let the AI make a multi-file change on it, and confirmed `main` was untouched by switching back and watching the change vanish. You've **discarded** an experiment with `git branch -D` and seen `main` show no trace, and you've **merged** one in and seen it land. You can explain in one sentence why a branch costs essentially nothing (it's a movable pointer, not a copy). And you've read those `<<<<<<<` / `=======` / `>>>>>>>` markers, resolved a real conflict to a clean file that runs, and completed the merge.
You've created a branch, let the AI make a multi-file change on it, and confirmed `main` was untouched by switching back and watching the change vanish. You've **discarded** an experiment with `git branch -D` and seen `main` show no trace, and you've **merged** one in and seen it land. You can explain in one sentence why a branch costs essentially nothing (it's a movable pointer, not a copy). And you've seen those `<<<<<<<` / `=======` / `>>>>>>>` markers at least once, then watched the AI merge for real and resolve the conflict silently, and you verified the result with `git diff` even though no marker was ever shown to you.
When "let the agent try something wild" feels like a one-line decision instead of a risk assessment, you've got it.
+2 -2
View File
@@ -119,8 +119,8 @@ git worktree list
Then you point one editor/AI session at `tasks-app-wipe` and a second at `tasks-app-remaining`, and let both work at the same time. While they run, you can prove the isolation from a third terminal:
```bash
cd ~/ai-workflow-course/tasks-app-wipe && python cli.py add "from worktree A" && python cli.py list
cd ~/ai-workflow-course/tasks-app-remaining && python cli.py add "from worktree B" && python cli.py list
cd ~/ai-workflow-course/tasks-app-wipe && python3 cli.py add "from worktree A" && python3 cli.py list
cd ~/ai-workflow-course/tasks-app-remaining && python3 cli.py add "from worktree B" && python3 cli.py list
```
Each `list` shows only its own task. Worktree A never sees "from worktree B." Each worktree even has its own `tasks.json` runtime state: separate files, separate state, while both agents work. Total isolation. When they're done, each commit lands on its own branch, and bringing both home is trivial because it's all already in one repo:
+2 -2
View File
@@ -53,7 +53,7 @@ Nobody (human or agent) can do anything with that without coming back to ask you
> **Title:** `done` command crashes on an out-of-range or non-integer index
>
> **Context:** `python cli.py done 99` on a list with 3 tasks raises an uncaught `IndexError` and dumps a traceback. `python cli.py done abc` raises `ValueError`. Either way the user sees a stack trace instead of a helpful message.
> **Context:** `python3 cli.py done 99` on a list with 3 tasks raises an uncaught `IndexError` and dumps a traceback. `python3 cli.py done abc` raises `ValueError`. Either way the user sees a stack trace instead of a helpful message.
>
> **Acceptance criteria:**
> - `done <index>` with an out-of-range index prints a clear error (e.g. `no task at index 99`) and exits non-zero.
@@ -109,7 +109,7 @@ The reframe: writing a clear issue used to be a courtesy to your teammates. Now
The lab is deliberately low-stakes: you're writing issues, not code, so your AI assistant can stay in a browser tab. Against the `tasks-app` repo you pushed to a forge:
1. **Find three real pieces of work.** A bug (`python cli.py done 99` and `done abc` both crash (run them and watch)), a small patterned feature (`delete <index>`, mirroring `done`), and a judgment-heavy one (task priorities).
1. **Find three real pieces of work.** A bug (`python3 cli.py done 99` and `done abc` both crash (run them and watch)), a small patterned feature (`delete <index>`, mirroring `done`), and a judgment-heavy one (task priorities).
2. **Draft all three as well-formed issues:** title, context with repro steps, acceptance criteria, out-of-scope. This is a great place to *use* the AI: paste a file, ask it to draft acceptance criteria, then **edit them down.** The model over-produces; tightening its draft is exactly the skill.
3. **Create, label, and route them.** Assign the priorities feature to a human (it has open design questions). Earmark the bug and the `delete` feature for an agent: actual agent assignee, an `agent-ready` label, or just a note saying "suitable for an issue-to-PR agent." The mechanism doesn't matter yet; the *decision* does.
4. **Write one sentence per issue explaining why it went where it went**, in terms of the issue's clarity, not the model's smarts. That sentence *is* the routing skill.
+2 -2
View File
@@ -55,7 +55,7 @@ And here's the part people resist: this holds **even when you're the only human
Talk is cheap, so here's the lab the course runs, compressed. You've got a tiny `tasks-app`, a command-line to-do list. In the base version, `complete()` validates the index, so `done 99` on a list with three tasks gives you a clean, loud error and a non-zero exit code:
```bash
python cli.py done 99 # prints "error: no task at index 99", exits non-zero
python3 cli.py done 99 # prints "error: no task at index 99", exits non-zero
echo "exit code: $?"
```
@@ -74,7 +74,7 @@ The diff adds a `delete` command. It works: try `delete 0`, the task goes away,
But run the *failure* path, not the happy one:
```bash
python cli.py done 99 # the trap
python3 cli.py done 99 # the trap
echo "exit code: $?"
```
+1 -1
View File
@@ -33,7 +33,7 @@ That "one done" case is the one where a correct implementation and a buggy one g
A test file sitting in your repo is useful right up until you forget to run it, which, like every manual check, you eventually will. Continuous Integration removes the "eventually." It's a grand name for a mundane core: **the same checks you'd run by hand (lint, build, test) bound to a trigger, on a clean machine you don't control, on every single push.**
The magic is entirely in *automatically*. You don't run CI; pushing runs it. It can't be skipped by forgetting, it doesn't get tired on the fortieth push of the day, and its whole enforcement mechanism is the humble exit code: `python -m unittest` returns non-zero when a test fails, and one non-zero turns the run red. The actual config is shorter than this paragraph:
The magic is entirely in *automatically*. You don't run CI; pushing runs it. It can't be skipped by forgetting, it doesn't get tired on the fortieth push of the day, and its whole enforcement mechanism is the humble exit code: `python3 -m unittest` returns non-zero when a test fails, and one non-zero turns the run red. The actual config is shorter than this paragraph:
```yaml
name: CI
+5 -5
View File
@@ -40,9 +40,9 @@ The lab makes this concrete and local: no hosted bot account required. You run a
```bash
cd modules/24-assistive-agents/lab
python reviewer.py prompt # builds: your committed rubric + the diff
python3 reviewer.py prompt # builds: your committed rubric + the diff
# (paste into your AI, save its JSON to my-review.json)
python reviewer.py apply my-review.json
python3 reviewer.py apply my-review.json
```
The diff it's reviewing has a real trap planted in it: a new `clear` command that prints "cleared all tasks" but never actually calls `save()`, so `tasks.json` is untouched. Did your AI catch it? Either way, *you* make the merge call, and you learn exactly how much this reviewer is worth before the stakes go up.
@@ -70,7 +70,7 @@ The lab runs the whole thing locally against the `tasks-app`, and the best part
```bash
git checkout -b agent/delete-command
python agent_runner.py issue-to-pr issue-delete-command.md --simulate bad
python3 agent_runner.py issue-to-pr issue-delete-command.md --simulate bad
# → ruff + pytest run, a test fails, the script refuses to call the work ready.
# Exit code non-zero. No PR. Nothing reached main.
```
@@ -124,8 +124,8 @@ The lab is the punchline of the whole series. You run the same eval set against
```bash
cd modules/27-evals/lab
python run_eval.py candidates/current_model # 100%, exit 0, your baseline
python run_eval.py candidates/swapped_model # 60%, exit 1, blocked
python3 run_eval.py candidates/current_model # 100%, exit 0, your baseline
python3 run_eval.py candidates/swapped_model # 60%, exit 1, blocked
```
The "swapped model" is a stand-in for the day a cheaper model ships, or your provider deprecates the one you're on, or someone edits the agent's prompt. The easy cases still pass (this output would sail through a casual skim), but the eval caught a regression a skim would have missed, *and the non-zero exit code means a pipeline would have blocked the merge.* That's a **regression eval**, and it's the moment this course's thesis stops being a slogan and becomes a procedure you run from the keyboard.
+1 -1
View File
@@ -22,7 +22,7 @@ If you've been following the series here on the blog, this is the part where the
Here's the trick that makes a capstone honest: pick something *small* enough to finish in one sitting but *real* enough to touch the whole stack. We're adding due dates to the running `tasks-app`:
- A task can carry an optional due date: `python cli.py add "file taxes" --due 2026-09-15`.
- A task can carry an optional due date: `python3 cli.py add "file taxes" --due 2026-09-15`.
- A new `overdue` command lists pending tasks whose due date has already passed.
- The deployed service grows a matching `GET /overdue` endpoint, so the change is visible in the *running container*, not just the CLI.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

+16 -5
View File
@@ -47,7 +47,7 @@ already standing; it doesn't re-pour the foundation.
Pick something small enough to finish in one sitting and real enough to touch the whole stack. We'll
add **due dates**:
- A task can carry an optional due date: `python cli.py add "file taxes" --due <YYYY-MM-DD>`.
- A task can carry an optional due date: `python3 cli.py add "file taxes" --due <YYYY-MM-DD>`.
- A new `overdue` command lists pending tasks whose due date has already passed.
- The deployed service grows a matching `GET /overdue` endpoint, so the change is visible in the
running container, not just the CLI.
@@ -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.
@@ -173,9 +184,9 @@ agent), your forge account, and a working Docker install.
in the future, one safely in the past) so the assertion below holds whenever you run this:
```bash
python cli.py add "file taxes" --due <a date a few months out> # future → NOT overdue
python cli.py add "renew domain" --due 2020-01-01 # past → overdue
python cli.py overdue # should list "renew domain", not "file taxes"
python3 cli.py add "file taxes" --due <a date a few months out> # future → NOT overdue
python3 cli.py add "renew domain" --due 2020-01-01 # past → overdue
python3 cli.py overdue # should list "renew domain", not "file taxes"
```
> *Verify-before-publish: refresh the example due dates so the "future" one is still in the future
@@ -188,7 +199,7 @@ agent), your forge account, and a working Docker install.
them by name. Confirm the suite is green:
```bash
pytest # or: python -m unittest
pytest # or: python3 -m unittest
```
Once it's green, tell the AI to commit the change. Then verify what it actually staged and wrote:
+25
View File
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
+62
View File
@@ -0,0 +1,62 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | count | delete <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
+42
View File
@@ -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)
+28 -14
View File
@@ -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.
@@ -136,18 +148,20 @@ purpose** so you recognize it later.
- Python 3.10 or newer (`python --version` or `python3 --version` to check).
- Your usual AI chat assistant, open in a browser tab.
> **One command name, the whole course through:** whichever of `python` / `python3` just printed a
> 3.10+ version is the command to use in *every* lab from here on. The labs are written with
> `python`; if that's "command not found" on your machine (common on current macOS and default
> Debian/Ubuntu, where Python is installed only as `python3`), read it as `python3` (and `pip3`
> wherever a lab uses `pip`). This note holds course-wide; we won't repeat it.
> **One command name, the whole course through:** the labs are written with `python3`, the command
> name current macOS and default Debian/Ubuntu actually ship (they install Python only as `python3`,
> with no bare `python` on PATH). Run `python3 --version`; if it prints a 3.10+ version, use `python3`
> in *every* lab from here on. If `python3` is "command not found" but `python --version` shows a
> 3.10+ version (older or some Windows setups), read every `python3` in the labs as `python` instead.
> Where a lab runs `pip`, use whichever pairs with your Python (`pip3` commonly goes with `python3`).
> This note holds course-wide; we won't repeat it.
### Get the course materials
Everything you'll run in this course lives in one repo. Grab it once, up front; no tools required
beyond a web browser:
1. Open the course's home page, **`https://git.jpaul.io/justin/ai-workflow-course`**, and use its
1. Open the course's home page, **`https://github.com/recklessop/ai-workflow-course`**, and use its
**Download ZIP** (archive) link.
2. Unzip it under your home directory so the course's `modules/` folder lands at
`~/ai-workflow-course/modules/`. (Rename the unzipped folder to `ai-workflow-course` if your download
@@ -181,8 +195,8 @@ You now have every module's files locally, including this one's under
3. Run it in your terminal to confirm it works:
```bash
python cli.py add "finish module 1"
python cli.py list
python3 cli.py add "finish module 1"
python3 cli.py list
```
You should see your task listed. **This is your "real local project, an editor, and a terminal."**
@@ -193,14 +207,14 @@ You now have every module's files locally, including this one's under
Now reproduce each failure deliberately. Keep the AI strictly in the **browser chat**; no
editor-integrated tools yet (those arrive in Module 4). This is the "before" picture on purpose.
1. **Seam 1 (multiple files).** First mark a task done so there's something to hide. Run `python
cli.py done 0`, then `python cli.py list` shows it as `[x]`. Now paste *only* `cli.py` into your
1. **Seam 1 (multiple files).** First mark a task done so there's something to hide. Run `python3
cli.py done 0`, then `python3 cli.py list` shows it as `[x]`. Now paste *only* `cli.py` into your
chat and ask: *"Make the `list` command hide tasks that are already done."* Apply whatever it
gives you and run `python cli.py list`. The clean version of this change lives in `tasks.py`, the
gives you and run `python3 cli.py list`. The clean version of this change lives in `tasks.py`, the
file you *didn't* paste: open it and you'll see `render()` already owns the `[x]`/`[ ]`
box-and-index formatting, and a `pending()` helper already returns exactly the not-done tasks. But
the chat never saw that file, so it had to do one of two things. Either it guessed at methods it
couldn't see (and `python cli.py list` errors out), or it reached into the raw task list and
couldn't see (and `python3 cli.py list` errors out), or it reached into the raw task list and
*re-created* that box-and-index formatting inside `cli.py`, duplicating logic that already existed
one file over. Either way, *you* had to be the one who knew the change really belonged in the
other file.
@@ -239,7 +253,7 @@ Be honest about the limits of this module's claims:
**You're done when:**
- You can run `python cli.py list` in your terminal and see output; your project, editor, and
- You can run `python3 cli.py list` in your terminal and see output; your project, editor, and
terminal are working together.
- You can name the three seams where copy-paste breaks (more than one file, more than one day, no
undo) without looking back at the lesson.
@@ -253,5 +267,5 @@ rest of the course safe to attempt.
## Verify-before-publish
- [ ] Confirm the **Download ZIP** URL (`https://git.jpaul.io/justin/ai-workflow-course`) points at
- [ ] Confirm the **Download ZIP** URL (`https://github.com/recklessop/ai-workflow-course`) points at
the published course host before shipping.
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,56 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
@@ -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)
@@ -4,8 +4,7 @@ A deliberately tiny command-line task tracker. It exists to be *changed by an AI
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).
This is the running example throughout the course: pain in Module 1, safety net in Module 2, docs in Module 3, agent-driven edits in Module 4, and it keeps showing up all the way to the capstone.
## Files
@@ -15,11 +14,11 @@ This is the running example for **Module 1** (where you feel the copy-paste prob
## 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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -1,8 +1,8 @@
"""Tiny command-line front end for the demo task app.
Run it:
python cli.py add "write the lesson"
python cli.py list
python3 cli.py add "write the lesson"
python3 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.
@@ -31,7 +31,7 @@ def save(tlist: TaskList) -> None:
def main(argv: list[str]) -> int:
tlist = load()
if not argv:
print("usage: python cli.py [add <title> | list | done <index>]")
print("usage: python3 cli.py [add <title> | list | done <index>]")
return 1
command = argv[0]
@@ -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
@@ -192,7 +204,7 @@ and your AI assistant.
This is the habit that replaces "paste it back and hope." You're reading exactly what changed,
nothing more, nothing less. Confirm it does what you asked and didn't touch anything it shouldn't.
Run it (`python cli.py count`), then commit:
Run it (`python3 cli.py count`), then commit:
```bash
git add .
@@ -211,7 +223,7 @@ and your AI assistant.
git status # shows tasks.py as modified
git restore tasks.py # discard the change; back to your last commit, byte for byte
git diff # empty: nothing changed. you're clean.
python cli.py list # works again
python3 cli.py list # works again
```
You just recovered from a bad AI change in one command, with zero retyping and zero guesswork.
@@ -246,7 +258,7 @@ and your AI assistant.
9. Close the loop and leave the repo clean. The cold session just told you what's in progress and
what to do next: finish the `delete <index>` command. Do that with the AI (paste in `cli.py` the
same way as Part B), run it to confirm it works (`python cli.py delete 1`), then commit:
same way as Part B), run it to confirm it works (`python3 cli.py delete 1`), then commit:
```bash
git add .
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,56 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
@@ -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)
@@ -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 12. The AI stays in the **browser**; you copy its draft into the file yourself, exactly as
in Module 2.
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,58 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | 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:]))
@@ -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)
@@ -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.
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,58 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | 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:]))
@@ -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)
@@ -71,7 +71,7 @@ echo "Running delete-command check with: $PY"
# Delete the middle task (index 1 = "beta").
if ! "$PY" cli.py delete 1 >/dev/null 2>&1; then
echo "FAIL: 'python cli.py delete 1' errored. Is the delete command wired up in cli.py?" >&2
echo "FAIL: 'python3 cli.py delete 1' errored. Is the delete command wired up in cli.py?" >&2
exit 1
fi
+17 -5
View File
@@ -48,7 +48,7 @@ committed instructions file from the repo, and you control what's in it.**
> content if so. The principle outlives any one vendor's filename.
Without this file, you re-explain your project every session: "we use 4-space indent," "run the tests
with `python -m unittest` before you say you're done," "don't touch the generated `tasks.json`." You say it,
with `python3 -m unittest` before you say you're done," "don't touch the generated `tasks.json`." You say it,
the AI complies, the session ends, the memory evaporates (Module 1's second seam), and tomorrow you
say it all again. The instructions file is where that knowledge stops being something you retype and
becomes something the project *carries*.
@@ -62,7 +62,7 @@ a briefing for an agent that will edit this code. Keep it to what changes the AI
uses. "Core logic lives in `tasks.py`; the CLI front end is `cli.py`; state persists to
`tasks.json`."
- **Build and test commands**: the exact commands, copy-pasteable. "Run the app with
`python cli.py <command>`. Run tests with `python -m unittest`. Don't claim a change works until
`python3 cli.py <command>`. Run tests with `python3 -m unittest`. Don't claim a change works until
the tests pass." This single line stops the AI from inventing a test runner you don't use.
- **Coding standards**: formatting, typing, error handling, the libraries you do and don't want.
"Use the standard library only, no third-party packages. Type-hint public functions."
@@ -83,7 +83,7 @@ useful for personal preferences, but it's the wrong home for project knowledge,
lives: on *your* laptop, invisible to everyone else.
Picture a two-person project with no committed instructions file. You've trained your local setup to
run `python -m unittest` and avoid `tasks.json`. Your teammate's setup hasn't, so their agent reformats whole files
run `python3 -m unittest` and avoid `tasks.json`. Your teammate's setup hasn't, so their agent reformats whole files
and hand-edits the generated JSON. You're both "using AI on the same repo," but you're getting
different behavior, and neither of you can see the other's configuration. That's **drift**: the same
codebase, diverging because the rules live in two heads instead of one file.
@@ -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 12. You'll use your
editor-integrated AI (Module 4) for the part where the AI obeys the file.
@@ -203,7 +215,7 @@ editor-integrated AI (Module 4) for the part where the AI obeys the file.
- The `tasks-app` repo from Module 2 (already a Git repo with some history).
- Your agentic coding tool from Module 4, and knowledge of which filename it reads for repo-level
instructions (check its docs; see the note in *Key concepts*).
- Optionally, a test command for the AI to honor; Python's built-in `python -m unittest` works with
- Optionally, a test command for the AI to honor; Python's built-in `python3 -m unittest` works with
nothing to install (you'll write a real suite in Module 13; until then it simply reports no tests).
### Part A: Write the instructions file and let the AI commit the config
@@ -309,7 +321,7 @@ Be honest about what a committed instructions file does and doesn't buy you:
- **Bloat kills it.** A 300-line instructions file is read the way *you* read a 300-line terms-of-
service: not really. Every line you add dilutes the rest. Keep it to what actually changes behavior,
and prune lines the model already honors without being told.
- **Stale instructions are worse than none.** A file that says "run the tests with `python -m
- **Stale instructions are worse than none.** A file that says "run the tests with `python3 -m
unittest`" after you've switched to a different runner will actively misdirect the AI. The file is
code-adjacent: it has to be maintained like code, and reviewed like code. That's exactly why
committing it (so changes are
@@ -25,8 +25,8 @@ minute but real enough to have more than one file. Keep it that way; don't grow
## Build and test commands
- Run the app: `python cli.py <command>` (e.g. `python cli.py list`).
- Run the tests: `python -m unittest` <!-- EDIT: set this to your real test command, or delete if you have no tests yet -->
- Run the app: `python3 cli.py <command>` (e.g. `python3 cli.py list`).
- Run the tests: `python3 -m unittest` <!-- EDIT: set this to your real test command, or delete if you have no tests yet -->
- Do not claim a change works until you have actually run it. If tests exist, they must pass first.
## Coding standards
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,62 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | count | delete <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
@@ -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)
@@ -36,8 +36,8 @@ By the end of this module you can:
2. Let the AI make a bold, multi-commit change on a branch while `main` stays untouched and runnable.
3. Decide the experiment's fate and have the agent carry it out: **merge** it into `main` to keep it,
or **delete the branch** to throw it away with zero trace. You make the call and check the result.
4. Read a merge conflict (the `<<<<<<<`/`=======`/`>>>>>>>` markers) and hand it to the AI to
resolve, then verify the resolution is right before the merge completes.
4. Recognize a merge conflict (the `<<<<<<<`/`=======`/`>>>>>>>` markers) when you see one, and
verify the AI's resolution even when the agent resolved it silently and you never saw a marker.
5. Tell the difference between a fast-forward merge and a merge commit, and know which one you got.
---
@@ -164,9 +164,9 @@ decide:
```python
<<<<<<< HEAD
print("usage: python cli.py [add <title> | list | done <index> | stats]")
print("usage: python3 cli.py [add <title> | list | done <index> | stats]")
=======
print("usage: python cli.py [add <title> | list | done <index> | purge]")
print("usage: python3 cli.py [add <title> | list | done <index> | purge]")
>>>>>>> experiment
```
@@ -181,13 +181,25 @@ Read it like this:
Resolving isn't picking a side mechanically. It's deciding what the line *should* say. Often that's
one side; sometimes it's a blend of both (here, a usage string that lists *both* `stats` and `purge`).
This is the kind of bounded reasoning task the AI is good at: it sees both versions and the
surrounding code, so you hand it the conflict and let it produce the combined version. Once the file
is correct and marker-free, telling Git the conflict is settled is two more commands the agent runs
(`git add cli.py` to mark the file resolved, then `git commit` to complete the merge).
surrounding code. Once the file is correct and marker-free, telling Git the conflict is settled is
two more commands the agent runs (`git add cli.py` to mark the file resolved, then `git commit` to
complete the merge).
`git status` during a conflict is your map; it lists every file still "unmerged." Your job is the
verify: read the resolution, confirm it's what you meant, and check `git status` comes back clean. If
things go sideways, `git merge --abort` rewinds to before the merge with no harm done.
Here's the part that has changed under your feet, and it's the real lesson of this module's lab. The
markers above are what a conflict looks like *if you ever see one*. Tell a current frontier
editor-agent to "merge `feature/stats` into `feature/purge`" and it usually never stops: it reads
both sides, resolves the collision, completes the merge, and reports a clean result, all in one turn.
You never saw a marker. From your seat the conflict simply did not happen. That is convenient right
up until the silent resolution is wrong (it can keep the worse of the two sides, or blend them into a
line that satisfies neither), and now a bad merge is sitting in your history with nothing that looked
like an error.
So the skill is no longer "edit the markers by hand." It is two things: **know what a conflict is**
(so you recognize one when an agent does surface it) and **check `git diff` after every merge** (so a
silent resolution can't slip a wrong line past you). `git status` during a conflict is your map; it
lists every file still "unmerged." If you want to *see* the markers before the agent touches them,
tell it to stop on conflict and show you (you'll do exactly that in the lab). And if things go
sideways, `git merge --abort` rewinds to before the merge with no harm done.
---
@@ -207,22 +219,38 @@ Everything above is standard Git. Here's why it matters *more* in an AI-assisted
- **Compare, don't commit-and-hope.** Ask the AI for approach A on one branch and approach B on
another. Run both. Keep the winner, delete the loser. You're using branches as cheap A/B
experiments on implementation, something that's painful without them and trivial with them.
- **Conflicts are a great place to put the AI to work.** A merge conflict is a small, perfectly
- **The AI resolves conflicts so well you may never see one.** A merge conflict is a small, perfectly
bounded reasoning task: here are two versions of the same lines and the surrounding code; produce
the correct combined version. The AI can see both sides and the intent. You still decide whether
its resolution is right (it can absolutely merge two changes into something that satisfies neither),
but "explain this conflict and propose a resolution" is one of the highest-hit-rate uses of an
editor-integrated agent. You'll do exactly this in the lab.
the correct combined version. A current editor-agent is good enough at this that, told to "merge X
into Y," it usually resolves the collision and completes the merge in the same turn, no markers
shown, no question asked. That's the highest-hit-rate convenience of the tool and its sharpest trap:
you still decide whether the resolution is right (it can absolutely merge two changes into something
that satisfies neither), except now you might not even know there *was* a conflict to second-guess.
The defense is mechanical and non-negotiable: read `git diff` after every merge. You'll feel both
the convenience and the trap in the lab.
---
## 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 12 with your
editor-integrated AI from Module 4.
You'll do three things: let the AI try a bold change on a branch, decide its fate, and then
deliberately create and resolve a merge conflict, using the AI to help resolve it.
You'll do three things: let the AI try a bold change on a branch, decide its fate, and then engineer
a merge conflict so you can see one once, undo it, and watch the AI resolve it silently while you do
the one job that's still yours: verify the result.
**You'll need:**
@@ -267,9 +295,9 @@ deliberately create and resolve a merge conflict, using the AI to help resolve i
```bash
git diff # read what it actually changed
python cli.py add "ship module 6" --priority high
python cli.py add "water plants" --priority low
python cli.py list # see if priorities work and sort
python3 cli.py add "ship module 6" --priority high
python3 cli.py add "water plants" --priority low
python3 cli.py list # see if priorities work and sort
```
Once the diff looks right and the feature runs, tell the agent:
@@ -284,7 +312,7 @@ deliberately create and resolve a merge conflict, using the AI to help resolve i
> *"Switch back to `main`."*
```bash
python cli.py list # no priorities; main is exactly as you left it
python3 cli.py list # no priorities; main is exactly as you left it
```
Your bold change exists only on the branch. `main` never saw it, and that's the whole point.
@@ -303,7 +331,7 @@ Then verify the result yourself:
```bash
git log --oneline --graph # straight line = fast-forward merge
python cli.py list # the feature is now on main
python3 cli.py list # the feature is now on main
git branch # experiment/priorities is gone
```
@@ -315,7 +343,7 @@ Then verify:
```bash
git log --oneline # no trace of the experiment on main
python cli.py list # main is untouched, exactly as before
python3 cli.py list # main is untouched, exactly as before
git branch # the branch is gone
```
@@ -365,72 +393,87 @@ Merge conflicts have an outsized reputation for difficulty. You'll engineer a gu
Both branches changed the same `usage:` line, each adding a *different* command. Git won't be able
to auto-merge that line.
3. Now trigger the conflict. Tell the agent:
3. **Witness the conflict first.** If you tell a current agent to just "merge them," it will resolve
the collision and finish the merge in one turn, and you'll never see a marker (you'll do exactly
that in step 5, on purpose). So this once, ask it to stop and show you instead, the same way
Module 26 does it:
> *"You're on `feature/purge`. Merge `feature/stats` into it."*
> *"You're on `feature/purge`. Merge `feature/stats` into it. If it conflicts, stop and show me the
> conflict; do not resolve it yet."*
Git stops with a conflict. Confirm the conflict state yourself:
The merge stops on the usage line. Confirm the conflict state yourself, then open `cli.py` and find
the markers (your usage string will be longer (it carries the commands from earlier modules), but
the collision is exactly this: both branches appended a different new command to the same line):
```bash
git status # cli.py listed under "Unmerged paths"
```
4. Open `cli.py` and find the conflict markers around the usage line (your usage string will be
longer (it carries the commands from earlier modules), but the collision is exactly this: both
branches appended a different new command to it):
```python
<<<<<<< HEAD
print("usage: python cli.py [add <title> | list | done <index> | purge]")
print("usage: python3 cli.py [add <title> | list | done <index> | purge]")
=======
print("usage: python cli.py [add <title> | list | done <index> | stats]")
print("usage: python3 cli.py [add <title> | list | done <index> | stats]")
>>>>>>> feature/stats
```
(The command bodies for `stats` and `purge` touch different lines, so Git merged *those* cleanly
on its own; the only collision is the usage string both branches edited.)
This is the whole point of the step: *see one real conflict* so you can recognize the shape. `HEAD`
is your current branch (`feature/purge`); the block below the `=======` is what `feature/stats`
wants. (The command bodies for `stats` and `purge` touch different lines, so Git merged *those*
cleanly on its own; the only collision is the usage string both branches edited.)
5. **Resolve it with the AI.** This is exactly the bounded task the agent is good at. Ask:
4. **Undo it.** You've seen the conflict; now rewind so the AI can handle it from scratch. Tell the
agent (or run it yourself, it's the safe-undo from the Key concepts section):
> *"`cli.py` has a merge conflict on the usage line. I want the final version to list BOTH the
> `stats` and `purge` commands. Resolve the conflict and remove the markers."*
> *"Abort the merge."*
It should produce a single, marker-free line listing both commands, e.g.:
```bash
git merge --abort
git status # clean again, back on feature/purge, no merge in progress
```
You're now exactly where you were before step 3, mid-experiment with two colliding branches and no
merge underway.
5. **Now let the AI do it for real, and watch it auto-resolve.** This time, no stop-on-conflict guard.
Direct it the way you actually would in a real workflow:
> *"You're on `feature/purge`. Merge `feature/stats` into it. The usage line collides; the final
> version should list BOTH the `stats` and `purge` commands."*
Notice what happens: the agent hits the same conflict you just saw, resolves it, and completes the
merge in one turn. It probably never shows you a marker. From your seat the merge just "worked." It
should have produced a single, marker-free line listing both commands, e.g.:
```python
print("usage: python cli.py [add <title> | list | done <index> | stats | purge]")
print("usage: python3 cli.py [add <title> | list | done <index> | stats | purge]")
```
**Verify its work; this is the part the AI can get subtly wrong.** A conflict resolver can
confidently drop one side, leave a stray marker, or "blend" the lines into something that runs but
means the wrong thing. Read the result and run it:
```bash
git diff # check ONLY what you intended changed; no markers remain
python cli.py # run with no args, see the merged usage string
python cli.py stats # both commands actually work
python cli.py purge
```
6. Once you've verified the resolution, have the agent finish the merge:
> *"The resolution looks right. Stage `cli.py` and complete the merge."*
Then confirm the merge landed as a merge commit:
**Here is the punchline of the whole module: you have no idea yet whether that's right, so verify.**
The conflict was invisible, which means a wrong resolution would have been invisible too. A resolver
can confidently drop one side, leave a stray marker, or "blend" the lines into something that runs
but means the wrong thing. The only thing standing between you and a silently-bad merge is the
`git diff` you run *after every merge*, conflict or not:
```bash
git diff HEAD~1 # what the merge actually changed; confirm no markers remain
git log --oneline --graph # the fork-and-join: this is a merge commit
python3 cli.py # run with no args, see the merged usage string
python3 cli.py stats # both commands actually work
python3 cli.py purge
```
You just resolved a real merge conflict: you directed it, the agent did the plumbing, and you
verified the result. The marker syntax is identical no matter the file or the project. Once you can
read those three lines and check the resolution, a conflict is a short, routine task.
If the usage line lists both commands and both run, the AI's silent resolution was correct. If it
dropped one, you just caught a bug that left no error message behind, which is precisely why the
check isn't optional. You directed the merge, the agent did the plumbing *and* the resolution, and
the verify was yours. That last part is the skill: not reading markers by hand, but knowing a
conflict can happen and checking the AI's work even when it never tells you one did.
> **Guaranteed-conflict generator.** AI edits are nondeterministic, so if the agent didn't touch the
> same line on both branches and you *didn't* get a conflict in step 3, run the helper script to
> manufacture one deterministically, then practice steps 46 on it. Copy it into your `tasks-app`
> first (the course's lab scripts live in the course repo, not in `tasks-app`; see Module 4's
> *You'll need*), then run it from inside the repo:
> manufacture one deterministically, then practice the witness-and-verify flow on it. Copy it into
> your `tasks-app` first (the course's lab scripts live in the course repo, not in `tasks-app`; see
> Module 4's *You'll need*), then run it from inside the repo:
>
> ```bash
> cp ~/ai-workflow-course/the-workflow-course/modules/06-branches-sandboxes-for-experiments/lab/make-conflict.sh .
@@ -457,11 +500,14 @@ The honest limits, so you don't over-trust the sandbox:
branch isn't shared, backed up, or visible to anyone else until there's a remote; that's
**Module 8**. Right now `git branch -D` deletes work that exists nowhere else, permanently. Treat
an unpushed branch as exactly as fragile as the rest of your local-only repo.
- **The AI can resolve a conflict into something plausible and wrong.** It sees both sides and the
intent, which makes it good at this, but "good" isn't "trusted." A resolution that runs cleanly can
still mean the wrong thing (silently keeping the worse of two changes, or merging two behaviors
into one that satisfies neither). The `git diff` + run-it check in the lab isn't optional ceremony;
it's the actual safeguard. Reviewing AI output is its own discipline; that's Module 10.
- **The AI can resolve a conflict into something plausible and wrong, and you may never know one
happened.** It sees both sides and the intent, which makes it good at this, but "good" isn't
"trusted." Worse, a current agent resolves silently: told to merge, it fixes the collision and
finishes the merge in one turn, so a resolution that runs cleanly but means the wrong thing
(silently keeping the worse of two changes, or merging two behaviors into one that satisfies
neither) leaves no marker, no prompt, no error behind. That invisibility is exactly *why* the
post-merge `git diff` is the safeguard, not optional ceremony: it's the only thing that surfaces a
conflict the agent already swallowed. Reviewing AI output is its own discipline; that's Module 10.
- **Long-lived branches drift and conflict harder.** The longer a branch lives away from `main`, the
more `main` moves underneath it and the gnarlier the eventual merge. The defense is the same as
"commit often": branch small, merge soon, delete promptly. A branch that's been open for three
@@ -482,9 +528,9 @@ The honest limits, so you don't over-trust the sandbox:
trace, and you have **merged** one in and seen it land on `main`.
- You can explain, in one sentence, why creating a branch costs essentially nothing (it's a movable
pointer, not a copy).
- You deliberately created a merge conflict, read the `<<<<<<<`/`=======`/`>>>>>>>` markers, had the
AI resolve it to a marker-free file that runs, verified the result, and let the agent complete the
merge.
- You saw a real merge conflict at least once (the `<<<<<<<`/`=======`/`>>>>>>>` markers), then let
the AI merge for real and resolve it silently, and you verified the result with `git diff` even
though no marker was ever shown to you, confirming the merged file runs.
- You can name the limit: a branch isolates tracked files, not your database, ignored files, or the
outside world.
@@ -73,10 +73,12 @@ echo "================================================================"
echo
echo " Next steps (the skill you're practicing):"
echo " 1. git status # see $FILE under 'Unmerged paths'"
echo " 2. ask your agent to resolve the conflict in $FILE and complete the merge"
echo " 2. open $FILE and read the <<<<<<< / ======= / >>>>>>> markers yourself FIRST"
echo " (this is your chance to see a real conflict before an agent resolves it away)"
echo " 3. ask your agent to resolve the conflict in $FILE and complete the merge"
echo " (\"resolve the conflict markers in $FILE and finish the merge\")"
echo " 3. verify: open $FILE, confirm no <<<<<<< / ======= / >>>>>>> markers remain"
echo " 4. git log --oneline --graph # confirm the merge commit landed"
echo " 4. verify: open $FILE, confirm no <<<<<<< / ======= / >>>>>>> markers remain"
echo " 5. git log --oneline --graph # confirm the merge commit landed"
echo " (to do it by hand instead: edit out the markers, then git add $FILE && git commit)"
echo
echo " Chicken out? Undo the whole thing with: git merge --abort"
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,62 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | count | delete <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
@@ -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)
@@ -8,15 +8,15 @@
## Prerequisites
- **Module 6: Branches.** You can create a branch, switch to it, merge it back, and resolve a
conflict. A worktree is the physical counterpart to the logical isolation a branch already gives
you, so this module makes no sense without it.
- **Module 4: Getting the AI out of the browser.** The agents in this module edit real files in a
folder. You'll point an editor-integrated AI session at each worktree directory.
- **Module 1: the `tasks-app`.** The running example continues here.
- **Module 2: Version control.** The `tasks-app` is already a Git repo with commits, and you read
a project's state from `git status` / `git diff` / `git log`. Each worktree has its own answer to
those, which is the whole point.
- **Module 1: the `tasks-app`.** The running example continues here.
- **Module 4: Getting the AI out of the browser.** The agents in this module edit real files in a
folder. You'll point an editor-integrated AI session at each worktree directory.
- **Module 6: Branches.** You can create a branch, switch to it, merge it back, and resolve a
conflict. A worktree is the physical counterpart to the logical isolation a branch already gives
you, so this module makes no sense without it.
If you parachuted in: you minimally need a Git repo with at least one commit and a working
understanding of branches.
@@ -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
@@ -311,8 +323,8 @@ This is the part to actually *do simultaneously*, not one then the other.
writing them.) Give each worktree its own task and list it:
```bash
cd ~/ai-workflow-course/tasks-app-wipe && python cli.py add "from worktree A" && python cli.py list
cd ~/ai-workflow-course/tasks-app-remaining && python cli.py add "from worktree B" && python cli.py list
cd ~/ai-workflow-course/tasks-app-wipe && python3 cli.py add "from worktree A" && python3 cli.py list
cd ~/ai-workflow-course/tasks-app-remaining && python3 cli.py add "from worktree B" && python3 cli.py list
```
Each `list` shows only its own task: worktree A never sees "from worktree B" and vice versa. Each
@@ -337,8 +349,8 @@ This is the part to actually *do simultaneously*, not one then the other.
5. *Now* the new commands exist: run each in its own worktree to watch it work:
```bash
cd ~/ai-workflow-course/tasks-app-wipe && python cli.py wipe # agent A's new command
cd ~/ai-workflow-course/tasks-app-remaining && python cli.py remaining # agent B's new command
cd ~/ai-workflow-course/tasks-app-wipe && python3 cli.py wipe # agent A's new command
cd ~/ai-workflow-course/tasks-app-remaining && python3 cli.py remaining # agent B's new command
```
`remaining` counts a single pending task, the one you added to worktree B in step 3, because B's
@@ -366,9 +378,9 @@ Then **verify** the result before you trust it, the same way you did in Module 6
```bash
cd ~/ai-workflow-course/tasks-app
git diff # no conflict markers remain
python cli.py list # the app still runs
python cli.py wipe # both new commands work
python cli.py remaining
python3 cli.py list # the app still runs
python3 cli.py wipe # both new commands work
python3 cli.py remaining
```
Now tear down the worktrees. Direct the coordinating session:
@@ -8,8 +8,8 @@ Add a `wipe` command to this task app that removes **all** tasks.
- Put the deletion logic on `TaskList` in `tasks.py` (a `wipe()` method that empties the list),
and wire a `wipe` command into the dispatch in `cli.py` that calls it and saves.
- Running `python cli.py wipe` should empty the list and print a short confirmation like
- Running `python3 cli.py wipe` should empty the list and print a short confirmation like
`wiped all tasks`.
- After `wipe`, `python cli.py list` should print `(no tasks yet)`.
- After `wipe`, `python3 cli.py list` should print `(no tasks yet)`.
Make the change, then stop. I'll review the diff, then have you commit it on this branch.
@@ -8,7 +8,7 @@ Add a `remaining` command to this task app that prints how many tasks are still
- Reuse the existing `pending()` method on `TaskList` in `tasks.py`; don't reimplement it.
- Wire a `remaining` command into the dispatch in `cli.py`.
- Running `python cli.py remaining` should print something like `2 pending` (the number of tasks not
- Running `python3 cli.py remaining` should print something like `2 pending` (the number of tasks not
marked done).
Make the change, then stop. I'll review the diff, then have you commit it on this branch.
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,62 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | count | delete <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
@@ -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)
+99
View File
@@ -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.
@@ -311,6 +323,88 @@ WSL, or Git Bash on Windows. Continues the `tasks-app` repo from Module 2.
*direct the agent* to do the git work (add the remote, push, clone, fetch, pull) and you verify
each result yourself. You don't type the git commands by hand.
### Set up GitHub authentication (do this first)
This is the one part you do by hand in the web UI, and it's failure mode #1 above: the single most
common first-push wall. Set it up *before* Part A so the push just works. You have two paths; do
**one**. This lab walks the **PAT / HTTPS** path step by step on GitHub as the worked example,
because it's all in the browser and needs no command-line setup. SSH is the optional alternative,
linked below.
> **Other host?** These are GitHub's exact menu paths as the worked example. On GitLab, Bitbucket,
> Codeberg, or your own Forgejo/Gitea the *shape* is identical (see the "Getting a credential" callout
> in the lesson) but the menu names drift; find your host's "access tokens" or "SSH keys" settings.
**Path 1: Personal access token (PAT) over HTTPS.** Generate a token in GitHub's web UI, then paste
it once when Git asks for a password.
1. On GitHub, go to your avatar (top right) → **Settings****Developer settings** (bottom of the
left sidebar) → **Personal access tokens**. GitHub offers two token types:
- **Fine-grained tokens** (recommended): scoped to a single repository, with explicit permissions.
This lab uses fine-grained.
- **Tokens (classic)**: older, broader; access is controlled by a coarse `repo` scope that grants
all your repos at once.
Pick **Fine-grained tokens****Generate new token**.
2. Fill in the token:
- **Token name:** anything memorable, e.g. `tasks-app-push`.
- **Expiration:** pick a real expiry (30 to 90 days is fine for the lab). Tokens expire by design;
that's a rotation cost you accept for the convenience.
- **Repository access:** choose **Only select repositories** and select your `tasks-app` repo. If
you haven't created the empty remote yet (Part A step 1), come back and select it after, or
create the repo first and then make the token. The token only needs to *reach* a repo that
exists.
- **Permissions → Repository permissions → Contents:** set it to **Read and write**. This is the
write scope, and it is *the* gotcha: a token without it authenticates fine and then `403`s on
push (failure mode #1's scope trap). GitHub auto-adds **Metadata: Read** when you do this; leave
it.
3. Click **Generate token** and **copy the value immediately.** GitHub shows it exactly once. If you
lose it, you generate a new one rather than recover the old.
4. At the first push (Part A step 2), Git prompts for a **username** and **password**:
- **Username:** your GitHub username.
- **Password:** paste the **token** (not your GitHub account password; password auth over HTTPS
was removed years ago). Most terminals show *nothing* while you paste a secret; that's normal,
not a hang. Press Enter.
A **credential helper** caches it after the first success (`git config --global credential.helper`,
set to `osxkeychain` on macOS, `manager` on Windows, or `store`/`cache` on Linux), so you paste the
token *once*, not on every push.
> **Verify-before-publish:** GitHub's menu wording, token-type names, and the **Contents: Read and
> write** permission label drift. Re-confirm the path **Settings → Developer settings → Personal
> access tokens → Fine-grained tokens** and the Contents scope before relying on these exact names.
**Path 2: SSH key (optional alternative).** A key you add to your account skips passwords entirely.
It's more upfront setup (generate a keypair, load the ssh-agent, paste the *public* key into GitHub),
but then there's no token to scope, expire, or cache. Follow GitHub's official docs, in order:
- [Generating a new SSH key and adding it to the ssh-agent](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/generating-a-new-ssh-key-and-adding-it-to-the-ssh-agent)
- [Adding a new SSH key to your GitHub account](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/adding-a-new-ssh-key-to-your-github-account)
- [Testing your SSH connection](https://docs.github.com/en/authentication/connecting-to-github-with-ssh/testing-your-ssh-connection)
If you go SSH, use the **SSH** URL (`git@github.com:…`) when you create the remote in Part A, not the
HTTPS one.
**Which should you pick?**
| | **PAT / HTTPS** | **SSH key** |
|---|---|---|
| **Setup** | Fast, all in the web UI; nothing to install | More upfront: keygen, ssh-agent, add the public key |
| **After setup** | Credential helper caches the token; otherwise re-paste | No prompts ever; nothing to cache |
| **Network** | Port 443; sails through corporate proxies/firewalls | Port 22; sometimes blocked on locked-down networks |
| **Maintenance** | Expires; needs rotation; the write-scope `403` trap; shown once | No expiry by default; no scope to misconfigure |
| **Risk to manage** | A leaked token until it expires/is revoked | A private key + passphrase on your disk |
Short version: **PAT** is the faster start and the friendlier path behind a corporate firewall;
**SSH** is the lower-friction *long-term* setup once you're past the initial keygen. Either one
satisfies the lab. If you're unsure, do the PAT.
### Part A: Create the empty remote and push
1. On your host's web UI, create a **new, empty** repository named `tasks-app`. Do **not** add a
@@ -502,4 +596,9 @@ tables, and update the "as of" date when you do.
- [ ] **Credential/token UI**: the "Getting a credential" callout names menu paths and the
write-scope label (`repo` / "read and write") generically; confirm the current wording and
scope name on the default-example host before publishing.
- [ ] **GitHub PAT walkthrough** (lab "Set up GitHub authentication"): confirm the menu path
**Settings → Developer settings → Personal access tokens → Fine-grained tokens**, the two token
types (**fine-grained** vs **classic**/`repo`), and that the write scope is **Repository
permissions → Contents: Read and write** (with **Metadata: Read** auto-added). These are
volatile GitHub UI labels; also re-confirm the three linked SSH docs URLs still resolve.
- [ ] Update the comparison's **"as of" date** to the build date.
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.
@@ -0,0 +1,62 @@
"""Tiny command-line front end for the demo task app.
Run it:
python3 cli.py add "write the lesson"
python3 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: python3 cli.py [add <title> | list | done <index> | count | delete <index>]")
return 1
command = argv[0]
if command == "add":
title = " ".join(argv[1:])
tlist.add(title)
save(tlist)
print(f"added: {title}")
elif command == "list":
print(tlist.render())
elif command == "done":
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:]))
@@ -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)
+20 -8
View File
@@ -8,14 +8,14 @@
## Prerequisites
- **Module 1**: the `tasks-app` project. The lab writes issues against it.
- **Module 2**: the repo-as-durable-memory reframe. Issues are the team-scale version of the same
idea: shared memory for the work that *hasn't happened yet*.
- **Module 5**: you committed your AI instructions file. That file plus a good issue is what gives
an agent enough context to attempt a task; this module puts that pairing to work.
- **Module 8**: you have a repo on a remote forge (GitHub or any alternative). Issues live on the
forge, alongside the code, so this module needs the remote you set up there. Everything here is
provider-neutral: issues exist on every forge.
- **Module 5**: you committed your AI instructions file. That file plus a good issue is what gives
an agent enough context to attempt a task; this module puts that pairing to work.
- **Module 2**: the repo-as-durable-memory reframe. Issues are the team-scale version of the same
idea: shared memory for the work that *hasn't happened yet*.
- **Module 1**: the `tasks-app` project. The lab writes issues against it.
You do **not** yet need pull requests (Module 10) or the full collaboration loop (Module 11). This
module produces the *input* to that loop. We'll point forward to it, not teach it here.
@@ -105,8 +105,8 @@ well-formed version of the same bug:
> **Title:** `done` command crashes on an out-of-range or non-integer index
>
> **Context:** `python cli.py done 99` on a list with 3 tasks raises an uncaught `IndexError` and
> dumps a traceback. `python cli.py done abc` raises `ValueError`. Either way the user sees a stack
> **Context:** `python3 cli.py done 99` on a list with 3 tasks raises an uncaught `IndexError` and
> dumps a traceback. `python3 cli.py done abc` raises `ValueError`. Either way the user sees a stack
> trace instead of a helpful message.
>
> **Acceptance criteria:**
@@ -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
@@ -252,7 +264,7 @@ plenty it still can't do. Because it's carried forward across modules, skip anyt
already built (a `delete` command, task priorities) and pick work that's genuinely still missing.
Good candidates:
1. **A bug**: `python cli.py done 99` (an out-of-range index) and `python cli.py done abc` (a
1. **A bug**: `python3 cli.py done 99` (an out-of-range index) and `python3 cli.py done abc` (a
non-integer) both crash with an uncaught traceback. Run them and watch.
2. **A small, patterned feature**: an `undone <index>` command that clears a task's done flag,
mirroring the existing `done` command (it's the inverse).
@@ -18,16 +18,16 @@
## Context / problem
`python cli.py done 99` on a list with 3 tasks raises an uncaught `IndexError` and dumps a Python
traceback. `python cli.py done abc` raises `ValueError` the same way. The user sees a stack trace
`python3 cli.py done 99` on a list with 3 tasks raises an uncaught `IndexError` and dumps a Python
traceback. `python3 cli.py done abc` raises `ValueError` the same way. The user sees a stack trace
instead of a helpful message, and the process exits as if it crashed.
Reproduce:
```
python cli.py add "first"
python cli.py done 99 # IndexError traceback
python cli.py done abc # ValueError traceback
python3 cli.py add "first"
python3 cli.py done 99 # IndexError traceback
python3 cli.py done abc # ValueError traceback
```
## Acceptance criteria
@@ -61,7 +61,7 @@ command, which already takes an index and flips a task's state; this is simply i
## Acceptance criteria
- [ ] `python cli.py undone <index>` clears the done flag on the task at that index and saves.
- [ ] `python3 cli.py undone <index>` clears the done flag on the task at that index and saves.
- [ ] `undone` with an out-of-range or non-integer index prints a clear error and exits non-zero
(same behavior as the fixed `done`, see Issue 1).
- [ ] `list` after `undone` shows that task as not done (`[ ]`).
@@ -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
python3 cli.py add "read module 1"
python3 cli.py add "set up my editor"
python3 cli.py list
python3 cli.py done 0
python3 cli.py list
```
Requires Python 3.10+ (it uses `list[Task]` style type hints). No third-party packages.

Some files were not shown because too many files have changed in this diff Show More