Compare commits
28 Commits
173f39ac44
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 562197c1e0 | |||
| 019426b7d1 | |||
| 7c5920dc1a | |||
| 361ae4bb8d | |||
| f61c46fe19 | |||
| bdbb91f1d1 | |||
| 164559fcb5 | |||
| 585aeabd9a | |||
| efc5161698 | |||
| 60989dc640 | |||
| 5c745e9748 | |||
| 1be4e5b56c | |||
| 11b1995d77 | |||
| c4479e1041 | |||
| dec55e4c9f | |||
| 75d9e2b419 | |||
| 125802616d | |||
| c46715b811 | |||
| 9b6658563b | |||
| 556b5a7256 | |||
| 70d91722b7 | |||
| 58f54ce745 | |||
| 95e5911957 | |||
| 7f439212ac | |||
| 74f23534c0 | |||
| edf3f34336 | |||
| 8e0ae0d58a | |||
| 0f8e7497a4 |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."*
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: $?"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 172 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 177 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 217 KiB |
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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 1–2. 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
|
||||
|
||||
|
||||
@@ -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 1–2. 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 1–2 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 4–6 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)
|
||||
@@ -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)
|
||||
@@ -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.
|
||||