diff --git a/.claude/agents/merge-conflict-resolver.md b/.claude/agents/merge-conflict-resolver.md new file mode 100644 index 0000000..5e22440 --- /dev/null +++ b/.claude/agents/merge-conflict-resolver.md @@ -0,0 +1,93 @@ +--- +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 +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) +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`, 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, 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 + 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..f6a426d --- /dev/null +++ b/.claude/agents/orchestrator.md @@ -0,0 +1,150 @@ +--- +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, 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, 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. 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. **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. +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. +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. +6. **Delegation.** Spawn a `task-worker` for each task (see "Spawning workers"). Pick the + model per task (see "Model selection"). +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. +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. +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 + +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, 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. + +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. + +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` | 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; `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. 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 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 + 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 + 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..e0b97e2 --- /dev/null +++ b/.claude/agents/task-worker.md @@ -0,0 +1,77 @@ +--- +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: 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 +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 and do not touch branches other than + your own. You cannot push, open pull requests or change remotes: a hook rejects those + 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. + +# 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) +- +``` diff --git a/.claude/hooks/no-remote-writes.py b/.claude/hooks/no-remote-writes.py new file mode 100755 index 0000000..e24b479 --- /dev/null +++ b/.claude/hooks/no-remote-writes.py @@ -0,0 +1,216 @@ +#!/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. + +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", + "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 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()) + try: + 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: + 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() 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/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 30e0ace..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 @@ -231,6 +271,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.