From 8afdb884a06856df901c6436d4b8112a18a46a85 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Fri, 30 Oct 2026 23:50:30 +0200 Subject: [PATCH 1/2] Add 20 hands-on labs in five lanes with a progress CLI Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + Makefile | 12 ++- biome.json | 1 + docs/labs/README.md | 56 ++++++++++++++ labs/advanced/01-reviewer-subagent.md | 30 ++++++++ labs/advanced/02-measure-a-call.md | 30 ++++++++ labs/advanced/03-parallel-worktree.md | 30 ++++++++ labs/advanced/04-structural-check.md | 35 +++++++++ labs/daily/01-resume-a-session.md | 30 ++++++++ labs/daily/02-path-scoped-rule.md | 30 ++++++++ labs/daily/03-first-skill.md | 30 ++++++++ labs/daily/04-first-hook.md | 32 ++++++++ labs/factory/01-write-intent.md | 30 ++++++++ labs/factory/02-acceptance-first.md | 30 ++++++++ labs/factory/03-dry-run-the-factory.md | 30 ++++++++ labs/factory/04-run-the-contract.md | 30 ++++++++ labs/start/01-first-prompt.md | 30 ++++++++ labs/start/02-run-the-gate.md | 29 +++++++ labs/start/03-plan-first.md | 29 +++++++ labs/start/04-local-memory.md | 29 +++++++ labs/team/01-deny-secrets.md | 30 ++++++++ labs/team/02-review-rules.md | 30 ++++++++ labs/team/03-prove-a-level.md | 29 +++++++ labs/team/04-plugin-manifest.md | 30 ++++++++ levels.json | 7 +- scripts/labs/catalog.test.ts | 65 ++++++++++++++++ scripts/labs/catalog.ts | 76 ++++++++++++++++++ scripts/labs/cli.test.ts | 95 +++++++++++++++++++++++ scripts/labs/cli.ts | 92 ++++++++++++++++++++++ scripts/labs/main.ts | 23 ++++++ scripts/labs/progress.test.ts | 68 +++++++++++++++++ scripts/labs/progress.ts | 46 +++++++++++ scripts/labs/render.test.ts | 69 +++++++++++++++++ scripts/labs/render.ts | 67 ++++++++++++++++ tests/structure/lab-solutions.test.ts | 102 +++++++++++++++++++++++++ tests/structure/labs.test.ts | 30 ++++++++ vitest.config.ts | 3 +- 37 files changed, 1413 insertions(+), 3 deletions(-) create mode 100644 docs/labs/README.md create mode 100644 labs/advanced/01-reviewer-subagent.md create mode 100644 labs/advanced/02-measure-a-call.md create mode 100644 labs/advanced/03-parallel-worktree.md create mode 100644 labs/advanced/04-structural-check.md create mode 100644 labs/daily/01-resume-a-session.md create mode 100644 labs/daily/02-path-scoped-rule.md create mode 100644 labs/daily/03-first-skill.md create mode 100644 labs/daily/04-first-hook.md create mode 100644 labs/factory/01-write-intent.md create mode 100644 labs/factory/02-acceptance-first.md create mode 100644 labs/factory/03-dry-run-the-factory.md create mode 100644 labs/factory/04-run-the-contract.md create mode 100644 labs/start/01-first-prompt.md create mode 100644 labs/start/02-run-the-gate.md create mode 100644 labs/start/03-plan-first.md create mode 100644 labs/start/04-local-memory.md create mode 100644 labs/team/01-deny-secrets.md create mode 100644 labs/team/02-review-rules.md create mode 100644 labs/team/03-prove-a-level.md create mode 100644 labs/team/04-plugin-manifest.md create mode 100644 scripts/labs/catalog.test.ts create mode 100644 scripts/labs/catalog.ts create mode 100644 scripts/labs/cli.test.ts create mode 100644 scripts/labs/cli.ts create mode 100644 scripts/labs/main.ts create mode 100644 scripts/labs/progress.test.ts create mode 100644 scripts/labs/progress.ts create mode 100644 scripts/labs/render.test.ts create mode 100644 scripts/labs/render.ts create mode 100644 tests/structure/lab-solutions.test.ts create mode 100644 tests/structure/labs.test.ts diff --git a/.gitignore b/.gitignore index b8ebf46..f5a3c87 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ test-results/ playwright-report/ .factory-base/ **/.stryker-tmp/ +.labs/ diff --git a/Makefile b/Makefile index 3e46273..11a5c6a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: prove-homework doctor dev check check-affected prove levels claude smoke hooks eval factory exercise +.PHONY: lab next progress prove-homework doctor dev check check-affected prove levels claude smoke hooks eval factory exercise doctor: @node -v | grep -qE '^v2[2-9]' && echo "ok node $$(node -v)" || (echo "need Node 22+" && exit 1) @@ -51,3 +51,13 @@ levels: # A session that ignores your own ~/.claude, so a demo shows only this repo's config. claude: CLAUDE_CONFIG_DIR=$(CURDIR)/.workshop-home claude + +# Hands-on labs, start to advanced. HINT=1 reveals one hint; ID= picks a lab; progress is in .labs/ (gitignored). +lab: + npx tsx scripts/labs/main.ts lab $(ID) $(if $(HINT),--hint) + +next: + npx tsx scripts/labs/main.ts next + +progress: + npx tsx scripts/labs/main.ts progress diff --git a/biome.json b/biome.json index a4ebb53..089be16 100644 --- a/biome.json +++ b/biome.json @@ -7,6 +7,7 @@ "!**/node_modules", "!fixtures", "!supplied", + "!.labs", "!test-results", "!playwright-report", "!.workshop-home", diff --git a/docs/labs/README.md b/docs/labs/README.md new file mode 100644 index 0000000..4ea1e56 --- /dev/null +++ b/docs/labs/README.md @@ -0,0 +1,56 @@ +# Labs: one line from first prompt to a running factory + +The levels explain. The labs make you do it. Take them in order, one command at a time. Each lab has one goal, exact steps, a proof that is red until you finish, hints that appear one at a time, and a line on what you just learned. + +Two workshops use the same path. + +- **Claude Code, beginner to pro:** the `start`, `daily`, `team` and `advanced` lanes. +- **Software Factory:** the `factory` lane, after `start` and `daily`. + +## Commands + +``` +make progress the lane map with a bar per lane +make next show the next lab without running anything +make lab show the current lab, run its proof, record a pass +make lab HINT=1 reveal the next hint, one per call +make lab ID=daily-03 work on any lab, in any order +``` + +Colour turns off when `NO_COLOR` is set or output is not a terminal. Progress sits in `.labs/progress.json`, which git ignores. Your work for each lab goes under `.labs/work/`, so a lab never changes tracked files. + +Run Claude Code in one terminal and `make lab` in a second one beside it. + +## The path + +| Lab | Lane | Goal | Pairs with | +| ------------- | -------- | --------------------------------------------- | ------------------------------------------ | +| `start-01` | start | Ask Claude about the repo | [hand-built](../levels/hand-built.md) | +| `start-02` | start | Run the gate yourself | [hand-built](../levels/hand-built.md) | +| `start-03` | start | Plan before you edit | [context](../levels/context.md) | +| `start-04` | start | Teach Claude a personal habit | [context](../levels/context.md) | +| `daily-01` | daily | Continue where you left off | [hand-built](../levels/hand-built.md) | +| `daily-02` | daily | Write a rule that loads only where it applies | [context](../levels/context.md) | +| `daily-03` | daily | Package a habit as a skill | [skills](../levels/skills.md) | +| `daily-04` | daily | Block a mistake with a hook | [boundary](../levels/boundary.md) | +| `team-01` | team | Deny what Claude must never read | [boundary](../levels/boundary.md) | +| `team-02` | team | Write the review rules | [verification](../levels/verification.md) | +| `team-03` | team | Run a proof and keep the receipt | [verification](../levels/verification.md) | +| `team-04` | team | Package your setup as a plugin | [sharing](../levels/sharing.md) | +| `factory-01` | factory | Turn a ticket into an intent | [F1 Plan](../factory/f1-plan.md) | +| `factory-02` | factory | Write acceptance before code | [F2 Design](../factory/f2-design.md) | +| `factory-03` | factory | See what a run would do | [F3 Build](../factory/f3-build.md) | +| `factory-04` | factory | Check the workflow contract | [F8 Workflows](../factory/f8-workflows.md) | +| `advanced-01` | advanced | Write a read-only reviewer subagent | [execution](../levels/execution.md) | +| `advanced-02` | advanced | Measure what a call costs | [agent SDK](../agent/agent-sdk.md) | +| `advanced-03` | advanced | Work in a parallel worktree | [agent teams](../electives/agent-teams.md) | +| `advanced-04` | advanced | Write a check that walks the whole tree | [workflows](../levels/workflows.md) | + +## How the labs are kept correct + +- A test parses every lab and fails if one lacks a goal, steps, a proof or two hints. +- A test runs every proof in an empty folder and expects red, then applies a known solution and expects green. A proof that passes with no work, or can never pass, fails the build. +- A test fails if a lab or its pairing is missing from the table above. +- `levels.json` gives every lab file to trunk, so every level copy carries the labs. + +To add a lab: write `labs//NN-kebab-name.md`, add its row here, add its solution to `tests/structure/lab-solutions.test.ts`, then run `make check`. diff --git a/labs/advanced/01-reviewer-subagent.md b/labs/advanced/01-reviewer-subagent.md new file mode 100644 index 0000000..7354db1 --- /dev/null +++ b/labs/advanced/01-reviewer-subagent.md @@ -0,0 +1,30 @@ +# Write a read-only reviewer subagent + +**Pairs with:** `docs/levels/execution.md` + +## Goal + +Define a subagent that can read and search but cannot write, so a review can never change the code. + +## Steps + +1. Read a real one: `ls .claude/agents` then `cat` one. +2. Make the folder: `mkdir -p .labs/work/agents`. +3. Write `.labs/work/agents/reviewer.md` with front matter `name`, `description`, `tools` and `model`, then a short prompt. +4. The `tools` line lists only `Read, Grep, Glob`. + +## Proof + +```sh +f=.labs/work/agents/reviewer.md; grep -q '^name: reviewer' $f && grep -q '^description: ' $f && grep -q '^tools: Read, Grep, Glob$' $f && grep -q '^model: ' $f && ! grep -Eq '^tools:.*(Write|Edit|Bash)' $f +``` + +## Hints + +1. Four front matter keys: `name`, `description`, `tools`, `model`. +2. The tools line is exactly `tools: Read, Grep, Glob`. +3. Pick a small model for the `model` line. A reviewer reads, it does not need the largest. + +## What you just learned + +The tools list is the fence. A reviewer without Write cannot fix things quietly, so its report is the whole truth. diff --git a/labs/advanced/02-measure-a-call.md b/labs/advanced/02-measure-a-call.md new file mode 100644 index 0000000..3b45a51 --- /dev/null +++ b/labs/advanced/02-measure-a-call.md @@ -0,0 +1,30 @@ +# Measure what a call costs + +**Pairs with:** `docs/agent/agent-sdk.md` + +## Goal + +Run one headless call with a turn cap and read its cost from the JSON result. + +## Steps + +1. Make the folder: `mkdir -p .labs/work`. +2. Call with a cap: `claude -p "List the folders in this repo." --max-turns 2 --output-format json > .labs/work/call.json`. +3. Read the cost: `node -e "console.log(require('./.labs/work/call.json').total_cost_usd)"`. +4. Write the number into `.labs/work/cost.txt`. + +## Proof + +```sh +grep -q 'total_cost_usd' .labs/work/call.json && grep -Eq '^[0-9]+(\.[0-9]+)?$' .labs/work/cost.txt +``` + +## Hints + +1. Two files: `.labs/work/call.json` from the call and `.labs/work/cost.txt` with one number. +2. `echo 0.012 > .labs/work/cost.txt` shows the shape. Use your own number. +3. `--max-turns` stops the loop, so the cost has a ceiling. + +## What you just learned + +Cost is a field in the output, not a surprise on the invoice. Cap the turns and read the number. diff --git a/labs/advanced/03-parallel-worktree.md b/labs/advanced/03-parallel-worktree.md new file mode 100644 index 0000000..2f0e36c --- /dev/null +++ b/labs/advanced/03-parallel-worktree.md @@ -0,0 +1,30 @@ +# Work in a parallel worktree + +**Pairs with:** `docs/electives/agent-teams.md` + +## Goal + +Create a second checkout of the repo so two Claude sessions can edit at once without touching each other's files. + +## Steps + +1. Make the folder: `mkdir -p .labs/work`. +2. Add the worktree: `git worktree add .labs/work/wt -b lab/parallel`. +3. Start a session in it: `cd .labs/work/wt && claude`, then leave with `/exit`. +4. Remove it when you are done: `git worktree remove .labs/work/wt`. Keep it until the proof is green. + +## Proof + +```sh +test -f .labs/work/wt/.git && grep -q 'gitdir' .labs/work/wt/.git +``` + +## Hints + +1. A worktree has a `.git` file, not a folder, that points at the main checkout. +2. `-b lab/parallel` makes a new branch, because one branch cannot be checked out twice. +3. Run the add command from the repo root, not from inside `.labs`. + +## What you just learned + +Parallel work needs separate working folders. Two sessions in one folder overwrite each other. diff --git a/labs/advanced/04-structural-check.md b/labs/advanced/04-structural-check.md new file mode 100644 index 0000000..056eef1 --- /dev/null +++ b/labs/advanced/04-structural-check.md @@ -0,0 +1,35 @@ +# Write a check that walks the whole tree + +**Pairs with:** `docs/levels/workflows.md` + +## Goal + +Write a script that fails when any file name is not kebab-case, then prove it fails on a bad tree. + +## Steps + +1. Read one: `cat scripts/check-conventions.ts`. +2. Make the folder: `mkdir -p .labs/work`. +3. Write `.labs/work/check-kebab.mjs`. It takes a folder, walks it, prints each file whose name is not lowercase with dashes and dots, and exits 1 if it found any. +4. Try it on the docs: `node .labs/work/check-kebab.mjs docs; echo $?`. + +## Proof + +```sh +s=.labs/work/check-kebab.mjs +node $s docs >/dev/null 2>&1 || exit 1 +mkdir -p .labs/work/bad && : > .labs/work/bad/BadName.txt +node $s .labs/work/bad >/dev/null 2>&1; r=$? +rm -rf .labs/work/bad +test $r -eq 1 +``` + +## Hints + +1. Use `readdirSync(dir, { recursive: true })` and test each name with a regular expression. +2. Test the last path segment only, such as `/^[a-z0-9.-]+$/`. Folders can be tested the same way. +3. Print each offender, set `process.exitCode = 1` when the list is not empty. + +## What you just learned + +One script that walks every file beats a reviewer's memory. Prove it by planting a bad file and watching it go red. diff --git a/labs/daily/01-resume-a-session.md b/labs/daily/01-resume-a-session.md new file mode 100644 index 0000000..efd6606 --- /dev/null +++ b/labs/daily/01-resume-a-session.md @@ -0,0 +1,30 @@ +# Continue where you left off + +**Pairs with:** `docs/levels/hand-built.md` + +## Goal + +Start a session, leave it, and pick it up again with its context intact. + +## Steps + +1. Make the folder: `mkdir -p .labs/work`. +2. Start a session and save its metadata: `claude -p "Remember the word tangerine." --output-format json > .labs/work/session.json`. +3. Continue the same session: `claude -p --continue "What word did I ask you to remember?" > .labs/work/recall.md`. +4. Read the answer: `cat .labs/work/recall.md`. + +## Proof + +```sh +grep -q 'session_id' .labs/work/session.json && grep -qi 'tangerine' .labs/work/recall.md +``` + +## Hints + +1. Two files must exist: `.labs/work/session.json` and `.labs/work/recall.md`. +2. `--continue` reopens the most recent session in this folder. +3. In the interactive app the same move is `claude --continue`, or `/resume` to choose from a list. + +## What you just learned + +A session is a saved conversation. Resume it instead of re-explaining the task. diff --git a/labs/daily/02-path-scoped-rule.md b/labs/daily/02-path-scoped-rule.md new file mode 100644 index 0000000..db80c11 --- /dev/null +++ b/labs/daily/02-path-scoped-rule.md @@ -0,0 +1,30 @@ +# Write a rule that loads only where it applies + +**Pairs with:** `docs/levels/context.md` + +## Goal + +Write a rule file scoped to one folder, so Claude reads it only when it works there. + +## Steps + +1. Read a real one: `ls .claude/rules` then `cat` any file in it. +2. Make a draft folder: `mkdir -p .labs/work/rules`. +3. Create `.labs/work/rules/api-errors.md` with a `paths:` front matter for `api/**` and two rules about customer-facing errors. +4. When you like it, copy it to `.claude/rules/` in your own project. + +## Proof + +```sh +head -1 .labs/work/rules/api-errors.md | grep -q '^---$' && grep -q '^paths:' .labs/work/rules/api-errors.md && test "$(grep -c '^- ' .labs/work/rules/api-errors.md)" -ge 2 +``` + +## Hints + +1. The file starts with `---`, then a `paths:` list, then `---`, then bullets starting with `- `. +2. A path is a glob such as `api/**`. +3. Write rules as checks you could fail, such as "an error to a customer never names the model". + +## What you just learned + +Rules cost context only when their paths match. Scope every rule to the files it governs. diff --git a/labs/daily/03-first-skill.md b/labs/daily/03-first-skill.md new file mode 100644 index 0000000..f4099f0 --- /dev/null +++ b/labs/daily/03-first-skill.md @@ -0,0 +1,30 @@ +# Package a habit as a skill + +**Pairs with:** `docs/levels/skills.md` + +## Goal + +Write a skill that Claude loads by its description, so you stop pasting the same instructions. + +## Steps + +1. Read one: `ls .claude/skills` then open a `SKILL.md`. +2. Make the folder: `mkdir -p .labs/work/skills/commit-message`. +3. Create `.labs/work/skills/commit-message/SKILL.md` with `name:` and `description:` front matter, then the steps. +4. The description says when to use it, not what it is. + +## Proof + +```sh +f=.labs/work/skills/commit-message/SKILL.md; grep -q '^name: commit-message' $f && grep -q '^description: Use when' $f && test "$(grep -c . $f)" -ge 8 +``` + +## Hints + +1. Front matter needs exactly `name: commit-message` and a `description:` that starts with `Use when`. +2. The body should have at least a few lines of steps. +3. Good first steps: read the staged diff, write a subject under 60 characters, say why in the body. + +## What you just learned + +A skill is loaded on demand. The description is the trigger, so write it as a situation. diff --git a/labs/daily/04-first-hook.md b/labs/daily/04-first-hook.md new file mode 100644 index 0000000..a603ef9 --- /dev/null +++ b/labs/daily/04-first-hook.md @@ -0,0 +1,32 @@ +# Block a mistake with a hook + +**Pairs with:** `docs/levels/boundary.md` + +## Goal + +Write a hook that stops Claude from editing `.env`, and prove it by feeding it both cases. + +## Steps + +1. Read a real hook: `cat .claude/hooks/pretooluse-file-guard.mjs`. +2. Make the folder: `mkdir -p .labs/work/hooks`. +3. Write `.labs/work/hooks/block-env.mjs`. It reads JSON from stdin, looks at `tool_input.file_path`, and exits with code 2 and a message on stderr when the path ends in `.env`. Otherwise it exits 0. +4. Try it: `echo '{"tool_input":{"file_path":".env"}}' | node .labs/work/hooks/block-env.mjs; echo $?`. + +## Proof + +```sh +h=.labs/work/hooks/block-env.mjs +echo '{"tool_input":{"file_path":".env"}}' | node $h >/dev/null 2>&1; test $? -eq 2 || exit 1 +echo '{"tool_input":{"file_path":"a.ts"}}' | node $h >/dev/null 2>&1 +``` + +## Hints + +1. Read all of stdin first: `const input = JSON.parse(readFileSync(0, "utf8"))`. +2. Exit code 2 tells Claude Code to block the call and show stderr to the model. +3. Import `readFileSync` from `node:fs` and end with `process.exit(0)` for the allowed case. + +## What you just learned + +A hook is a program, not a prompt. Exit code 2 blocks the action every time, even when the model would have forgotten the rule. diff --git a/labs/factory/01-write-intent.md b/labs/factory/01-write-intent.md new file mode 100644 index 0000000..1ce14f9 --- /dev/null +++ b/labs/factory/01-write-intent.md @@ -0,0 +1,30 @@ +# Turn a ticket into an intent + +**Pairs with:** `docs/factory/f1-plan.md` + +## Goal + +Write the one-page intent a factory run starts from: the problem and how anyone will know it is done. + +## Steps + +1. Read the stage: `cat docs/factory/f1-plan.md`. +2. Make the folder: `mkdir -p .labs/work/specs/1`. +3. Write `.labs/work/specs/1/intent.md` with the headings `## Problem`, `## Done when` and `## Out of scope`. +4. Under Done when, write two checks a command could run. + +## Proof + +```sh +f=.labs/work/specs/1/intent.md; grep -q '^## Problem' $f && grep -q '^## Done when' $f && grep -q '^## Out of scope' $f && test "$(grep -c '^- ' $f)" -ge 2 +``` + +## Hints + +1. Three headings, spelled exactly, plus at least two bullets. +2. "Done when" is a list of bullets such as "`make check` exits 0". +3. Out of scope names one thing you will not do. It stops a run from growing. + +## What you just learned + +The factory reads only what was committed. An intent with a checkable "done" is the contract every later stage tests against. diff --git a/labs/factory/02-acceptance-first.md b/labs/factory/02-acceptance-first.md new file mode 100644 index 0000000..e4e0c4f --- /dev/null +++ b/labs/factory/02-acceptance-first.md @@ -0,0 +1,30 @@ +# Write acceptance before code + +**Pairs with:** `docs/factory/f2-design.md` + +## Goal + +Turn the intent into Given, When, Then scenarios that a test can be written from before any code exists. + +## Steps + +1. Read the stage: `cat docs/factory/f2-design.md`. +2. Open your intent: `cat .labs/work/specs/1/intent.md`. +3. Write `.labs/work/specs/1/acceptance.md` with two scenarios. Each has one `Given`, one `When` and one `Then` line. +4. Each Then is something a program can assert, such as a status code or a file. + +## Proof + +```sh +f=.labs/work/specs/1/acceptance.md; test "$(grep -c '^Given' $f)" -ge 2 && test "$(grep -c '^When' $f)" -ge 2 && test "$(grep -c '^Then' $f)" -ge 2 +``` + +## Hints + +1. Two scenarios means two lines each starting `Given`, `When` and `Then`. +2. Lines start at the left edge, with no bullet. +3. "Then the worksheet has twelve questions" can be asserted. "Then it looks good" cannot. + +## What you just learned + +The spec is the first test. If a Then cannot be asserted, the requirement is not finished. diff --git a/labs/factory/03-dry-run-the-factory.md b/labs/factory/03-dry-run-the-factory.md new file mode 100644 index 0000000..5a59395 --- /dev/null +++ b/labs/factory/03-dry-run-the-factory.md @@ -0,0 +1,30 @@ +# See what a run would do + +**Pairs with:** `docs/factory/f3-build.md` + +## Goal + +Run the factory in dry-run mode and read which model, turn cap and budget each stage gets. + +## Steps + +1. Make the folder: `mkdir -p .labs/work`. +2. Dry run, no key and no spend: `make factory ISSUE=1 FLAGS=--dry-run | tee .labs/work/dry-run.txt`. +3. Read the config behind it: `cat .factory/config.json`. +4. Note which stage has the largest budget. + +## Proof + +```sh +grep -q 'stages that would run' .labs/work/dry-run.txt && grep -q 'review loop' .labs/work/dry-run.txt +``` + +## Hints + +1. The proof reads `.labs/work/dry-run.txt`. Did `tee` write it? +2. `FLAGS=--dry-run` makes the runner print and stop. +3. Run `make factory ISSUE=1 FLAGS=--dry-run` from the repo root. + +## What you just learned + +Every stage has a model, a turn cap and a budget set in config. You can read the cost of a run before you start it. diff --git a/labs/factory/04-run-the-contract.md b/labs/factory/04-run-the-contract.md new file mode 100644 index 0000000..9b5ad26 --- /dev/null +++ b/labs/factory/04-run-the-contract.md @@ -0,0 +1,30 @@ +# Check the workflow contract + +**Pairs with:** `docs/factory/f8-workflows.md` + +## Goal + +Run the workflow contract tests and keep the log, so you know the stages and their artifacts are consistent. + +## Steps + +1. Read the module: `cat docs/factory/f8-workflows.md`. +2. Make the folder: `mkdir -p .labs/work`. +3. Run the contract tests: `npx vitest run tests/contract > .labs/work/contract.log 2>&1`. +4. Read what failed or passed: `tail -8 .labs/work/contract.log`. + +## Proof + +```sh +grep -q 'passed' .labs/work/contract.log && ! grep -q 'failed' .labs/work/contract.log +``` + +## Hints + +1. The proof reads `.labs/work/contract.log` for `passed` and no `failed`. +2. The tests live in `tests/contract/`. +3. A failure names the workflow file. Open it under `.factory/workflows/`. + +## What you just learned + +A workflow is data, and a test walks every one. You change a stage by editing a file and the contract tells you what you broke. diff --git a/labs/start/01-first-prompt.md b/labs/start/01-first-prompt.md new file mode 100644 index 0000000..f609bd1 --- /dev/null +++ b/labs/start/01-first-prompt.md @@ -0,0 +1,30 @@ +# Ask Claude about the repo + +**Pairs with:** `docs/levels/hand-built.md` + +## Goal + +Run Claude Code once, headless, and keep its answer in a file you can read. + +## Steps + +1. Check your machine: `make doctor`. +2. Make the lab work folder: `mkdir -p .labs/work`. +3. Ask a question and save the answer: `claude -p "In five bullets, what does this repo do?" > .labs/work/repo-tour.md`. +4. Read it: `cat .labs/work/repo-tour.md`. + +## Proof + +```sh +test "$(grep -c . .labs/work/repo-tour.md)" -ge 3 +``` + +## Hints + +1. The proof reads `.labs/work/repo-tour.md`. Does the file exist? +2. `-p` prints one answer and exits, so `>` can capture it. +3. If `claude` is not found, install it from the official docs, then run `make doctor` again. + +## What you just learned + +`claude -p` is Claude Code as a command: a question in, an answer out, no chat window. diff --git a/labs/start/02-run-the-gate.md b/labs/start/02-run-the-gate.md new file mode 100644 index 0000000..686754a --- /dev/null +++ b/labs/start/02-run-the-gate.md @@ -0,0 +1,29 @@ +# Run the gate yourself + +**Pairs with:** `docs/levels/hand-built.md` + +## Goal + +Run the repo's own tests and keep the log, so you know what green looks like before Claude touches anything. + +## Steps + +1. Install once: `npm ci`. +2. Run the shared tests and save the log: `mkdir -p .labs/work && npx vitest run shared/ > .labs/work/gate.log 2>&1`. +3. Read the last lines: `tail -5 .labs/work/gate.log`. + +## Proof + +```sh +grep -q 'passed' .labs/work/gate.log && ! grep -q 'failed' .labs/work/gate.log +``` + +## Hints + +1. The proof wants the word `passed` in `.labs/work/gate.log` and no `failed`. +2. `2>&1` sends errors into the same log as the output. +3. If a test fails, you changed something under `shared/`. Undo it and run the step again. + +## What you just learned + +A change is only done when a command you can run says so. Know the green output first, then judge Claude's work against it. diff --git a/labs/start/03-plan-first.md b/labs/start/03-plan-first.md new file mode 100644 index 0000000..d35357c --- /dev/null +++ b/labs/start/03-plan-first.md @@ -0,0 +1,29 @@ +# Plan before you edit + +**Pairs with:** `docs/levels/context.md` + +## Goal + +Get a written plan for a small change from Claude without letting it edit a single file. + +## Steps + +1. Make the folder: `mkdir -p .labs/work`. +2. Ask for a plan only: `claude -p --permission-mode plan "Plan how to add a grade band for university to shared/. List the files and how you will verify it." > .labs/work/plan.md`. +3. Read the plan and check it names files that exist: `cat .labs/work/plan.md`. + +## Proof + +```sh +grep -qi 'verify' .labs/work/plan.md && test "$(grep -c . .labs/work/plan.md)" -ge 5 +``` + +## Hints + +1. The proof wants at least five lines in `.labs/work/plan.md` that mention how to verify. +2. Plan mode is read-only. Claude can look but not write. +3. Your prompt must ask for verification, or the plan may leave it out. + +## What you just learned + +Plan mode turns "do it" into "show me first". You review a plan for free instead of reviewing a diff. diff --git a/labs/start/04-local-memory.md b/labs/start/04-local-memory.md new file mode 100644 index 0000000..a97918f --- /dev/null +++ b/labs/start/04-local-memory.md @@ -0,0 +1,29 @@ +# Teach Claude a personal habit + +**Pairs with:** `docs/levels/context.md` + +## Goal + +Write one private preference into `CLAUDE.local.md` so Claude follows it in this repo and nowhere else. + +## Steps + +1. See the example: `cat docs/examples/CLAUDE.local.md`. +2. Add a line to your own file: `echo "- lab-note: explain each change in two plain sentences." >> CLAUDE.local.md`. +3. Check it loads: `claude -p "What preferences do my local notes give you?"`. + +## Proof + +```sh +grep -q 'lab-note' CLAUDE.local.md && grep -q 'CLAUDE.local.md' .gitignore +``` + +## Hints + +1. The proof wants the text `lab-note` in `CLAUDE.local.md` at the repo root. +2. The file is already in `.gitignore`, so it never reaches a commit. +3. Use `>>` to append. A single `>` replaces the file. + +## What you just learned + +`AGENTS.md` is for the team and `CLAUDE.local.md` is for you. Private habits never belong in a shared file. diff --git a/labs/team/01-deny-secrets.md b/labs/team/01-deny-secrets.md new file mode 100644 index 0000000..ab7c438 --- /dev/null +++ b/labs/team/01-deny-secrets.md @@ -0,0 +1,30 @@ +# Deny what Claude must never read + +**Pairs with:** `docs/levels/boundary.md` + +## Goal + +Write a settings file that denies reading `.env` files, so the rule holds for everyone who clones the repo. + +## Steps + +1. Read the real one: `cat .claude/settings.json`. +2. Make the folder: `mkdir -p .labs/work`. +3. Write `.labs/work/settings.json` with a `permissions.deny` list that blocks `Read(./.env)` and `Read(./.env.*)`. +4. Check it is valid JSON: `node -e "JSON.parse(require('fs').readFileSync('.labs/work/settings.json','utf8'))"`. + +## Proof + +```sh +node -e "const s=require('./.labs/work/settings.json'); const d=s.permissions.deny; process.exit(d.includes('Read(./.env)') && d.includes('Read(./.env.*)') ? 0 : 1)" +``` + +## Hints + +1. The shape is `{ "permissions": { "deny": ["..."] } }`. +2. Each entry is a tool name with a pattern in brackets. +3. The two entries to write are `Read(./.env)` and `Read(./.env.*)`. + +## What you just learned + +`.claude/settings.json` is committed, so a deny rule protects the whole team, not only you. diff --git a/labs/team/02-review-rules.md b/labs/team/02-review-rules.md new file mode 100644 index 0000000..91af624 --- /dev/null +++ b/labs/team/02-review-rules.md @@ -0,0 +1,30 @@ +# Write the review rules + +**Pairs with:** `docs/levels/verification.md` + +## Goal + +Write a short `REVIEW.md` that tells a reviewer what to flag, each rule something a diff can fail. + +## Steps + +1. Read the real one: `cat REVIEW.md`. +2. Make the folder: `mkdir -p .labs/work`. +3. Write `.labs/work/REVIEW.md` with a heading and at least three bullets. Each bullet starts with "Flag" and names a concrete thing in a diff. +4. Try it on a diff: `claude -p "Review the staged changes using .labs/work/REVIEW.md"`. + +## Proof + +```sh +test "$(grep -c '^- Flag' .labs/work/REVIEW.md)" -ge 3 && head -1 .labs/work/REVIEW.md | grep -q '^# ' +``` + +## Hints + +1. The first line is a `# ` heading. Then three or more lines starting with `- Flag`. +2. "Flag a default export outside web/src/pages/" can fail. "Write clean code" cannot. +3. Reuse the standards in `AGENTS.md` and turn each into a flag. + +## What you just learned + +A review rule is useful only if a diff can break it. Vague advice is noise. diff --git a/labs/team/03-prove-a-level.md b/labs/team/03-prove-a-level.md new file mode 100644 index 0000000..fc32e2b --- /dev/null +++ b/labs/team/03-prove-a-level.md @@ -0,0 +1,29 @@ +# Run a proof and keep the receipt + +**Pairs with:** `docs/levels/verification.md` + +## Goal + +Run one level's proof and save the output, so you can show the team it passed. + +## Steps + +1. Make the folder: `mkdir -p .labs/work`. +2. Run the proof: `make prove LEVEL=hand-built | tee .labs/work/prove.txt`. +3. Run the exercise view: `make exercise LEVEL=hand-built TRACK=b`. + +## Proof + +```sh +grep -q '^PASS hand-built' .labs/work/prove.txt +``` + +## Hints + +1. The proof checks for a line starting `PASS hand-built` in `.labs/work/prove.txt`. +2. `tee` prints and saves at once. +3. If it prints FAIL, the line says which claim broke. Fix that, not the check. + +## What you just learned + +A proof is a command with a verdict. Share the output, not a sentence saying it works. diff --git a/labs/team/04-plugin-manifest.md b/labs/team/04-plugin-manifest.md new file mode 100644 index 0000000..8d298a8 --- /dev/null +++ b/labs/team/04-plugin-manifest.md @@ -0,0 +1,30 @@ +# Package your setup as a plugin + +**Pairs with:** `docs/levels/sharing.md` + +## Goal + +Write a plugin manifest so a teammate can install your skill and hook in one command. + +## Steps + +1. See one: `ls plugins` then `cat plugins/factory-kit/.claude-plugin/plugin.json`. +2. Make the folder: `mkdir -p .labs/work/plugin/.claude-plugin`. +3. Write `.labs/work/plugin/.claude-plugin/plugin.json` with `name`, `version` and `description`. +4. Copy your skill from the earlier lab into `.labs/work/plugin/skills/`. + +## Proof + +```sh +node -e "const m=require('./.labs/work/plugin/.claude-plugin/plugin.json'); process.exit(m.name && /^\d+\.\d+\.\d+$/.test(m.version) && m.description ? 0 : 1)" +``` + +## Hints + +1. The manifest is plain JSON with three string fields. +2. The version looks like `0.1.0`. +3. The name is kebab-case, such as `my-team-kit`. + +## What you just learned + +A plugin is your `.claude/` folder with a name and a version. Sharing becomes an install, not a copy and paste. diff --git a/levels.json b/levels.json index 6477e60..084022e 100644 --- a/levels.json +++ b/levels.json @@ -85,7 +85,12 @@ "scripts/commands.ts", "scripts/commands.test.ts", "docs/commands-by-level.md", - "tests/structure/recordings.test.ts" + "tests/structure/recordings.test.ts", + "labs/**", + "scripts/labs/**", + "docs/labs/**", + "tests/structure/labs.test.ts", + "tests/structure/lab-solutions.test.ts" ], "execution": [ ".claude/agents/**", diff --git a/scripts/labs/catalog.test.ts b/scripts/labs/catalog.test.ts new file mode 100644 index 0000000..61e75e3 --- /dev/null +++ b/scripts/labs/catalog.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { LANES, loadLabs, parseLab } from "./catalog.ts"; + +const good = `# First prompt + +**Pairs with:** \`docs/levels/hand-built.md\` + +## Goal +Ask Claude one question. + +## Steps +1. Run \`claude\`. +2. Ask it. + +## Proof +\`\`\`sh +test -f .labs/work/a.md +\`\`\` + +## Hints +1. Look in .labs. +2. Create the file. + +## What you just learned +A prompt is just a question. +`; + +describe("parseLab", () => { + it("reads every section", () => { + const lab = parseLab("start", "01-first-prompt.md", good); + expect(lab).toMatchObject({ + id: "start-01", + lane: "start", + title: "First prompt", + pairs: "docs/levels/hand-built.md", + goal: "Ask Claude one question.", + proof: "test -f .labs/work/a.md", + learned: "A prompt is just a question.", + }); + expect(lab.steps).toEqual(["Run `claude`.", "Ask it."]); + expect(lab.hints).toHaveLength(2); + }); + + it("rejects a lab with no proof", () => { + const text = good.replace(/## Proof[\s\S]*?## Hints/, "## Hints"); + expect(() => parseLab("start", "01-x.md", text)).toThrow(/proof/i); + }); + + it("rejects a lab with fewer than two hints", () => { + expect(() => parseLab("start", "01-x.md", good.replace("2. Create the file.\n", ""))).toThrow(); + }); + + it("rejects a file name that is not kebab-case with a number", () => { + expect(() => parseLab("start", "FirstPrompt.md", good)).toThrow(/file name/); + }); +}); + +describe("loadLabs", () => { + it("orders labs by lane then number", () => { + const labs = loadLabs("labs"); + const lanes = labs.map((l) => LANES.indexOf(l.lane)); + expect(lanes).toEqual([...lanes].sort((a, b) => a - b)); + expect(new Set(labs.map((l) => l.id)).size).toBe(labs.length); + }); +}); diff --git a/scripts/labs/catalog.ts b/scripts/labs/catalog.ts new file mode 100644 index 0000000..8be5bed --- /dev/null +++ b/scripts/labs/catalog.ts @@ -0,0 +1,76 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; + +// Teaching order: a learner who finishes one lane is ready for the next. +export const LANES = ["start", "daily", "team", "factory", "advanced"] as const; +export type Lane = (typeof LANES)[number]; + +const oneLine = z + .string() + .min(1) + .max(240) + .regex(/^[^\n]+$/, "must be one line"); + +export const labSchema = z.object({ + id: z.string().regex(/^[a-z]+-\d{2}$/), + lane: z.enum(LANES), + title: oneLine, + pairs: z.string().min(1), + goal: oneLine, + steps: z.array(z.string().min(1)).min(1), + proof: z.string().min(1), + // Two or more, so the first nudges and the last gives the answer away. + hints: z.array(z.string().min(1)).min(2), + learned: oneLine, +}); +export type Lab = z.infer; + +function sections(text: string): Map { + const map = new Map(); + for (const part of text.split(/^## /m).slice(1)) { + const [heading = "", ...rest] = part.split("\n"); + map.set(heading.trim().toLowerCase(), rest.join("\n").trim()); + } + return map; +} + +// "1. text" starts an item; lines indented under it belong to it. +function listItems(body: string): string[] { + const items: string[] = []; + for (const line of body.split("\n")) { + if (/^\d+\. /.test(line)) items.push(line.replace(/^\d+\. /, "")); + else if (items.length > 0 && /^\s+\S/.test(line)) items[items.length - 1] += `\n${line}`; + } + return items; +} + +function proofOf(body: string): string { + return /```sh\n([\s\S]*?)\n```/.exec(body)?.[1]?.trim() ?? ""; +} + +export function parseLab(lane: string, file: string, text: string): Lab { + const number = /^(\d{2})-[a-z0-9]+(-[a-z0-9]+)*\.md$/.exec(file)?.[1]; + if (!number) throw new Error(`${lane}/${file}: file name must be NN-kebab-case.md`); + const parts = sections(text); + return labSchema.parse({ + id: `${lane}-${number}`, + lane, + title: /^# (.+)$/m.exec(text)?.[1], + pairs: /^\*\*Pairs with:\*\* `([^`]+)`/m.exec(text)?.[1], + goal: parts.get("goal"), + steps: listItems(parts.get("steps") ?? ""), + proof: proofOf(parts.get("proof") ?? ""), + hints: listItems(parts.get("hints") ?? ""), + learned: parts.get("what you just learned"), + }); +} + +export function loadLabs(root: string): Lab[] { + return LANES.flatMap((lane) => + readdirSync(join(root, lane)) + .filter((f) => f.endsWith(".md")) + .sort() + .map((f) => parseLab(lane, f, readFileSync(join(root, lane, f), "utf8"))), + ); +} diff --git a/scripts/labs/cli.test.ts b/scripts/labs/cli.test.ts new file mode 100644 index 0000000..dd06287 --- /dev/null +++ b/scripts/labs/cli.test.ts @@ -0,0 +1,95 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Lab } from "./catalog.ts"; +import { type CliDeps, run } from "./cli.ts"; +import { loadProgress } from "./progress.ts"; + +const lab = (id: string, lane: Lab["lane"]): Lab => ({ + id, + lane, + title: `Title ${id}`, + pairs: "docs/README.md", + goal: `Goal ${id}`, + steps: ["Do it."], + proof: `proof-${id}`, + hints: ["first hint", "second hint"], + learned: `Learned ${id}`, +}); + +function setup(passes: boolean) { + const out: string[] = []; + const dir = mkdtempSync(join(tmpdir(), "labs-cli-")); + const deps: CliDeps = { + labs: [lab("start-01", "start"), lab("start-02", "start")], + progressPath: join(dir, "progress.json"), + color: false, + proof: () => ({ ok: passes, output: "boom line\n" }), + now: () => "2026-10-30", + out: (s) => out.push(s), + }; + return { deps, out, text: () => out.join("\n") }; +} + +describe("cli", () => { + it("progress prints the lane map and exits 0", () => { + const s = setup(true); + expect(run(["progress"], s.deps)).toBe(0); + expect(s.text()).toContain("start"); + expect(s.text()).toContain("0/2"); + }); + + it("lab runs the proof, records a pass, and points at the next lab", () => { + const s = setup(true); + expect(run(["lab"], s.deps)).toBe(0); + expect(s.text()).toContain("PASS start-01"); + expect(s.text()).toContain("Learned start-01"); + expect(s.text()).toContain("start-02"); + expect(loadProgress(s.deps.progressPath).done["start-01"]).toBe("2026-10-30"); + }); + + it("a red proof exits 1, shows output and records nothing", () => { + const s = setup(false); + expect(run(["lab"], s.deps)).toBe(1); + expect(s.text()).toContain("FAIL start-01"); + expect(s.text()).toContain("boom line"); + expect(s.text()).not.toContain("first hint"); + expect(loadProgress(s.deps.progressPath).done).toEqual({}); + }); + + it("--hint reveals one hint per call", () => { + const s = setup(false); + expect(run(["lab", "--hint"], s.deps)).toBe(0); + expect(s.text()).toContain("first hint"); + expect(s.text()).not.toContain("second hint"); + expect(run(["lab", "--hint"], s.deps)).toBe(0); + expect(s.text()).toContain("second hint"); + expect(run(["lab", "--hint"], s.deps)).toBe(0); + expect(s.text()).toContain("No more hints"); + }); + + it("next shows the brief without running the proof", () => { + const s = setup(true); + s.deps.proof = () => { + throw new Error("must not run"); + }; + expect(run(["next"], s.deps)).toBe(0); + expect(s.text()).toContain("Goal start-01"); + }); + + it("an unknown id exits 2 and lists the ids", () => { + const s = setup(true); + expect(run(["lab", "nope"], s.deps)).toBe(2); + expect(s.text()).toContain("start-01"); + }); + + it("says so when every lab is done", () => { + const s = setup(true); + run(["lab"], s.deps); + run(["lab"], s.deps); + s.out.length = 0; + expect(run(["next"], s.deps)).toBe(0); + expect(s.text()).toContain("Every lab is done"); + }); +}); diff --git a/scripts/labs/cli.ts b/scripts/labs/cli.ts new file mode 100644 index 0000000..7ec49ea --- /dev/null +++ b/scripts/labs/cli.ts @@ -0,0 +1,92 @@ +import type { Lab } from "./catalog.ts"; +import { + currentLab, + laneStats, + loadProgress, + markDone, + type Progress, + revealHint, + saveProgress, +} from "./progress.ts"; +import { paint, renderBrief, renderMap } from "./render.ts"; + +export type CliDeps = { + labs: Lab[]; + progressPath: string; + color: boolean; + proof: (command: string) => { ok: boolean; output: string }; + now: () => string; + out: (text: string) => void; +}; + +const tail = (text: string, n: number) => text.trimEnd().split("\n").slice(-n).join("\n"); + +function showMap(deps: CliDeps, progress: Progress): void { + const here = currentLab(deps.labs, progress)?.id; + deps.out(renderMap(laneStats(deps.labs, progress), deps.labs, progress, here, deps.color)); +} + +function finish(deps: CliDeps, progress: Progress, lab: Lab): void { + const c = deps.color; + deps.out(`${paint(c, "green", `PASS ${lab.id}`)} ${lab.title}`); + deps.out(`${paint(c, "bold", "What you just learned")} ${lab.learned}`); + const after = markDone(progress, lab.id, deps.now()); + saveProgress(deps.progressPath, after); + const next = currentLab(deps.labs, after); + deps.out(next ? `\nNext: ${next.id} ${next.title}. Run: make lab` : "\nEvery lab is done."); +} + +function hint(deps: CliDeps, progress: Progress, lab: Lab): void { + if ((progress.hints[lab.id] ?? 0) >= lab.hints.length) { + deps.out("No more hints. Read the steps again, then run make lab."); + } else { + saveProgress(deps.progressPath, revealHint(progress, lab).progress); + } + deps.out(renderBrief(lab, loadProgress(deps.progressPath), deps.color)); +} + +export function run(args: string[], deps: CliDeps): number { + const [command = "progress", ...rest] = args; + const id = rest.find((a) => !a.startsWith("--")); + const progress = loadProgress(deps.progressPath); + + if (command === "progress") { + showMap(deps, progress); + return 0; + } + if (command !== "lab" && command !== "next") { + deps.out("usage: make lab [ID=] [HINT=1] | make next | make progress"); + return 2; + } + const wanted = command === "next" ? undefined : id; + const lab = wanted ? deps.labs.find((l) => l.id === wanted) : currentLab(deps.labs, progress); + if (!lab) { + if (wanted) { + deps.out(`Unknown lab "${wanted}". Labs: ${deps.labs.map((l) => l.id).join(", ")}`); + return 2; + } + deps.out("Every lab is done. Run make progress for the map."); + return 0; + } + if (command === "next") { + deps.out(renderBrief(lab, progress, deps.color)); + return 0; + } + if (rest.includes("--hint")) { + hint(deps, progress, lab); + return 0; + } + + deps.out(renderBrief(lab, progress, deps.color)); + deps.out(`\n${paint(deps.color, "dim", `Proof: ${lab.proof}`)}`); + const result = deps.proof(lab.proof); + if (result.ok) { + finish(deps, progress, lab); + return 0; + } + deps.out( + `${paint(deps.color, "red", `FAIL ${lab.id}`)} the proof is red. Finish the steps, then run make lab again.`, + ); + if (result.output.trim()) deps.out(tail(result.output, 6)); + return 1; +} diff --git a/scripts/labs/main.ts b/scripts/labs/main.ts new file mode 100644 index 0000000..f9ac15d --- /dev/null +++ b/scripts/labs/main.ts @@ -0,0 +1,23 @@ +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { loadLabs } from "./catalog.ts"; +import { run } from "./cli.ts"; +import { colorEnabled } from "./render.ts"; + +// Gitignored, so progress stays on the learner's machine and never reaches a commit. +const PROGRESS_FILE = ".labs/progress.json"; + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const code = run(process.argv.slice(2), { + labs: loadLabs("labs"), + progressPath: PROGRESS_FILE, + color: colorEnabled(process.env, Boolean(process.stdout.isTTY)), + proof: (command) => { + const r = spawnSync("sh", ["-c", command], { encoding: "utf8", timeout: 120_000 }); + return { ok: r.status === 0, output: `${r.stdout}${r.stderr}` }; + }, + now: () => new Date().toISOString(), + out: (text) => console.log(text), + }); + process.exit(code); +} diff --git a/scripts/labs/progress.test.ts b/scripts/labs/progress.test.ts new file mode 100644 index 0000000..9eef41b --- /dev/null +++ b/scripts/labs/progress.test.ts @@ -0,0 +1,68 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import type { Lab } from "./catalog.ts"; +import { + currentLab, + laneStats, + loadProgress, + markDone, + revealHint, + saveProgress, +} from "./progress.ts"; + +const lab = (id: string, lane: Lab["lane"]): Lab => ({ + id, + lane, + title: id, + pairs: "docs/README.md", + goal: "g", + steps: ["s"], + proof: "true", + hints: ["a", "b"], + learned: "l", +}); +const labs = [lab("start-01", "start"), lab("start-02", "start"), lab("daily-01", "daily")]; + +describe("progress", () => { + it("starts empty when the file is missing or corrupt", () => { + const dir = mkdtempSync(join(tmpdir(), "labs-")); + expect(loadProgress(join(dir, "none.json"))).toEqual({ done: {}, hints: {} }); + writeFileSync(join(dir, "bad.json"), "{nope"); + expect(loadProgress(join(dir, "bad.json"))).toEqual({ done: {}, hints: {} }); + writeFileSync(join(dir, "shape.json"), '{"done":[1]}'); + expect(loadProgress(join(dir, "shape.json"))).toEqual({ done: {}, hints: {} }); + }); + + it("round-trips through the file", () => { + const dir = mkdtempSync(join(tmpdir(), "labs-")); + const file = join(dir, "sub", "progress.json"); + saveProgress(file, markDone({ done: {}, hints: {} }, "start-01", "2026-10-30")); + expect(loadProgress(file).done["start-01"]).toBe("2026-10-30"); + expect(readFileSync(file, "utf8")).toContain("start-01"); + }); + + it("current lab is the first one not done", () => { + expect(currentLab(labs, { done: {}, hints: {} })?.id).toBe("start-01"); + expect(currentLab(labs, { done: { "start-01": "x" }, hints: {} })?.id).toBe("start-02"); + const all = { done: { "start-01": "x", "start-02": "x", "daily-01": "x" }, hints: {} }; + expect(currentLab(labs, all)).toBeUndefined(); + }); + + it("reveals hints one at a time and stops at the last", () => { + let p = { done: {}, hints: {} }; + const first = revealHint(p, labs[0] as Lab); + expect(first.revealed).toBe(1); + p = first.progress; + const second = revealHint(p, labs[0] as Lab); + expect(second.revealed).toBe(2); + expect(revealHint(second.progress, labs[0] as Lab).revealed).toBe(2); + }); + + it("counts per lane", () => { + const stats = laneStats(labs, { done: { "start-01": "x" }, hints: {} }); + expect(stats.find((s) => s.lane === "start")).toMatchObject({ done: 1, total: 2 }); + expect(stats.find((s) => s.lane === "team")).toMatchObject({ done: 0, total: 0 }); + }); +}); diff --git a/scripts/labs/progress.ts b/scripts/labs/progress.ts new file mode 100644 index 0000000..e4464d5 --- /dev/null +++ b/scripts/labs/progress.ts @@ -0,0 +1,46 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { z } from "zod"; +import { LANES, type Lab } from "./catalog.ts"; + +const progressSchema = z.object({ + done: z.record(z.string(), z.string()), + hints: z.record(z.string(), z.number().int().min(0)), +}); +export type Progress = z.infer; + +// A missing or damaged file is a fresh start, not a crash: the file is only a bookmark. +export function loadProgress(path: string): Progress { + try { + const parsed = progressSchema.safeParse(JSON.parse(readFileSync(path, "utf8"))); + if (parsed.success) return parsed.data; + } catch { + // fall through to a fresh start + } + return { done: {}, hints: {} }; +} + +export function saveProgress(path: string, progress: Progress): void { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(progress, null, 2)}\n`); +} + +export function markDone(progress: Progress, id: string, when: string): Progress { + return { ...progress, done: { ...progress.done, [id]: when } }; +} + +export function revealHint(progress: Progress, lab: Lab): { progress: Progress; revealed: number } { + const revealed = Math.min((progress.hints[lab.id] ?? 0) + 1, lab.hints.length); + return { progress: { ...progress, hints: { ...progress.hints, [lab.id]: revealed } }, revealed }; +} + +export function currentLab(labs: Lab[], progress: Progress): Lab | undefined { + return labs.find((l) => !(l.id in progress.done)); +} + +export function laneStats(labs: Lab[], progress: Progress) { + return LANES.map((lane) => { + const inLane = labs.filter((l) => l.lane === lane); + return { lane, total: inLane.length, done: inLane.filter((l) => l.id in progress.done).length }; + }); +} diff --git a/scripts/labs/render.test.ts b/scripts/labs/render.test.ts new file mode 100644 index 0000000..c234956 --- /dev/null +++ b/scripts/labs/render.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import type { Lab } from "./catalog.ts"; +import { bar, colorEnabled, renderBrief, renderMap } from "./render.ts"; + +const lab: Lab = { + id: "start-01", + lane: "start", + title: "First prompt", + pairs: "docs/README.md", + goal: "Ask one question.", + steps: ["Run `claude`."], + proof: "true", + hints: ["one", "two"], + learned: "l", +}; +// biome-ignore lint/suspicious/noControlCharactersInRegex: the test looks for the escape byte +const ESC = /\u001b/; + +describe("colorEnabled", () => { + it("respects NO_COLOR and FORCE_COLOR", () => { + expect(colorEnabled({ NO_COLOR: "1" }, true)).toBe(false); + expect(colorEnabled({ NO_COLOR: "1", FORCE_COLOR: "1" }, true)).toBe(false); + expect(colorEnabled({ FORCE_COLOR: "1" }, false)).toBe(true); + expect(colorEnabled({}, true)).toBe(true); + expect(colorEnabled({}, false)).toBe(false); + }); +}); + +describe("bar", () => { + it("fills in proportion", () => { + expect(bar(1, 4, 8, false)).toBe("██░░░░░░"); + expect(bar(0, 0, 4, false)).toBe("░░░░"); + expect(bar(4, 4, 4, false)).toBe("████"); + }); +}); + +describe("renderMap", () => { + const stats = [{ lane: "start" as const, done: 1, total: 2 }]; + it("has no escape codes without color", () => { + const text = renderMap( + stats, + [lab], + { done: { "start-01": "x" }, hints: {} }, + "start-01", + false, + ); + expect(text).not.toMatch(ESC); + expect(text).toContain("start"); + expect(text).toContain("1/2"); + }); + it("uses escape codes with color", () => { + expect(renderMap(stats, [lab], { done: {}, hints: {} }, "start-01", true)).toMatch(ESC); + }); +}); + +describe("renderBrief", () => { + it("shows the goal and steps but hides unrevealed hints", () => { + const text = renderBrief(lab, { done: {}, hints: {} }, false); + expect(text).toContain("Ask one question."); + expect(text).toContain("1. Run `claude`."); + expect(text).not.toContain("one\n"); + expect(text).toContain("0 of 2"); + }); + it("shows revealed hints only", () => { + const text = renderBrief(lab, { done: {}, hints: { "start-01": 1 } }, false); + expect(text).toContain("Hint 1: one"); + expect(text).not.toContain("two"); + }); +}); diff --git a/scripts/labs/render.ts b/scripts/labs/render.ts new file mode 100644 index 0000000..9e27127 --- /dev/null +++ b/scripts/labs/render.ts @@ -0,0 +1,67 @@ +import type { Lab, Lane } from "./catalog.ts"; +import type { Progress } from "./progress.ts"; + +type Stat = { lane: Lane; done: number; total: number }; + +// NO_COLOR always wins; FORCE_COLOR helps in CI logs; otherwise only a terminal gets color. +export function colorEnabled(env: Record, isTTY: boolean): boolean { + if (env.NO_COLOR) return false; + if (env.FORCE_COLOR) return true; + return isTTY; +} + +const CODES = { bold: 1, dim: 2, red: 31, green: 32, yellow: 33, cyan: 36 } as const; +export function paint(color: boolean, style: keyof typeof CODES, text: string): string { + return color ? `\u001b[${CODES[style]}m${text}\u001b[0m` : text; +} + +export function bar(done: number, total: number, width: number, color: boolean): string { + const filled = total === 0 ? 0 : Math.round((done / total) * width); + return paint(color, "green", "█".repeat(filled)) + "░".repeat(width - filled); +} + +// Backticked commands stand out, so a learner finds what to type. +function commands(text: string, color: boolean): string { + return text.replace(/`([^`]+)`/g, (_m, cmd: string) => `\`${paint(color, "cyan", cmd)}\``); +} + +export function renderMap( + stats: Stat[], + labs: Lab[], + progress: Progress, + currentId: string | undefined, + color: boolean, +): string { + const lines = [paint(color, "bold", "Lab map"), ""]; + for (const s of stats) { + lines.push(`${s.lane.padEnd(9)} ${bar(s.done, s.total, 20, color)} ${s.done}/${s.total}`); + for (const lab of labs.filter((l) => l.lane === s.lane)) { + const mark = lab.id in progress.done ? paint(color, "green", "done") : " "; + const here = lab.id === currentId ? paint(color, "yellow", " <- you are here") : ""; + lines.push(` ${mark} ${lab.id} ${lab.title}${here}`); + } + } + return lines.join("\n"); +} + +export function renderBrief(lab: Lab, progress: Progress, color: boolean): string { + const shown = progress.hints[lab.id] ?? 0; + const lines = [ + paint(color, "bold", `${lab.id} ${lab.title}`), + paint(color, "dim", `Pairs with ${lab.pairs}`), + "", + `${paint(color, "bold", "Goal")} ${lab.goal}`, + "", + paint(color, "bold", "Steps"), + ...lab.steps.map((s, i) => `${i + 1}. ${commands(s, color)}`), + "", + paint( + color, + "dim", + `Hints: ${shown} of ${lab.hints.length} revealed. Reveal the next with: make lab HINT=1`, + ), + ]; + for (let i = 0; i < shown; i++) + lines.push(`Hint ${i + 1}: ${commands(lab.hints[i] ?? "", color)}`); + return lines.join("\n"); +} diff --git a/tests/structure/lab-solutions.test.ts b/tests/structure/lab-solutions.test.ts new file mode 100644 index 0000000..e84ce3d --- /dev/null +++ b/tests/structure/lab-solutions.test.ts @@ -0,0 +1,102 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadLabs } from "../../scripts/labs/catalog.ts"; + +const W = ".labs/work"; +const BLOCK_ENV = `import{readFileSync as r}from"node:fs";const i=JSON.parse(r(0,"utf8"));if((i.tool_input?.file_path??"").endsWith(".env")){console.error("no");process.exit(2)}`; +const KEBAB = `import{readdirSync as d}from"node:fs";const bad=d(process.argv[2],{recursive:true}).filter(f=>!/^[a-z0-9.-]+$/.test(String(f).split("/").pop()));bad.forEach(f=>console.log(f));process.exitCode=bad.length?1:0`; +const sh = (...lines: string[]) => lines.join("\n"); + +// One known-good way to finish each lab, written as shell. Claude-powered steps are replaced by +// their output, because the proof only reads the artifact, not how it was made. +const SOLUTIONS: Record = { + "start-01": sh(`mkdir -p ${W}`, `printf 'a\\nb\\nc\\n' > ${W}/repo-tour.md`), + "start-02": sh(`mkdir -p ${W}`, `echo 'Tests 3 passed' > ${W}/gate.log`), + "start-03": sh( + `mkdir -p ${W}`, + `printf 'one\\ntwo\\nthree\\nfour\\nVerify it\\n' > ${W}/plan.md`, + ), + "start-04": sh(`echo '- lab-note: x' > CLAUDE.local.md`, `echo '/CLAUDE.local.md' > .gitignore`), + "daily-01": sh( + `mkdir -p ${W}`, + `echo '{"session_id":"s"}' > ${W}/session.json`, + `echo tangerine > ${W}/recall.md`, + ), + "daily-02": sh( + `mkdir -p ${W}/rules`, + `printf -- '---\\npaths:\\n - api/**\\n---\\n- a\\n- b\\n' > ${W}/rules/api-errors.md`, + ), + "daily-03": sh( + `mkdir -p ${W}/skills/commit-message`, + `printf 'name: commit-message\\ndescription: Use when committing\\n1\\n2\\n3\\n4\\n5\\n6\\n' > ${W}/skills/commit-message/SKILL.md`, + ), + "daily-04": sh( + `mkdir -p ${W}/hooks`, + `cat > ${W}/hooks/block-env.mjs <<'EOF'\n${BLOCK_ENV}\nEOF`, + ), + "team-01": sh( + `mkdir -p ${W}`, + `echo '{"permissions":{"deny":["Read(./.env)","Read(./.env.*)"]}}' > ${W}/settings.json`, + ), + "team-02": sh( + `mkdir -p ${W}`, + `printf '# Review\\n- Flag a\\n- Flag b\\n- Flag c\\n' > ${W}/REVIEW.md`, + ), + "team-03": sh(`mkdir -p ${W}`, `echo 'PASS hand-built: ok' > ${W}/prove.txt`), + "team-04": sh( + `mkdir -p ${W}/plugin/.claude-plugin`, + `echo '{"name":"kit","version":"0.1.0","description":"d"}' > ${W}/plugin/.claude-plugin/plugin.json`, + ), + "factory-01": sh( + `mkdir -p ${W}/specs/1`, + `printf '## Problem\\n## Done when\\n- a\\n- b\\n## Out of scope\\n' > ${W}/specs/1/intent.md`, + ), + "factory-02": sh( + `mkdir -p ${W}/specs/1`, + `printf 'Given a\\nWhen b\\nThen c\\nGiven d\\nWhen e\\nThen f\\n' > ${W}/specs/1/acceptance.md`, + ), + "factory-03": sh( + `mkdir -p ${W}`, + `printf 'stages that would run\\nreview loop\\n' > ${W}/dry-run.txt`, + ), + "factory-04": sh(`mkdir -p ${W}`, `echo 'Tests 4 passed' > ${W}/contract.log`), + "advanced-01": sh( + `mkdir -p ${W}/agents`, + `printf 'name: reviewer\\ndescription: d\\ntools: Read, Grep, Glob\\nmodel: haiku\\n' > ${W}/agents/reviewer.md`, + ), + "advanced-02": sh( + `mkdir -p ${W}`, + `echo '{"total_cost_usd":0.01}' > ${W}/call.json`, + `echo 0.012 > ${W}/cost.txt`, + ), + "advanced-03": sh(`mkdir -p ${W}/wt`, `echo 'gitdir: /x' > ${W}/wt/.git`), + "advanced-04": sh(`mkdir -p ${W} docs`, `cat > ${W}/check-kebab.mjs <<'EOF'\n${KEBAB}\nEOF`), +}; + +function inTempDir(setup: string | undefined, proof: string) { + const dir = mkdtempSync(join(tmpdir(), "lab-proof-")); + if (setup) { + writeFileSync(join(dir, "setup.sh"), setup); + spawnSync("sh", [join(dir, "setup.sh")], { cwd: dir }); + } + return spawnSync("sh", ["-c", proof], { cwd: dir, encoding: "utf8" }).status; +} + +describe("lab proofs", () => { + const labs = loadLabs("labs"); + + it("has a solution for every lab and none extra", () => { + expect(Object.keys(SOLUTIONS).sort()).toEqual(labs.map((l) => l.id).sort()); + }); + + it.each(labs)("$id is red with no work", (lab) => { + expect(inTempDir(undefined, lab.proof)).not.toBe(0); + }); + + it.each(labs)("$id is green once the steps are done", (lab) => { + expect(inTempDir(SOLUTIONS[lab.id], lab.proof)).toBe(0); + }); +}); diff --git a/tests/structure/labs.test.ts b/tests/structure/labs.test.ts new file mode 100644 index 0000000..1ac791f --- /dev/null +++ b/tests/structure/labs.test.ts @@ -0,0 +1,30 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { LANES, loadLabs } from "../../scripts/labs/catalog.ts"; + +// A lab without a goal, steps, a proof or hints is a doc, not a lab. loadLabs parses with zod and throws. +const labs = loadLabs("labs"); +const readme = readFileSync("docs/labs/README.md", "utf8"); + +describe("labs", () => { + it("every lane has labs, noob to advanced", () => { + for (const lane of LANES) expect(labs.filter((l) => l.lane === lane).length).toBeGreaterThan(0); + }); + + it("no stray files in a lane", () => { + const stray = LANES.flatMap((lane) => + readdirSync(`labs/${lane}`) + .filter((f) => !/^\d{2}-[a-z0-9-]+\.md$/.test(f)) + .map((f) => `${lane}/${f}`), + ); + expect(stray).toEqual([]); + }); + + it.each(labs)("$id pairs with a doc that exists and is listed in the path table", (lab) => { + expect(existsSync(lab.pairs), `${lab.pairs} is missing`).toBe(true); + const row = readme.split("\n").find((line) => line.includes(`\`${lab.id}\``)); + expect(row, `${lab.id} is not in docs/labs/README.md`).toBeDefined(); + const target = lab.pairs.replace(/^docs\//, "../"); + expect(row).toContain(`(${target})`); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8d8628d..1e37aea 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,7 +1,8 @@ import { configDefaults, defineConfig } from "vitest/config"; // plugins/*/hooks tests need `claude-code/testing`, so `claude plugin test` runs them, not vitest. +// .labs/ holds learner work, including worktrees that hold whole copies of the repo. // supplied/ holds generated copies of the repo; running their tests here would test the copy, not the source. export default defineConfig({ - test: { exclude: [...configDefaults.exclude, "supplied/**", "plugins/*/hooks/**"] }, + test: { exclude: [...configDefaults.exclude, "supplied/**", "plugins/*/hooks/**", ".labs/**"] }, }); From 21c6df1f0cbef34a69d3e403ffaf86469c8cbeb7 Mon Sep 17 00:00:00 2001 From: Param Harrison Date: Fri, 30 Oct 2026 23:55:12 +0200 Subject: [PATCH 2/2] Labs: a pairing doc counts when any level owns it Co-Authored-By: Claude Sonnet 5.5 --- tests/structure/labs.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/structure/labs.test.ts b/tests/structure/labs.test.ts index 1ac791f..42cd8dc 100644 --- a/tests/structure/labs.test.ts +++ b/tests/structure/labs.test.ts @@ -1,9 +1,14 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; +import levels from "../../levels.json" with { type: "json" }; import { LANES, loadLabs } from "../../scripts/labs/catalog.ts"; // A lab without a goal, steps, a proof or hints is a doc, not a lab. loadLabs parses with zod and throws. const labs = loadLabs("labs"); +// A level snapshot holds only its own docs, so a pairing doc counts when some level owns it. +const owned = (Object.values(levels) as string[][]).flat(); +const isOwned = (f: string) => + owned.some((g) => g === f || (g.endsWith("/**") && f.startsWith(g.slice(0, -2)))); const readme = readFileSync("docs/labs/README.md", "utf8"); describe("labs", () => { @@ -21,7 +26,7 @@ describe("labs", () => { }); it.each(labs)("$id pairs with a doc that exists and is listed in the path table", (lab) => { - expect(existsSync(lab.pairs), `${lab.pairs} is missing`).toBe(true); + expect(existsSync(lab.pairs) || isOwned(lab.pairs), `${lab.pairs} is missing`).toBe(true); const row = readme.split("\n").find((line) => line.includes(`\`${lab.id}\``)); expect(row, `${lab.id} is not in docs/labs/README.md`).toBeDefined(); const target = lab.pairs.replace(/^docs\//, "../");