diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..4083c37 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "allow": [ + "Bash(python -m pytest *)", + "Bash(python3 -m pytest *)", + "Bash(py -m pytest *)", + "Bash(python -m shop *)", + "Bash(python3 -m shop *)", + "Bash(py -m shop *)", + "Bash(git status *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git show *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git push *)", + "Bash(gh run list *)", + "Bash(gh run view *)", + "Bash(gh run watch *)", + "Bash(gh pr view *)", + "Bash(gh pr list *)", + "Bash(gh pr checks *)", + "Bash(gh pr diff *)", + "Bash(gh pr create *)", + "Bash(gh pr comment *)", + "Bash(gh issue list *)", + "Bash(gh issue view *)", + "Bash(gh issue create *)", + "Bash(gh issue comment *)" + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d84e3b9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep LF everywhere, so `workshop` and the scripts run the same on a Windows checkout. +* text=auto eol=lf diff --git a/README.md b/README.md index 76502df..724acf3 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,18 @@ Amounts are integer cents; `python3 -m shop` prints a receipt. Routines (`/schedule`, the optional part of exercise 5) need a subscription login; with an API key only, you run the same prompts by hand, which the exercise expects. -- macOS or Linux. On Windows, use WSL; the commands below assume a POSIX shell. +- macOS, Linux or Windows. On Windows, use PowerShell, with + [Git for Windows](https://git-scm.com/downloads/win) installed: Claude Code runs its + hooks and shell commands through the Git Bash it brings. + +**On Windows**, type `py workshop` wherever these pages say `./workshop`, and `py` +where they say `python3`. Everything else is the same: the prompts you paste into +Claude Code, `git`, `gh` and `claude` do not change. + +**Not yet tested on Windows: exercise 3, run 2.** The `fairmind-coding` plugin runs +its loop gate as `python3` inside Git Bash, and a Python installed from python.org or +with `winget` is called `python` and `py` there, not `python3`. `./workshop doctor` +checks it on Windows. If it shows ✗, do run 2 alongside someone on macOS or Linux. ## Before the workshop · 20 minutes @@ -34,18 +45,39 @@ the review secret; the GitHub App it cannot see, so check that one by hand. in your own account. Make it **public**: branch rulesets, which exercise 5 uses, need a public repository or a paid plan. Your exercise work will be visible. Then clone your copy and `cd` into it. -2. **Install the tools:** [Claude Code](https://claude.com/claude-code), logged in - with your subscription or started with `export ANTHROPIC_API_KEY=`; +2. **Install the tools:** [Claude Code](https://claude.com/claude-code), [GitHub CLI](https://cli.github.com) then `gh auth login`, `jq`, Python 3.10 or - later, and `python3 -m pip install pytest`. -3. **Install the plugin** (exercise 3). Inside Claude Code: + later, and `python3 -m pip install pytest`. On Windows, in PowerShell: + + ```powershell + winget install Anthropic.ClaudeCode + winget install Git.Git + winget install GitHub.cli + winget install jqlang.jq + winget install Python.Python.3.12 + py -m pip install pytest # in a new terminal, after the installs + ``` + + Log Claude Code in with your subscription, **or** give it the API key in the + terminal you will work in (every new terminal needs it again): + + ```bash + export ANTHROPIC_API_KEY= # macOS, Linux + $env:ANTHROPIC_API_KEY = "" # Windows PowerShell + ``` + +3. **Trust your copy.** In the folder of your copy, run `claude` once, answer **yes** + to the question about trusting the folder, then type `/exit`. Until you do, Claude + Code ignores `.claude/settings.json`, which pre-approves the test and `git`/`gh` + commands the exercises run, and `/goal` (exercise 3) is not available. +4. **Install the plugin** (exercise 3). Inside Claude Code: ``` /plugin marketplace add FairMind-Gen-AI-Studio/fairmind-plugins-public /plugin install fairmind-coding@fairmind-plugins ``` -4. **Let Claude review your pull requests** (exercises 4 and 5). Two things, both on +5. **Let Claude review your pull requests** (exercises 4 and 5). Two things, both on your copy: - install the [Claude GitHub App](https://github.com/apps/claude) and give it access to your copy: the review posts through it; @@ -58,10 +90,10 @@ the review secret; the GitHub App it cannot see, so check that one by hand. gh secret set CLAUDE_CODE_OAUTH_TOKEN # paste it when asked ``` -5. **Check, then set up:** +6. **Check, then set up:** ```bash - ./workshop doctor # every line should be ✓ + ./workshop doctor # every line should be ✓ (Windows: py workshop doctor) ./workshop setup # labels, CODEOWNERS, the main ruleset ``` @@ -89,15 +121,19 @@ your own `main`, so the pull requests you open go to your copy. | `.github/workflows/review.yml` | Claude reviews every pull request, then labels it `reviewed` | | `.github/ISSUE_TEMPLATE/defect.yml` | a defect with a stable key and a Reproduce block | | `.github/CODEOWNERS`, `.github/rulesets/main.json` | the paths that stay human, and what GitHub enforces | +| `.claude/settings.json` | the commands the exercises may run without asking: tests, `git`, and the `gh` verbs that read or comment. Merging and raw API calls still ask | | `.claude/skills/review-loop/` | the skill you write in exercise 4 | | `scripts/fake_deploy.py` | the fake production log for exercise 2 | -| `workshop` | `doctor`, `setup`, `reset`, `deploy` | +| `workshop` | `doctor`, `setup`, `reset`, `events`, `hook`, `deploy` | ## If something goes wrong | Symptom | Cause and fix | |---|---| | `./workshop doctor` shows ✗ | the line after the arrow says what to install or run | +| Claude asks permission for every test or `git` command | the folder is not trusted: run `claude` there once and answer yes | +| `./workshop` does nothing on Windows | type `py workshop …` instead | +| `claude` says Git Bash was not found (Windows) | install Git for Windows, then open a new terminal | | `reset` says it could not start an exercise | your `main` has diverged from the template's; ask the facilitator | | the review check is green and nothing was posted | it ran for ~15 s: the workflow file differs from `main`'s, or the app is not installed on your copy | | the review check is red: no review credential | `gh secret set ANTHROPIC_API_KEY` on your copy (or the subscription token) | diff --git a/kit/ex1.md b/kit/ex1.md index 4aa615f..dc7d477 100644 --- a/kit/ex1.md +++ b/kit/ex1.md @@ -6,6 +6,9 @@ One turn of Claude Code is many passes: the model asks for a tool, the harness r it, the result goes back in, until the model answers without asking for a tool. Here you count those passes, then make the harness record them for you. +> **Commands** go in your terminal; on Windows type `py workshop` wherever this page +> says `./workshop`. **Prompts** go inside Claude Code and are the same everywhere. + ## The task `python3 -m shop` prints a receipt with amounts like `24.5 EUR`. Money should always @@ -13,35 +16,90 @@ show two decimals: `24.50 EUR`. ## Steps -1. **Run the task headless, streaming every event to a file.** +### 1 · Start clean + +```bash +./workshop reset ex1 +``` + +**Expect:** `on branch ex1-work, starting point of ex1`. + +### 2 · Run the task headless, streaming every event to a file + +One line: copy all of it. + +```bash +claude -p "Receipts must always show two decimals, like 24.50 EUR. Fix it and add a test." --output-format stream-json --verbose --permission-mode acceptEdits > run.jsonl +``` + +It prints nothing for a minute or two: every event goes into `run.jsonl`. +`--verbose` is required, because in print mode stream-json refuses without it. +`--permission-mode acceptEdits` lets it edit files; the test commands it runs are +pre-approved in `.claude/settings.json`. + +### 3 · Count the tool calls, and the passes + +```bash +./workshop events run.jsonl +``` + +**Expect:** a numbered list of tool calls (`Bash`, `Read`, `Edit`…), the number of +turns the run reports, and the last message Claude wrote. + +### 4 · Make the harness log every tool call + +```bash +./workshop hook +``` + +It writes `.claude/settings.local.json` and prints it. **Read it before going on:** a +`PostToolUse` hook with matcher `*` runs a command after every tool call. The hook gets +the event as JSON on its stdin; the command appends the event's `tool_name` to +`tool-log.txt`. The file is gitignored and `./workshop reset` removes it. + +Put the code back the way it was, so Claude has the same work to do again: + +```bash +git stash -u +``` + +Run the step 2 command again, then compare what the hook saw with what the stream +recorded: + +```bash +./workshop events run.jsonl +``` + +Open `tool-log.txt` in your editor, next to that output. - ```bash - claude -p "Receipts must always show two decimals, like 24.50 EUR. Fix it and add a test." \ - --output-format stream-json --verbose > run.jsonl - ``` +**Expect:** the same tools in the same order, **or fewer**. The hook fires on +successful calls only; a call that fails fires `PostToolUseFailure`, which this hook +does not listen to. - `--verbose` is required: in print mode, stream-json refuses without it. +### 5 · Find the moment it decided to stop -2. **Count the tool calls, and the passes.** +```bash +claude +``` - ```bash - jq -r 'select(.type=="assistant") | .message.content[] | select(.type=="tool_use") | .name' run.jsonl - jq 'select(.type=="result") | .num_turns' run.jsonl - ``` +```text +Open run.jsonl. It is the event stream of a headless Claude Code run. Find the last +assistant message that contains no tool_use block and quote it. Then tell me what +decided that this was the end of the turn: a test, a hook, a limit, or the model +itself? +``` -3. **Make the harness log every tool call.** Create `.claude/settings.json`: +## Variant · make the hook catch the failures too - ```json - {"hooks": {"PostToolUse": [{"matcher": "*", "hooks": [ - {"type": "command", "command": "jq -r '.tool_name' >> tool-log.txt"}]}]}} - ``` +In the same session: - Undo the fix (`git checkout shop/`), run step 1 again, and read `tool-log.txt`. - The hook gets each event as JSON on stdin. It fires on successful calls only; - failed ones fire `PostToolUseFailure`. +```text +Add a PostToolUseFailure hook to .claude/settings.local.json that runs exactly the +same command as the PostToolUse one. Then tell me why PostToolUse alone missed the +failed calls. +``` -4. **Find the moment it decided to stop.** The last assistant message with no tool - call. Who chose it? +Run `git stash -u` and the step 2 command again, and read `tool-log.txt`. ## Watch for diff --git a/kit/ex2.md b/kit/ex2.md index 56577a1..eaa757e 100644 --- a/kit/ex2.md +++ b/kit/ex2.md @@ -3,38 +3,103 @@ **15 minutes · ring 2, `/loop` · branch: `./workshop reset ex2`** `/loop` repeats a prompt: on a fixed interval (`/loop 2m …`) or at a pace Claude picks -after each pass (`/loop …`, one minute to one hour). It has no exit condition of its -own. Run the two loops below **at the same time, in two terminals**: each one spends +after each pass (`/loop …`, one minute to one hour). A loop on a fixed interval never +ends by itself. A self-paced loop ends when Claude decides the job is done, **if your +prompt says what done means.** + +You run two loops **at the same time**, each in its own terminal. Each one spends most of its life waiting. +> **Commands** go in your terminal; on Windows type `py workshop` wherever this page +> says `./workshop`. **Prompts** go inside Claude Code and are the same everywhere. + ## Loop A · watch a pipeline until it is green This branch has a bug that makes CI fail. The pipeline takes about two minutes. +**Terminal 1:** + ```bash -git push -u origin ex2-work # CI runs on every push +./workshop reset ex2 +git push -u origin ex2-work claude ``` -Then, in Claude Code, write a `/loop` that watches the CI run of this branch, fixes -what fails, and **stops** when it is green. You choose the interval and the stop. -Count the passes it took. +The push starts CI. Paste this in Claude Code: + +```text +/loop Watch the CI run of branch ex2-work. Each pass, run: +gh run list --branch ex2-work --workflow CI --limit 1 +- Still queued or in progress: say "waiting", nothing else, and check again in about + two minutes. +- Failed: read why with gh run view --log-failed, fix the cause in the code, + run the tests, then commit and push. The push starts a new CI run. +- Succeeded, on the commit that is at the tip of ex2-work: tell me how many passes + this loop took and what you fixed, then stop the loop. Do not check again. +``` + +**Expect:** a few `waiting` passes, one fix and push, more `waiting`, then a summary +and the loop ends. Count the passes. + +### Variant · forget the stop + +Once Loop A has ended, try the same watch with a fixed interval and **no stop rule**: + +```text +/loop 1m Run gh run list --branch ex2-work --workflow CI --limit 1 and tell me the +status of the latest run. +``` + +Let it fire three times after the run is green. Every pass costs tokens and tells you +the same thing. Then stop it. A fixed-interval loop does not stop itself, and `Esc` +only stops self-paced ones: + +```text +What scheduled tasks do I have? Cancel the CI status one. +``` ## Loop B · a fix going to production +**Terminal 2:** start the fake production log. It writes one line a minute, and a +regression comes later. Leave this terminal running. + ```bash -./workshop deploy # one line a minute into deploy.log; a regression comes later +./workshop deploy +``` + +**Terminal 3:** + +```bash +claude +``` + +```text +/loop Watch deploy.log: a fix just went to production, and the log gets one line per +minute. Each pass, read only the lines added since your last pass. +- Every new line says status=ok: say "quiet", then wait longer than last time before + the next check: 1 minute, then 2, then 5, then stay at 5. +- Any new line says status=degraded: tell me the minute and the error_rate, and go + back to checking every minute. +- Once the log has reached minute=20: summarise what you saw and stop the loop. +``` + +In the room, minutes stand in for hours. + +**Expect:** the waits get longer while the log is quiet, drop back to one minute +around `minute=7`, and get longer again once the regression is over. + +### Variant · a clock with no judgement + +```text +/loop 1m Read the last line of deploy.log and tell me the status. ``` -In a second Claude Code session, ask the agent to watch `deploy.log` and to **re-arm -its own loop** with longer waits after every quiet check, and to go back to the -shortest wait and tell you on any regression. In the room, minutes stand in for -hours: 1, 2, 5. +Count how many answers in a row are identical. Then cancel it as in Loop A. ## Watch for - The forgotten stop: the loop keeps firing after the pipeline is green, and every pass is paid for. - The interval: one minute feels responsive and mostly buys identical answers. -- This loop dies with your session. Which of these watches should have been a +- These loops die with your session. Which of these watches should have been a routine (`/schedule`), which keeps running with the laptop closed? diff --git a/kit/ex3.md b/kit/ex3.md index 5251b15..1552095 100644 --- a/kit/ex3.md +++ b/kit/ex3.md @@ -6,6 +6,9 @@ One task, run twice. First the exit condition is judged by a model reading the session (`/goal`). Then it is judged by checks that run (`/fairmind-loop`). Compare what each one accepted as done. +> **Commands** go in your terminal; on Windows type `py workshop` wherever this page +> says `./workshop`. **Prompts** go inside Claude Code and are the same everywhere. + ## Run 1 · `/goal` ```bash @@ -13,40 +16,83 @@ what each one accepted as done. claude ``` -Read `TICKET.md`, then write one `/goal` condition taken from the ticket, for example: +Paste the goal. It is one condition, taken from `TICKET.md`, and it starts the work +straight away: the condition is also the instruction. +```text +/goal TICKET.md is implemented with tests for what it asks: discount codes work as TICKET.md describes and the test suite passes ``` -/goal discount codes work as TICKET.md describes and python3 -m pytest passes + +Claude keeps working until the goal's evaluator, a small model reading the +conversation, judges the condition met. When it stops: + +```text +Commit your work on this branch with the message "DISC-1 via /goal". Then list, in +one line each, every case you tested. For FREE100, say whether the order still pays +for shipping. ``` -Note what it accepted as done. Keep the branch: `git log -1` shows its commit. +**Keep this answer:** it is what `/goal` accepted as done. Then `/exit`. ## Run 2 · `/fairmind-loop` ```bash -./workshop reset ex3-loop # same code, same ticket, plus a prepared contract +./workshop reset ex3-loop claude ``` -``` +Same code and same ticket as before your run 1, plus a prepared contract: a brief +(`.fairmind/design/DISC-1.md`) and checks (`.fairmind/gate/`). Paste: + +```text /fairmind-loop DISC-1 +The brief is .fairmind/design/DISC-1.md and the checks are .fairmind/gate/. Use them +as they are: do not write new checks and do not edit these. I have no Fairmind +workspace. Keep the budget at six iterations. Stop at the first green evaluation. +``` + +It asks questions as it goes. The answers: + +| If it asks… | Answer | +|---|---| +| whether you have a Fairmind workspace, or for a project or session | **no**, the loop runs on git, python3 and bash alone | +| whether to write or generate checks | **no**, use the prepared ones | +| to fix or replace a quarantined check | **no**, leave it quarantined | +| the budget | **six iterations** | +| to continue past the first green evaluation | **no**, stop there | + +### 1 · Read the brief before the checks + +While it prepares, open `.fairmind/design/DISC-1.md` in your editor. The brief says +what to build; the checks only say what is measured. Find the sentence the ticket +never says. + +### 2 · Watch admission quarantine a check + +One check is badly written on purpose. When the loop reports admission, ask: + +```text +Which check did admission quarantine, and why? Quote the reason recorded in +.fairmind/loop-state.json, and tell me what a check has to do to be admitted. ``` -The brief (`.fairmind/design/DISC-1.md`) and the checks (`.fairmind/gate/`) are -prepared. Tell it to use them, not to write new ones. When it asks whether you have a -Fairmind workspace, answer **no**: the loop runs on git, python3 and bash alone. +### 3 · Let the gate refuse at least once -1. **Read the brief before the checks.** The brief says what to build; the checks - only say what is measured. -2. **Watch admission quarantine a check.** One is badly written on purpose. Do not - fix it: read why it was refused. -3. **Confirm a small budget: six iterations.** -4. **Let the gate refuse at least once**, then stop at the first green evaluation. - Reaching `passed_pending_human` takes three greens and a completeness review: - that is the overflow, not the exercise. +**Expect:** at least one red evaluation, where the gate names the check that failed, +then a fix, then a green evaluation. The exercise ends at the first green +(`confirmation 1/3`). Reaching `passed_pending_human` takes three greens and a +completeness review: that is the overflow, not the exercise. ## Compare +In the same session: + +```text +Run the checks in .fairmind/gate/ against the code on branch ex3-goal-work, the +/goal run. Use a temporary git worktree for it and remove it afterwards. Which checks +fail there? For each one, say what the brief asked for and what that code does. +``` + What did the goal accept that the gate refused? The ticket hides one edge case. Which run caught it? diff --git a/kit/ex4.md b/kit/ex4.md index ada18c1..75c4b5c 100644 --- a/kit/ex4.md +++ b/kit/ex4.md @@ -7,36 +7,106 @@ posts findings on specific lines and one summary comment. A PR is delivered when review has run, been read in both places it writes, and every finding has a fix or a reply. +> **Commands** go in your terminal; on Windows type `py workshop` wherever this page +> says `./workshop`. **Prompts** go inside Claude Code and are the same everywhere. + ## Steps -1. **Fill in the skill skeleton** at `.claude/skills/review-loop/SKILL.md`. Four - sections are stubbed: when to run, how to wait, where to read, what to answer. -2. **Open a PR and invoke the skill.** +### 1 · Fill in the skill + +```bash +./workshop reset ex4 +claude +``` + +`.claude/skills/review-loop/SKILL.md` has four stubbed sections: when to run, how to +wait, where to read, what to answer. Paste: + +```text +Fill in the four sections of .claude/skills/review-loop/SKILL.md. Keep the headings +and the frontmatter; replace each HTML comment with instructions an agent can follow. +Use these facts, all measured on this repository: +- The review is the workflow "Claude review". It starts when a PR is opened and again + on every push to it. A real review takes one to eight minutes. +- Wait with: gh run watch , where the id comes from + gh run list --workflow "Claude review" --branch --limit 1 --json databaseId -q ".[0].databaseId" + A run that finished in about 15 seconds reviewed nothing: say so, do not report it + as clean. +- It writes in two places. The summary: gh pr view --comments. The findings on + lines: gh api repos/{owner}/{repo}/pulls//comments --jq '.[] | "\(.path):\(.line // .original_line)\n\(.body)\n"' + After a later push, .line becomes null for a finding whose line moved; that is why + the query falls back to .original_line. +- For each finding: reproduce it first (run the code or write a failing test) before + changing anything. The reviewer read the code; it did not run it. Reproduced: fix it + and push. Not reproduced, or you disagree: reply on the PR saying why. +- The loop ends when every finding has a fix or a reply, never before. +Show me the finished file. +``` + +Read what it wrote. It is your skill now: change anything you would do differently. +Then commit it and leave Claude Code: - ```bash - git push -u origin ex4-work - gh pr create --fill - claude # then: use the review-loop skill on this PR - ``` +```bash +git add .claude/skills/review-loop/SKILL.md +git commit -m "review-loop skill" +``` + +```text +/exit +``` + +### 2 · Open a PR and let the skill run the loop + +```bash +git push -u origin ex4-work +gh pr create --fill +claude +``` - Let the skill do the waiting, not you. -3. **Answer every finding.** Fix it, or reply on the PR saying why not. Push, and let - the loop run the review again. -4. **Stop only on nothing unanswered.** Then look at the run's duration in the - Actions tab. +A new session loads the skill you just wrote. Paste: + +```text +/review-loop on the pull request of this branch. +``` + +Let the skill do the waiting, not you. + +**Expect:** it finds the run, waits one to eight minutes, then lists the summary **and** +the inline findings. + +### 3 · Answer every finding + +If the skill has not already started on them: + +```text +Work through every finding as the review-loop skill says: reproduce first, then fix +and push, or reply on the PR saying why not. After the push, run the review loop +again on the new review. +``` + +### 4 · Stop only on nothing unanswered + +```text +List every finding of the latest review, each with the commit that fixed it or the +link to your reply. Is anything unanswered? +``` + +Then open the PR's **Actions** tab and look at how long each review run took. One full review round is the exercise. A second round is the overflow: a review takes one to eight minutes. -## Useful commands +## Variant · read only one place -```bash -gh run watch "$(gh run list --workflow 'Claude review' --branch ex4-work --limit 1 --json databaseId -q '.[0].databaseId')" -gh pr view --comments # the summary, not the inline findings -gh api repos/{owner}/{repo}/pulls//comments \ - --jq '.[] | "\(.path):\(.line // .original_line)\n\(.body)\n"' # the inline findings +Remove the inline-findings command from the **Where to read** section of your skill, +then ask: + +```text +/review-loop on the pull request of this branch. Report only what is unanswered. ``` +What does the loop report now, and what did it stop seeing? + ## Watch for - Reading only the summary and missing the inline findings. diff --git a/kit/ex5.md b/kit/ex5.md index d66df9f..272ec0c 100644 --- a/kit/ex5.md +++ b/kit/ex5.md @@ -1,39 +1,126 @@ # Exercise 5 · Close the loop on today -**30 minutes, in teams of two or three · ring 5, dogfooding · branch: your `main`** +**30 minutes, in teams of two or three · ring 5, dogfooding · branch: `./workshop reset ex5`** What broke during exercises 1 to 4 becomes a queue; a routine works the queue at night; a second routine merges what passes the policy you write. Today you build the first two steps and write the third one down. +> **Commands** go in your terminal; on Windows type `py workshop` wherever this page +> says `./workshop`. **Prompts** go inside Claude Code and are the same everywhere. + +```bash +./workshop reset ex5 +claude +``` + ## Steps -1. **File two defects you hit today** with the Defect issue template. The key goes - in the body: see [failure-shapes.md](ex5/failure-shapes.md). File one of them - twice from a second session: the second filing must find the first and comment - on it instead. +### 1 · File two defects you hit today + +Pick two things that broke or surprised you in exercises 1 to 4. For each one, paste +this and replace the first line: - ```bash +```text +Defect: + +File it as a GitHub issue in this repository, following the Defect template in +.github/ISSUE_TEMPLATE/defect.yml: +1. Derive the key :::: from WHAT BROKE, not from + my wording. The shape comes from the closed list in kit/ex5/failure-shapes.md. + For a skill, prompt or workflow, the symbol is the section heading. +2. Before creating anything, search every issue, open and closed: gh issue list --state all --search " in:body" - ``` + If one exists, comment on it with the new evidence instead, and stop there. +3. Otherwise create it with gh issue create --label defect, and a body with the + template's sections: Key, What broke, Reproduce (commit, exact command, expected / + actual, anchor), Evidence (verbatim output, never a paraphrase), Severity (S1-S4). +Show me the key and the full gh command, and wait for my OK before running it. +``` + +**Then file one of them twice**, in the **same copy** of the repository: the search +only sees the issues of the repository it runs in. Open a **second** session there +(`/exit`, then `claude`, or a second terminal). Your teammate, at your keyboard, +pastes the same prompt and describes the **same** defect in **different words**. + +**Expect:** the second filing finds the first issue and comments on it. If it opens a +new issue instead, the key was written from the description, not from what broke: +compare the two keys. + +### 2 · Write the repair routine's prompt, and run it once by hand + +Open `kit/ex5/repair-routine.md` in your editor and change what your team disagrees +with. Then, **in a new session**: + +```text +Read kit/ex5/repair-routine.md and follow the prompt in its text block exactly, as if +you were the routine, with = 1. Print the ordered list of issues before touching +any code. +``` + +**Watch:** does it take the first issue of the list it printed, or the easiest one? + +### 3 · Write the merge policy as rules -2. **Write the repair routine's prompt.** Start from - [repair-routine.md](ex5/repair-routine.md). Run it once **by hand**, in a Claude - Code session, before anyone schedules it: does it follow the order, or pick? -3. **Write the merge policy as rules**, in [merge-policy.md](ex5/merge-policy.md): - what the routine checks, what GitHub enforces, which paths never merge without a - person. -4. **Decide what stays human**, as a team, out loud. Put those paths in - `.github/CODEOWNERS`. +This one is a **team discussion**, not a prompt: the decisions are the exercise. Open +`kit/ex5/merge-policy.md` and fill in the three columns together. Decide, out loud: + +- Which checks can only be written in the first column, where they persuade? Which + can move to the second column, where GitHub refuses? +- What is the highest severity a routine may merge without a person? +- In your copy, who is the code owner, and who is the routine? (Read "The trap in a + copy that only you own" before you answer.) + +Once the worksheet is filled in: + +```text +Turn the filled-in worksheet in kit/ex5/merge-policy.md into the final merge routine +prompt: start from its prompt starter, and make every rule in the first column a +numbered check. Keep "Never use --admin" and "Never approve a pull request". +Write it into the same file, under a heading "Merge routine · final prompt". +``` + +### 4 · Decide what stays human + +As a team, list the paths no routine may merge without a person. Then: + +```text +Add these paths to .github/CODEOWNERS, owned by the user already named there: + +Commit on a new branch and open a pull request. Do not merge it. +``` If your team is behind, skip the hand run in step 2. Do not skip the merge policy. ## Scheduling it (if your plan has routines) -`/schedule` creates a routine in the cloud. The repair routine runs on a schedule of -an hour or more. The merge routine runs on the pull request label `reviewed`, which -`review.yml` adds when a review has run; the Claude GitHub App you installed for the -review is also what delivers that event to the routine. Without routines, run both prompts by hand in a session. +`/schedule` needs a **subscription login**. With `ANTHROPIC_API_KEY` set in your +shell, it is not offered: run both prompts by hand in a session instead, which is +what the steps above did. + +The repair routine, on a schedule of an hour or more, off the hour: + +```text +/schedule every night at 02:17, on this repository. The routine's prompt is the text +block of kit/ex5/repair-routine.md with = 2: read the file here and copy that +text into the routine word for word. +``` + +The merge routine, on the pull request label `reviewed`, which `review.yml` adds when +a review has run: + +```text +/schedule a routine on this repository. Its prompt is the "Merge routine · final +prompt" in kit/ex5/merge-policy.md: read the file here and copy that text into the +routine word for word. Trigger it on the GitHub event pull request labeled, filtered +to the label reviewed. It has no schedule. +``` + +The Claude GitHub App you installed for the review is also what delivers that event +to the routine. If the terminal cannot add the GitHub trigger, add it on the +routine's page at claude.ai/code/routines. A routine starts from a fresh clone of +`main`: the prompt travels inside the routine, but the code it works on is whatever +`main` holds. ## Watch for diff --git a/workshop b/workshop index 6e3504b..d22471f 100755 --- a/workshop +++ b/workshop @@ -4,8 +4,12 @@ ./workshop doctor check the tools, the plugin and the review secret ./workshop setup labels, CODEOWNERS and the main ruleset on your copy ./workshop reset start an exercise from a clean branch + ./workshop events what a headless run did, pass by pass (exercise 1) + ./workshop hook log every tool call to tool-log.txt (exercise 1) ./workshop deploy [args] write the fake production log for exercise 2 +On Windows, type `py workshop` where this says `./workshop`. + Exercises you can reset to: ex1, ex2, ex3-goal, ex3-loop, ex4, ex5. The starting branches are fetched from the public template, so they work in a copy made without "Include all branches". @@ -31,7 +35,7 @@ EXERCISES = { "ex5": "main", } # Files an exercise writes that a reset must not carry into the next one. -SCRATCH = [".fairmind/active-context.json", "deploy.log", "run.jsonl", "tool-log.txt"] +SCRATCH = [".fairmind/active-context.json", ".claude/settings.local.json", "deploy.log", "run.jsonl", "tool-log.txt"] LABELS = { "reviewed": ("0E8A16", "The Claude review has run on the latest push"), "defect": ("D73A4A", "Found while using our own tools"), @@ -57,8 +61,8 @@ def ok(label, passed, hint=""): def doctor(): print("tools") results = [] - for tool, hint in [("git", "install git"), ("gh", "https://cli.github.com"), - ("jq", "brew install jq / apt install jq"), ("claude", "https://claude.com/claude-code")]: + for tool, hint in [("git", "install git (on Windows: Git for Windows, which brings Git Bash)"), ("gh", "https://cli.github.com"), + ("jq", "brew install jq / apt install jq / winget install jqlang.jq"), ("claude", "https://claude.com/claude-code")]: results.append(ok(tool, shutil.which(tool) is not None, hint)) results.append(ok(f"python {sys.version.split()[0]}", sys.version_info >= (3, 10), "python 3.10 or later")) try: @@ -86,6 +90,26 @@ def doctor(): "in Claude Code: /plugin marketplace add FairMind-Gen-AI-Studio/fairmind-plugins-public, " "then /plugin install fairmind-coding@fairmind-plugins")) + # Claude Code ignores this repository's .claude/settings.json (the commands the + # exercises pre-approve) until you have opened `claude` here once and trusted it. + try: + projects = json.loads((pathlib.Path.home() / ".claude.json").read_text()).get("projects", {}) + except (OSError, ValueError): + projects = {} + # Trusting a folder also trusts everything inside it. + trusted = any(projects.get(key, {}).get("hasTrustDialogAccepted") + for folder in (ROOT, *ROOT.parents) for key in {str(folder), folder.as_posix()}) + results.append(ok("this folder is trusted", trusted, + "run `claude` here once, accept the trust question, then /exit")) + + if os.name == "nt": + # The fairmind-coding loop gate (exercise 3, run 2) calls `python3` from Git + # Bash; a python.org or winget Python only answers to `python` and `py`. + bash = shutil.which("bash") + found = bool(bash) and run(bash, "-lc", "python3 --version", check=False).returncode == 0 + results.append(ok("python3 inside Git Bash (exercise 3, run 2)", found, + "not tested on Windows yet: do exercise 3 run 2 with someone on macOS or Linux")) + print("\nready" if all(results) else "\nfix the ✗ lines above, then run ./workshop doctor again") return 0 if all(results) else 1 @@ -155,6 +179,69 @@ def reset(exercise): return 0 +def read_events(path): + # Windows PowerShell 5.1 writes `>` redirections as UTF-16, so honour a BOM. + raw = pathlib.Path(path).read_bytes() + encoding = "utf-16" if raw[:2] in (b"\xff\xfe", b"\xfe\xff") else "utf-8-sig" + events = [] + for line in raw.decode(encoding).splitlines(): + line = line.strip() + if line.startswith("{"): + events.append(json.loads(line)) + return events + + +def events(path): + try: + stream = read_events(path) + except OSError as err: + sys.exit(f"cannot read {path}: {err.strerror}. Run step 1 of exercise 1 first.") + passes, tools, last_text = 0, [], "" + for event in stream: + if event.get("type") != "assistant": + continue + passes += 1 + content = event.get("message", {}).get("content", []) + calls = [block for block in content if block.get("type") == "tool_use"] + tools.extend(call["name"] for call in calls) + text = " ".join(block.get("text", "") for block in content if block.get("type") == "text").strip() + if text and not calls: + last_text = text + result = next((event for event in reversed(stream) if event.get("type") == "result"), {}) + + print(f"tool calls, in order ({len(tools)}):") + for number, name in enumerate(tools, 1): + print(f" {number:>3} {name}") + print(f"assistant messages: {passes}") + print(f"turns reported by the run: {result.get('num_turns', '?')}") + print("\nthe message that ended the turn (no tool call):") + print(" " + (last_text[:600] or "(none found)").replace("\n", "\n ")) + return 0 + + +def hook(): + # The interpreter running this script, by absolute path, so the hook works on + # every OS without jq. Forward slashes work in Git Bash, which README requires + # on Windows, as well as everywhere else. + command = f'"{pathlib.Path(sys.executable).as_posix()}" "{(ROOT / "workshop").as_posix()}" log-tool' + settings = {"hooks": {"PostToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": command}]}]}} + target = ROOT / ".claude" / "settings.local.json" + target.parent.mkdir(exist_ok=True) + target.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + print(f"wrote {target.relative_to(ROOT).as_posix()} (not committed; ./workshop reset removes it):\n") + print(target.read_text(encoding="utf-8")) + return 0 + + +def log_tool(): + # Called by the hook: the event arrives as JSON on stdin. + event = json.loads(sys.stdin.buffer.read().decode("utf-8") or "{}") + with open(ROOT / "tool-log.txt", "a", encoding="utf-8") as log: + failed = event.get("hook_event_name") == "PostToolUseFailure" + print(event.get("tool_name", "?") + (" FAILED" if failed else ""), file=log) + return 0 + + def deploy(args): return subprocess.call([sys.executable, str(ROOT / "scripts" / "fake_deploy.py"), *args], cwd=ROOT) @@ -170,6 +257,12 @@ def main(argv): return setup() if command == "reset" and len(rest) == 1: return reset(rest[0]) + if command == "events" and len(rest) == 1: + return events(rest[0]) + if command == "hook": + return hook() + if command == "log-tool": + return log_tool() if command == "deploy": return deploy(rest) print(__doc__) @@ -177,5 +270,7 @@ def main(argv): if __name__ == "__main__": + # ✓ and ✗ must survive a Windows console or a redirected stdout. + sys.stdout.reconfigure(encoding="utf-8") os.chdir(ROOT) sys.exit(main(sys.argv[1:]))