Compare commits
27 Commits
claude/issue-97
...
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 |
@@ -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
|
||||
```
|
||||
|
||||
@@ -127,9 +127,9 @@ Most merges just work; Git is genuinely good at combining changes that touch *di
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -149,8 +149,8 @@ It resolves silently and the merge lands. And here is the only part that's still
|
||||
|
||||
```bash
|
||||
git diff HEAD~1 # what the merge actually changed; confirm no markers, both commands present
|
||||
python cli.py # run it: see the merged usage string
|
||||
python cli.py stats && python cli.py purge # both actually work
|
||||
python3 cli.py # run it: see the merged usage string
|
||||
python3 cli.py stats && python3 cli.py purge # both actually work
|
||||
```
|
||||
|
||||
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.)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -233,6 +233,18 @@ Everything above is standard Git. Here's why it matters *more* in an AI-assisted
|
||||
|
||||
## Hands-on lab
|
||||
|
||||
|
||||
> **Starting point (this lab is skip-friendly).** You do not need to have done the earlier labs.
|
||||
> To begin from a clean, known state, copy this module's snapshot into a fresh `tasks-app` and
|
||||
> make the first commit:
|
||||
>
|
||||
> ```bash
|
||||
> mkdir -p ~/ai-workflow-course/tasks-app
|
||||
> cp -r ~/ai-workflow-course/modules/06-branches-sandboxes-for-experiments/lab/start/. ~/ai-workflow-course/tasks-app/
|
||||
> cd ~/ai-workflow-course/tasks-app && git init -b main && git add -A && git commit -m "start: module 6"
|
||||
> ```
|
||||
>
|
||||
> Already carrying your `tasks-app` from earlier modules? Keep using it and ignore this box.
|
||||
**Lab language:** shell (Git commands), driving the `tasks-app` from Modules 1–2 with your
|
||||
editor-integrated AI from Module 4.
|
||||
|
||||
@@ -283,9 +295,9 @@ the one job that's still yours: verify the result.
|
||||
|
||||
```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:
|
||||
@@ -300,7 +312,7 @@ the one job that's still yours: verify the result.
|
||||
> *"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.
|
||||
@@ -319,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
|
||||
```
|
||||
|
||||
@@ -331,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
|
||||
```
|
||||
|
||||
@@ -399,9 +411,9 @@ Merge conflicts have an outsized reputation for difficulty. You'll engineer a gu
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
@@ -434,7 +446,7 @@ Merge conflicts have an outsized reputation for difficulty. You'll engineer a gu
|
||||
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]")
|
||||
```
|
||||
|
||||
**Here is the punchline of the whole module: you have no idea yet whether that's right, so verify.**
|
||||
@@ -446,9 +458,9 @@ Merge conflicts have an outsized reputation for difficulty. You'll engineer a gu
|
||||
```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
|
||||
python cli.py # run with no args, see the merged usage string
|
||||
python cli.py stats # both commands actually work
|
||||
python cli.py purge
|
||||
python3 cli.py # run with no args, see the merged usage string
|
||||
python3 cli.py stats # both commands actually work
|
||||
python3 cli.py purge
|
||||
```
|
||||
|
||||
If the usage line lists both commands and both run, the AI's silent resolution was correct. If it
|
||||
|
||||
@@ -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.
|
||||
@@ -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:]))
|
||||