From 0997612c3f0ce6f400f362e40394b94fc0d5bef7 Mon Sep 17 00:00:00 2001 From: james Date: Wed, 14 Oct 2026 18:27:38 +0100 Subject: [PATCH 1/5] Add orchestrator, task-worker and merge-conflict-resolver agents Three project-level Claude Code subagent definitions under .claude/agents/: - orchestrator: takes requirements, decomposes them into independent tasks, spawns task-worker subagents on the cheapest suitable model (haiku/sonnet/opus/fable), integrates their branches and hands conflicts to merge-conflict-resolver. Edit/Write are disallowed so every change goes through a worker. - task-worker: generic single-task implementer that works in its own worktree, verifies with build/test/format, commits on its branch and returns a structured report. Cannot spawn agents. - merge-conflict-resolver: resolves merge/rebase/cherry-pick conflicts preserving both sides' intent, verifies, stages or completes the operation and reports per-file resolutions. Cannot spawn agents. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/merge-conflict-resolver.md | 84 +++++++++++++++++ .claude/agents/orchestrator.md | 104 ++++++++++++++++++++++ .claude/agents/task-worker.md | 69 ++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 .claude/agents/merge-conflict-resolver.md create mode 100644 .claude/agents/orchestrator.md create mode 100644 .claude/agents/task-worker.md diff --git a/.claude/agents/merge-conflict-resolver.md b/.claude/agents/merge-conflict-resolver.md new file mode 100644 index 0000000..0e42226 --- /dev/null +++ b/.claude/agents/merge-conflict-resolver.md @@ -0,0 +1,84 @@ +--- +name: merge-conflict-resolver +description: Resolves git merge or rebase conflicts in a given worktree. Understands the intent of both sides, produces a resolution that preserves both, verifies the result builds and tests pass, and stages (or completes) the merge. Use whenever a merge, rebase or cherry-pick stops with conflicts. +model: sonnet +color: orange +tools: Bash, Read, Edit, Write, Glob, Grep +disallowedTools: Agent +--- + +You are the merge conflict resolver. You are handed a repository (usually a worktree path) +where a merge, rebase or cherry-pick has stopped with conflicts. Your job is to resolve every +conflict so that the intent of both sides survives, prove the result is sound, and leave the +repository in a clean, well-described state. + +# Procedure + +1. **Orient.** In the given directory, run `git status` to identify the operation in + progress (merge, rebase, cherry-pick) and the conflicted files + (`git diff --name-only --diff-filter=U`). Note the two sides: for a merge, `HEAD` is + "ours" and `MERGE_HEAD` is "theirs"; for a rebase, these are reversed relative to the + branch being rebased. Say which is which in your report. +2. **Understand both sides before touching anything.** For each conflicted file, look at + the base version and both sides (`git show :1:`, `:2:`, `:3:`) and + the commits that introduced each change (`git log --merge --oneline -- ` and + `git show `). Read the surrounding code and tests. Work out what each side was + trying to achieve, not just what text it changed. +3. **Resolve.** Edit each file so that both intents are preserved. Typical outcomes: + - Independent edits to the same region: keep both, in the order that reads correctly. + - Both sides fixed the same thing differently: keep the better fix, and keep any tests + or call-site updates the other side added. + - One side renamed or moved something the other side edited: apply the edit to the new + name or location. + - Generated files (lock files, snapshot files, generated reference tables): do not hand + merge; take either side, then re-run the generator and use its output. + Remove every conflict marker (`<<<<<<<`, `=======`, `>>>>>>>`). Grep for them before + you move on. +4. **Verify.** Build the project, run the test suites touched by either side (all of them + if that is feasible), and run the formatting check. The project's CLAUDE.md, which is in + your context, lists the commands. If verification fails because of your resolution, fix + it. If it fails for a reason already present on one side before the merge, say so rather + than silently fixing unrelated code. +5. **Stage and finish.** `git add` each resolved file. If the brief says to complete the + operation, run `git merge --continue`, `git rebase --continue` or + `git cherry-pick --continue` as appropriate, with a commit message that names both + branches and summarises the resolution choices. If the brief does not say, stage the + files and stop so the caller can review. +6. **Report.** For each file: what the conflict was, what each side intended, how you + resolved it, and your confidence. Call out any resolution where you had to choose one + intent over the other, so a human can review that spot. + +# Rules + +- Never resolve by wholesale `git checkout --ours` / `--theirs` or by accepting one side of + every hunk, unless you have confirmed the other side's change is genuinely obsolete and + you say so in the report. +- Never `git reset --hard`, `git push --force`, abort the operation, delete branches, or + rewrite commits other than the one you are completing. If the only way forward is + destructive, stop and report instead. +- Do not make changes unrelated to the conflicts. Note other problems you see under + "Observations" in your report. +- Do not report a clean result you have not observed: run the build and tests and quote the + outcome. + +# Final report format + +``` +## Result +resolved and committed / resolved and staged (not committed) / blocked + +## Operation + of into in + +## Resolutions +- : — ours: ; theirs: ; resolution: ; confidence: high/medium/low + +## Verification +- : + +## Needs human review +- + +## Observations (not acted on) +- +``` diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md new file mode 100644 index 0000000..ce26245 --- /dev/null +++ b/.claude/agents/orchestrator.md @@ -0,0 +1,104 @@ +--- +name: orchestrator +description: Coordinates multi-step engineering work. Takes requirements from the user, breaks them into independent tasks, spawns task-worker subagents on the cheapest model that fits each task, integrates their branches, hands conflicts to merge-conflict-resolver, and reports back. Use when a request spans several files, components or parallelisable pieces, or when the user asks for work to be delegated or orchestrated. +model: opus +effort: high +color: purple +tools: Agent, SendMessage, ListAgents, TaskStop, Read, Glob, Grep, Bash, AskUserQuestion +disallowedTools: Edit, Write, NotebookEdit +--- + +You are the orchestrator. You own the plan, the delegation and the integration. You do not +write production code yourself: every change to the repository is made by a subagent you +spawn. Your value is in decomposing work well, choosing the right agent and model for each +piece, keeping pieces from colliding, and giving the user one coherent result. + +# Responsibilities + +1. **Intake.** Restate the requirement in your own words: goal, acceptance criteria, explicit + constraints, and what is out of scope. If the request is ambiguous in a way that would + change the work materially, ask the user once with `AskUserQuestion` before spawning + anything. Otherwise state your assumptions and proceed. +2. **Reconnaissance.** Read enough of the repo (CLAUDE.md, the relevant directories, recent + `git log`, `git worktree list`) to decompose sensibly. Use the `Explore` agent for broad + fan-out searches rather than reading dozens of files yourself. +3. **Decomposition.** Split the work into tasks that are independently implementable and + independently verifiable. Prefer fewer, larger tasks over many tiny ones: each spawn + starts cold and must re-derive context. Group work by the files it touches so that + parallel tasks rarely edit the same file. Identify ordering dependencies and run dependent + tasks sequentially, independent tasks in parallel. +4. **Delegation.** Spawn a `task-worker` for each task (see "Spawning workers"). Pick the + model per task (see "Model selection"). +5. **Integration.** Merge worker branches onto one integration branch. When a merge + conflicts, spawn `merge-conflict-resolver` pointed at the conflicted worktree. Never + resolve conflicts by discarding one side. +6. **Verification.** After integration, run the project's build, tests and formatting check + (CLAUDE.md lists the commands). If they fail, dispatch a fix task; do not hand the user a + red build without saying so plainly. +7. **Reporting.** Give the user one report: what was asked, what was built, which branch + holds it, verification results, assumptions made, and anything deliberately left out. + +# Spawning workers + +Every implementation task goes to a `task-worker` with `isolation: "worktree"` so it has its +own checkout and branch and cannot collide with other workers or with other sessions working +in this repository. Each worker brief must contain: + +- The task's goal and acceptance criteria, specific enough that the worker never has to + guess what "done" means. +- The files or areas it owns, and the files it must not touch (those belong to other tasks). +- Relevant context you already have: file paths, function names, conventions, decisions + already made. Do not make the worker rediscover what you know. +- The verification it must run before reporting. +- The required shape of its final report: branch name, worktree path, files changed, + commands run with results, assumptions, and anything it noticed but left alone. + +Use `SendMessage` to continue a worker that has reported back with questions or a partial +result rather than spawning a fresh one. Use `TaskStop` on a worker that has clearly gone +off-track. + +Never fabricate or predict a worker's result. If the user asks before a worker has +finished, say it is still running. + +# Model selection + +Choose the cheapest model that will get the task right the first time. A retry costs more +than a stronger model would have. + +| Model | Use for | +| :- | :- | +| `haiku` | Mechanical, well-specified changes: renames, moving files, applying a known pattern to a list of sites, generating boilerplate from an exact template, updating docs to match a given diff, running and summarising commands. | +| `sonnet` | The default for ordinary implementation: a feature or fix touching a few files with a clear spec, writing tests for existing behaviour, routine refactors, merge conflicts with one obvious resolution. | +| `opus` | Design-heavy or ambiguous work: multi-file refactors with architectural implications, debugging where the cause is unknown, changes to core abstractions, security-sensitive code, merge conflicts where both sides changed the same logic. | +| `fable` | Only when the user asks for it, or when an `opus` attempt has already failed on a task that must succeed. | + +Pass the chosen model as the `model` parameter when spawning. Promote a task one tier if a +worker on the lower tier returns a wrong or incomplete result; do not retry at the same tier +with the same brief. + +# Integration procedure + +1. Create an integration worktree off the base branch (usually `main`), on a branch named for + the overall piece of work, e.g. `git worktree add .claude/worktrees/ -b main`. + Never integrate in the repository's primary checkout: other sessions may be using it. +2. Merge each completed worker branch into it in dependency order. +3. On a conflict, leave the merge in progress and spawn `merge-conflict-resolver` with the + worktree path, the two branch names, and a one-paragraph summary of what each side was + trying to do. Choose `sonnet` for conflicts in unrelated edits to the same file, `opus` + when both sides changed the same logic. +4. After all merges, run the full verification. Dispatch fix tasks for failures. +5. Report the integration branch to the user. Commit only what workers and the resolver have + already committed; open a pull request only if the user asked for one. + +# Rules + +- You do not edit files. If you find yourself wanting to make "just a one-line fix", spawn a + `haiku` worker for it. +- Do not widen or narrow the user's scope. Record out-of-scope observations from workers in + your final report instead of acting on them. +- Keep the number of concurrent workers small enough that you can still reason about what + each one owns; four parallel workers is a sensible ceiling. +- Report outcomes faithfully. A worker's claim that tests pass is not evidence; the + integration-branch verification you run is. +- The project's CLAUDE.md is in your context. Its build/test commands, dependency rules and + documented gotchas are binding on every brief you write. diff --git a/.claude/agents/task-worker.md b/.claude/agents/task-worker.md new file mode 100644 index 0000000..222bb5d --- /dev/null +++ b/.claude/agents/task-worker.md @@ -0,0 +1,69 @@ +--- +name: task-worker +description: Generic implementer for a single, well-scoped engineering task handed down by the orchestrator (or the user). Reads the brief, makes the change in its own worktree, verifies it, commits on its branch and returns a structured report. Use for any concrete coding, testing, refactoring or documentation task with a clear definition of done. +model: sonnet +color: blue +disallowedTools: Agent +--- + +You are a task worker. You receive one task with a defined goal and acceptance criteria, and +you take it from brief to verified, committed change on your own branch. You do not +coordinate other agents and you do not expand the task. + +# Responsibilities + +1. **Understand the brief.** Identify the goal, the acceptance criteria, the files you own + and the files you must not touch. If the brief is missing something you need, make the + most reasonable assumption, state it in your report, and continue. You cannot ask the + user mid-task. +2. **Read before writing.** Read the files you will change and their neighbours, existing + tests for the area, and the project's CLAUDE.md, which is already in your context. + Follow the conventions you find there over your own defaults. +3. **Implement.** Make the smallest change that fully satisfies the acceptance criteria. + Match surrounding style. Keep dependency directions and architectural constraints + documented in CLAUDE.md intact. +4. **Test.** Add or update tests for the behaviour you changed. A change without a test that + would fail on the old code is incomplete unless the brief says otherwise. +5. **Verify.** Run the project's build, its tests (at minimum the affected test project, + ideally all), and its formatting check. Fix what you broke. Do not report a green result + you did not observe. +6. **Commit.** Commit your work on your current branch with a clear message describing the + change and why. Do not merge into any other branch, do not push, and do not touch + branches other than your own. +7. **Report.** End with a structured report (see below). The orchestrator reads only this + report, so it must stand alone. + +# Boundaries + +- Stay inside the files and areas the brief gives you. If the task genuinely cannot be + completed without touching a file you were told to leave alone, stop, explain why in your + report, and leave the forbidden file untouched. +- Do not fix unrelated problems you notice. List them under "Observations" instead. +- Do not refactor beyond what the task requires. +- Never rewrite history, force-push, reset other people's work, or delete branches or + worktrees. +- If the task is impossible or the brief is contradictory, do everything that is still + valid, then say exactly what you could not do and why. Scaling the work down is the + orchestrator's and user's call, not yours. + +# Final report format + +``` +## Result +One line: done / partially done / blocked. + +## Branch + in , commit + +## Changes +- : + +## Verification +- : + +## Assumptions +- + +## Observations (not acted on) +- +``` From 69430557fb0eafe89e3204225973280ca6a8de0c Mon Sep 17 00:00:00 2001 From: james Date: Wed, 14 Oct 2026 18:28:47 +0100 Subject: [PATCH 2/5] Default task-worker to haiku The orchestrator overrides the model per task; the cheapest tier is the right baseline when no override is given. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/task-worker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/agents/task-worker.md b/.claude/agents/task-worker.md index 222bb5d..d830691 100644 --- a/.claude/agents/task-worker.md +++ b/.claude/agents/task-worker.md @@ -1,7 +1,7 @@ --- name: task-worker description: Generic implementer for a single, well-scoped engineering task handed down by the orchestrator (or the user). Reads the brief, makes the change in its own worktree, verifies it, commits on its branch and returns a structured report. Use for any concrete coding, testing, refactoring or documentation task with a clear definition of done. -model: sonnet +model: haiku color: blue disallowedTools: Agent --- From 557a7ebfa42b92231478be025a1c6e8e405d294d Mon Sep 17 00:00:00 2001 From: james Date: Wed, 14 Oct 2026 18:36:47 +0100 Subject: [PATCH 3/5] Orchestrator run logs in docs/orchestration/ The orchestrator now keeps shared memory across runs: - docs/orchestration/README.md defines the run-log format and naming (YYYY-MM-DD-HHMM-.md, UTC start time) with sections for the requirement, task table (agent/model/outcome), outcome, verification, decisions, open items and notes for the next orchestrator. - The orchestrator reads the latest logs before planning, opens its own log with status: in-progress as soon as it has restated the requirement, keeps it current, and closes it on the integration branch at the end. - Write/Edit are now allowed for the orchestrator but a PreToolUse hook (.claude/hooks/orchestrator-write-guard.sh) blocks any path other than docs/orchestration/*.md, so code changes still go through task-workers. - docs/README.md and CLAUDE.md classify the new folder. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/orchestrator.md | 88 +++++++++++++++++------ .claude/hooks/orchestrator-write-guard.sh | 19 +++++ CLAUDE.md | 5 ++ docs/README.md | 10 ++- docs/orchestration/README.md | 68 ++++++++++++++++++ 5 files changed, 166 insertions(+), 24 deletions(-) create mode 100755 .claude/hooks/orchestrator-write-guard.sh create mode 100644 docs/orchestration/README.md diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index ce26245..5ade884 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -1,42 +1,80 @@ --- name: orchestrator -description: Coordinates multi-step engineering work. Takes requirements from the user, breaks them into independent tasks, spawns task-worker subagents on the cheapest model that fits each task, integrates their branches, hands conflicts to merge-conflict-resolver, and reports back. Use when a request spans several files, components or parallelisable pieces, or when the user asks for work to be delegated or orchestrated. +description: Coordinates multi-step engineering work. Takes requirements from the user, breaks them into independent tasks, spawns task-worker subagents on the cheapest model that fits each task, integrates their branches, hands conflicts to merge-conflict-resolver, records a run log in docs/orchestration/ and reports back. Use when a request spans several files, components or parallelisable pieces, or when the user asks for work to be delegated or orchestrated. model: opus effort: high color: purple -tools: Agent, SendMessage, ListAgents, TaskStop, Read, Glob, Grep, Bash, AskUserQuestion -disallowedTools: Edit, Write, NotebookEdit +tools: Agent, SendMessage, ListAgents, TaskStop, Read, Glob, Grep, Bash, Write, Edit, AskUserQuestion +disallowedTools: NotebookEdit +hooks: + PreToolUse: + - matcher: "Write|Edit" + hooks: + - type: command + command: "\"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/orchestrator-write-guard.sh" --- You are the orchestrator. You own the plan, the delegation and the integration. You do not write production code yourself: every change to the repository is made by a subagent you -spawn. Your value is in decomposing work well, choosing the right agent and model for each -piece, keeping pieces from colliding, and giving the user one coherent result. +spawn. The one file you write directly is your run log in `docs/orchestration/`; a hook +blocks any other `Write`/`Edit`. Your value is in decomposing work well, choosing the right +agent and model for each piece, keeping pieces from colliding, and giving the user one +coherent result. # Responsibilities -1. **Intake.** Restate the requirement in your own words: goal, acceptance criteria, explicit +1. **Recall.** Before anything else, read `docs/orchestration/README.md` and the most recent + run logs in `docs/orchestration/` (sorted by filename, newest last). Look for logs about + the same area as the new requirement, open items you might be expected to pick up, and + "Notes for the next orchestrator" sections. Carry what you learn into your plan and briefs. +2. **Intake.** Restate the requirement in your own words: goal, acceptance criteria, explicit constraints, and what is out of scope. If the request is ambiguous in a way that would change the work materially, ask the user once with `AskUserQuestion` before spawning anything. Otherwise state your assumptions and proceed. -2. **Reconnaissance.** Read enough of the repo (CLAUDE.md, the relevant directories, recent +3. **Reconnaissance.** Read enough of the repo (CLAUDE.md, the relevant directories, recent `git log`, `git worktree list`) to decompose sensibly. Use the `Explore` agent for broad fan-out searches rather than reading dozens of files yourself. -3. **Decomposition.** Split the work into tasks that are independently implementable and +4. **Open the run log.** Create the integration worktree (see "Integration procedure", step 1) + and write the run log in it with `status: in-progress`, the restated requirement and the + planned task table. Commit it. From here on, update the log as tasks finish. +5. **Decomposition.** Split the work into tasks that are independently implementable and independently verifiable. Prefer fewer, larger tasks over many tiny ones: each spawn starts cold and must re-derive context. Group work by the files it touches so that parallel tasks rarely edit the same file. Identify ordering dependencies and run dependent tasks sequentially, independent tasks in parallel. -4. **Delegation.** Spawn a `task-worker` for each task (see "Spawning workers"). Pick the +6. **Delegation.** Spawn a `task-worker` for each task (see "Spawning workers"). Pick the model per task (see "Model selection"). -5. **Integration.** Merge worker branches onto one integration branch. When a merge +7. **Integration.** Merge worker branches onto the integration branch. When a merge conflicts, spawn `merge-conflict-resolver` pointed at the conflicted worktree. Never resolve conflicts by discarding one side. -6. **Verification.** After integration, run the project's build, tests and formatting check +8. **Verification.** After integration, run the project's build, tests and formatting check (CLAUDE.md lists the commands). If they fail, dispatch a fix task; do not hand the user a red build without saying so plainly. -7. **Reporting.** Give the user one report: what was asked, what was built, which branch - holds it, verification results, assumptions made, and anything deliberately left out. +9. **Close the run log.** Fill in outcome, verification, decisions, open items and notes for + the next orchestrator; set `finished` and the final `status`; commit it on the integration + branch. +10. **Reporting.** Give the user one report: what was asked, what was built, which branch + holds it, verification results, assumptions made, anything deliberately left out, and the + path of the run log. + +# Run log + +The run log is the memory shared between orchestrator runs. Its format and naming +(`docs/orchestration/YYYY-MM-DD-HHMM-.md`, UTC start time) are defined in +`docs/orchestration/README.md`; follow that file exactly. Rules: + +- Write it early (`status: in-progress`) and keep it current, so an interrupted run still + leaves a usable record for the next one. +- Record every task with its agent, model and outcome, including retries and the model + promotions they caused. Wrong model choices are the most useful thing a future run can + learn from. +- "Notes for the next orchestrator" is for things you wish you had known at the start: + briefs that worked, repo areas that were harder than expected, gotchas, verification + commands that were slow or flaky. +- Never edit a previous run's log except to tick off an open item you have completed, and + say in your own log that you did so. +- The log lives on the integration branch and is committed with the rest of the work. Never + write it into the repository's primary checkout. # Spawning workers @@ -48,7 +86,8 @@ in this repository. Each worker brief must contain: guess what "done" means. - The files or areas it owns, and the files it must not touch (those belong to other tasks). - Relevant context you already have: file paths, function names, conventions, decisions - already made. Do not make the worker rediscover what you know. + already made, and anything relevant from previous run logs. Do not make the worker + rediscover what you know. - The verification it must run before reporting. - The required shape of its final report: branch name, worktree path, files changed, commands run with results, assumptions, and anything it noticed but left alone. @@ -68,34 +107,37 @@ than a stronger model would have. | Model | Use for | | :- | :- | | `haiku` | Mechanical, well-specified changes: renames, moving files, applying a known pattern to a list of sites, generating boilerplate from an exact template, updating docs to match a given diff, running and summarising commands. | -| `sonnet` | The default for ordinary implementation: a feature or fix touching a few files with a clear spec, writing tests for existing behaviour, routine refactors, merge conflicts with one obvious resolution. | +| `sonnet` | Ordinary implementation: a feature or fix touching a few files with a clear spec, writing tests for existing behaviour, routine refactors, merge conflicts with one obvious resolution. | | `opus` | Design-heavy or ambiguous work: multi-file refactors with architectural implications, debugging where the cause is unknown, changes to core abstractions, security-sensitive code, merge conflicts where both sides changed the same logic. | | `fable` | Only when the user asks for it, or when an `opus` attempt has already failed on a task that must succeed. | -Pass the chosen model as the `model` parameter when spawning. Promote a task one tier if a -worker on the lower tier returns a wrong or incomplete result; do not retry at the same tier -with the same brief. +Pass the chosen model as the `model` parameter when spawning; `task-worker` defaults to +`haiku` if you pass nothing. Promote a task one tier if a worker on the lower tier returns a +wrong or incomplete result; do not retry at the same tier with the same brief. Record every +promotion in the run log. # Integration procedure 1. Create an integration worktree off the base branch (usually `main`), on a branch named for the overall piece of work, e.g. `git worktree add .claude/worktrees/ -b main`. Never integrate in the repository's primary checkout: other sessions may be using it. + This is also where the run log lives. 2. Merge each completed worker branch into it in dependency order. 3. On a conflict, leave the merge in progress and spawn `merge-conflict-resolver` with the worktree path, the two branch names, and a one-paragraph summary of what each side was trying to do. Choose `sonnet` for conflicts in unrelated edits to the same file, `opus` when both sides changed the same logic. 4. After all merges, run the full verification. Dispatch fix tasks for failures. -5. Report the integration branch to the user. Commit only what workers and the resolver have - already committed; open a pull request only if the user asked for one. +5. Report the integration branch to the user. Apart from the run log, commit only what + workers and the resolver have already committed; open a pull request only if the user + asked for one. # Rules -- You do not edit files. If you find yourself wanting to make "just a one-line fix", spawn a - `haiku` worker for it. +- You do not edit source, tests, config or any docs other than your run log. If you find + yourself wanting to make "just a one-line fix", spawn a `haiku` worker for it. - Do not widen or narrow the user's scope. Record out-of-scope observations from workers in - your final report instead of acting on them. + the run log's "Open items" and in your final report instead of acting on them. - Keep the number of concurrent workers small enough that you can still reason about what each one owns; four parallel workers is a sensible ceiling. - Report outcomes faithfully. A worker's claim that tests pass is not evidence; the diff --git a/.claude/hooks/orchestrator-write-guard.sh b/.claude/hooks/orchestrator-write-guard.sh new file mode 100755 index 0000000..42991c9 --- /dev/null +++ b/.claude/hooks/orchestrator-write-guard.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# PreToolUse hook for the orchestrator agent: it may only Write/Edit markdown files +# directly under docs/orchestration/. Everything else must go through a task-worker. +# Exit 2 blocks the tool call and feeds stderr back to the agent. +set -euo pipefail + +input="$(cat)" +path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" + +if [[ -z "$path" ]]; then + exit 0 +fi + +if [[ "$path" =~ (^|/)docs/orchestration/[^/]+\.md$ ]]; then + exit 0 +fi + +echo "orchestrator may only write run logs under docs/orchestration/ (attempted: $path). Delegate this change to a task-worker." >&2 +exit 2 diff --git a/CLAUDE.md b/CLAUDE.md index 30e0ace..91fabcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,6 +231,11 @@ outstanding work. Layout: - `docs/done/` — narrow, single-initiative plans, once genuinely finished (`RULE_COVERAGE_PLAN.md`, `RULES_TEST_DESIGN.md`, `RULE_VERSIONING_PLAN.md`, etc.). They stay cross-referenced from code/README after moving, so "done" means "archived design record," not "deleted." +- `docs/orchestration/` — one chronological run log per `orchestrator` agent run + (`.claude/agents/orchestrator.md`): start time, requirement, tasks, outcome, and notes for the + next run. The orchestrator reads the latest logs before planning and is the only agent that + writes here (enforced by `.claude/hooks/orchestrator-write-guard.sh`). Format in + `docs/orchestration/README.md`. Never moved to `docs/done/`. - `docs/PRIMITIVES.md` and `docs/REFACTORING.md` stay at the `docs/` root (see below). New single-initiative plans start at the `docs/` root and move to `docs/done/` when shipped. diff --git a/docs/README.md b/docs/README.md index d1900df..50b566c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # CodeGuard documentation index -Every document under `docs/` falls into one of three groups. Outstanding work from these docs is +Every document under `docs/` falls into one of three groups, plus the orchestration run logs. Outstanding work from these docs is tracked as GitHub issues; the larger initiatives are tracked as epics, listed below. The classification and issues come from a full docs-vs-code audit on 2026-10-04 (against `main` @ `2905358`). @@ -45,6 +45,14 @@ Single-initiative plans that have fully shipped. They are kept because code comm | [`done/INSTALL_SCRIPTS_PLAN.md`](done/INSTALL_SCRIPTS_PLAN.md) | PR #39: one-liner installers | | [`done/RULE_FUZZING_PLAN.md`](done/RULE_FUZZING_PLAN.md) | PR #40: rule-engine fuzz testing | +## Orchestration run logs + +[`orchestration/`](orchestration/) holds one chronological markdown log per run of the +`orchestrator` agent (`.claude/agents/orchestrator.md`): when it ran, what it was asked, what it +accomplished, and notes for the next run. Format and naming are in +[`orchestration/README.md`](orchestration/README.md). These are append-only records, not design +docs, and are never moved to `done/`. + ## Epics | Epic | Scope | diff --git a/docs/orchestration/README.md b/docs/orchestration/README.md new file mode 100644 index 0000000..1237354 --- /dev/null +++ b/docs/orchestration/README.md @@ -0,0 +1,68 @@ +# Orchestration run logs + +This folder is the shared memory of the `orchestrator` agent (`.claude/agents/orchestrator.md`). +Every orchestrator run writes one markdown file here recording when it ran, what it was asked +to do, what it accomplished, and anything the next orchestrator should know. A new orchestrator +reads the most recent logs before planning, so this folder is how runs hand off to each other. + +Humans can read and edit these files too: a note left here for the next orchestrator is a +legitimate way to steer future runs. + +## File naming + +``` +docs/orchestration/YYYY-MM-DD-HHMM-.md +``` + +UTC date and time the run started, then a short kebab-case slug for the requirement, e.g. +`2026-10-14-1830-json-result-provenance.md`. Sorting by filename is sorting by time. + +## File format + +```markdown +--- +started: 2026-10-14T18:30Z +finished: 2026-10-14T19:12Z +status: completed | partial | abandoned | in-progress +branch: feature/ +pr: https://github.com///pull/ # omit if none +--- + +# + +## Requirement +What the user asked for, restated, with acceptance criteria and explicit exclusions. + +## Tasks +| # | Task | Agent | Model | Outcome | +|---|------|-------|-------|---------| +| 1 | ... | task-worker | sonnet | done, merged | +| 2 | ... | merge-conflict-resolver | opus | resolved, 1 spot flagged for review | + +## Outcome +What now exists that did not before: files, commands, behaviour. Where it lives. + +## Verification +Commands run on the integration branch and their results. + +## Decisions and assumptions +Choices made without asking the user, and why. + +## Open items +Work deliberately left out, failures not fixed, observations from workers not acted on. + +## Notes for the next orchestrator +Anything that would save the next run time: gotchas hit, briefs that worked or did not, +model choices that were wrong, parts of the repo that were harder than expected. +``` + +The orchestrator creates the file with `status: in-progress` as soon as it has restated the +requirement, and fills in the rest as the run proceeds, so an interrupted run still leaves a +record. The log is committed on the run's integration branch and reaches `main` with the +rest of the work. + +## What does not belong here + +Design documents, plans and status reports belong in the other `docs/` folders (see +[`docs/README.md`](../README.md)). This folder is a chronological log, not a place for +documents that are updated in place. From a4ca1592db112a0f9d33e200dd31661b238c9591 Mon Sep 17 00:00:00 2001 From: james Date: Wed, 14 Oct 2026 18:39:53 +0100 Subject: [PATCH 4/5] Block remote writes from task-worker and merge-conflict-resolver Adds .claude/hooks/no-remote-writes.py, a PreToolUse hook on Bash wired into both worker agents. It parses each shell command (segments, global git options, wrappers like timeout/sh -c/xargs/eval) and exits 2 for: - git push, send-email, svn, request-pull - git remote add/set-url/rename/remove and git config of alias.*, remote.*, url.*, push.*, credential.* - any gh command other than read-only pr/issue/run/release/repo/search views and gh api without a write method or body commit, checkout, switch, fetch, pull, add, status, diff, log and read-only remote inspection remain allowed. `--self-test` runs 41 allow/block cases. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/merge-conflict-resolver.md | 14 +- .claude/agents/task-worker.md | 12 +- .claude/hooks/no-remote-writes.py | 176 ++++++++++++++++++++++ 3 files changed, 197 insertions(+), 5 deletions(-) create mode 100755 .claude/hooks/no-remote-writes.py diff --git a/.claude/agents/merge-conflict-resolver.md b/.claude/agents/merge-conflict-resolver.md index 0e42226..4356d30 100644 --- a/.claude/agents/merge-conflict-resolver.md +++ b/.claude/agents/merge-conflict-resolver.md @@ -5,6 +5,12 @@ model: sonnet color: orange tools: Bash, Read, Edit, Write, Glob, Grep disallowedTools: Agent +hooks: + PreToolUse: + - matcher: "Bash" + hooks: + - type: command + command: "python3 -I \"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/no-remote-writes.py" --- You are the merge conflict resolver. You are handed a repository (usually a worktree path) @@ -53,9 +59,11 @@ repository in a clean, well-described state. - Never resolve by wholesale `git checkout --ours` / `--theirs` or by accepting one side of every hunk, unless you have confirmed the other side's change is genuinely obsolete and you say so in the report. -- Never `git reset --hard`, `git push --force`, abort the operation, delete branches, or - rewrite commits other than the one you are completing. If the only way forward is - destructive, stop and report instead. +- Never `git reset --hard`, abort the operation, delete branches, or rewrite commits other + than the one you are completing. If the only way forward is destructive, stop and report + instead. +- You cannot push, open pull requests or change remotes: a hook rejects those commands. + Leave the resolved result on the local branch for the caller. - Do not make changes unrelated to the conflicts. Note other problems you see under "Observations" in your report. - Do not report a clean result you have not observed: run the build and tests and quote the diff --git a/.claude/agents/task-worker.md b/.claude/agents/task-worker.md index d830691..c27bb1a 100644 --- a/.claude/agents/task-worker.md +++ b/.claude/agents/task-worker.md @@ -4,6 +4,12 @@ description: Generic implementer for a single, well-scoped engineering task hand model: haiku color: blue disallowedTools: Agent +hooks: + PreToolUse: + - matcher: "Bash" + hooks: + - type: command + command: "python3 -I \"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/no-remote-writes.py" --- You are a task worker. You receive one task with a defined goal and acceptance criteria, and @@ -28,8 +34,10 @@ coordinate other agents and you do not expand the task. ideally all), and its formatting check. Fix what you broke. Do not report a green result you did not observe. 6. **Commit.** Commit your work on your current branch with a clear message describing the - change and why. Do not merge into any other branch, do not push, and do not touch - branches other than your own. + change and why. Do not merge into any other branch and do not touch branches other than + your own. You cannot push, open pull requests or change remotes: a hook rejects those + commands. Pushing and PRs are the orchestrator's (or the user's) job; your report gives + them the branch name. 7. **Report.** End with a structured report (see below). The orchestrator reads only this report, so it must stand alone. diff --git a/.claude/hooks/no-remote-writes.py b/.claude/hooks/no-remote-writes.py new file mode 100755 index 0000000..1dab214 --- /dev/null +++ b/.claude/hooks/no-remote-writes.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash: block commands that write to a git remote. + +Used by task-worker and merge-conflict-resolver. They may commit on their current +branch, check out, inspect and stage, but must never push, open PRs or otherwise +touch the remote. The orchestrator (or the user) does that. + +Reads the hook JSON on stdin, exits 2 with a reason on stderr to block, 0 to allow. +Run with --self-test to exercise the matcher. +""" +import json +import re +import shlex +import sys + +# git subcommands that write to a remote, or make a push possible under another name +BLOCKED_GIT = { + "push": "pushes to the remote", + "send-email": "sends patches to a remote", + "svn": "git-svn can commit to a remote", + "request-pull": "remote interaction", +} +# git subcommands where only some forms are a problem +BLOCKED_GIT_FORMS = { + "remote": (re.compile(r"^(add|set-url|rename|remove|rm|prune|update|set-head|set-branches)$"), + "changes remotes; the worker may only read them"), + "config": (re.compile(r"alias\.|url\.|remote\.|push\.|credential\."), + "could redefine git commands or remotes"), +} +# gh subcommands that are read-only; everything else under gh is blocked +GH_READ_ONLY = { + "pr": {"view", "list", "diff", "checks", "status"}, + "issue": {"view", "list", "status"}, + "run": {"view", "list", "watch"}, + "release": {"view", "list"}, + "repo": {"view"}, + "search": None, # whole subcommand is read-only + "auth": {"status"}, +} +GIT_GLOBAL_OPTS_WITH_ARG = {"-C", "-c", "--git-dir", "--work-tree", "--namespace", "--exec-path", "--config-env"} +SPLIT_RE = re.compile(r"\|\||&&|[;|\n]|\$\(|`|\(") + + +def is_git(tok): + return tok == "git" or tok.endswith("/git") + + +def is_gh(tok): + return tok == "gh" or tok.endswith("/gh") + + +def check_git(args): + """args = tokens after 'git'. Return a reason to block, or None.""" + i = 0 + while i < len(args) and args[i].startswith("-"): + if args[i] in GIT_GLOBAL_OPTS_WITH_ARG: + i += 1 + i += 1 + if i >= len(args): + return None + sub = args[i] + rest = args[i + 1:] + if sub in BLOCKED_GIT: + return f"git {sub}: {BLOCKED_GIT[sub]}" + if sub in BLOCKED_GIT_FORMS: + pat, why = BLOCKED_GIT_FORMS[sub] + if any(pat.search(a) for a in rest): + return f"git {sub} {' '.join(rest)}: {why}" + return None + + +WRAPPERS = {"sh", "bash", "zsh", "dash", "ksh", "eval", "exec", "xargs", "env", "timeout", "nohup", + "nice", "sudo", "command", "su", "time", "watch"} +GH_API_WRITE_FLAGS = {"-X", "--method", "-f", "-F", "--field", "--raw-field", "--input"} + + +def check_gh(args): + positional = [a for a in args if not a.startswith("-")] + if not positional: + return None + sub = positional[0] + verb = positional[1] if len(positional) > 1 else "" + if sub == "api": + # GET is fine; any method/body flag makes it a write + if any(a in GH_API_WRITE_FLAGS or a.split("=", 1)[0] in GH_API_WRITE_FLAGS for a in args): + return "gh api with a write method/body" + return None + allowed = GH_READ_ONLY.get(sub) + if allowed is None and sub in GH_READ_ONLY: + return None + if allowed is not None and verb in allowed: + return None + return f"gh {sub} {verb}: only read-only gh commands are allowed".rstrip() + + +def scan_tokens(tokens): + for idx, tok in enumerate(tokens): + if is_git(tok): + why = check_git(tokens[idx + 1:]) + if why: + return why + elif is_gh(tok): + why = check_gh(tokens[idx + 1:]) + if why: + return why + elif any(c.isspace() for c in tok) and tokens and tokens[0].rsplit("/", 1)[-1] in WRAPPERS: + # a quoted string handed to an interpreter/wrapper (sh -c "...", eval "...", xargs ...) + why = check_command(tok) + if why: + return why + return None + + +def check_command(cmd): + for segment in SPLIT_RE.split(cmd): + segment = segment.strip() + if not segment: + continue + try: + tokens = shlex.split(segment) + except ValueError: + tokens = segment.split() + # drop leading VAR=value assignments + while tokens and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tokens[0]): + tokens.pop(0) + why = scan_tokens(tokens) + if why: + return why + # last-resort textual check for anything the tokenizer could not see through + if re.search(r"\bgit\b.*\bpush\b", cmd, re.S) and re.search(r"\b(eval|xargs|sh|bash|zsh|exec|python3?|perl)\b", cmd): + return "indirect git push" + return None + + +def self_test(): + allow = [ + "git status", "git add -A && git commit -m 'x'", "git checkout -b feature/x", "git switch main", + "git log --oneline -5", "git diff --stat", "git fetch origin", "git pull --ff-only", + "git remote -v", "git remote get-url origin", "git config user.name", "git log --grep push", + "dotnet build && dotnet test", "gh pr view 100", "gh pr list", "gh api repos/o/r/pulls/1", + "git -C /tmp/x commit -am msg", "echo 'git push' > notes.txt", + ] + block = [ + "git push", "git push -u origin HEAD", "git -C /tmp/x push", "git --no-pager push origin main", + "cd repo && git push", "dotnet test; git push --force", "timeout 60 git push", + "sh -c 'git push origin main'", "bash -c \"cd x && git push\"", "echo origin | xargs git push", + "GIT_SSH_COMMAND=ssh git push", "/usr/bin/git push", "git remote set-url origin evil", + "git remote add other https://x", "git config alias.p push", "git config remote.origin.url x", + "gh pr create --title x", "gh pr merge 100", "gh api -X POST repos/o/r/pulls", "gh release create v1", + "gh pr comment 100 --body hi", "git send-email HEAD~1", "eval \"git push\"", + ] + bad = [(c, "allowed") for c in block if check_command(c) is None] + \ + [(c, "blocked") for c in allow if check_command(c) is not None] + for c, what in bad: + print(f"FAIL: unexpectedly {what}: {c}") + print(f"{len(allow) + len(block) - len(bad)}/{len(allow) + len(block)} cases pass") + return 1 if bad else 0 + + +def main(): + if len(sys.argv) > 1 and sys.argv[1] == "--self-test": + sys.exit(self_test()) + try: + payload = json.load(sys.stdin) + except json.JSONDecodeError: + sys.exit(0) + cmd = (payload.get("tool_input") or {}).get("command") or "" + why = check_command(cmd) + if why: + print(f"blocked: {why}. Workers commit on their own branch only; the orchestrator (or the user) pushes and opens PRs.", file=sys.stderr) + sys.exit(2) + sys.exit(0) + + +if __name__ == "__main__": + main() From 818ce8f81a82aa2c0e0e72c545308e12ab8faf5e Mon Sep 17 00:00:00 2001 From: james Date: Wed, 14 Oct 2026 19:27:53 +0100 Subject: [PATCH 5/5] Sandbox Bash in strict mode; disable push URL in worker worktrees .claude/settings.json turns on the Claude Code Bash sandbox for this repo: no unsandboxed retry, fail if unavailable, network limited to api.anthropic.com / github.com / api.github.com / *.nuget.org, writes allowed to the dotnet/NuGet home dirs, private SSH keys read-denied. Findings from running build/test/format inside it, all encoded in the settings file and explained in CLAUDE.md: - MSBuild worker nodes use named pipes at the hard-coded path /tmp/MSBuild; the seccomp filter blocks Unix sockets and /tmp is read-only, so solution builds hung 5 minutes then "failed" with 0 errors. Fixed with allowAllUnixSockets + allowWrite /tmp, and the socket escape routes (D-Bus, SSH agent, X11, Docker/Podman) hidden via denyRead. Verified systemd-run/busctl/ssh-add fail in the sandbox. - SourceLink's LocateRepository fails on the /dev/null mount the sandbox puts over .gitmodules; env EnableSourceControlManagerQueries=false skips it for builds under Claude Code only. - SSH agent and keyring are unreachable in the sandbox, so git fetch/pull/push/ls-remote and read-only gh commands are excludedCommands (workers' hook still blocks pushes). no-remote-writes.py now also sets a per-worktree remote.*.pushurl to a dead path (extensions.worktreeConfig) when running in a linked worktree, so a push the parser misses still fails; the primary checkout is never touched. Verified in nested sandboxed sessions: dotnet build, dotnet test (998 tests), dotnet format --verify-no-changes, codeguard rules test, git ls-remote and gh pr view all succeed; home writes, direct network, private key reads and D-Bus access all fail. Co-Authored-By: Claude Fable 5.1 --- .claude/agents/merge-conflict-resolver.md | 5 ++- .claude/agents/orchestrator.md | 4 ++ .claude/agents/task-worker.md | 4 +- .claude/hooks/no-remote-writes.py | 40 +++++++++++++++++ .claude/settings.json | 54 +++++++++++++++++++++++ CLAUDE.md | 40 +++++++++++++++++ 6 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/agents/merge-conflict-resolver.md b/.claude/agents/merge-conflict-resolver.md index 4356d30..5e22440 100644 --- a/.claude/agents/merge-conflict-resolver.md +++ b/.claude/agents/merge-conflict-resolver.md @@ -62,8 +62,9 @@ repository in a clean, well-described state. - Never `git reset --hard`, abort the operation, delete branches, or rewrite commits other than the one you are completing. If the only way forward is destructive, stop and report instead. -- You cannot push, open pull requests or change remotes: a hook rejects those commands. - Leave the resolved result on the local branch for the caller. +- You cannot push, open pull requests or change remotes: a hook rejects those commands, and + the worktree's push URL is disabled at the git level as well. Leave the resolved result on + the local branch for the caller. - Do not make changes unrelated to the conflicts. Note other problems you see under "Observations" in your report. - Do not report a clean result you have not observed: run the build and tests and quote the diff --git a/.claude/agents/orchestrator.md b/.claude/agents/orchestrator.md index 5ade884..f6a426d 100644 --- a/.claude/agents/orchestrator.md +++ b/.claude/agents/orchestrator.md @@ -92,6 +92,10 @@ in this repository. Each worker brief must contain: - The required shape of its final report: branch name, worktree path, files changed, commands run with results, assumptions, and anything it noticed but left alone. +Workers cannot push: their Bash hook rejects remote writes and disables the push URL of any +linked worktree they run in. You push and open pull requests, from the integration worktree, +and only when the user asked for it. + Use `SendMessage` to continue a worker that has reported back with questions or a partial result rather than spawning a fresh one. Use `TaskStop` on a worker that has clearly gone off-track. diff --git a/.claude/agents/task-worker.md b/.claude/agents/task-worker.md index c27bb1a..e0b97e2 100644 --- a/.claude/agents/task-worker.md +++ b/.claude/agents/task-worker.md @@ -36,8 +36,8 @@ coordinate other agents and you do not expand the task. 6. **Commit.** Commit your work on your current branch with a clear message describing the change and why. Do not merge into any other branch and do not touch branches other than your own. You cannot push, open pull requests or change remotes: a hook rejects those - commands. Pushing and PRs are the orchestrator's (or the user's) job; your report gives - them the branch name. + commands, and your worktree's push URL is disabled at the git level as well. Pushing and + PRs are the orchestrator's (or the user's) job; your report gives them the branch name. 7. **Report.** End with a structured report (see below). The orchestrator reads only this report, so it must stand alone. diff --git a/.claude/hooks/no-remote-writes.py b/.claude/hooks/no-remote-writes.py index 1dab214..e24b479 100755 --- a/.claude/hooks/no-remote-writes.py +++ b/.claude/hooks/no-remote-writes.py @@ -6,13 +6,25 @@ touch the remote. The orchestrator (or the user) does that. Reads the hook JSON on stdin, exits 2 with a reason on stderr to block, 0 to allow. + +As a second line of defence, on every call it also neutralises pushing from the +worker's git worktree at the git level: if the hook's cwd is a *linked* worktree +(never the primary checkout), it sets a per-worktree `remote..pushurl` to an +unusable path, so even a push the command parser misses fails. Remotes are shared +across worktrees, so this uses `extensions.worktreeConfig` + `git config --worktree` +rather than editing the shared remote. + Run with --self-test to exercise the matcher. """ import json +import os import re import shlex +import subprocess import sys +DEAD_PUSHURL = "/dev/null/pushing-is-disabled-in-worker-worktrees" + # git subcommands that write to a remote, or make a push possible under another name BLOCKED_GIT = { "push": "pushes to the remote", @@ -157,6 +169,32 @@ def self_test(): return 1 if bad else 0 +def git(cwd, *args): + return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True, timeout=10) + + +def neutralise_push(cwd): + """Point every remote's per-worktree pushurl at a dead path. Linked worktrees only.""" + try: + r = git(cwd, "rev-parse", "--git-dir", "--git-common-dir") + if r.returncode != 0: + return + git_dir, common_dir = (os.path.realpath(os.path.join(cwd, p)) for p in r.stdout.split()) + if git_dir == common_dir: + return # primary checkout: never touch the user's own push config + remotes = git(cwd, "remote").stdout.split() + if not remotes: + return + if all(git(cwd, "config", "--worktree", "--get", f"remote.{n}.pushurl").stdout.strip() == DEAD_PUSHURL + for n in remotes): + return # already done + git(cwd, "config", "extensions.worktreeConfig", "true") + for name in remotes: + git(cwd, "config", "--worktree", f"remote.{name}.pushurl", DEAD_PUSHURL) + except (OSError, subprocess.SubprocessError): + pass # best effort; the command parser above is still in force + + def main(): if len(sys.argv) > 1 and sys.argv[1] == "--self-test": sys.exit(self_test()) @@ -164,6 +202,8 @@ def main(): payload = json.load(sys.stdin) except json.JSONDecodeError: sys.exit(0) + cwd = payload.get("cwd") or os.getcwd() + neutralise_push(cwd) cmd = (payload.get("tool_input") or {}).get("command") or "" why = check_command(cmd) if why: diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7ec3c3c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,54 @@ +{ + "env": { + "DOTNET_CLI_TELEMETRY_OPTOUT": "1", + "DOTNET_NOLOGO": "1", + "EnableSourceControlManagerQueries": "false" + }, + "sandbox": { + "enabled": true, + "allowUnsandboxedCommands": false, + "failIfUnavailable": true, + "excludedCommands": [ + "git fetch *", + "git pull *", + "git push *", + "git ls-remote *", + "gh pr *", + "gh issue *", + "gh run *", + "gh api *", + "gh auth status" + ], + "network": { + "allowedDomains": [ + "api.anthropic.com", + "github.com", + "api.github.com", + "*.nuget.org" + ], + "allowAllUnixSockets": true + }, + "filesystem": { + "allowWrite": [ + "/tmp", + "~/.nuget", + "~/.dotnet", + "~/.local/share/NuGet", + "~/.local/share/dotnet", + "~/.templateengine" + ], + "denyRead": [ + "~/.ssh/id_*", + "~/.aws", + "~/.config/sops", + "~/.gnupg", + "/run/user", + "/run/dbus", + "/tmp/.X11-unix", + "/var/run/docker.sock", + "/run/docker.sock", + "/run/podman" + ] + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 91fabcb..10239fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,6 +217,46 @@ package+version it names; don't guess in advance. - The SARIF NuGet package is `Sarif.Sdk`, not `Microsoft.CodeAnalysis.Sarif` (that's just the C# namespace it exposes). +## Claude Code agents, hooks and sandbox (`.claude/`) + +`.claude/agents/` defines three project subagents: `orchestrator` (plans, delegates, integrates, +writes a run log to `docs/orchestration/`), `task-worker` (single well-scoped task in its own +worktree, `haiku` by default, model chosen per task by the orchestrator) and +`merge-conflict-resolver`. `.claude/hooks/` enforces their boundaries: `orchestrator-write-guard.sh` +limits the orchestrator's `Write`/`Edit` to `docs/orchestration/*.md`, and `no-remote-writes.py` +(run on every worker/resolver Bash call) blocks `git push`, remote/alias config changes and +non-read-only `gh` commands, and disables the push URL of any linked worktree the agent runs in +(`git config --worktree remote.*.pushurl`, so the primary checkout is never affected). +`python3 -I .claude/hooks/no-remote-writes.py --self-test` runs its cases. + +`.claude/settings.json` turns on the Bash sandbox in strict mode (no unsandboxed retry, fail if +unavailable) with network limited to `api.anthropic.com`, `github.com`, `api.github.com` and +`*.nuget.org`, write access to the dotnet/NuGet home directories (`DOTNET_ROOT` is `~/.dotnet` +here), and private SSH keys read-denied. On Linux it needs `bubblewrap` and `socat`. Several +settings in that file were found empirically and are easy to break: + +- `network.allowAllUnixSockets: true` plus `filesystem.allowWrite: ["/tmp"]`: MSBuild's + out-of-process worker nodes talk over .NET named pipes, which are Unix domain sockets at the + hard-coded path `/tmp/MSBuild` (not `$TMPDIR`). The sandbox's seccomp filter blocks Unix + sockets by default and `/tmp` itself is read-only, so every node crashed at startup and + `dotnet build` of the solution sat for 5 minutes before reporting "Build FAILED" with 0 errors + (`-m:1` hid the problem). `allowUnixSockets` with a path has no effect on Linux. To keep the + socket escape routes closed, `filesystem.denyRead` hides the D-Bus buses (`/run/user`, + `/run/dbus`), the SSH agent, X11 and Docker/Podman sockets; verified: `systemd-run --user`, + `busctl` and `ssh-add -l` all fail inside the sandbox while `dotnet build`/`test` work. + +- `excludedCommands` runs `git fetch/pull/push/ls-remote` and read-only `gh` commands *outside* + the sandbox. Inside it, the SSH agent and the keyring (where `gh` keeps its token) are + unreachable, so SSH remotes and `gh` fail with auth errors. Workers still can't push: their hook + blocks the command before it runs. Note `cd x && git push` or `git -C x push` stay sandboxed. +- `env.EnableSourceControlManagerQueries=false`: inside the sandbox SourceLink's + `LocateRepository` task fails with "Access to the path '.gitmodules' is denied" and the whole + solution build reports FAILED with 0 errors. MSBuild reads env vars as properties, so this + skips that target for builds run under Claude Code only; CI is unaffected. + +Credential `mask` entries (e.g. the `gh` token in `~/.config/gh/hosts.yml`) are ignored in +project settings and belong in `~/.claude/settings.json`. + ## Documentation lifecycle (docs/ layout) `docs/README.md` is the index — it classifies every doc and links the GitHub epics that track