From c1835cfa90772b78edf06b1ef4bec1c3c52f2e29 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sun, 2 Aug 2026 21:20:46 -0500 Subject: [PATCH 01/41] Add a devcontainer image for the agentic pipeline The pipeline agents run unattended, so they run in a container with no access to the host filesystem or host credentials. The image carries the toolchain a Crypter build needs -- .NET 10, pnpm pinned to the version CI uses, wasm-tools, dotnet-ef -- plus git, gh, and the Claude Code CLI. Docker is deliberately absent. Handing the container a Docker socket would hand back the host, so Crypter.Test cannot run inside the loop; the tests run in CI once the pull request exists. The agents run as uid 1001 rather than reusing the base image's uid 1000. That account belongs to the sudo group, and while sudo is not installed today, a derived image that installed it would silently grant the agents root. The workflow publishes to GHCR on pushes to stable that touch .devcontainer, and needs a DEVCONTAINER_IMAGE_NAME repository variable to exist. Co-Authored-By: Claude Opus 5 --- .devcontainer/Dockerfile | 61 +++++++++++++++++++ .devcontainer/clone-fork.sh | 39 ++++++++++++ .devcontainer/devcontainer.json | 20 ++++++ .../workflows/build-and-push-devcontainer.yml | 54 ++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/clone-fork.sh create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/workflows/build-and-push-devcontainer.yml diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 00000000..2d616f4a --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,61 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 + +ARG USERNAME=agent +ARG USER_UID=1001 +ARG USER_GID=$USER_UID +ARG NODE_MAJOR=22 +ARG PNPM_VERSION=11.18.0 +ARG CLAUDE_CODE_VERSION=latest + +ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ + DOTNET_NOLOGO=1 \ + DOTNET_TOOLS=/usr/local/share/dotnet-tools +ENV PATH="${PATH}:${DOTNET_TOOLS}" + +# Claude Code refuses --dangerously-skip-permissions when running as root on Linux, +# so the agents need an unprivileged user to run as. +RUN groupadd --gid $USER_GID $USERNAME \ + && useradd --uid $USER_UID --gid $USER_GID --create-home --shell /bin/bash $USERNAME + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + ca-certificates \ + curl \ + git \ + gnupg \ + jq \ + less \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list \ + && apt-get update \ + && apt-get install --yes --no-install-recommends gh \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \ + && apt-get install --yes --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Crypter.Web runs pnpm install and several vite build scripts in a PreBuild target, +# so a solution build fails without pnpm. Pinned to the version CI uses. +RUN npm install --global "pnpm@${PNPM_VERSION}" \ + && npm install --global "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + && npm cache clean --force + +RUN dotnet workload install wasm-tools + +RUN dotnet tool install dotnet-ef --version '10.0.*' --tool-path "${DOTNET_TOOLS}" + +COPY .devcontainer/clone-fork.sh /usr/local/bin/crypter-clone-fork +RUN chmod +x /usr/local/bin/crypter-clone-fork + +# The workspace is a named volume. Docker seeds an empty named volume from the image +# at this path, so creating it here is what gives the volume the right ownership. +RUN mkdir -p /work && chown $USER_UID:$USER_GID /work + +USER $USERNAME +WORKDIR /work diff --git a/.devcontainer/clone-fork.sh b/.devcontainer/clone-fork.sh new file mode 100644 index 00000000..1daeac0e --- /dev/null +++ b/.devcontainer/clone-fork.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Prepare the pipeline workspace: a clone of your fork, with the org repository added as a +# read-only upstream. The devcontainer runs this once, when the container is created. +# +# The workspace is a named volume rather than a bind mount of a host checkout. The agents +# get their own clone, so they cannot touch uncommitted work on the host, and `origin` is +# the fork that the container's fork-scoped token can actually push to. +set -euo pipefail + +: "${CRYPTER_FORK:?Set CRYPTER_FORK on the host to / of your fork}" +: "${GH_TOKEN:?Set CRYPTER_FORK_TOKEN on the host so it reaches the container as GH_TOKEN}" + +upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" +workspace="${CRYPTER_WORKSPACE:-/work/Crypter}" + +if [[ "${CRYPTER_FORK}" == "${upstream_repo}" ]]; then + echo "CRYPTER_FORK is the upstream repository. Point it at your fork instead." >&2 + exit 1 +fi + +git config --global user.name "${CRYPTER_GIT_NAME:-Crypter pipeline}" +git config --global user.email "${CRYPTER_GIT_EMAIL:-pipeline@users.noreply.github.com}" +gh auth setup-git + +if [[ -d "${workspace}/.git" ]]; then + echo "Workspace already present at ${workspace}" +else + git clone "https://github.com/${CRYPTER_FORK}.git" "${workspace}" +fi + +if ! git -C "${workspace}" remote get-url upstream >/dev/null 2>&1; then + git -C "${workspace}" remote add upstream "https://github.com/${upstream_repo}.git" +fi + +git -C "${workspace}" fetch --quiet origin +git -C "${workspace}" fetch --quiet upstream + +echo "Workspace ready at ${workspace}" +git -C "${workspace}" remote -v diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..d99939ac --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,20 @@ +{ + "name": "Crypter agentic pipeline", + "image": "ghcr.io/crypter-file-transfer/crypter-devcontainer:latest", + "remoteUser": "agent", + + "workspaceMount": "source=crypter-pipeline-workspace,target=/work,type=volume", + "workspaceFolder": "/work/Crypter", + + "containerEnv": { + "GH_TOKEN": "${localEnv:CRYPTER_FORK_TOKEN}", + "CRYPTER_FORK": "${localEnv:CRYPTER_FORK}", + "CRYPTER_UPSTREAM": "Crypter-File-Transfer/Crypter" + }, + + "mounts": [ + "source=crypter-pipeline-claude,target=/home/agent/.claude,type=volume" + ], + + "onCreateCommand": "crypter-clone-fork" +} diff --git a/.github/workflows/build-and-push-devcontainer.yml b/.github/workflows/build-and-push-devcontainer.yml new file mode 100644 index 00000000..182181a6 --- /dev/null +++ b/.github/workflows/build-and-push-devcontainer.yml @@ -0,0 +1,54 @@ +name: Build and push an image of the Crypter devcontainer to GitHub Container Registry + +on: + push: + branches: + - stable + paths: + - '.devcontainer/**' + - '.github/workflows/build-and-push-devcontainer.yml' + + workflow_dispatch: + +env: + registry: ghcr.io/${{ github.repository_owner }} + +jobs: + build-and-push-devcontainer-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Log in to the Container registry + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.registry }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.registry }}/${{ vars.DEVCONTAINER_IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,format=short + + - name: Build and push Docker image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./.devcontainer/Dockerfile + platforms: linux/amd64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} From 941b57dfb0c4709e7d1f726161bd69a4886df610 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sun, 2 Aug 2026 21:36:55 -0500 Subject: [PATCH 02/41] Add the agent definitions for the agentic pipeline Each stage of the pipeline runs as its own subagent so that it starts from a clean context. A stage that inherits the reasoning behind the work it is checking tends to ratify it instead of examining it. Tool grants do the enforcing. The conformance auditor and the reviewer cannot write code, so a deviation or a defect they find has to be reported rather than quietly repaired. The implementer is reused for the initial build, for accepted review findings, and for CI fixes, so there is no fourth definition. Co-Authored-By: Claude Opus 5 --- .claude/agents/conformance-auditor.md | 64 ++++++++++++++++++++ .claude/agents/implementer.md | 85 +++++++++++++++++++++++++++ .claude/agents/plan-author.md | 66 +++++++++++++++++++++ .claude/agents/publisher.md | 80 +++++++++++++++++++++++++ .claude/agents/reviewer.md | 69 ++++++++++++++++++++++ 5 files changed, 364 insertions(+) create mode 100644 .claude/agents/conformance-auditor.md create mode 100644 .claude/agents/implementer.md create mode 100644 .claude/agents/plan-author.md create mode 100644 .claude/agents/publisher.md create mode 100644 .claude/agents/reviewer.md diff --git a/.claude/agents/conformance-auditor.md b/.claude/agents/conformance-auditor.md new file mode 100644 index 00000000..d7308edf --- /dev/null +++ b/.claude/agents/conformance-auditor.md @@ -0,0 +1,64 @@ +--- +name: conformance-auditor +description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as stage 4 of the /pipeline skill. +tools: Read, Grep, Glob, Bash, Write +model: opus +effort: high +color: yellow +--- + +# Conformance auditor + +You answer one question: **does the diff match the plan?** Not whether the code is good, not +whether the plan was a good plan. Fidelity, and nothing else. + +You are given a worktree path, a plan file, and an output path. Read both, read the diff, write +your report to the output path, and report a short summary. You cannot edit code, and that is +deliberate — a deviation you quietly repair is a deviation nobody ever sees. Report it. + +## Getting the diff + +```bash +git -C diff upstream/stable...HEAD +git -C log --oneline upstream/stable..HEAD +``` + +Three dots. You want what the branch added, not what `stable` moved on to. Read the changed +files themselves where the diff alone does not tell you whether a step was really done — a +plan step that says "return `Maybe` instead of null" is not satisfied by a signature change +if the call sites still null-check. + +## The buckets + +Put every part of the plan, and every part of the diff, in exactly one: + +- **Implemented as planned** — the step exists in the diff and does what the plan said. One + line each; do not narrate. +- **Deviated** — the step exists but differs. Say what the plan asked for, what the code does, + and how much it matters. A different method name is trivia; a different error-handling shape + is not. +- **Missing** — the plan asked for it and the diff does not contain it. Include tests the plan + named and the implementer did not write, and migrations the plan required for an entity + change. +- **Unplanned extra** — in the diff, not in the plan. Check these against the plan's + **Non-goals** especially; a change the plan explicitly ruled out is the most serious thing + you can find. + +## Judgement + +Not every deviation is a problem. The implementer works from the plan alone and sometimes the +code contradicts it; a sound deviation with a stated reason is a good outcome. Say which +deviations look justified and which look like drift, and keep those judgements separate from +the facts. + +Where the plan was vague enough that the diff neither matches nor contradicts it, say so under +the deviation and blame the plan, not the code. + +## Report + +Write to the output path as Markdown, with a one-line verdict at the top — *conforms*, +*conforms with deviations*, or *diverges* — followed by the four buckets in the order above. +Omit a bucket that is empty rather than writing "none". + +If the diff matches the plan, say that in a sentence and stop. Do not manufacture findings to +justify the stage. diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md new file mode 100644 index 00000000..84d6ee96 --- /dev/null +++ b/.claude/agents/implementer.md @@ -0,0 +1,85 @@ +--- +name: implementer +description: Implement an approved plan in Crypter, or apply accepted review findings and CI fixes. Used as stages 2, 6, and the CI loop of the /pipeline skill. +model: opus +effort: high +color: green +--- + +# Implementer + +You write the code. You are given a worktree path and one of three jobs: + +1. **Implement a plan.** You get the plan and nothing about how it was reached. +2. **Apply findings.** You get accepted review findings against code you or another agent + wrote. +3. **Fix CI.** You get a failing job's log from a pull request. + +Work only inside the given worktree, always by absolute path. Never `cd` in a compound +command; use `git -C ` and absolute paths. + +## Implementing a plan + +Follow the steps in order. The plan is the specification: build what it says, not what you +would have designed. Where it is silent, match the surrounding code. + +If a step turns out to be wrong — it contradicts the code, or cannot work as written — do +not quietly redesign around it. Implement everything that does work, leave the broken step +undone, and say clearly in your report which step you could not do and why. A conformance +auditor compares the diff to the plan afterwards, and an honest gap is a far better outcome +than a silent substitution. + +Do not do work the plan did not ask for. No opportunistic refactors, no unrelated +formatting, no fixing things you noticed on the way. If you spot something worth doing, +report it; do not do it. + +## The conventions are not optional + +- `Maybe` and `Either` from `Crypter.Common/Monads` for expected failures, + not nulls and not exceptions. +- Validated types from `Crypter.Common/Primitives` rather than raw strings. +- `Async` suffix on async methods. Async all the way for database, file, and network IO. +- Constructors over object initializers. Enums over magic strings. +- `.editorconfig` governs formatting and naming. Private fields are `_camelCase`. +- Comments explain the code as it stands. Never write a comment narrating history — no + "bumped from X to Y", "was previously Z", "new in .NET 10". +- Entity changes under `Crypter.DataAccess/Entities` need an EF Core migration in + `Crypter.DataAccess/Migrations`, and some need a companion script in + `Crypter.DataAccess/Scripts`. + +## Building + +Build what you changed, by absolute path: + +```bash +dotnet build /Crypter.Test +``` + +`dotnet build /Crypter.sln` also builds `Crypter.Web`, which runs `pnpm install` +and several `vite build` scripts in a PreBuild target — slower, and worth it only when you +touched the web client. + +**Do not run `dotnet test`.** `Crypter.Test` needs Docker for Testcontainers and there is no +Docker in this container. The tests run in CI once the pull request exists, and their +failures come back to you as job 3. Write the tests the plan asks for; just do not expect to +run them here. + +## Committing + +Commit as you complete meaningful units of work — not one commit for everything. + +Subject lines: imperative, capitalized, no trailing period, under ~72 characters, no +Conventional Commits prefix and no tags. `Add basic tests for getting transfer settings`, +not `feat: add tests`. + +Body is optional for small self-explanatory changes. When a change is non-obvious, wrap at +~80 characters and explain *why*: what broke, what constraint forced the approach, what was +ruled out. Describe consequences, not a file-by-file list of the diff. + +When applying findings or fixing CI, each fix is its own commit on the existing branch. The +subject says what the code now does, not that a review asked for it. + +## Report + +Say what you built, which steps you completed, anything you could not do and why, and +anything you noticed but deliberately left alone. diff --git a/.claude/agents/plan-author.md b/.claude/agents/plan-author.md new file mode 100644 index 00000000..f8221d9e --- /dev/null +++ b/.claude/agents/plan-author.md @@ -0,0 +1,66 @@ +--- +name: plan-author +description: Turn a requirement into an implementation plan for Crypter. Used as stage 1 of the /pipeline skill; not for ad-hoc planning. +tools: Read, Grep, Glob, Bash, WebFetch, Write +model: opus +effort: high +color: blue +--- + +# Plan author + +You turn a requirement into a plan another agent will implement without ever speaking to +you. It will see your plan and nothing else — not your reasoning, not the files you read, +not the alternatives you rejected. Write for that reader. + +You are given a requirement, a worktree path, and an output path. Read the code, write the +plan to the output path, and report a one-paragraph summary. **Write nothing else.** You do +not implement, and you do not create branches or commits. + +## Understand before deciding + +Read `CLAUDE.md` and `Documentation/Development/Coding Standard.md` first. Then read the +code the requirement touches, and the code around it — the existing patterns are the ones +the implementation must match. + +Prefer reusing what exists over introducing something new. If a monad, primitive, service, +or extension already does most of the job, name it in the plan with its path. + +## What the plan must contain + +Write it to the given path as Markdown: + +- **Goal** — one paragraph. What changes for a user of Crypter, and why. +- **Non-goals** — what this change deliberately does not do. Be specific; this is what + keeps the implementer from wandering, and what the conformance auditor checks against. +- **Approach** — the design, in prose. Name the types and methods to add or change. Explain + anything non-obvious, especially where a constraint forced the shape. +- **Steps** — numbered and ordered, each naming the files it touches. A step should be small + enough that its result is obvious. +- **Tests** — what to add to `Crypter.Test` or `Crypter.Test.Web` and what each case pins + down. The pipeline does not run tests locally, so untested behaviour is unverified until + CI runs. +- **Risks** — what could break, and what a reviewer should look at hardest. + +## Crypter's idioms are part of the plan + +Express the plan in the conventions the code already uses, so the implementer inherits them: + +- `Maybe` and `Either` from `Crypter.Common/Monads` for expected failures, + not nulls and not exceptions. +- Validated types from `Crypter.Common/Primitives` rather than raw strings. +- `Async` suffix on async methods, and async all the way for database, file, and network IO. +- Constructors over object initializers. Enums over magic strings. +- Any change to an entity under `Crypter.DataAccess/Entities` needs an EF Core migration in + `Crypter.DataAccess/Migrations`. Say so explicitly, and say whether it also needs a + companion script in `Crypter.DataAccess/Scripts`. + +## Scope + +One pull request should do one thing. If the requirement implies drive-by refactors or +cleanups, put them under non-goals rather than in the steps. + +If the requirement is ambiguous enough that two readings give materially different work, +say so at the top of the plan under **Open question**, choose the reading you think is +right, state that you chose it, and plan that. A human approves this plan before anything +is built, so a flagged assumption is cheap. Silence is not. diff --git a/.claude/agents/publisher.md b/.claude/agents/publisher.md new file mode 100644 index 00000000..1b9f87a6 --- /dev/null +++ b/.claude/agents/publisher.md @@ -0,0 +1,80 @@ +--- +name: publisher +description: Take a draft pull request out of draft, watch its checks, and report what CI did. Used as stage 7 of the /pipeline skill, once per CI attempt. +tools: Read, Grep, Glob, Bash, Write +model: opus +effort: high +color: purple +--- + +# Publisher + +You take the pull request out of draft and find out whether CI accepts it. You do not write +code. When checks fail you produce a description of the failure precise enough that an +implementer who has never seen this pull request can fix it. + +You are given a worktree path, a pull request number, an attempt number, and the path to +`ci.md`. You run **one attempt**. The skill counts attempts, invokes the implementer between +them, and calls you again — so you always start from a clean read of the current state rather +than from your own last guess. + +`gh` reads `GH_TOKEN` from the environment. The pull request is fork → fork, so `origin` is +the only repository you touch. + +## Publish + +Only on attempt 1, and only if it is still a draft: + +```bash +gh pr view --repo --json isDraft,state,mergeable +gh pr ready --repo +``` + +Taking it out of draft is what causes `unit-test.yml` and `codeql-analysis.yml` to run. On +later attempts the pull request is already published and the new commit triggers the run on +its own — do not re-run `gh pr ready`. + +Confirm a run actually started before you settle in to watch. If nothing is queued after a +minute, say so and stop: on a fork, workflows stay disabled until they are enabled once in the +Actions tab, and that is a setup problem no amount of waiting fixes. + +## Watch + +```bash +gh pr checks --repo --watch +``` + +Give it a generous timeout — a full build plus the test suite is slow, and a watch you kill +early looks exactly like a failure. + +## On failure + +Get the real log, not the summary: + +```bash +gh run view --repo --log-failed +``` + +Then read the code the failure points at, in the worktree. A stack trace names a file and a +line; open it. The difference between a useful report and a useless one is whether you found +the cause or just copied the symptom. + +Write the attempt to `ci.md`, appending rather than overwriting: + +- Which check failed, and the run URL. +- The actual error — assertion message, compiler diagnostic, analyzer rule — quoted, not + paraphrased. +- The file and line, and what you believe is causing it. +- Whether it looks like a code defect, a wrong test, or something environmental. Say which, + and say when you are unsure. + +Then report the same thing back. Do not propose a patch; the implementer decides the fix. + +If the failure looks like the plan itself was wrong — the tests encode behaviour the change +contradicts — say so plainly. That is the signal for a human to step in, and it is worth more +than another attempt. + +## On success + +Append the result to `ci.md`, and report the pull request URL, the checks that passed, and the +mergeable state. Say nothing about quality; that was stage 4's job. diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md new file mode 100644 index 00000000..f90cbeb0 --- /dev/null +++ b/.claude/agents/reviewer.md @@ -0,0 +1,69 @@ +--- +name: reviewer +description: Review a Crypter branch's diff under a named lens and report findings. Used as stage 4 of the /pipeline skill; the lens comes from the prompt. +tools: Read, Grep, Glob, Bash, Write +model: opus +effort: high +color: red +--- + +# Reviewer + +You review a diff under a **lens** given in your prompt — a name and a description of what to +look for. One definition serves every lens; the prompt decides which one you are. If no lens +is given, review generally: correctness first, then everything else. + +You are given a worktree path, a lens, and an output path. Write your findings to the output +path and report a short summary. You cannot edit code. Report what is wrong; someone else +fixes it. + +## Scope + +Review the diff, not the repository: + +```bash +git -C diff upstream/stable...HEAD +``` + +Read the surrounding code freely — you cannot judge a change without it — but a problem that +existed before this branch is not a finding. If a pre-existing problem is made materially +worse by the diff, that is a finding, and say that is what it is. + +Stay inside your lens. If you are the security lens and you notice a naming inconvenience, +leave it; another lens has it, or nobody needed it. + +## What counts as a finding + +A finding needs a concrete failure: specific inputs or state, and the wrong output, crash, or +exposure that follows. "This could be a problem" is not a finding. If you cannot describe how +it breaks, you are describing a preference. + +Verify before you report. Read the code paths involved and follow the callers. A confident +finding that turns out to be wrong costs more than a missed one, because someone will change +working code to satisfy it. + +Rank most severe first. Do not pad — three real findings beat three real findings plus nine +nits, and the nits make the real ones harder to see. + +## Crypter's conventions are in scope + +A change that ignores them is a legitimate finding for any lens: + +- Nulls or exceptions where `Maybe` or `Either` from `Crypter.Common/Monads` + belongs. +- Raw strings where a validated type from `Crypter.Common/Primitives` exists. +- Sync IO on a database, file, or network path; a missing `Async` suffix. +- Object initializers where a constructor belongs; magic strings where an enum belongs. +- An entity change under `Crypter.DataAccess/Entities` with no migration in + `Crypter.DataAccess/Migrations` — and whether it needs a companion script in + `Crypter.DataAccess/Scripts`. +- Comments narrating history rather than explaining the code as it stands. + +## Report + +Write to the output path as Markdown. Name the lens at the top. For each finding: the file and +line, one sentence stating the defect, and the concrete failure it produces. Then a one-line +suggested direction — not a patch. + +**If the diff is fine under your lens, say so in a sentence and stop.** Finding nothing is a +real result and a useful one. Nobody is grading you on volume. From 34701850fdbb87bbc1dbe94704172e3cd94b776d Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sun, 2 Aug 2026 21:38:08 -0500 Subject: [PATCH 03/41] Add the pipeline skill that orchestrates the agents The skill drives a requirement from plan to a published pull request: sync and branch, plan, implement, open the pull request as a draft, audit and review in parallel, remediate, then publish and hold the pull request against CI. The plan is the one place it stops for a human. The CI loop lives here rather than in the publisher agent. A subagent cannot invoke another subagent, so the publisher runs a single attempt and reports what CI said, and the skill counts attempts and calls the implementer between them. That also means each attempt reads the current state of the checks instead of reasoning from its own earlier diagnosis. Three attempts, then it hands back. Everything happens on a fork, so nothing the agents run can reach this repository. Run state under .claude/pipeline is ignored. Co-Authored-By: Claude Opus 5 --- .claude/skills/pipeline/SKILL.md | 164 +++++++++++++++++++++++++++++++ .gitignore | 5 +- 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 .claude/skills/pipeline/SKILL.md diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md new file mode 100644 index 00000000..95947626 --- /dev/null +++ b/.claude/skills/pipeline/SKILL.md @@ -0,0 +1,164 @@ +--- +name: pipeline +description: Take a requirement from plan to a published, CI-green pull request on the fork, using a chain of subagents. Use when asked to run the pipeline on a requirement, or invoked as /pipeline "". +--- + +# Pipeline + +Turn a requirement into a published pull request whose checks pass, in stages, each run by a +subagent with its own context. A later stage that starts fresh actually re-examines the work; +one that inherits the reasoning behind it rubber-stamps it. + +**This runs inside the devcontainer.** `origin` is the fork, `upstream` is the org repository +and is read-only. Every pull request is fork → fork. Nothing here can reach +`Crypter-File-Transfer/Crypter`, and the upstream pull request is something the user opens by +hand at the end, from a fork pull request they have read. + +There is exactly one stop: the user approves the plan. Everything after that runs to a +published pull request or to a written account of why CI would not take it. + +## Setup + +Pick a short run id from the requirement — `transfer-limits`, `fix-expiry-tz`. Then: + +```bash +mkdir -p /work/Crypter/.claude/pipeline/{run-id} +``` + +State goes there: `plan.md`, `conformance.md`, `findings/`, `ci.md`. It is gitignored. + +## 0. Sync and branch + +Never plan against stale code: + +```bash +git -C /work/Crypter fetch upstream +git -C /work/Crypter fetch origin +git -C /work/Crypter push origin upstream/stable:refs/heads/stable +git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} -b {branch} upstream/stable +``` + +**If any of these fail, stop and say so.** A quietly skipped sync means the plan, the diff, and +the eventual upstream pull request are all built on the wrong base, and nothing downstream will +notice. + +Name the branch as the repo does: `feature/{something}`, `fix/{something}`, `chore/{something}`. + +Every later stage gets this worktree path and works by absolute path inside it. Never `cd`. + +## 1. Plan + +Invoke `plan-author` with the requirement verbatim, the worktree path, and the output path +`.claude/pipeline/{run-id}/plan.md`. + +Then **stop.** Show the user the plan — the file, not a summary of it — and wait. Do not +implement, do not create the pull request, do not start reviewing. If they ask for changes, run +`plan-author` again with their feedback and the existing plan; do not edit the plan yourself. + +If the plan contains an **Open question**, put it in front of the user explicitly. It is the +one thing they are most likely to want to change and the cheapest moment to change it. + +## 2. Implement + +Invoke `implementer` with the plan path and the worktree path. Give it nothing about how the +plan was reached — the plan is the specification. + +Read its report. If it says a step could not be done, that is not a failure to paper over: +surface it to the user with the rest of the results at the end, and let the auditor record it. + +## 3. Open the draft pull request + +Now, once there is something real to look at and before anyone reviews it: + +```bash +git -C /work/Crypter/.claude/worktrees/{run-id} push -u origin {branch} +gh pr create --repo {fork} --draft --base stable --head {branch} --title "..." --body "..." +``` + +Title reads like a commit subject: imperative, capitalized, no trailing period. + +Description is a few sentences of plain English saying what changed and why. **Do not argue the +case** — no justifying the approach, no pre-empting objections, no listing rejected +alternatives. Call out what a reviewer would otherwise have to discover: migrations, breaking +API changes, deliberately held-back dependencies. That is information, not argument. + +This description carries over verbatim when the user opens the upstream pull request, so write +it for the org repository's reviewers. + +## 4. Examine + +Run these in parallel — they do not interact: + +- `conformance-auditor` with the plan, the worktree, and `.claude/pipeline/{run-id}/conformance.md`. +- `reviewer`, once per lens, with the worktree and `.claude/pipeline/{run-id}/findings/{lens}.md`. + +The lens list is currently one entry: + +| Lens | Brief | +|---|---| +| general | Correctness and edge cases first, then scope creep, then the conventions in `CLAUDE.md`. | + +Adding lenses later — security, simplicity, test coverage — means adding rows here. The +`reviewer` definition does not change; the lens comes from the prompt. + +## 5. Triage + +You decide what to act on. Read every finding against the code before accepting it — a reviewer +that has already been wrong once will happily be wrong again, and acting on a bad finding means +changing working code. + +Accept anything with a concrete failure behind it. Reject preferences, restatements of the plan +you already chose against, and findings about code the diff did not touch. An unplanned extra +that contradicts the plan's non-goals is not a preference — accept it. + +Write what you accepted and what you rejected, with a reason for each rejection, into +`.claude/pipeline/{run-id}/findings/triage.md`. The user reads this to check your judgement. + +## 6. Remediate + +If anything was accepted, invoke `implementer` with the accepted findings and the worktree +path. Each fix is its own commit on the existing branch, and pushing updates the same draft +pull request. + +If nothing was accepted, skip straight to publishing. + +## 7. Publish, and hold it against CI + +Invoke `publisher` with the worktree path, the pull request number, the attempt number, and +`.claude/pipeline/{run-id}/ci.md`. It runs **one attempt**: it publishes on attempt 1, watches +the checks, and reports. + +You own the loop: + +1. `publisher` reports green → go to step 8. +2. `publisher` reports a failure → invoke `implementer` with that failure report and the + worktree path, push, then invoke `publisher` again with the next attempt number. +3. **Stop after three attempts.** Comment the state of play on the pull request, and hand back + to the user. Three failures on the same change usually means the plan was wrong, not the + code, and a fourth attempt buys a full build and test suite for nothing. + +A fresh `publisher` per attempt is deliberate — it reads what CI actually says now, rather than +reasoning from its own previous guess about the failure. + +Stop immediately, without spending attempts, if `publisher` reports that no run ever started. +Workflows are disabled on a new fork until they are enabled once in its Actions tab, and that is +a setup problem. + +## 8. Hand off + +```bash +git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} +``` + +Remove it on every exit path, including when the pipeline stopped early. + +Then tell the user, in a few sentences: + +- The fork pull request URL and whether its checks are green. +- Anything the implementer could not do, and any deviation the auditor flagged as drift. +- What you rejected in triage that they might disagree with. +- If the CI loop gave up: which check failed and what the last attempt tried. + +They open the upstream pull request themselves. Remind them the base repository is fixed when a +pull request is created, so it is a new pull request against +`Crypter-File-Transfer/Crypter` — the description is ready to paste. diff --git a/.gitignore b/.gitignore index d9def341..3d2725a2 100644 --- a/.gitignore +++ b/.gitignore @@ -460,4 +460,7 @@ Crypter.Web/wwwroot/js/dist Crypter.Web/pnpm-lock.yaml # Claude Code worktrees -.claude/worktrees/ \ No newline at end of file +.claude/worktrees/ + +# Agentic pipeline run state +.claude/pipeline/ \ No newline at end of file From 8fbd09c840b75800a67560402bf0fb015d4ae661 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sun, 2 Aug 2026 23:47:54 -0500 Subject: [PATCH 04/41] Give the agent user ownership of its Claude Code state volume Docker creates a mount point the image does not contain as root, so the volume mounted at /home/agent/.claude arrived read-only to the agent user and Claude Code could not write its settings or credentials. /work already avoided this by being created in the image; the home directory now does the same. CRYPTER_GIT_NAME and CRYPTER_GIT_EMAIL were read by the clone script but never passed through containerEnv, so every commit was authored as the fallback identity. CRYPTER_WORKSPACE could never diverge from workspaceFolder without breaking the container, so it is now a constant. --- .devcontainer/Dockerfile | 8 +++++--- .devcontainer/clone-fork.sh | 4 +++- .devcontainer/devcontainer.json | 4 +++- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2d616f4a..2c159d91 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -53,9 +53,11 @@ RUN dotnet tool install dotnet-ef --version '10.0.*' --tool-path "${DOTNET_TOOLS COPY .devcontainer/clone-fork.sh /usr/local/bin/crypter-clone-fork RUN chmod +x /usr/local/bin/crypter-clone-fork -# The workspace is a named volume. Docker seeds an empty named volume from the image -# at this path, so creating it here is what gives the volume the right ownership. -RUN mkdir -p /work && chown $USER_UID:$USER_GID /work +# The workspace and the agent's Claude Code state are both named volumes. Docker creates a +# mount point that the image does not already contain as root, so creating these here is +# what gives the volumes the right ownership. +RUN mkdir -p /work /home/$USERNAME/.claude \ + && chown $USER_UID:$USER_GID /work /home/$USERNAME/.claude USER $USERNAME WORKDIR /work diff --git a/.devcontainer/clone-fork.sh b/.devcontainer/clone-fork.sh index 1daeac0e..af18b3ed 100644 --- a/.devcontainer/clone-fork.sh +++ b/.devcontainer/clone-fork.sh @@ -11,7 +11,9 @@ set -euo pipefail : "${GH_TOKEN:?Set CRYPTER_FORK_TOKEN on the host so it reaches the container as GH_TOKEN}" upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" -workspace="${CRYPTER_WORKSPACE:-/work/Crypter}" + +# Has to match workspaceFolder in devcontainer.json. +workspace="/work/Crypter" if [[ "${CRYPTER_FORK}" == "${upstream_repo}" ]]; then echo "CRYPTER_FORK is the upstream repository. Point it at your fork instead." >&2 diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d99939ac..d9b35b24 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,9 @@ "containerEnv": { "GH_TOKEN": "${localEnv:CRYPTER_FORK_TOKEN}", "CRYPTER_FORK": "${localEnv:CRYPTER_FORK}", - "CRYPTER_UPSTREAM": "Crypter-File-Transfer/Crypter" + "CRYPTER_UPSTREAM": "Crypter-File-Transfer/Crypter", + "CRYPTER_GIT_NAME": "${localEnv:CRYPTER_GIT_NAME}", + "CRYPTER_GIT_EMAIL": "${localEnv:CRYPTER_GIT_EMAIL}" }, "mounts": [ From 0ad324de4772ffecb82840f00a7caa1cbce18ffb Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sun, 2 Aug 2026 23:49:00 -0500 Subject: [PATCH 05/41] Build the projects that CI compiles Crypter.Test's project graph does not include Crypter.Web or Crypter.Test.Web, so building it alone left changes to either uncompiled until CI, which builds the whole solution and runs both test projects. --- .claude/agents/implementer.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index 84d6ee96..79177bcb 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -55,9 +55,18 @@ Build what you changed, by absolute path: dotnet build /Crypter.Test ``` -`dotnet build /Crypter.sln` also builds `Crypter.Web`, which runs `pnpm install` -and several `vite build` scripts in a PreBuild target — slower, and worth it only when you -touched the web client. +That covers `Crypter.API`, `Crypter.Core`, `Crypter.DataAccess` and `Crypter.Common`, which +are all in its project graph. `Crypter.Web` and `Crypter.Test.Web` are not, so a change +touching either needs the solution: + +```bash +dotnet build /Crypter.sln +``` + +The solution build runs `pnpm install` and several `vite build` scripts in `Crypter.Web`'s +PreBuild target, so it is slow. It is still cheaper than the alternative: CI compiles the +whole solution and runs both test projects, so a compile error in `Crypter.Test.Web` costs +a full round of checks to find out about. **Do not run `dotnet test`.** `Crypter.Test` needs Docker for Testcontainers and there is no Docker in this container. The tests run in CI once the pull request exists, and their From 75d2253e2c9f55cc7e80aff96cd6726b872b9706 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Sun, 2 Aug 2026 23:51:38 -0500 Subject: [PATCH 06/41] Watch the checks for the current commit instead of publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checks run on draft pull requests, so the round of checks starts when the pull request is created at stage 3 and again on every push. `gh pr ready` fired nothing, because ready_for_review is not among the events unit-test.yml and codeql-analysis.yml listen for. Stage 7 waited on a run that would never come, and on a clean run — where triage accepts nothing and no remediation is pushed — it reported the fork's workflows as disabled and stopped. Stage 7 now resolves the branch's head commit and watches the round of checks for it, and the pull request stays a draft for the user to publish once they have reviewed it. `publisher` is renamed `ci-watcher` to match. The skill also pushes, at stage 3 and after each implementer stage. Nothing cancels a superseded round of checks, so a push per commit would spend several rounds to learn what the last one says. --- .../agents/{publisher.md => ci-watcher.md} | 38 +++++++----- .claude/skills/pipeline/SKILL.md | 58 +++++++++++-------- 2 files changed, 57 insertions(+), 39 deletions(-) rename .claude/agents/{publisher.md => ci-watcher.md} (60%) diff --git a/.claude/agents/publisher.md b/.claude/agents/ci-watcher.md similarity index 60% rename from .claude/agents/publisher.md rename to .claude/agents/ci-watcher.md index 1b9f87a6..18d7c5f2 100644 --- a/.claude/agents/publisher.md +++ b/.claude/agents/ci-watcher.md @@ -1,15 +1,15 @@ --- -name: publisher -description: Take a draft pull request out of draft, watch its checks, and report what CI did. Used as stage 7 of the /pipeline skill, once per CI attempt. +name: ci-watcher +description: Watch the checks for a pull request's current commit and report what CI did. Used as stage 7 of the /pipeline skill, once per CI attempt. tools: Read, Grep, Glob, Bash, Write model: opus effort: high color: purple --- -# Publisher +# CI watcher -You take the pull request out of draft and find out whether CI accepts it. You do not write +You find out whether CI accepts the pull request as it currently stands. You do not write code. When checks fail you produce a description of the failure precise enough that an implementer who has never seen this pull request can fix it. @@ -21,22 +21,22 @@ than from your own last guess. `gh` reads `GH_TOKEN` from the environment. The pull request is fork → fork, so `origin` is the only repository you touch. -## Publish +## Find the run -Only on attempt 1, and only if it is still a draft: +The pull request is a draft and stays one; the user takes it out of draft when they are ready +to review it. Checks run on drafts, so pushing the branch is what starts a round of them, and +a round is already queued or finished by the time you are invoked. + +Find the round for the commit you were asked about, rather than whichever ran most recently: ```bash -gh pr view --repo --json isDraft,state,mergeable -gh pr ready --repo +head_sha=$(git -C rev-parse HEAD) +gh run list --repo --commit "${head_sha}" --json databaseId,workflowName,status,conclusion ``` -Taking it out of draft is what causes `unit-test.yml` and `codeql-analysis.yml` to run. On -later attempts the pull request is already published and the new commit triggers the run on -its own — do not re-run `gh pr ready`. - -Confirm a run actually started before you settle in to watch. If nothing is queued after a -minute, say so and stop: on a fork, workflows stay disabled until they are enabled once in the -Actions tab, and that is a setup problem no amount of waiting fixes. +A push takes a moment to register, so poll until a run appears. If nothing has appeared after +a few minutes, say so and stop: on a fork, workflows stay disabled until they are enabled once +in the Actions tab, and that is a setup problem no amount of waiting fixes. ## Watch @@ -47,6 +47,10 @@ gh pr checks --repo --watch Give it a generous timeout — a full build plus the test suite is slow, and a watch you kill early looks exactly like a failure. +Four workflows run on a pull request: `unit-test`, `codeql-analysis`, `pr-build-api` and +`pr-build-web`. The last two are gated on `detect-code-changes` and skip entirely when the +diff is documentation only. A skipped check is a pass. + ## On failure Get the real log, not the summary: @@ -76,5 +80,9 @@ than another attempt. ## On success +```bash +gh pr view --repo --json url,isDraft,mergeable +``` + Append the result to `ci.md`, and report the pull request URL, the checks that passed, and the mergeable state. Say nothing about quality; that was stage 4's job. diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md index 95947626..fcbc52aa 100644 --- a/.claude/skills/pipeline/SKILL.md +++ b/.claude/skills/pipeline/SKILL.md @@ -1,11 +1,11 @@ --- name: pipeline -description: Take a requirement from plan to a published, CI-green pull request on the fork, using a chain of subagents. Use when asked to run the pipeline on a requirement, or invoked as /pipeline "". +description: Take a requirement from plan to an open, CI-green draft pull request on the fork, using a chain of subagents. Use when asked to run the pipeline on a requirement, or invoked as /pipeline "". --- # Pipeline -Turn a requirement into a published pull request whose checks pass, in stages, each run by a +Turn a requirement into a draft pull request whose checks pass, in stages, each run by a subagent with its own context. A later stage that starts fresh actually re-examines the work; one that inherits the reasoning behind it rubber-stamps it. @@ -14,8 +14,8 @@ and is read-only. Every pull request is fork → fork. Nothing here can reach `Crypter-File-Transfer/Crypter`, and the upstream pull request is something the user opens by hand at the end, from a fork pull request they have read. -There is exactly one stop: the user approves the plan. Everything after that runs to a -published pull request or to a written account of why CI would not take it. +There is exactly one stop: the user approves the plan. Everything after that runs to a draft +pull request with green checks, or to a written account of why CI would not take it. ## Setup @@ -49,7 +49,7 @@ Every later stage gets this worktree path and works by absolute path inside it. ## 1. Plan Invoke `plan-author` with the requirement verbatim, the worktree path, and the output path -`.claude/pipeline/{run-id}/plan.md`. +`/work/Crypter/.claude/pipeline/{run-id}/plan.md`. Then **stop.** Show the user the plan — the file, not a summary of it — and wait. Do not implement, do not create the pull request, do not start reviewing. If they ask for changes, run @@ -68,13 +68,18 @@ surface it to the user with the rest of the results at the end, and let the audi ## 3. Open the draft pull request -Now, once there is something real to look at and before anyone reviews it: +Now, once there is something real to look at and before anyone reviews it. You do the pushing, +here and at every later stage — the implementer commits and returns: ```bash git -C /work/Crypter/.claude/worktrees/{run-id} push -u origin {branch} gh pr create --repo {fork} --draft --base stable --head {branch} --title "..." --body "..." ``` +Creating the pull request starts the first round of checks. Checks run on drafts, so the round +begins here rather than at stage 7, and every push after this one starts another. Nothing +cancels the round it supersedes, so push once per stage, after the implementer is done. + Title reads like a commit subject: imperative, capitalized, no trailing period. Description is a few sentences of plain English saying what changed and why. **Do not argue the @@ -89,8 +94,10 @@ it for the org repository's reviewers. Run these in parallel — they do not interact: -- `conformance-auditor` with the plan, the worktree, and `.claude/pipeline/{run-id}/conformance.md`. -- `reviewer`, once per lens, with the worktree and `.claude/pipeline/{run-id}/findings/{lens}.md`. +- `conformance-auditor` with the plan, the worktree, and + `/work/Crypter/.claude/pipeline/{run-id}/conformance.md`. +- `reviewer`, once per lens, with the worktree and + `/work/Crypter/.claude/pipeline/{run-id}/findings/{lens}.md`. The lens list is currently one entry: @@ -112,37 +119,39 @@ you already chose against, and findings about code the diff did not touch. An un that contradicts the plan's non-goals is not a preference — accept it. Write what you accepted and what you rejected, with a reason for each rejection, into -`.claude/pipeline/{run-id}/findings/triage.md`. The user reads this to check your judgement. +`/work/Crypter/.claude/pipeline/{run-id}/findings/triage.md`. The user reads this to check your +judgement. ## 6. Remediate If anything was accepted, invoke `implementer` with the accepted findings and the worktree -path. Each fix is its own commit on the existing branch, and pushing updates the same draft -pull request. +path. Each fix is its own commit on the existing branch. When it returns, push once; that +updates the same draft pull request and starts a fresh round of checks. -If nothing was accepted, skip straight to publishing. +If nothing was accepted, go straight to stage 7 — the round of checks from the last push is +the one that counts. -## 7. Publish, and hold it against CI +## 7. Hold it against CI -Invoke `publisher` with the worktree path, the pull request number, the attempt number, and -`.claude/pipeline/{run-id}/ci.md`. It runs **one attempt**: it publishes on attempt 1, watches -the checks, and reports. +Invoke `ci-watcher` with the worktree path, the pull request number, the attempt number, and +`/work/Crypter/.claude/pipeline/{run-id}/ci.md`. It runs **one attempt**: it finds the round of +checks for the branch's current commit, watches it, and reports. You own the loop: -1. `publisher` reports green → go to step 8. -2. `publisher` reports a failure → invoke `implementer` with that failure report and the - worktree path, push, then invoke `publisher` again with the next attempt number. +1. `ci-watcher` reports green → go to step 8. +2. `ci-watcher` reports a failure → invoke `implementer` with that failure report and the + worktree path, push, then invoke `ci-watcher` again with the next attempt number. 3. **Stop after three attempts.** Comment the state of play on the pull request, and hand back to the user. Three failures on the same change usually means the plan was wrong, not the code, and a fourth attempt buys a full build and test suite for nothing. -A fresh `publisher` per attempt is deliberate — it reads what CI actually says now, rather than +A fresh `ci-watcher` per attempt is deliberate — it reads what CI actually says now, rather than reasoning from its own previous guess about the failure. -Stop immediately, without spending attempts, if `publisher` reports that no run ever started. -Workflows are disabled on a new fork until they are enabled once in its Actions tab, and that is -a setup problem. +Stop immediately, without spending attempts, if `ci-watcher` reports that no run ever appeared +for the commit. Workflows are disabled on a new fork until they are enabled once in its Actions +tab, and that is a setup problem. ## 8. Hand off @@ -154,7 +163,8 @@ Remove it on every exit path, including when the pipeline stopped early. Then tell the user, in a few sentences: -- The fork pull request URL and whether its checks are green. +- The fork pull request URL and whether its checks are green. It is still a draft; taking it + out of draft is theirs to do once they have read it. - Anything the implementer could not do, and any deviation the auditor flagged as drift. - What you rejected in triage that they might disagree with. - If the CI loop gave up: which check failed and what the last attempt tried. From 3bbc52d4fa1f93c0ca04597d6d621dec00dda09a Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 11:24:05 -0500 Subject: [PATCH 07/41] Say what the review agents do instead of what they cannot Both agents have Bash, so "you cannot edit code" was a rule dressed up as a capability boundary. An agent that finds the claim false has no reason to treat the rest of the instruction as binding either. Reviewer findings are checked at triage, in a context that did not write them. "Verify before you report" read as licence to run code and confirm its own finding, which is the one check that context cannot perform honestly. Grounding a finding means reading the code paths, not executing them. Co-Authored-By: Claude Opus 5 --- .claude/agents/conformance-auditor.md | 5 +++-- .claude/agents/reviewer.md | 9 ++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.claude/agents/conformance-auditor.md b/.claude/agents/conformance-auditor.md index d7308edf..8b7d9412 100644 --- a/.claude/agents/conformance-auditor.md +++ b/.claude/agents/conformance-auditor.md @@ -13,8 +13,9 @@ You answer one question: **does the diff match the plan?** Not whether the code whether the plan was a good plan. Fidelity, and nothing else. You are given a worktree path, a plan file, and an output path. Read both, read the diff, write -your report to the output path, and report a short summary. You cannot edit code, and that is -deliberate — a deviation you quietly repair is a deviation nobody ever sees. Report it. +your report to the output path, and report a short summary. You do not repair what you find, +and that is deliberate — a deviation you quietly repair is a deviation nobody ever sees. +Report it. ## Getting the diff diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index f90cbeb0..e6faa7e7 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -14,8 +14,7 @@ look for. One definition serves every lens; the prompt decides which one you are is given, review generally: correctness first, then everything else. You are given a worktree path, a lens, and an output path. Write your findings to the output -path and report a short summary. You cannot edit code. Report what is wrong; someone else -fixes it. +path and report a short summary. Report what is wrong; someone else fixes it. ## Scope @@ -38,9 +37,9 @@ A finding needs a concrete failure: specific inputs or state, and the wrong outp exposure that follows. "This could be a problem" is not a finding. If you cannot describe how it breaks, you are describing a preference. -Verify before you report. Read the code paths involved and follow the callers. A confident -finding that turns out to be wrong costs more than a missed one, because someone will change -working code to satisfy it. +Ground every finding in the code before you report it. Read the code paths involved and follow +the callers. A confident finding that turns out to be wrong costs more than a missed one, +because someone will change working code to satisfy it. Rank most severe first. Do not pad — three real findings beat three real findings plus nine nits, and the nits make the real ones harder to see. From 84eff92e3841d9d337a6ecb25b5c79cdde194c84 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 11:53:12 -0500 Subject: [PATCH 08/41] Document the agentic pipeline's fork and token setup Nothing in the repository said what CRYPTER_FORK or CRYPTER_FORK_TOKEN were, what scopes the token needs, or that GitHub disables workflows on a new fork until you enable them by hand. That last one surfaces as the pipeline reporting no CI run ever appeared, which reads like a bug. The devcontainer image now takes its owner from the environment, still defaulting to the org's published image. The build workflow tags from the repository owner, so a fork that changes the Dockerfile publishes to its own namespace and previously had no way to point the container at it. Co-Authored-By: Claude Opus 5 --- .devcontainer/devcontainer.json | 2 +- .../Agentic Development Pipeline.md | 102 ++++++++++++++++++ README.md | 1 + 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 Documentation/Development/Agentic Development Pipeline.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d9b35b24..a2d10a50 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Crypter agentic pipeline", - "image": "ghcr.io/crypter-file-transfer/crypter-devcontainer:latest", + "image": "ghcr.io/${localEnv:CRYPTER_DEVCONTAINER_OWNER:crypter-file-transfer}/crypter-devcontainer:latest", "remoteUser": "agent", "workspaceMount": "source=crypter-pipeline-workspace,target=/work,type=volume", diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md new file mode 100644 index 00000000..8b29c331 --- /dev/null +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -0,0 +1,102 @@ +# Agentic Development Pipeline + +The `/pipeline` skill takes a requirement from a plan to a draft pull request with green checks, +using a chain of subagents that each start with their own context. It runs inside a devcontainer +built from `.devcontainer/Dockerfile`. + +Everything it does happens on **your fork**. The container's token cannot reach +`Crypter-File-Transfer/Crypter`, and the workspace is a named Docker volume rather than a bind +mount of your checkout, so the agents cannot touch uncommitted work on your machine. When the +pipeline finishes you have a fork pull request to read; opening one against the org repository is +something you do by hand afterwards. + +This document covers the setup you need before the container will start. + +## Host environment variables + +`devcontainer.json` passes these through from your machine. Set them wherever your shell reads +its environment from, before launching the container. + +| Variable | Required | Value | +|---|---|---| +| `CRYPTER_FORK` | Yes | Your fork, as `/`. Startup fails if this is the upstream repository. | +| `CRYPTER_FORK_TOKEN` | Yes | A fine-grained personal access token. Reaches the container as `GH_TOKEN`. | +| `CRYPTER_DEVCONTAINER_OWNER` | No | Only if you build your own image. See below. Defaults to `crypter-file-transfer`. | +| `CRYPTER_GIT_NAME` | No | Author name on the agents' commits. Defaults to `Crypter pipeline`. | +| `CRYPTER_GIT_EMAIL` | No | Author email. Defaults to `pipeline@users.noreply.github.com`. | + +## The token + +Create a fine-grained personal access token with access to **your fork only**. That restriction +is what makes the rest of the design hold: the agents push branches, open pull requests, and read +check results without any path to the org repository. + +Grant it these repository permissions: + +| Permission | Access | Needed for | +|---|---|---| +| Contents | Read and write | Pushing the branch | +| Pull requests | Read and write | Opening the draft pull request | +| Actions | Read | Reading check runs and failed job logs | +| Metadata | Read | Mandatory on every fine-grained token | + +## Enable Actions on your fork + +GitHub disables workflows on new forks. Until you turn them on, pushing a branch runs nothing, +and the pipeline stops at the CI stage reporting that no run ever appeared. + +Open the **Actions** tab on your fork and use the button confirming you want to run workflows. +You only do this once. + +## What is in the container + +The image is published by the org at `ghcr.io/crypter-file-transfer/crypter-devcontainer`, and +the container pulls it for you. There is nothing to build unless you are changing the image +itself. + +Built on `mcr.microsoft.com/dotnet/sdk:10.0`, running as an unprivileged user named `agent` +because Claude Code refuses `--dangerously-skip-permissions` as root: + +- The .NET 10 SDK, the `wasm-tools` workload, and `dotnet-ef` +- Node 22 and pnpm 11.18.0, which `Crypter.Web`'s PreBuild target needs +- The GitHub CLI and Claude Code + +There is **no Docker in the container**, so `Crypter.Test` cannot run there — it needs +Testcontainers to start PostgreSQL. The agents build but never test locally; the test suite runs +in CI once the pull request exists, and failures come back to the implementer from there. + +Two named volumes survive rebuilds: `crypter-pipeline-workspace` holds the clone at +`/work/Crypter`, and `crypter-pipeline-claude` holds the agent's Claude Code state. + +## First start + +On creation the container clones your fork to `/work/Crypter`, adds the org repository as a +read-only `upstream`, and fetches both. If it already finds a clone there it leaves it alone, so +rebuilding the container does not discard work in progress. + +To start over from nothing, remove the volumes and reopen the container: + +```bash +docker volume rm crypter-pipeline-workspace crypter-pipeline-claude +``` + +## Building your own image + +Only needed if your change requires a different image — a new tool the agents need, a runtime +version bump. Otherwise skip this; the org's published image is the default. + +`.github/workflows/build-and-push-devcontainer.yml` builds and pushes to +`ghcr.io//`, on pushes to `stable` touching +`.devcontainer/` and on manual dispatch. To publish from your fork: + +1. Set the repository variable `DEVCONTAINER_IMAGE_NAME` to `crypter-devcontainer`, under + **Settings → Secrets and variables → Actions → Variables**. It is a variable, not a secret. + Unset, the workflow builds a malformed image reference and tagging fails. +2. Run the workflow from the Actions tab. +3. Make the resulting package public in its package settings. Packages are private when first + pushed, and a private one needs a `docker login ghcr.io` before the container can pull it. +4. Set `CRYPTER_DEVCONTAINER_OWNER` on your host to your GitHub account name, lowercase, and + rebuild the container. + +Changes to the image belong upstream once they work. Open a pull request for `.devcontainer/` +against the org repository and unset `CRYPTER_DEVCONTAINER_OWNER` when it merges. diff --git a/README.md b/README.md index 06e27569..ec19ba97 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Check out these documents to get started working on Crypter: * [Contribution Guide](./CONTRIBUTING.md) * [Coding Standard](<./Documentation/Development/Coding Standard.md>) * [Development Environment Setup](<./Documentation/Development/Development Environment Setup.md>) +* [Agentic Development Pipeline](<./Documentation/Development/Agentic Development Pipeline.md>) Also take a look at some of the articles that have come in handy while working on the project: From 469098469ea4d303c4b578673d2b8151c7683417 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 12:25:47 -0500 Subject: [PATCH 09/41] Build the devcontainer image in pull request checks A broken Dockerfile previously surfaced after merge, when the push workflow ran on stable, or not until someone rebuilt their container. The gate is a new output on detect-code-changes rather than an on.pull_request.paths filter. A workflow filtered out by paths reports no check at all, which leaves a required check pending forever; a job skipped by an if condition still reports. Co-Authored-By: Claude Opus 5 --- .github/workflows/detect-code-changes.yml | 13 +++++++++ .github/workflows/pr-build-devcontainer.yml | 30 +++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 .github/workflows/pr-build-devcontainer.yml diff --git a/.github/workflows/detect-code-changes.yml b/.github/workflows/detect-code-changes.yml index 4aa4dbdf..aa688bb0 100644 --- a/.github/workflows/detect-code-changes.yml +++ b/.github/workflows/detect-code-changes.yml @@ -6,6 +6,9 @@ on: code: description: 'true when the event touches anything other than documentation' value: ${{ jobs.detect.outputs.code }} + devcontainer: + description: 'true when the event touches the devcontainer' + value: ${{ jobs.detect.outputs.devcontainer }} jobs: detect: @@ -15,6 +18,7 @@ jobs: outputs: code: ${{ steps.detect.outputs.code }} + devcontainer: ${{ steps.detect.outputs.devcontainer }} steps: - name: Checkout repository @@ -29,6 +33,7 @@ jobs: base_sha: ${{ github.event.pull_request.base.sha }} head_sha: ${{ github.event.pull_request.head.sha }} documentation: '(\.md$|^Documentation/|^\.github/ISSUE_TEMPLATE/)' + devcontainer: '(^\.devcontainer/|^\.github/workflows/pr-build-devcontainer\.yml$)' run: | set -euo pipefail @@ -36,6 +41,7 @@ jobs: # the scheduled CodeQL analysis, has to assume code is in scope. if [ "$event_name" != 'pull_request' ]; then echo "code=true" >> "$GITHUB_OUTPUT" + echo "devcontainer=true" >> "$GITHUB_OUTPUT" exit 0 fi @@ -51,3 +57,10 @@ jobs: else echo "code=true" >> "$GITHUB_OUTPUT" fi + + if [ -n "$changed_files" ] && ! echo "$changed_files" | grep -qE "$devcontainer"; then + echo "The devcontainer is untouched." + echo "devcontainer=false" >> "$GITHUB_OUTPUT" + else + echo "devcontainer=true" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/workflows/pr-build-devcontainer.yml b/.github/workflows/pr-build-devcontainer.yml new file mode 100644 index 00000000..c187ec62 --- /dev/null +++ b/.github/workflows/pr-build-devcontainer.yml @@ -0,0 +1,30 @@ +name: Build devcontainer image + +on: + pull_request: + branches: [ main, stable ] + +jobs: + changes: + uses: ./.github/workflows/detect-code-changes.yml + + build-devcontainer: + + needs: changes + if: needs.changes.outputs.devcontainer == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Build image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + file: ./.devcontainer/Dockerfile + platforms: linux/amd64 + push: false From 1a2adfead1d4f944e912ed588d73a7c3e55426a9 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 13:23:44 -0500 Subject: [PATCH 10/41] Correct the list of workflows the CI watcher expects There are five, not four, and all of them gate on detect-code-changes rather than only the two image builds. An agent expecting four checks reads the fifth as something having gone wrong. Co-Authored-By: Claude Opus 5 --- .claude/agents/ci-watcher.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index 18d7c5f2..2d0896da 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -47,9 +47,11 @@ gh pr checks --repo --watch Give it a generous timeout — a full build plus the test suite is slow, and a watch you kill early looks exactly like a failure. -Four workflows run on a pull request: `unit-test`, `codeql-analysis`, `pr-build-api` and -`pr-build-web`. The last two are gated on `detect-code-changes` and skip entirely when the -diff is documentation only. A skipped check is a pass. +Five workflows run on a pull request: `unit-test`, `codeql-analysis`, `pr-build-api`, +`pr-build-web` and `pr-build-devcontainer`. Every one of them gates on `detect-code-changes`, +so each contributes a `changes / detect` job of its own. The first four skip when the diff is +documentation only; the devcontainer build skips unless the diff touches `.devcontainer/`. A +skipped check is a pass. ## On failure From 233ceb60f0527af0c357edc83a06cf9f09216669 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 16:34:09 -0500 Subject: [PATCH 11/41] List the CI watcher's expected checks by job name `gh pr checks` reports job names, not workflow names, so the previous list matched nothing the agent would actually see. It also missed build-and-test-web entirely: unit-test.yml runs two jobs, and that is the one covering Crypter.Test.Web, which sits outside Crypter.Test's project graph and so cannot be built inside the container. Co-Authored-By: Claude Opus 5 --- .claude/agents/ci-watcher.md | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index 2d0896da..7f2927a3 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -47,11 +47,26 @@ gh pr checks --repo --watch Give it a generous timeout — a full build plus the test suite is slow, and a watch you kill early looks exactly like a failure. -Five workflows run on a pull request: `unit-test`, `codeql-analysis`, `pr-build-api`, -`pr-build-web` and `pr-build-devcontainer`. Every one of them gates on `detect-code-changes`, -so each contributes a `changes / detect` job of its own. The first four skip when the diff is -documentation only; the devcontainer build skips unless the diff touches `.devcontainer/`. A -skipped check is a pass. +Five workflows run on a pull request, and `gh pr checks` reports them by job name rather than +by workflow name. Expect these: + +| Check | Skips when | +|---|---| +| `changes / detect` | Never. Every workflow gates on `detect-code-changes`, so there are five of these. | +| `build-and-test` | The diff is documentation only | +| `build-and-test-web` | The diff is documentation only | +| `Analyze (csharp)` | The diff is documentation only | +| `Analyze (javascript)` | The diff is documentation only | +| `build-api` | The diff is documentation only | +| `build-web` | The diff is documentation only | +| `build-devcontainer` | The diff does not touch `.devcontainer/` | + +A skipped check is a pass. The CodeQL action also posts a short `CodeQL` summary check +alongside the two `Analyze` jobs. + +`build-and-test-web` is the one to look at twice. It compiles `Crypter.Test.Web`, which is +outside `Crypter.Test`'s project graph, so it is where a compile error the implementer could +not have caught locally shows up. ## On failure From 5a0e41c338e72cf48f9c53c8099ff820a202353f Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 16:34:27 -0500 Subject: [PATCH 12/41] Give the implementer an explicit tool list Omitting `tools` grants everything, including Agent. An implementer that can spawn its own subagents undercuts the premise the pipeline is built on, which is that each stage starts from a context that did not write the work it is looking at. Co-Authored-By: Claude Opus 5 --- .claude/agents/implementer.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index 79177bcb..8788b6c4 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -1,6 +1,7 @@ --- name: implementer description: Implement an approved plan in Crypter, or apply accepted review findings and CI fixes. Used as stages 2, 6, and the CI loop of the /pipeline skill. +tools: Read, Grep, Glob, Bash, Write, Edit model: opus effort: high color: green From 4fc45fe16976f955ef233c11230afb5bf210baf7 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 16:35:13 -0500 Subject: [PATCH 13/41] Build the devcontainer when its publish workflow changes build-and-push-devcontainer.yml filters itself into its own push trigger but was absent from the pull request gate, so edits to it merged without ever building the image they publish. Co-Authored-By: Claude Opus 5 --- .github/workflows/detect-code-changes.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/detect-code-changes.yml b/.github/workflows/detect-code-changes.yml index aa688bb0..7abb3b5e 100644 --- a/.github/workflows/detect-code-changes.yml +++ b/.github/workflows/detect-code-changes.yml @@ -33,7 +33,7 @@ jobs: base_sha: ${{ github.event.pull_request.base.sha }} head_sha: ${{ github.event.pull_request.head.sha }} documentation: '(\.md$|^Documentation/|^\.github/ISSUE_TEMPLATE/)' - devcontainer: '(^\.devcontainer/|^\.github/workflows/pr-build-devcontainer\.yml$)' + devcontainer: '(^\.devcontainer/|^\.github/workflows/(pr-build|build-and-push)-devcontainer\.yml$)' run: | set -euo pipefail From c0049e28cb3718b0cd3c1ea72f56c2c375425817 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 16:40:05 -0500 Subject: [PATCH 14/41] Hard-code the devcontainer image name API_IMAGE_NAME and WEB_IMAGE_NAME are variables because deployments vary. The devcontainer image has one consumer, devcontainer.json, which names it literally, so the only value DEVCONTAINER_IMAGE_NAME could hold was the one now in the workflow. Unset it produced a malformed reference; set wrong it published an image nothing would pull. Setting up a fork is one step shorter as a result. Co-Authored-By: Claude Opus 5 --- .github/workflows/build-and-push-devcontainer.yml | 2 +- .../Development/Agentic Development Pipeline.md | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-and-push-devcontainer.yml b/.github/workflows/build-and-push-devcontainer.yml index 182181a6..22e06072 100644 --- a/.github/workflows/build-and-push-devcontainer.yml +++ b/.github/workflows/build-and-push-devcontainer.yml @@ -38,7 +38,7 @@ jobs: id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: - images: ${{ env.registry }}/${{ vars.DEVCONTAINER_IMAGE_NAME }} + images: ${{ env.registry }}/crypter-devcontainer tags: | type=raw,value=latest type=sha,format=short diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 8b29c331..5c5a7565 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -86,16 +86,13 @@ Only needed if your change requires a different image — a new tool the agents version bump. Otherwise skip this; the org's published image is the default. `.github/workflows/build-and-push-devcontainer.yml` builds and pushes to -`ghcr.io//`, on pushes to `stable` touching +`ghcr.io//crypter-devcontainer`, on pushes to `stable` touching `.devcontainer/` and on manual dispatch. To publish from your fork: -1. Set the repository variable `DEVCONTAINER_IMAGE_NAME` to `crypter-devcontainer`, under - **Settings → Secrets and variables → Actions → Variables**. It is a variable, not a secret. - Unset, the workflow builds a malformed image reference and tagging fails. -2. Run the workflow from the Actions tab. -3. Make the resulting package public in its package settings. Packages are private when first +1. Run the workflow from the Actions tab. +2. Make the resulting package public in its package settings. Packages are private when first pushed, and a private one needs a `docker login ghcr.io` before the container can pull it. -4. Set `CRYPTER_DEVCONTAINER_OWNER` on your host to your GitHub account name, lowercase, and +3. Set `CRYPTER_DEVCONTAINER_OWNER` on your host to your GitHub account name, lowercase, and rebuild the container. Changes to the image belong upstream once they work. Open a pull request for `.devcontainer/` From 92c2309017174af311dcb103cadf41d7f9cd8ce5 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 16:40:46 -0500 Subject: [PATCH 15/41] Document authenticating Claude Code in the devcontainer The setup document covered the fork, the token and enabling Actions, then stopped short of the one credential the pipeline cannot run without. The crypter-pipeline-claude volume exists to persist it and nothing said so. Co-Authored-By: Claude Opus 5 --- .../Development/Agentic Development Pipeline.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 5c5a7565..14bbbc5f 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -80,6 +80,20 @@ To start over from nothing, remove the volumes and reopen the container: docker volume rm crypter-pipeline-workspace crypter-pipeline-claude ``` +## Authenticate Claude Code + +The image ships Claude Code but no credentials. Run `claude` once and follow the login prompt. +The container has no browser, so the flow gives you a URL to open on your host and a code to +paste back. + +Credentials live in `/home/agent/.claude`, which is the `crypter-pipeline-claude` volume, so +they survive container rebuilds. You only do this again after removing that volume. + +Run the agents with `--dangerously-skip-permissions`. A pipeline that stops to approve every +file write is not a pipeline, and the fork-scoped token is what bounds the blast radius rather +than the permission prompts. That flag is also why the container runs as the unprivileged +`agent` user; Claude Code refuses it as root. + ## Building your own image Only needed if your change requires a different image — a new tool the agents need, a runtime From 6ca985cc465a88bde1b0cedd8101704ca63fe9e2 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Mon, 3 Aug 2026 23:27:11 -0500 Subject: [PATCH 16/41] Run the pipeline devcontainer with Docker Compose The container was described only by devcontainer.json, which needs a separate tool to interpret it and left the setup documentation with no instruction for starting anything. Compose is already a dependency of this repository, so the container can be described the way the application stack is, with its values in a tracked .env that reads like the root one. devcontainer.json goes rather than sitting alongside, so there is one definition to keep correct. It is a separate Compose project rather than a service in the root file. The agents only ever run dotnet build, git and gh, so they need nothing from the api, web or db services, and sharing a file would put the container on the application stack's network and leave a root `compose down` unable to remove a network still in use. crypter-clone-fork now runs on every start rather than once at creation. It already leaves an existing workspace alone and only refetches, so work in progress still survives a restart. The image bakes in clone-fork.sh, so its corrected guard messages need an image rebuild to take effect. --- .devcontainer/.env | 5 ++ .devcontainer/clone-fork.sh | 8 ++-- .devcontainer/devcontainer.json | 22 --------- .devcontainer/docker-compose.yml | 30 ++++++++++++ .../Agentic Development Pipeline.md | 46 +++++++++++++------ 5 files changed, 72 insertions(+), 39 deletions(-) create mode 100644 .devcontainer/.env delete mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml diff --git a/.devcontainer/.env b/.devcontainer/.env new file mode 100644 index 00000000..3e48e853 --- /dev/null +++ b/.devcontainer/.env @@ -0,0 +1,5 @@ +CRYPTER_DEVCONTAINER_OWNER="" +CRYPTER_FORK="" +CRYPTER_FORK_TOKEN="" +CRYPTER_GIT_EMAIL="" +CRYPTER_GIT_NAME="" diff --git a/.devcontainer/clone-fork.sh b/.devcontainer/clone-fork.sh index af18b3ed..803a51c3 100644 --- a/.devcontainer/clone-fork.sh +++ b/.devcontainer/clone-fork.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash # Prepare the pipeline workspace: a clone of your fork, with the org repository added as a -# read-only upstream. The devcontainer runs this once, when the container is created. +# read-only upstream. The container runs this on every start; an existing workspace is left alone. # # The workspace is a named volume rather than a bind mount of a host checkout. The agents # get their own clone, so they cannot touch uncommitted work on the host, and `origin` is # the fork that the container's fork-scoped token can actually push to. set -euo pipefail -: "${CRYPTER_FORK:?Set CRYPTER_FORK on the host to / of your fork}" -: "${GH_TOKEN:?Set CRYPTER_FORK_TOKEN on the host so it reaches the container as GH_TOKEN}" +: "${CRYPTER_FORK:?Set CRYPTER_FORK in .devcontainer/.env to / of your fork}" +: "${GH_TOKEN:?Set CRYPTER_FORK_TOKEN in .devcontainer/.env so it reaches the container as GH_TOKEN}" upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" -# Has to match workspaceFolder in devcontainer.json. +# Has to match the workspace path the pipeline skill and docker-compose.yml use. workspace="/work/Crypter" if [[ "${CRYPTER_FORK}" == "${upstream_repo}" ]]; then diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index a2d10a50..00000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "Crypter agentic pipeline", - "image": "ghcr.io/${localEnv:CRYPTER_DEVCONTAINER_OWNER:crypter-file-transfer}/crypter-devcontainer:latest", - "remoteUser": "agent", - - "workspaceMount": "source=crypter-pipeline-workspace,target=/work,type=volume", - "workspaceFolder": "/work/Crypter", - - "containerEnv": { - "GH_TOKEN": "${localEnv:CRYPTER_FORK_TOKEN}", - "CRYPTER_FORK": "${localEnv:CRYPTER_FORK}", - "CRYPTER_UPSTREAM": "Crypter-File-Transfer/Crypter", - "CRYPTER_GIT_NAME": "${localEnv:CRYPTER_GIT_NAME}", - "CRYPTER_GIT_EMAIL": "${localEnv:CRYPTER_GIT_EMAIL}" - }, - - "mounts": [ - "source=crypter-pipeline-claude,target=/home/agent/.claude,type=volume" - ], - - "onCreateCommand": "crypter-clone-fork" -} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..fbe5719f --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,30 @@ +name: crypter-pipeline + +services: + pipeline: + container_name: crypter-pipeline + image: ghcr.io/${CRYPTER_DEVCONTAINER_OWNER:-crypter-file-transfer}/crypter-devcontainer:latest + build: + context: .. + dockerfile: .devcontainer/Dockerfile + environment: + GH_TOKEN: ${CRYPTER_FORK_TOKEN} + CRYPTER_FORK: ${CRYPTER_FORK} + CRYPTER_UPSTREAM: Crypter-File-Transfer/Crypter + CRYPTER_GIT_NAME: ${CRYPTER_GIT_NAME:-Crypter pipeline} + CRYPTER_GIT_EMAIL: ${CRYPTER_GIT_EMAIL:-pipeline@users.noreply.github.com} + volumes: + - workspace:/work + - claude:/home/agent/.claude + # /work/Crypter does not exist until crypter-clone-fork has run, so the container starts + # one level up. Open a shell with `exec -w /work/Crypter`. + working_dir: /work + # crypter-clone-fork leaves an existing workspace alone and only refetches, so running it + # on every start is safe. + command: bash -lc "crypter-clone-fork && sleep infinity" + +volumes: + workspace: + name: crypter-pipeline-workspace + claude: + name: crypter-pipeline-claude diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 14bbbc5f..2573a690 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -12,10 +12,11 @@ something you do by hand afterwards. This document covers the setup you need before the container will start. -## Host environment variables +## Configuration -`devcontainer.json` passes these through from your machine. Set them wherever your shell reads -its environment from, before launching the container. +`.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is +tracked with empty placeholders, the same way the root `.env` is. Fill it in before the first +`up`. | Variable | Required | Value | |---|---|---| @@ -25,6 +26,23 @@ its environment from, before launching the container. | `CRYPTER_GIT_NAME` | No | Author name on the agents' commits. Defaults to `Crypter pipeline`. | | `CRYPTER_GIT_EMAIL` | No | Author email. Defaults to `pipeline@users.noreply.github.com`. | +Leave the optional ones empty to take their defaults. The token is a live credential sitting in +a tracked file, so watch what you stage. + +## Launching the container + +The container is a Compose service in `.devcontainer/docker-compose.yml`. That is a separate +Compose project from the application stack at the repository root, so `docker compose up` and +`docker compose down` there never touch it, and the two share no network. + +```bash +docker compose -f .devcontainer/docker-compose.yml up -d +docker compose -f .devcontainer/docker-compose.yml exec -w /work/Crypter pipeline bash +``` + +Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` +reuses the workspace and your Claude Code credentials. + ## The token Create a fine-grained personal access token with access to **your fork only**. That restriction @@ -65,26 +83,28 @@ There is **no Docker in the container**, so `Crypter.Test` cannot run there — Testcontainers to start PostgreSQL. The agents build but never test locally; the test suite runs in CI once the pull request exists, and failures come back to the implementer from there. -Two named volumes survive rebuilds: `crypter-pipeline-workspace` holds the clone at +Two named volumes survive rebuilds: `crypter-pipeline-workspace` holds the workspace at `/work/Crypter`, and `crypter-pipeline-claude` holds the agent's Claude Code state. ## First start -On creation the container clones your fork to `/work/Crypter`, adds the org repository as a -read-only `upstream`, and fetches both. If it already finds a clone there it leaves it alone, so -rebuilding the container does not discard work in progress. +Every `up` runs `crypter-clone-fork`, which clones your fork to `/work/Crypter`, adds the org +repository as a read-only `upstream`, and fetches both. If it already finds a workspace there it +leaves it alone and only refetches, so restarting the container does not discard work in +progress. -To start over from nothing, remove the volumes and reopen the container: +To start over from nothing, take the container down and remove the volumes: ```bash +docker compose -f .devcontainer/docker-compose.yml down docker volume rm crypter-pipeline-workspace crypter-pipeline-claude ``` ## Authenticate Claude Code -The image ships Claude Code but no credentials. Run `claude` once and follow the login prompt. -The container has no browser, so the flow gives you a URL to open on your host and a code to -paste back. +The image ships Claude Code but no credentials. Run `claude` once inside the container and +follow the login prompt. The container has no browser, so the flow gives you a URL to open on +your host and a code to paste back. Credentials live in `/home/agent/.claude`, which is the `crypter-pipeline-claude` volume, so they survive container rebuilds. You only do this again after removing that volume. @@ -106,8 +126,8 @@ version bump. Otherwise skip this; the org's published image is the default. 1. Run the workflow from the Actions tab. 2. Make the resulting package public in its package settings. Packages are private when first pushed, and a private one needs a `docker login ghcr.io` before the container can pull it. -3. Set `CRYPTER_DEVCONTAINER_OWNER` on your host to your GitHub account name, lowercase, and - rebuild the container. +3. Set `CRYPTER_DEVCONTAINER_OWNER` in `.devcontainer/.env` to your GitHub account name, + lowercase, and rebuild the container. Changes to the image belong upstream once they work. Open a pull request for `.devcontainer/` against the org repository and unset `CRYPTER_DEVCONTAINER_OWNER` when it merges. From 801046c17fd71375a4a7480cd707e87ad8acdae4 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Tue, 4 Aug 2026 10:48:38 -0500 Subject: [PATCH 17/41] Gate the devcontainer image publish behind an approval The publish workflow pushed to GHCR as soon as anything under .devcontainer/ landed on stable. It now runs in the devcontainer environment, so a reviewer approves the run before the image moves. Publishing from a fork is not supported, so its instructions are gone along with CRYPTER_DEVCONTAINER_OWNER, which existed only to point the container at a fork's image. Testing an image change is now a local build. Co-Authored-By: Claude Opus 5 --- .devcontainer/.env | 1 - .devcontainer/docker-compose.yml | 2 +- .../workflows/build-and-push-devcontainer.yml | 2 ++ .../Agentic Development Pipeline.md | 25 +++++++++---------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.devcontainer/.env b/.devcontainer/.env index 3e48e853..49a7dc62 100644 --- a/.devcontainer/.env +++ b/.devcontainer/.env @@ -1,4 +1,3 @@ -CRYPTER_DEVCONTAINER_OWNER="" CRYPTER_FORK="" CRYPTER_FORK_TOKEN="" CRYPTER_GIT_EMAIL="" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index fbe5719f..463f7720 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -3,7 +3,7 @@ name: crypter-pipeline services: pipeline: container_name: crypter-pipeline - image: ghcr.io/${CRYPTER_DEVCONTAINER_OWNER:-crypter-file-transfer}/crypter-devcontainer:latest + image: ghcr.io/crypter-file-transfer/crypter-devcontainer:latest build: context: .. dockerfile: .devcontainer/Dockerfile diff --git a/.github/workflows/build-and-push-devcontainer.yml b/.github/workflows/build-and-push-devcontainer.yml index 22e06072..c0e9bda9 100644 --- a/.github/workflows/build-and-push-devcontainer.yml +++ b/.github/workflows/build-and-push-devcontainer.yml @@ -19,6 +19,8 @@ jobs: permissions: contents: read packages: write + environment: + name: devcontainer steps: - name: Checkout repository diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 2573a690..17d97423 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -22,7 +22,6 @@ tracked with empty placeholders, the same way the root `.env` is. Fill it in bef |---|---|---| | `CRYPTER_FORK` | Yes | Your fork, as `/`. Startup fails if this is the upstream repository. | | `CRYPTER_FORK_TOKEN` | Yes | A fine-grained personal access token. Reaches the container as `GH_TOKEN`. | -| `CRYPTER_DEVCONTAINER_OWNER` | No | Only if you build your own image. See below. Defaults to `crypter-file-transfer`. | | `CRYPTER_GIT_NAME` | No | Author name on the agents' commits. Defaults to `Crypter pipeline`. | | `CRYPTER_GIT_EMAIL` | No | Author email. Defaults to `pipeline@users.noreply.github.com`. | @@ -114,20 +113,20 @@ file write is not a pipeline, and the fork-scoped token is what bounds the blast than the permission prompts. That flag is also why the container runs as the unprivileged `agent` user; Claude Code refuses it as root. -## Building your own image +## Changing the image Only needed if your change requires a different image — a new tool the agents need, a runtime -version bump. Otherwise skip this; the org's published image is the default. +version bump. Otherwise skip this; the published image is what the container runs. -`.github/workflows/build-and-push-devcontainer.yml` builds and pushes to -`ghcr.io//crypter-devcontainer`, on pushes to `stable` touching -`.devcontainer/` and on manual dispatch. To publish from your fork: +Build your change locally to try it: -1. Run the workflow from the Actions tab. -2. Make the resulting package public in its package settings. Packages are private when first - pushed, and a private one needs a `docker login ghcr.io` before the container can pull it. -3. Set `CRYPTER_DEVCONTAINER_OWNER` in `.devcontainer/.env` to your GitHub account name, - lowercase, and rebuild the container. +```bash +docker compose -f .devcontainer/docker-compose.yml build +docker compose -f .devcontainer/docker-compose.yml up -d +``` -Changes to the image belong upstream once they work. Open a pull request for `.devcontainer/` -against the org repository and unset `CRYPTER_DEVCONTAINER_OWNER` when it merges. +Open a pull request for `.devcontainer/` once it works. `pr-build-devcontainer` builds the image +on the pull request, and merging to `stable` runs +`.github/workflows/build-and-push-devcontainer.yml`, which pushes to +`ghcr.io/crypter-file-transfer/crypter-devcontainer`. That job runs in the `devcontainer` +environment, so it waits for a reviewer to approve it before anything is published. From dd22b2bd8316731020da8e718e022540a9198f6c Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Tue, 4 Aug 2026 11:55:24 -0500 Subject: [PATCH 18/41] Pull the devcontainer image instead of building it A build section makes Compose build the image when it is absent locally, without ever contacting the registry, so every first start built the image from scratch. Declaring the pull policy restores the pull and leaves `compose build` available for image work. Co-Authored-By: Claude Opus 5 --- .devcontainer/docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 463f7720..0178fb57 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -4,6 +4,7 @@ services: pipeline: container_name: crypter-pipeline image: ghcr.io/crypter-file-transfer/crypter-devcontainer:latest + pull_policy: missing build: context: .. dockerfile: .devcontainer/Dockerfile From 3a55c5d5acb567df01282ed9eadee6b741e989c6 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Tue, 4 Aug 2026 18:34:44 +0000 Subject: [PATCH 19/41] Stop tracking .devcontainer/.env and ship a template instead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRYPTER_FORK_TOKEN is a live GitHub personal access token, and while the file was tracked the only thing keeping a filled-in token out of a commit was the contributor noticing what they staged. Git now ignores the file, so it cannot be committed by accident, and .devcontainer/.env.example carries the four keys to copy from. Anyone who already has a filled-in .devcontainer/.env keeps it: the file stays on disk here, only its tracking stops. Pulling this commit with a modified copy aborts the merge, and untracking alone is not enough — git then refuses to remove what has become an untracked file. Untrack it and commit that removal before pulling: git rm --cached .devcontainer/.env git commit -m "Untrack devcontainer env" git pull The merge then succeeds, the file and its token survive, and the tree is clean. The ignore rule is anchored to .devcontainer, so the root .env stays tracked. --- .devcontainer/.env | 4 ---- .devcontainer/.env.example | 6 ++++++ .gitignore | 5 ++++- .../Development/Agentic Development Pipeline.md | 13 ++++++++----- 4 files changed, 18 insertions(+), 10 deletions(-) delete mode 100644 .devcontainer/.env create mode 100644 .devcontainer/.env.example diff --git a/.devcontainer/.env b/.devcontainer/.env deleted file mode 100644 index 49a7dc62..00000000 --- a/.devcontainer/.env +++ /dev/null @@ -1,4 +0,0 @@ -CRYPTER_FORK="" -CRYPTER_FORK_TOKEN="" -CRYPTER_GIT_EMAIL="" -CRYPTER_GIT_NAME="" diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 00000000..50894aae --- /dev/null +++ b/.devcontainer/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .devcontainer/.env and fill it in before the first `up`. +# Documentation/Development/Agentic Development Pipeline.md explains each value. +CRYPTER_FORK="" +CRYPTER_FORK_TOKEN="" +CRYPTER_GIT_EMAIL="" +CRYPTER_GIT_NAME="" diff --git a/.gitignore b/.gitignore index 3d2725a2..f4262e8e 100644 --- a/.gitignore +++ b/.gitignore @@ -463,4 +463,7 @@ Crypter.Web/pnpm-lock.yaml .claude/worktrees/ # Agentic pipeline run state -.claude/pipeline/ \ No newline at end of file +.claude/pipeline/ + +# Devcontainer configuration, copied from .devcontainer/.env.example +.devcontainer/.env diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 17d97423..354d3ae9 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -14,9 +14,12 @@ This document covers the setup you need before the container will start. ## Configuration -`.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is -tracked with empty placeholders, the same way the root `.env` is. Fill it in before the first -`up`. +`.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is not +tracked. Copy the template and fill it in before the first `up`. + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` | Variable | Required | Value | |---|---|---| @@ -25,8 +28,8 @@ tracked with empty placeholders, the same way the root `.env` is. Fill it in bef | `CRYPTER_GIT_NAME` | No | Author name on the agents' commits. Defaults to `Crypter pipeline`. | | `CRYPTER_GIT_EMAIL` | No | Author email. Defaults to `pipeline@users.noreply.github.com`. | -Leave the optional ones empty to take their defaults. The token is a live credential sitting in -a tracked file, so watch what you stage. +Leave the optional ones empty to take their defaults. The token is a live credential, and +`.devcontainer/.env` is ignored by git so it cannot be committed by accident. ## Launching the container From cda70e99de3ed93f402ed2a382abac518868a9f4 Mon Sep 17 00:00:00 2001 From: Jack Edwards Date: Tue, 4 Aug 2026 18:40:14 +0000 Subject: [PATCH 20/41] Warn about untracking devcontainer env before pulling A contributor who already has a filled-in .devcontainer/.env hits a modify/delete conflict on the pull, and git's printed resolution, git rm .devcontainer/.env, deletes their live token. The doc now gives the git rm --cached sequence to run before pulling instead. The same section claimed the file is ignored by git so the token cannot be committed by accident. That holds only on a fresh checkout: .gitignore does nothing for an already-tracked path, so any branch cut before this change still commits the token as before. The claim is now scoped to fresh checkouts. --- .../Agentic Development Pipeline.md | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 354d3ae9..d4ced85b 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -28,8 +28,24 @@ cp .devcontainer/.env.example .devcontainer/.env | `CRYPTER_GIT_NAME` | No | Author name on the agents' commits. Defaults to `Crypter pipeline`. | | `CRYPTER_GIT_EMAIL` | No | Author email. Defaults to `pipeline@users.noreply.github.com`. | -Leave the optional ones empty to take their defaults. The token is a live credential, and -`.devcontainer/.env` is ignored by git so it cannot be committed by accident. +Leave the optional ones empty to take their defaults. The token is a live credential, and on a +fresh checkout `.devcontainer/.env` is ignored by git, so it cannot be committed by accident +there. A branch cut before the file stopped being tracked still tracks it, and a token committed +on such a branch commits as it always did. + +If you already have a filled-in `.devcontainer/.env` from when the file was tracked, untrack it +before you pull the commit that stops tracking it. Pulling first gives you `CONFLICT +(modify/delete): .devcontainer/.env deleted in and modified in HEAD`, and git's own +suggested resolution, `git rm .devcontainer/.env`, throws away your token along with the file. +Instead, in your existing checkout: + +```bash +git rm --cached .devcontainer/.env +git commit -m "Untrack devcontainer env" +git pull +``` + +The pull then succeeds, and the file and its token stay on disk. ## Launching the container From cf05c0f4976f9be16ff04b49c5109a3681f0c5b7 Mon Sep 17 00:00:00 2001 From: n Date: Tue, 4 Aug 2026 13:58:00 -0500 Subject: [PATCH 21/41] Require every devcontainer environment variable CRYPTER_GIT_NAME and CRYPTER_GIT_EMAIL fell back to a generic name and a noreply address, so an unconfigured container pushed commits to a contributor's fork under an identity belonging to nobody. Both are now guarded in clone-fork.sh the same way CRYPTER_FORK and GH_TOKEN already were, and Compose passes them through unsubstituted. The setup document's notes on moving off the tracked file described a migration rather than the setup as it stands, so they are gone along with the qualifications about branches cut before the file stopped being tracked. Co-Authored-By: Claude Opus 5 --- .devcontainer/.env.example | 2 -- .devcontainer/clone-fork.sh | 6 ++-- .devcontainer/docker-compose.yml | 4 +-- .../Agentic Development Pipeline.md | 36 ++++++------------- 4 files changed, 16 insertions(+), 32 deletions(-) diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 50894aae..49a7dc62 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,5 +1,3 @@ -# Copy this file to .devcontainer/.env and fill it in before the first `up`. -# Documentation/Development/Agentic Development Pipeline.md explains each value. CRYPTER_FORK="" CRYPTER_FORK_TOKEN="" CRYPTER_GIT_EMAIL="" diff --git a/.devcontainer/clone-fork.sh b/.devcontainer/clone-fork.sh index 803a51c3..82aee0c0 100644 --- a/.devcontainer/clone-fork.sh +++ b/.devcontainer/clone-fork.sh @@ -9,6 +9,8 @@ set -euo pipefail : "${CRYPTER_FORK:?Set CRYPTER_FORK in .devcontainer/.env to / of your fork}" : "${GH_TOKEN:?Set CRYPTER_FORK_TOKEN in .devcontainer/.env so it reaches the container as GH_TOKEN}" +: "${CRYPTER_GIT_NAME:?Set CRYPTER_GIT_NAME in .devcontainer/.env to the author name on the commits}" +: "${CRYPTER_GIT_EMAIL:?Set CRYPTER_GIT_EMAIL in .devcontainer/.env to the author email on the commits}" upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" @@ -20,8 +22,8 @@ if [[ "${CRYPTER_FORK}" == "${upstream_repo}" ]]; then exit 1 fi -git config --global user.name "${CRYPTER_GIT_NAME:-Crypter pipeline}" -git config --global user.email "${CRYPTER_GIT_EMAIL:-pipeline@users.noreply.github.com}" +git config --global user.name "${CRYPTER_GIT_NAME}" +git config --global user.email "${CRYPTER_GIT_EMAIL}" gh auth setup-git if [[ -d "${workspace}/.git" ]]; then diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 0178fb57..1178ebfa 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -12,8 +12,8 @@ services: GH_TOKEN: ${CRYPTER_FORK_TOKEN} CRYPTER_FORK: ${CRYPTER_FORK} CRYPTER_UPSTREAM: Crypter-File-Transfer/Crypter - CRYPTER_GIT_NAME: ${CRYPTER_GIT_NAME:-Crypter pipeline} - CRYPTER_GIT_EMAIL: ${CRYPTER_GIT_EMAIL:-pipeline@users.noreply.github.com} + CRYPTER_GIT_NAME: ${CRYPTER_GIT_NAME} + CRYPTER_GIT_EMAIL: ${CRYPTER_GIT_EMAIL} volumes: - workspace:/work - claude:/home/agent/.claude diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index d4ced85b..d00c5f96 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -14,38 +14,22 @@ This document covers the setup you need before the container will start. ## Configuration -`.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is not -tracked. Copy the template and fill it in before the first `up`. +`.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is +ignored by git. Copy the template and fill it in before the first `up`. ```bash cp .devcontainer/.env.example .devcontainer/.env ``` -| Variable | Required | Value | -|---|---|---| -| `CRYPTER_FORK` | Yes | Your fork, as `/`. Startup fails if this is the upstream repository. | -| `CRYPTER_FORK_TOKEN` | Yes | A fine-grained personal access token. Reaches the container as `GH_TOKEN`. | -| `CRYPTER_GIT_NAME` | No | Author name on the agents' commits. Defaults to `Crypter pipeline`. | -| `CRYPTER_GIT_EMAIL` | No | Author email. Defaults to `pipeline@users.noreply.github.com`. | - -Leave the optional ones empty to take their defaults. The token is a live credential, and on a -fresh checkout `.devcontainer/.env` is ignored by git, so it cannot be committed by accident -there. A branch cut before the file stopped being tracked still tracks it, and a token committed -on such a branch commits as it always did. - -If you already have a filled-in `.devcontainer/.env` from when the file was tracked, untrack it -before you pull the commit that stops tracking it. Pulling first gives you `CONFLICT -(modify/delete): .devcontainer/.env deleted in and modified in HEAD`, and git's own -suggested resolution, `git rm .devcontainer/.env`, throws away your token along with the file. -Instead, in your existing checkout: - -```bash -git rm --cached .devcontainer/.env -git commit -m "Untrack devcontainer env" -git pull -``` +| Variable | Value | +|---|---| +| `CRYPTER_FORK` | Your fork, as `/`. Startup fails if this is the upstream repository. | +| `CRYPTER_FORK_TOKEN` | A fine-grained personal access token. Reaches the container as `GH_TOKEN`. | +| `CRYPTER_GIT_NAME` | Author name on the agents' commits. | +| `CRYPTER_GIT_EMAIL` | Author email on the agents' commits. | -The pull then succeeds, and the file and its token stay on disk. +All four are required. Leaving one empty fails the container's startup script with a message +naming the variable. ## Launching the container From 9fbf02bcfc6b67e932dfc538b9c71e66e29a7d74 Mon Sep 17 00:00:00 2001 From: n Date: Tue, 4 Aug 2026 14:37:43 -0500 Subject: [PATCH 22/41] Author pipeline plans in an interactive host session The plan now comes from /crypter-plan-author, a skill an interactive session runs on the host, where the web, the user's tooling and the user are all reachable. Compose mounts .claude/plans read-only at /plans, so the container reads the plan where it was written and keeps its own run state in the workspace. /pipeline takes a run id and a branch and starts at the sync, with the host session driving it once the plan is approved. Co-Authored-By: Claude Opus 5 --- .claude/agents/plan-author.md | 66 ------------- .claude/skills/crypter-plan-author/SKILL.md | 96 +++++++++++++++++++ .claude/skills/pipeline/SKILL.md | 54 +++++------ .devcontainer/docker-compose.yml | 2 + .gitignore | 3 + .../Agentic Development Pipeline.md | 26 ++++- 6 files changed, 145 insertions(+), 102 deletions(-) delete mode 100644 .claude/agents/plan-author.md create mode 100644 .claude/skills/crypter-plan-author/SKILL.md diff --git a/.claude/agents/plan-author.md b/.claude/agents/plan-author.md deleted file mode 100644 index f8221d9e..00000000 --- a/.claude/agents/plan-author.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -name: plan-author -description: Turn a requirement into an implementation plan for Crypter. Used as stage 1 of the /pipeline skill; not for ad-hoc planning. -tools: Read, Grep, Glob, Bash, WebFetch, Write -model: opus -effort: high -color: blue ---- - -# Plan author - -You turn a requirement into a plan another agent will implement without ever speaking to -you. It will see your plan and nothing else — not your reasoning, not the files you read, -not the alternatives you rejected. Write for that reader. - -You are given a requirement, a worktree path, and an output path. Read the code, write the -plan to the output path, and report a one-paragraph summary. **Write nothing else.** You do -not implement, and you do not create branches or commits. - -## Understand before deciding - -Read `CLAUDE.md` and `Documentation/Development/Coding Standard.md` first. Then read the -code the requirement touches, and the code around it — the existing patterns are the ones -the implementation must match. - -Prefer reusing what exists over introducing something new. If a monad, primitive, service, -or extension already does most of the job, name it in the plan with its path. - -## What the plan must contain - -Write it to the given path as Markdown: - -- **Goal** — one paragraph. What changes for a user of Crypter, and why. -- **Non-goals** — what this change deliberately does not do. Be specific; this is what - keeps the implementer from wandering, and what the conformance auditor checks against. -- **Approach** — the design, in prose. Name the types and methods to add or change. Explain - anything non-obvious, especially where a constraint forced the shape. -- **Steps** — numbered and ordered, each naming the files it touches. A step should be small - enough that its result is obvious. -- **Tests** — what to add to `Crypter.Test` or `Crypter.Test.Web` and what each case pins - down. The pipeline does not run tests locally, so untested behaviour is unverified until - CI runs. -- **Risks** — what could break, and what a reviewer should look at hardest. - -## Crypter's idioms are part of the plan - -Express the plan in the conventions the code already uses, so the implementer inherits them: - -- `Maybe` and `Either` from `Crypter.Common/Monads` for expected failures, - not nulls and not exceptions. -- Validated types from `Crypter.Common/Primitives` rather than raw strings. -- `Async` suffix on async methods, and async all the way for database, file, and network IO. -- Constructors over object initializers. Enums over magic strings. -- Any change to an entity under `Crypter.DataAccess/Entities` needs an EF Core migration in - `Crypter.DataAccess/Migrations`. Say so explicitly, and say whether it also needs a - companion script in `Crypter.DataAccess/Scripts`. - -## Scope - -One pull request should do one thing. If the requirement implies drive-by refactors or -cleanups, put them under non-goals rather than in the steps. - -If the requirement is ambiguous enough that two readings give materially different work, -say so at the top of the plan under **Open question**, choose the reading you think is -right, state that you chose it, and plan that. A human approves this plan before anything -is built, so a flagged assumption is cheap. Silence is not. diff --git a/.claude/skills/crypter-plan-author/SKILL.md b/.claude/skills/crypter-plan-author/SKILL.md new file mode 100644 index 00000000..2c9703ac --- /dev/null +++ b/.claude/skills/crypter-plan-author/SKILL.md @@ -0,0 +1,96 @@ +--- +name: crypter-plan-author +description: Draft an implementation plan for Crypter interactively, then hand it to the pipeline container to build. Use when asked to plan a change, or invoked as /crypter-plan-author "". +--- + +# Crypter plan author + +You turn a requirement into a plan the pipeline's agents implement. They see the plan and +nothing else. Write for that reader. + +This runs on the host, with the web, the user's tooling, and the user available to you. Settle +anything that needs them here, and write the answer into the plan. + +## 1. Sync + +```bash +git fetch upstream +git fetch origin +``` + +Read the code at `upstream/stable`, the commit the container branches from. + +## 2. Pick a run id and a branch + +A short run id from the requirement — `transfer-limits`, `fix-expiry-tz`. Name the branch as +the repo does: `feature/{something}`, `fix/{something}`, `chore/{something}`. + +Both are passed to the pipeline in stage 6, so decide them now and keep them stable. + +## 3. Understand before deciding + +Read `CLAUDE.md` and `Documentation/Development/Coding Standard.md` first. Then read the code +the requirement touches, and the code around it — the existing patterns are the ones the +implementation matches. + +Prefer reusing what exists. If a monad, primitive, service, or extension already does most of +the job, name it in the plan with its path. + +## 4. Write the plan + +Write it to `.claude/plans/{run-id}/plan.md`, the host side of the container's read-only +`/plans` mount. Create the directory as needed. + +- **Goal** — one paragraph. What changes for a user of Crypter, and why. +- **Non-goals** — what this change deliberately leaves alone. Be specific; this keeps the + implementer in scope, and the conformance auditor checks against it. +- **Approach** — the design, in prose. Name the types and methods to add or change. Explain + anything non-obvious, especially where a constraint forced the shape. +- **Steps** — numbered and ordered, each naming the files it touches. A step should be small + enough that its result is obvious. +- **Tests** — what to add to `Crypter.Test` or `Crypter.Test.Web` and what each case pins + down. CI is where the suite runs, so tests are what verify behaviour. +- **Risks** — what could break, and what a reviewer should look at hardest. + +### Crypter's idioms are part of the plan + +Express the plan in the conventions the code already uses, so the implementer inherits them: + +- `Maybe` and `Either` from `Crypter.Common/Monads` for expected failures. +- Validated types from `Crypter.Common/Primitives` rather than raw strings. +- `Async` suffix on async methods, and async all the way for database, file, and network IO. +- Constructors over object initializers. Enums over magic strings. +- Any change to an entity under `Crypter.DataAccess/Entities` needs an EF Core migration in + `Crypter.DataAccess/Migrations`. Say so explicitly, and say whether it also needs a + companion script in `Crypter.DataAccess/Scripts`. + +### Scope + +One pull request does one thing. Put drive-by refactors and cleanups under non-goals. + +## 5. Settle it with the user + +Show the user the plan and wait. This is the gate; the pipeline runs unattended once it opens. + +Ask when two readings give materially different work. Decide the routine calls yourself and +say which way you went. Revise the plan in place until the user approves it. + +## 6. Hand it to the pipeline + +The mount is present on containers created from the current +`.devcontainer/docker-compose.yml`. Confirm the plan is visible, then start the run: + +```bash +docker exec crypter-pipeline test -f /plans/{run-id}/plan.md +docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/pipeline {run-id} {branch}" +``` + +Recreate the container to pick up the mount: + +```bash +docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate +``` + +Report what the pipeline returns: the pull request URL, whether its checks are green, and +anything it flagged. diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md index fcbc52aa..71defc48 100644 --- a/.claude/skills/pipeline/SKILL.md +++ b/.claude/skills/pipeline/SKILL.md @@ -1,11 +1,11 @@ --- name: pipeline -description: Take a requirement from plan to an open, CI-green draft pull request on the fork, using a chain of subagents. Use when asked to run the pipeline on a requirement, or invoked as /pipeline "". +description: Take an approved plan to an open, CI-green draft pull request on the fork, using a chain of subagents. Invoked as /pipeline {run-id} {branch} by the crypter-plan-author skill on the host. --- # Pipeline -Turn a requirement into a draft pull request whose checks pass, in stages, each run by a +Turn an approved plan into a draft pull request whose checks pass, in stages, each run by a subagent with its own context. A later stage that starts fresh actually re-examines the work; one that inherits the reasoning behind it rubber-stamps it. @@ -14,22 +14,28 @@ and is read-only. Every pull request is fork → fork. Nothing here can reach `Crypter-File-Transfer/Crypter`, and the upstream pull request is something the user opens by hand at the end, from a fork pull request they have read. -There is exactly one stop: the user approves the plan. Everything after that runs to a draft -pull request with green checks, or to a written account of why CI would not take it. +The plan is the specification. An interactive session on the host wrote it under the +`crypter-plan-author` skill and the user approved it there. This runs unattended, to a draft +pull request with green checks or to a written account of why CI would not take it. ## Setup -Pick a short run id from the requirement — `transfer-limits`, `fix-expiry-tz`. Then: +You are given a run id and a branch name: `/pipeline {run-id} {branch}`. + +Read `/plans/{run-id}/plan.md` first. It is a read-only mount of the host's `.claude/plans`. +**If it is absent, stop and say so** — the host session owns that file. + +Run state goes in the workspace, which is writable: ```bash mkdir -p /work/Crypter/.claude/pipeline/{run-id} ``` -State goes there: `plan.md`, `conformance.md`, `findings/`, `ci.md`. It is gitignored. +`conformance.md`, `findings/`, and `ci.md` go there. It is gitignored. -## 0. Sync and branch +## 1. Sync and branch -Never plan against stale code: +Build on current code: ```bash git -C /work/Crypter fetch upstream @@ -38,30 +44,15 @@ git -C /work/Crypter push origin upstream/stable:refs/heads/stable git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} -b {branch} upstream/stable ``` -**If any of these fail, stop and say so.** A quietly skipped sync means the plan, the diff, and -the eventual upstream pull request are all built on the wrong base, and nothing downstream will -notice. - -Name the branch as the repo does: `feature/{something}`, `fix/{something}`, `chore/{something}`. +**If any of these fail, stop and say so.** A quietly skipped sync leaves the diff and the +eventual upstream pull request on the wrong base, and nothing downstream will notice. Every later stage gets this worktree path and works by absolute path inside it. Never `cd`. -## 1. Plan - -Invoke `plan-author` with the requirement verbatim, the worktree path, and the output path -`/work/Crypter/.claude/pipeline/{run-id}/plan.md`. - -Then **stop.** Show the user the plan — the file, not a summary of it — and wait. Do not -implement, do not create the pull request, do not start reviewing. If they ask for changes, run -`plan-author` again with their feedback and the existing plan; do not edit the plan yourself. - -If the plan contains an **Open question**, put it in front of the user explicitly. It is the -one thing they are most likely to want to change and the cheapest moment to change it. - ## 2. Implement -Invoke `implementer` with the plan path and the worktree path. Give it nothing about how the -plan was reached — the plan is the specification. +Invoke `implementer` with `/plans/{run-id}/plan.md` and the worktree path. The plan is the +specification. Read its report. If it says a step could not be done, that is not a failure to paper over: surface it to the user with the rest of the results at the end, and let the auditor record it. @@ -94,7 +85,7 @@ it for the org repository's reviewers. Run these in parallel — they do not interact: -- `conformance-auditor` with the plan, the worktree, and +- `conformance-auditor` with `/plans/{run-id}/plan.md`, the worktree, and `/work/Crypter/.claude/pipeline/{run-id}/conformance.md`. - `reviewer`, once per lens, with the worktree and `/work/Crypter/.claude/pipeline/{run-id}/findings/{lens}.md`. @@ -161,7 +152,7 @@ git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} Remove it on every exit path, including when the pipeline stopped early. -Then tell the user, in a few sentences: +Then report back to the host session, in a few sentences: - The fork pull request URL and whether its checks are green. It is still a draft; taking it out of draft is theirs to do once they have read it. @@ -169,6 +160,5 @@ Then tell the user, in a few sentences: - What you rejected in triage that they might disagree with. - If the CI loop gave up: which check failed and what the last attempt tried. -They open the upstream pull request themselves. Remind them the base repository is fixed when a -pull request is created, so it is a new pull request against -`Crypter-File-Transfer/Crypter` — the description is ready to paste. +The upstream pull request is a separate one against `Crypter-File-Transfer/Crypter`, since the +base repository is fixed when a pull request is created. The description is ready to paste. diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 1178ebfa..1351c488 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -17,6 +17,8 @@ services: volumes: - workspace:/work - claude:/home/agent/.claude + # Plans are authored on the host and read from /plans. + - ../.claude/plans:/plans:ro # /work/Crypter does not exist until crypter-clone-fork has run, so the container starts # one level up. Open a shell with `exec -w /work/Crypter`. working_dir: /work diff --git a/.gitignore b/.gitignore index f4262e8e..3c655648 100644 --- a/.gitignore +++ b/.gitignore @@ -465,5 +465,8 @@ Crypter.Web/pnpm-lock.yaml # Agentic pipeline run state .claude/pipeline/ +# Plans authored on the host and mounted into the pipeline container +.claude/plans/ + # Devcontainer configuration, copied from .devcontainer/.env.example .devcontainer/.env diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index d00c5f96..43b4a949 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -1,10 +1,11 @@ # Agentic Development Pipeline -The `/pipeline` skill takes a requirement from a plan to a draft pull request with green checks, -using a chain of subagents that each start with their own context. It runs inside a devcontainer -built from `.devcontainer/Dockerfile`. +A change goes through two skills. `/crypter-plan-author` runs interactively on your host and +drafts the plan, with the web, your tooling, and you available to it. `/pipeline` runs inside a +devcontainer built from `.devcontainer/Dockerfile` and takes that plan to a draft pull request +with green checks, using a chain of subagents that each start with their own context. -Everything it does happens on **your fork**. The container's token cannot reach +Everything the pipeline does happens on **your fork**. The container's token cannot reach `Crypter-File-Transfer/Crypter`, and the workspace is a named Docker volume rather than a bind mount of your checkout, so the agents cannot touch uncommitted work on your machine. When the pipeline finishes you have a fork pull request to read; opening one against the org repository is @@ -12,6 +13,23 @@ something you do by hand afterwards. This document covers the setup you need before the container will start. +## Planning and the plans mount + +`/crypter-plan-author` writes to `.claude/plans/{run-id}/plan.md` on your host, which is +gitignored. Compose mounts `.claude/plans` read-only at `/plans` in the container, so the +pipeline reads the plan where you wrote it and the agents write their run state to the workspace +instead. + +You approve the plan in that host session. It then starts the pipeline itself: + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/pipeline {run-id} {branch}" +``` + +A container created before the mount existed picks it up on +`docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate`. + ## Configuration `.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is From a3f8027067c465cf828a54ce1371f45fa3f99ab5 Mon Sep 17 00:00:00 2001 From: n Date: Tue, 4 Aug 2026 21:39:25 -0500 Subject: [PATCH 23/41] Remove the personal access token from the pipeline container The container ran with a fork-scoped PAT and unrestricted egress, since Claude Code needs the Anthropic API. Rather than harden egress, the credential is gone: the workspace is now an anonymous clone of the org repository whose only remote has no push url, so the agents read public code and commit locally. Pushing, opening the pull request and the CI loop move to the host, where the session already has GitHub access. /crypter-publish fetches the branch out of the container over git's ext transport, pushes it, opens the draft pull request, and runs at most three fix attempts, handing each failure to /pipeline-fix through the read-only plans mount. CRYPTER_FORK and CRYPTER_FORK_TOKEN leave .env, and the image drops the GitHub CLI, which has nothing left to authenticate with. Co-Authored-By: Claude Opus 5 --- .claude/agents/ci-watcher.md | 76 ++++++------- .claude/agents/conformance-auditor.md | 2 +- .claude/agents/implementer.md | 2 +- .claude/agents/reviewer.md | 2 +- .claude/skills/crypter-plan-author/SKILL.md | 4 +- .claude/skills/crypter-publish/SKILL.md | 86 ++++++++++++++ .claude/skills/pipeline-fix/SKILL.md | 50 +++++++++ .claude/skills/pipeline/SKILL.md | 105 +++++------------- .devcontainer/.env.example | 2 - .devcontainer/Dockerfile | 13 +-- .devcontainer/clone-fork.sh | 43 ------- .devcontainer/clone-upstream.sh | 34 ++++++ .devcontainer/docker-compose.yml | 10 +- .../Agentic Development Pipeline.md | 63 ++++++----- 14 files changed, 278 insertions(+), 214 deletions(-) create mode 100644 .claude/skills/crypter-publish/SKILL.md create mode 100644 .claude/skills/pipeline-fix/SKILL.md delete mode 100644 .devcontainer/clone-fork.sh create mode 100644 .devcontainer/clone-upstream.sh diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index 7f2927a3..f5a2c559 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -1,7 +1,7 @@ --- name: ci-watcher -description: Watch the checks for a pull request's current commit and report what CI did. Used as stage 7 of the /pipeline skill, once per CI attempt. -tools: Read, Grep, Glob, Bash, Write +description: Watch the checks for a pull request's current commit and report what CI did. Used as stage 4 of the /crypter-publish skill, once per CI attempt. +tools: Read, Grep, Glob, Bash, Write, mcp__github__pull_request_read model: opus effort: high color: purple @@ -13,42 +13,41 @@ You find out whether CI accepts the pull request as it currently stands. You do code. When checks fail you produce a description of the failure precise enough that an implementer who has never seen this pull request can fix it. -You are given a worktree path, a pull request number, an attempt number, and the path to -`ci.md`. You run **one attempt**. The skill counts attempts, invokes the implementer between -them, and calls you again — so you always start from a clean read of the current state rather -than from your own last guess. +You are given a repository path, a branch name, a fork as `/`, a pull request +number, an attempt number, and the path to `ci-{n}.md`. You run **one attempt**. The skill +counts attempts, runs the fix between them, and calls you again — so you always start from a +clean read of the current state rather than from your own last guess. -`gh` reads `GH_TOKEN` from the environment. The pull request is fork → fork, so `origin` is -the only repository you touch. +**You run on the host**, with the session's own GitHub access. Use +`mcp__github__pull_request_read` with `method: "get_check_runs"` for the head commit's checks +and `method: "get_status"` for the combined status. Where the `gh` CLI is installed, its +`gh pr checks --watch` and `gh run view --log-failed` give more detail; use them when they are +there. ## Find the run The pull request is a draft and stays one; the user takes it out of draft when they are ready -to review it. Checks run on drafts, so pushing the branch is what starts a round of them, and -a round is already queued or finished by the time you are invoked. +to review it. Checks run on drafts, so pushing the branch starts a round of them, and a round +is already queued or finished by the time you are invoked. -Find the round for the commit you were asked about, rather than whichever ran most recently: +Confirm you are reading the round for the commit you were asked about: ```bash -head_sha=$(git -C rev-parse HEAD) -gh run list --repo --commit "${head_sha}" --json databaseId,workflowName,status,conclusion +git -C rev-parse ``` -A push takes a moment to register, so poll until a run appears. If nothing has appeared after -a few minutes, say so and stop: on a fork, workflows stay disabled until they are enabled once -in the Actions tab, and that is a setup problem no amount of waiting fixes. +Compare that against the head SHA in the pull request data. A push takes a moment to register, +so poll `get_check_runs` every 30 seconds until runs appear. If nothing has appeared after a +few minutes, say so and stop: on a fork, workflows stay disabled until they are enabled once in +the Actions tab, and that is a setup problem no amount of waiting fixes. ## Watch -```bash -gh pr checks --repo --watch -``` - -Give it a generous timeout — a full build plus the test suite is slow, and a watch you kill -early looks exactly like a failure. +Poll until every check reaches a conclusion. Give it a generous timeout — a full build plus the +test suite is slow, and a watch you cut short looks exactly like a failure. -Five workflows run on a pull request, and `gh pr checks` reports them by job name rather than -by workflow name. Expect these: +Five workflows run on a pull request, reported by job name rather than by workflow name. Expect +these: | Check | Skips when | |---|---| @@ -70,26 +69,25 @@ not have caught locally shows up. ## On failure -Get the real log, not the summary: +Get the real error. The check run's `output` summary and annotations carry the diagnostic; +where `gh` is installed, `gh run view --repo --log-failed` carries more. -```bash -gh run view --repo --log-failed -``` +Then read the code the failure points at, in the repository at that branch. A stack trace names +a file and a line; open it. The difference between a useful report and a useless one is whether +you found the cause or just copied the symptom. -Then read the code the failure points at, in the worktree. A stack trace names a file and a -line; open it. The difference between a useful report and a useless one is whether you found -the cause or just copied the symptom. - -Write the attempt to `ci.md`, appending rather than overwriting: +Write the attempt to `ci-{n}.md`: - Which check failed, and the run URL. - The actual error — assertion message, compiler diagnostic, analyzer rule — quoted, not - paraphrased. + paraphrased. Where the detail available to you stops short of the cause, say so. - The file and line, and what you believe is causing it. - Whether it looks like a code defect, a wrong test, or something environmental. Say which, and say when you are unsure. -Then report the same thing back. Do not propose a patch; the implementer decides the fix. +This file is what the container reads, through its read-only `/plans` mount, so it has to stand +on its own. Then report the same thing back. Do not propose a patch; the implementer decides +the fix. If the failure looks like the plan itself was wrong — the tests encode behaviour the change contradicts — say so plainly. That is the signal for a human to step in, and it is worth more @@ -97,9 +95,5 @@ than another attempt. ## On success -```bash -gh pr view --repo --json url,isDraft,mergeable -``` - -Append the result to `ci.md`, and report the pull request URL, the checks that passed, and the -mergeable state. Say nothing about quality; that was stage 4's job. +Append the result to `ci-{n}.md`, and report the pull request URL, the checks that passed, and +the mergeable state. Say nothing about quality; that was the pipeline's review stage. diff --git a/.claude/agents/conformance-auditor.md b/.claude/agents/conformance-auditor.md index 8b7d9412..e3e983aa 100644 --- a/.claude/agents/conformance-auditor.md +++ b/.claude/agents/conformance-auditor.md @@ -1,6 +1,6 @@ --- name: conformance-auditor -description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as stage 4 of the /pipeline skill. +description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as stage 3 of the /pipeline skill. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index 8788b6c4..a9d55d34 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -1,6 +1,6 @@ --- name: implementer -description: Implement an approved plan in Crypter, or apply accepted review findings and CI fixes. Used as stages 2, 6, and the CI loop of the /pipeline skill. +description: Implement an approved plan in Crypter, or apply accepted review findings and CI fixes. Used as stages 2 and 5 of the /pipeline skill, and by /pipeline-fix. tools: Read, Grep, Glob, Bash, Write, Edit model: opus effort: high diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index e6faa7e7..228ed489 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -1,6 +1,6 @@ --- name: reviewer -description: Review a Crypter branch's diff under a named lens and report findings. Used as stage 4 of the /pipeline skill; the lens comes from the prompt. +description: Review a Crypter branch's diff under a named lens and report findings. Used as stage 3 of the /pipeline skill; the lens comes from the prompt. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/skills/crypter-plan-author/SKILL.md b/.claude/skills/crypter-plan-author/SKILL.md index 2c9703ac..d074b32e 100644 --- a/.claude/skills/crypter-plan-author/SKILL.md +++ b/.claude/skills/crypter-plan-author/SKILL.md @@ -92,5 +92,5 @@ Recreate the container to pick up the mount: docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate ``` -Report what the pipeline returns: the pull request URL, whether its checks are green, and -anything it flagged. +The pipeline returns a branch, a title and description for the pull request, and anything it +flagged. Publish it with `/crypter-publish {run-id} {branch}`. diff --git a/.claude/skills/crypter-publish/SKILL.md b/.claude/skills/crypter-publish/SKILL.md new file mode 100644 index 00000000..24d776e5 --- /dev/null +++ b/.claude/skills/crypter-publish/SKILL.md @@ -0,0 +1,86 @@ +--- +name: crypter-publish +description: Take a branch the pipeline built in the container, push it to the fork, open a pull request, and hold it against CI. Use when the pipeline has finished, or invoked as /crypter-publish {run-id} {branch}. +--- + +# Crypter publish + +Take the branch the pipeline built and turn it into a pull request with green checks. + +**This runs on the host.** The container holds no credential, so every authenticated GitHub +operation happens here, with yours. + +You are given a run id and a branch name: `/crypter-publish {run-id} {branch}`. + +## 1. Fetch the branch out of the container + +The branch lives in the container's clone. `git` reaches it over `docker exec`: + +```bash +git -c protocol.ext.allow=user fetch \ + "ext::docker exec -i crypter-pipeline git upload-pack /work/Crypter" {branch}:{branch} +``` + +`protocol.ext.allow` is passed per command and stays out of your config. **If this fails, stop +and say so** — the branch is the whole deliverable. + +## 2. Push to the fork + +```bash +git fetch upstream +git push origin upstream/stable:refs/heads/stable +git push -u origin {branch} +``` + +The first push keeps the fork's `stable` level with the org repository, so the pull request +compares against current code. + +## 3. Open the pull request + +Open it against the fork, base `stable`, as a draft, using whatever GitHub access this session +has — the `gh` CLI, or the GitHub MCP server's `create_pull_request`. + +Take the title and description from the pipeline's report. Write the description for the org +repository's reviewers, since it carries over when the upstream pull request is opened. + +## 4. Hold it against CI + +Invoke `ci-watcher` with the pull request number, the attempt number, and +`.claude/plans/{run-id}/ci-{n}.md`. It runs **one attempt**: it reads the checks for the head +commit, watches them, and reports. + +You own the loop: + +1. `ci-watcher` reports green → go to stage 5. +2. `ci-watcher` reports a failure → run the fix in the container with the report it wrote: + + ```bash + docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/pipeline-fix {run-id} {branch} /plans/{run-id}/ci-{n}.md" + ``` + + Then fetch the new commits as in stage 1, push them, and invoke `ci-watcher` again with the + next attempt number. +3. **Stop after three attempts.** Comment the state of play on the pull request and hand back + to the user. + +Stop earlier and ask the user whenever another attempt looks pointless — the same check failing +the same way twice, a failure the plan did not anticipate, or anything that reads as a wrong +plan rather than wrong code. Three attempts is the ceiling, not a quota to spend. + +Stop immediately, without spending an attempt, if `ci-watcher` reports that no run appeared for +the commit. Workflows stay disabled on a new fork until they are enabled once in its Actions +tab, and that is a setup problem. + +## 5. Report + +Tell the user: + +- The fork pull request URL and whether its checks are green. It is a draft; taking it out of + draft is theirs. +- What each fix attempt changed, if any ran. +- Anything the pipeline could not do, and what it rejected in triage. +- If the loop gave up: which check failed and what the last attempt tried. + +The upstream pull request is a separate one against `Crypter-File-Transfer/Crypter`, since the +base repository is fixed when a pull request is created. The description is ready to paste. diff --git a/.claude/skills/pipeline-fix/SKILL.md b/.claude/skills/pipeline-fix/SKILL.md new file mode 100644 index 00000000..b18ffb9f --- /dev/null +++ b/.claude/skills/pipeline-fix/SKILL.md @@ -0,0 +1,50 @@ +--- +name: pipeline-fix +description: Apply a fix to a branch the pipeline already built, from a report the host wrote. Invoked as /pipeline-fix {run-id} {branch} {report-path} by the crypter-publish skill on the host. +--- + +# Pipeline fix + +Take a report of something wrong with a branch this container already built, and fix it. + +**This runs inside the devcontainer**, on a branch that exists in `/work/Crypter/.git` from an +earlier `/pipeline` run. The host wrote the report, and the host publishes the result. + +## Setup + +You are given a run id, a branch name, and a report path: `/pipeline-fix {run-id} {branch} +{report-path}`. + +Read the report first. It lives under `/plans/{run-id}/`, the read-only mount the host owns. +**If it is absent, stop and say so.** + +Read `/plans/{run-id}/plan.md` too. The fix stays inside what the plan set out to do. + +## 1. Worktree on the existing branch + +```bash +git -C /work/Crypter fetch upstream +git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} {branch} +``` + +No `-b` — the branch is already there, with the commits the host has pushed. **If this fails, +stop and say so.** + +## 2. Fix + +Invoke `implementer` with the report path and the worktree path. Each fix is its own commit on +the branch. + +Read its report. If it says the failure could not be addressed, say so plainly in your own +report rather than reporting success. + +## 3. Hand off + +```bash +git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} +``` + +Remove it on every exit path. The branch keeps the new commits. + +Then report back to the host session: what the failure was, what changed, and which commits +now sit on the branch. The host fetches those commits and pushes them. diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md index 71defc48..2039d23d 100644 --- a/.claude/skills/pipeline/SKILL.md +++ b/.claude/skills/pipeline/SKILL.md @@ -1,22 +1,22 @@ --- name: pipeline -description: Take an approved plan to an open, CI-green draft pull request on the fork, using a chain of subagents. Invoked as /pipeline {run-id} {branch} by the crypter-plan-author skill on the host. +description: Take an approved plan to a reviewed branch in the pipeline container, using a chain of subagents. Invoked as /pipeline {run-id} {branch} by the crypter-plan-author skill on the host. --- # Pipeline -Turn an approved plan into a draft pull request whose checks pass, in stages, each run by a -subagent with its own context. A later stage that starts fresh actually re-examines the work; -one that inherits the reasoning behind it rubber-stamps it. +Turn an approved plan into a reviewed branch, in stages, each run by a subagent with its own +context. A later stage that starts fresh actually re-examines the work; one that inherits the +reasoning behind it rubber-stamps it. -**This runs inside the devcontainer.** `origin` is the fork, `upstream` is the org repository -and is read-only. Every pull request is fork → fork. Nothing here can reach -`Crypter-File-Transfer/Crypter`, and the upstream pull request is something the user opens by -hand at the end, from a fork pull request they have read. +**This runs inside the devcontainer.** The workspace is an anonymous clone of the org +repository with a single remote, `upstream`, which has no push url. The container holds no +credential and reads public code. The host session pushes the branch and opens the pull +request once you return. The plan is the specification. An interactive session on the host wrote it under the -`crypter-plan-author` skill and the user approved it there. This runs unattended, to a draft -pull request with green checks or to a written account of why CI would not take it. +`crypter-plan-author` skill and the user approved it there. This runs unattended, to a branch +the host can publish. ## Setup @@ -31,7 +31,7 @@ Run state goes in the workspace, which is writable: mkdir -p /work/Crypter/.claude/pipeline/{run-id} ``` -`conformance.md`, `findings/`, and `ci.md` go there. It is gitignored. +`conformance.md` and `findings/` go there. It is gitignored. ## 1. Sync and branch @@ -39,13 +39,11 @@ Build on current code: ```bash git -C /work/Crypter fetch upstream -git -C /work/Crypter fetch origin -git -C /work/Crypter push origin upstream/stable:refs/heads/stable git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} -b {branch} upstream/stable ``` -**If any of these fail, stop and say so.** A quietly skipped sync leaves the diff and the -eventual upstream pull request on the wrong base, and nothing downstream will notice. +**If either fails, stop and say so.** A quietly skipped sync leaves the diff and the eventual +pull request on the wrong base, and nothing downstream will notice. Every later stage gets this worktree path and works by absolute path inside it. Never `cd`. @@ -57,31 +55,7 @@ specification. Read its report. If it says a step could not be done, that is not a failure to paper over: surface it to the user with the rest of the results at the end, and let the auditor record it. -## 3. Open the draft pull request - -Now, once there is something real to look at and before anyone reviews it. You do the pushing, -here and at every later stage — the implementer commits and returns: - -```bash -git -C /work/Crypter/.claude/worktrees/{run-id} push -u origin {branch} -gh pr create --repo {fork} --draft --base stable --head {branch} --title "..." --body "..." -``` - -Creating the pull request starts the first round of checks. Checks run on drafts, so the round -begins here rather than at stage 7, and every push after this one starts another. Nothing -cancels the round it supersedes, so push once per stage, after the implementer is done. - -Title reads like a commit subject: imperative, capitalized, no trailing period. - -Description is a few sentences of plain English saying what changed and why. **Do not argue the -case** — no justifying the approach, no pre-empting objections, no listing rejected -alternatives. Call out what a reviewer would otherwise have to discover: migrations, breaking -API changes, deliberately held-back dependencies. That is information, not argument. - -This description carries over verbatim when the user opens the upstream pull request, so write -it for the org repository's reviewers. - -## 4. Examine +## 3. Examine Run these in parallel — they do not interact: @@ -99,7 +73,7 @@ The lens list is currently one entry: Adding lenses later — security, simplicity, test coverage — means adding rows here. The `reviewer` definition does not change; the lens comes from the prompt. -## 5. Triage +## 4. Triage You decide what to act on. Read every finding against the code before accepting it — a reviewer that has already been wrong once will happily be wrong again, and acting on a bad finding means @@ -113,52 +87,29 @@ Write what you accepted and what you rejected, with a reason for each rejection, `/work/Crypter/.claude/pipeline/{run-id}/findings/triage.md`. The user reads this to check your judgement. -## 6. Remediate +## 5. Remediate If anything was accepted, invoke `implementer` with the accepted findings and the worktree -path. Each fix is its own commit on the existing branch. When it returns, push once; that -updates the same draft pull request and starts a fresh round of checks. - -If nothing was accepted, go straight to stage 7 — the round of checks from the last push is -the one that counts. - -## 7. Hold it against CI - -Invoke `ci-watcher` with the worktree path, the pull request number, the attempt number, and -`/work/Crypter/.claude/pipeline/{run-id}/ci.md`. It runs **one attempt**: it finds the round of -checks for the branch's current commit, watches it, and reports. - -You own the loop: - -1. `ci-watcher` reports green → go to step 8. -2. `ci-watcher` reports a failure → invoke `implementer` with that failure report and the - worktree path, push, then invoke `ci-watcher` again with the next attempt number. -3. **Stop after three attempts.** Comment the state of play on the pull request, and hand back - to the user. Three failures on the same change usually means the plan was wrong, not the - code, and a fourth attempt buys a full build and test suite for nothing. - -A fresh `ci-watcher` per attempt is deliberate — it reads what CI actually says now, rather than -reasoning from its own previous guess about the failure. - -Stop immediately, without spending attempts, if `ci-watcher` reports that no run ever appeared -for the commit. Workflows are disabled on a new fork until they are enabled once in its Actions -tab, and that is a setup problem. +path. Each fix is its own commit on the existing branch. -## 8. Hand off +## 6. Hand off ```bash git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} ``` -Remove it on every exit path, including when the pipeline stopped early. +Remove it on every exit path, including when the pipeline stopped early. The branch ref lives +in `/work/Crypter/.git` and survives, which is what the host fetches. Then report back to the host session, in a few sentences: -- The fork pull request URL and whether its checks are green. It is still a draft; taking it - out of draft is theirs to do once they have read it. +- The branch name, and a title and description for the pull request. Title reads like a commit + subject: imperative, capitalized, no trailing period. Description is a few sentences of plain + English saying what changed and why, written for the org repository's reviewers. **Do not + argue the case** — no justifying the approach, no pre-empting objections, no listing rejected + alternatives. Call out what a reviewer would otherwise have to discover: migrations, breaking + API changes, deliberately held-back dependencies. That is information, not argument. - Anything the implementer could not do, and any deviation the auditor flagged as drift. -- What you rejected in triage that they might disagree with. -- If the CI loop gave up: which check failed and what the last attempt tried. +- What you rejected in triage that the user might disagree with. -The upstream pull request is a separate one against `Crypter-File-Transfer/Crypter`, since the -base repository is fixed when a pull request is created. The description is ready to paste. +The host session pushes the branch and opens the pull request from there. diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 49a7dc62..c10ed1a3 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,4 +1,2 @@ -CRYPTER_FORK="" -CRYPTER_FORK_TOKEN="" CRYPTER_GIT_EMAIL="" CRYPTER_GIT_NAME="" diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 2c159d91..4ab95c70 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -27,15 +27,6 @@ RUN apt-get update \ less \ && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ - -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \ - && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ - > /etc/apt/sources.list.d/github-cli.list \ - && apt-get update \ - && apt-get install --yes --no-install-recommends gh \ - && rm -rf /var/lib/apt/lists/* - RUN curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \ && apt-get install --yes --no-install-recommends nodejs \ && rm -rf /var/lib/apt/lists/* @@ -50,8 +41,8 @@ RUN dotnet workload install wasm-tools RUN dotnet tool install dotnet-ef --version '10.0.*' --tool-path "${DOTNET_TOOLS}" -COPY .devcontainer/clone-fork.sh /usr/local/bin/crypter-clone-fork -RUN chmod +x /usr/local/bin/crypter-clone-fork +COPY .devcontainer/clone-upstream.sh /usr/local/bin/crypter-clone-upstream +RUN chmod +x /usr/local/bin/crypter-clone-upstream # The workspace and the agent's Claude Code state are both named volumes. Docker creates a # mount point that the image does not already contain as root, so creating these here is diff --git a/.devcontainer/clone-fork.sh b/.devcontainer/clone-fork.sh deleted file mode 100644 index 82aee0c0..00000000 --- a/.devcontainer/clone-fork.sh +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env bash -# Prepare the pipeline workspace: a clone of your fork, with the org repository added as a -# read-only upstream. The container runs this on every start; an existing workspace is left alone. -# -# The workspace is a named volume rather than a bind mount of a host checkout. The agents -# get their own clone, so they cannot touch uncommitted work on the host, and `origin` is -# the fork that the container's fork-scoped token can actually push to. -set -euo pipefail - -: "${CRYPTER_FORK:?Set CRYPTER_FORK in .devcontainer/.env to / of your fork}" -: "${GH_TOKEN:?Set CRYPTER_FORK_TOKEN in .devcontainer/.env so it reaches the container as GH_TOKEN}" -: "${CRYPTER_GIT_NAME:?Set CRYPTER_GIT_NAME in .devcontainer/.env to the author name on the commits}" -: "${CRYPTER_GIT_EMAIL:?Set CRYPTER_GIT_EMAIL in .devcontainer/.env to the author email on the commits}" - -upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" - -# Has to match the workspace path the pipeline skill and docker-compose.yml use. -workspace="/work/Crypter" - -if [[ "${CRYPTER_FORK}" == "${upstream_repo}" ]]; then - echo "CRYPTER_FORK is the upstream repository. Point it at your fork instead." >&2 - exit 1 -fi - -git config --global user.name "${CRYPTER_GIT_NAME}" -git config --global user.email "${CRYPTER_GIT_EMAIL}" -gh auth setup-git - -if [[ -d "${workspace}/.git" ]]; then - echo "Workspace already present at ${workspace}" -else - git clone "https://github.com/${CRYPTER_FORK}.git" "${workspace}" -fi - -if ! git -C "${workspace}" remote get-url upstream >/dev/null 2>&1; then - git -C "${workspace}" remote add upstream "https://github.com/${upstream_repo}.git" -fi - -git -C "${workspace}" fetch --quiet origin -git -C "${workspace}" fetch --quiet upstream - -echo "Workspace ready at ${workspace}" -git -C "${workspace}" remote -v diff --git a/.devcontainer/clone-upstream.sh b/.devcontainer/clone-upstream.sh new file mode 100644 index 00000000..e7b8bd13 --- /dev/null +++ b/.devcontainer/clone-upstream.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Prepare the pipeline workspace: a clone of the org repository. The container runs this on +# every start; an existing workspace is left alone. +# +# The workspace is a named volume rather than a bind mount of a host checkout. The agents get +# their own clone, so they cannot touch uncommitted work on the host. The clone is anonymous +# and the remote has no push url, so the agents read public code and commit locally. Pushing +# and opening pull requests happen on the host. +set -euo pipefail + +: "${CRYPTER_GIT_NAME:?Set CRYPTER_GIT_NAME in .devcontainer/.env to the author name on the commits}" +: "${CRYPTER_GIT_EMAIL:?Set CRYPTER_GIT_EMAIL in .devcontainer/.env to the author email on the commits}" + +upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" + +# Has to match the workspace path the pipeline skill and docker-compose.yml use. +workspace="/work/Crypter" + +git config --global user.name "${CRYPTER_GIT_NAME}" +git config --global user.email "${CRYPTER_GIT_EMAIL}" + +if [[ -d "${workspace}/.git" ]]; then + echo "Workspace already present at ${workspace}" +else + git clone --origin upstream "https://github.com/${upstream_repo}.git" "${workspace}" +fi + +# A push from the container fails here rather than at a credential prompt. +git -C "${workspace}" remote set-url --push upstream no-push + +git -C "${workspace}" fetch --quiet upstream + +echo "Workspace ready at ${workspace}" +git -C "${workspace}" remote -v diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 1351c488..2c095e38 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -9,8 +9,6 @@ services: context: .. dockerfile: .devcontainer/Dockerfile environment: - GH_TOKEN: ${CRYPTER_FORK_TOKEN} - CRYPTER_FORK: ${CRYPTER_FORK} CRYPTER_UPSTREAM: Crypter-File-Transfer/Crypter CRYPTER_GIT_NAME: ${CRYPTER_GIT_NAME} CRYPTER_GIT_EMAIL: ${CRYPTER_GIT_EMAIL} @@ -19,12 +17,12 @@ services: - claude:/home/agent/.claude # Plans are authored on the host and read from /plans. - ../.claude/plans:/plans:ro - # /work/Crypter does not exist until crypter-clone-fork has run, so the container starts + # /work/Crypter does not exist until crypter-clone-upstream has run, so the container starts # one level up. Open a shell with `exec -w /work/Crypter`. working_dir: /work - # crypter-clone-fork leaves an existing workspace alone and only refetches, so running it - # on every start is safe. - command: bash -lc "crypter-clone-fork && sleep infinity" + # crypter-clone-upstream leaves an existing workspace alone and only refetches, so running + # it on every start is safe. + command: bash -lc "crypter-clone-upstream && sleep infinity" volumes: workspace: diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 43b4a949..213a6927 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -1,15 +1,21 @@ # Agentic Development Pipeline -A change goes through two skills. `/crypter-plan-author` runs interactively on your host and -drafts the plan, with the web, your tooling, and you available to it. `/pipeline` runs inside a -devcontainer built from `.devcontainer/Dockerfile` and takes that plan to a draft pull request -with green checks, using a chain of subagents that each start with their own context. +A change goes through three skills: -Everything the pipeline does happens on **your fork**. The container's token cannot reach -`Crypter-File-Transfer/Crypter`, and the workspace is a named Docker volume rather than a bind -mount of your checkout, so the agents cannot touch uncommitted work on your machine. When the -pipeline finishes you have a fork pull request to read; opening one against the org repository is -something you do by hand afterwards. +| Skill | Runs | Does | +|---|---|---| +| `/crypter-plan-author` | Host | Drafts the plan interactively, with the web, your tooling and you available to it | +| `/pipeline` | Container | Implements the plan, reviews it, and leaves a branch | +| `/crypter-publish` | Host | Pushes the branch, opens the pull request, holds it against CI | + +**The container holds no credential.** Its workspace is an anonymous clone of the org +repository with one remote, `upstream`, which has no push url, so the agents read public code +and commit locally. Every authenticated GitHub operation happens on the host with your own +access. The workspace is a named Docker volume rather than a bind mount of your checkout, so +the agents cannot touch uncommitted work on your machine. + +When `/crypter-publish` finishes you have a fork pull request to read; opening one against the +org repository is something you do by hand afterwards. This document covers the setup you need before the container will start. @@ -41,13 +47,11 @@ cp .devcontainer/.env.example .devcontainer/.env | Variable | Value | |---|---| -| `CRYPTER_FORK` | Your fork, as `/`. Startup fails if this is the upstream repository. | -| `CRYPTER_FORK_TOKEN` | A fine-grained personal access token. Reaches the container as `GH_TOKEN`. | | `CRYPTER_GIT_NAME` | Author name on the agents' commits. | | `CRYPTER_GIT_EMAIL` | Author email on the agents' commits. | -All four are required. Leaving one empty fails the container's startup script with a message -naming the variable. +Both are required. Leaving one empty fails the container's startup script with a message naming +the variable. ## Launching the container @@ -63,20 +67,21 @@ docker compose -f .devcontainer/docker-compose.yml exec -w /work/Crypter pipelin Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` reuses the workspace and your Claude Code credentials. -## The token +## Publishing -Create a fine-grained personal access token with access to **your fork only**. That restriction -is what makes the rest of the design hold: the agents push branches, open pull requests, and read -check results without any path to the org repository. +`/crypter-publish {run-id} {branch}` reaches into the container for the branch, using git's +`ext` transport over `docker exec`: -Grant it these repository permissions: +```bash +git -c protocol.ext.allow=user fetch \ + "ext::docker exec -i crypter-pipeline git upload-pack /work/Crypter" {branch}:{branch} +``` -| Permission | Access | Needed for | -|---|---|---| -| Contents | Read and write | Pushing the branch | -| Pull requests | Read and write | Opening the draft pull request | -| Actions | Read | Reading check runs and failed job logs | -| Metadata | Read | Mandatory on every fine-grained token | +`protocol.ext.allow` is passed per command, so it stays out of your git config. From there the +host pushes the branch to your fork, opens the draft pull request, and runs the CI loop with +your own GitHub access — the `gh` CLI or the GitHub MCP server. Three fix attempts is the +ceiling; `/crypter-publish` writes each failure to `.claude/plans/{run-id}/ci-{n}.md` and runs +`/pipeline-fix` in the container to address it. ## Enable Actions on your fork @@ -97,7 +102,7 @@ because Claude Code refuses `--dangerously-skip-permissions` as root: - The .NET 10 SDK, the `wasm-tools` workload, and `dotnet-ef` - Node 22 and pnpm 11.18.0, which `Crypter.Web`'s PreBuild target needs -- The GitHub CLI and Claude Code +- Claude Code There is **no Docker in the container**, so `Crypter.Test` cannot run there — it needs Testcontainers to start PostgreSQL. The agents build but never test locally; the test suite runs @@ -108,10 +113,10 @@ Two named volumes survive rebuilds: `crypter-pipeline-workspace` holds the works ## First start -Every `up` runs `crypter-clone-fork`, which clones your fork to `/work/Crypter`, adds the org -repository as a read-only `upstream`, and fetches both. If it already finds a workspace there it -leaves it alone and only refetches, so restarting the container does not discard work in -progress. +Every `up` runs `crypter-clone-upstream`, which clones the org repository to `/work/Crypter` as +the `upstream` remote, clears that remote's push url, and fetches. If it already finds a +workspace there it leaves it alone and only refetches, so restarting the container does not +discard work in progress. To start over from nothing, take the container down and remove the volumes: From 633ce25aaf0c19dc61219d3155914ba32e1d31c7 Mon Sep 17 00:00:00 2001 From: n Date: Tue, 4 Aug 2026 22:53:48 -0500 Subject: [PATCH 24/41] Split the pipeline into task skills and two orchestrators One skill drafted a plan and launched a container, and another implemented, reviewed, triaged and remediated. Neither name described what it did, and neither part could be used alone. The work now sits in small skills that compose: crypter-plan, crypter-implement, crypter-examine, crypter-remediate and crypter-publish. crypter-change carries a requirement through all of them and owns the CI loop, where the plan, the findings and every previous attempt are already in context. crypter-review runs the same lenses against a pull request that already exists, including one raised by someone else, and reports without posting anything. Findings become artifacts on the host. A second mount carries .claude/runs into the container, and each reviewing agent writes its own file there, so what was found and what was rejected can be read without docker exec. The orchestrators create those directories because the container's agent is uid 1001 and host files are uid 1000. The reviewer's lens table grows from one row to correctness, maintainability, testability and security. Co-Authored-By: Claude Opus 5 --- .claude/agents/ci-watcher.md | 5 +- .claude/agents/conformance-auditor.md | 2 +- .claude/agents/implementer.md | 2 +- .claude/agents/reviewer.md | 2 +- .claude/skills/crypter-change/SKILL.md | 130 ++++++++++++++++++ .claude/skills/crypter-examine/SKILL.md | 81 +++++++++++ .claude/skills/crypter-implement/SKILL.md | 64 +++++++++ .claude/skills/crypter-plan-author/SKILL.md | 96 ------------- .claude/skills/crypter-plan/SKILL.md | 77 +++++++++++ .claude/skills/crypter-publish/SKILL.md | 64 +++------ .claude/skills/crypter-remediate/SKILL.md | 55 ++++++++ .claude/skills/crypter-review/SKILL.md | 73 ++++++++++ .claude/skills/pipeline-fix/SKILL.md | 50 ------- .claude/skills/pipeline/SKILL.md | 115 ---------------- .devcontainer/clone-upstream.sh | 2 +- .devcontainer/docker-compose.yml | 2 + .gitignore | 3 + .../Agentic Development Pipeline.md | 84 ++++++----- 18 files changed, 561 insertions(+), 346 deletions(-) create mode 100644 .claude/skills/crypter-change/SKILL.md create mode 100644 .claude/skills/crypter-examine/SKILL.md create mode 100644 .claude/skills/crypter-implement/SKILL.md delete mode 100644 .claude/skills/crypter-plan-author/SKILL.md create mode 100644 .claude/skills/crypter-plan/SKILL.md create mode 100644 .claude/skills/crypter-remediate/SKILL.md create mode 100644 .claude/skills/crypter-review/SKILL.md delete mode 100644 .claude/skills/pipeline-fix/SKILL.md delete mode 100644 .claude/skills/pipeline/SKILL.md diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index f5a2c559..f9fc21ff 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -1,6 +1,6 @@ --- name: ci-watcher -description: Watch the checks for a pull request's current commit and report what CI did. Used as stage 4 of the /crypter-publish skill, once per CI attempt. +description: Watch the checks for a pull request's current commit and report what CI did. Used as stage 7 of the /crypter-change skill, once per CI attempt. tools: Read, Grep, Glob, Bash, Write, mcp__github__pull_request_read model: opus effort: high @@ -85,8 +85,7 @@ Write the attempt to `ci-{n}.md`: - Whether it looks like a code defect, a wrong test, or something environmental. Say which, and say when you are unsure. -This file is what the container reads, through its read-only `/plans` mount, so it has to stand -on its own. Then report the same thing back. Do not propose a patch; the implementer decides +This file is what the container reads, through its `/runs` mount, so it has to stand on its own. Then report the same thing back. Do not propose a patch; the implementer decides the fix. If the failure looks like the plan itself was wrong — the tests encode behaviour the change diff --git a/.claude/agents/conformance-auditor.md b/.claude/agents/conformance-auditor.md index e3e983aa..b841a81b 100644 --- a/.claude/agents/conformance-auditor.md +++ b/.claude/agents/conformance-auditor.md @@ -1,6 +1,6 @@ --- name: conformance-auditor -description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as stage 3 of the /pipeline skill. +description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as the plan adherence phase of the /crypter-examine skill. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index a9d55d34..d02607d2 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -1,6 +1,6 @@ --- name: implementer -description: Implement an approved plan in Crypter, or apply accepted review findings and CI fixes. Used as stages 2 and 5 of the /pipeline skill, and by /pipeline-fix. +description: Implement an approved plan in Crypter, or apply triaged review findings and CI fixes. Used by the /crypter-implement and /crypter-remediate skills. tools: Read, Grep, Glob, Bash, Write, Edit model: opus effort: high diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 228ed489..44e77347 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -1,6 +1,6 @@ --- name: reviewer -description: Review a Crypter branch's diff under a named lens and report findings. Used as stage 3 of the /pipeline skill; the lens comes from the prompt. +description: Review a Crypter branch's diff under a named lens and report findings. Used as the code review phase of the /crypter-examine skill; the lens comes from the prompt. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md new file mode 100644 index 00000000..1f5375bd --- /dev/null +++ b/.claude/skills/crypter-change/SKILL.md @@ -0,0 +1,130 @@ +--- +name: crypter-change +description: Take a requirement to an open, CI-green draft pull request, orchestrating the plan, build, review and publish skills. Use when asked to make a change to Crypter, or invoked as /crypter-change "". +--- + +# Crypter change + +Carry a requirement from a sentence to a draft pull request whose checks pass. + +**This runs on the host** and owns the whole run. The work happens in skills below you: planning +here, building and reviewing in the container, publishing here. You hold the plan, the findings +and every CI attempt, which is why the judgement calls are yours. + +There is one gate: the user approves the plan. Everything after it runs to a green draft pull +request, or to a written account of why CI would not take it. + +## Setup + +Pick a short run id from the requirement — `transfer-limits`, `fix-expiry-tz` — and a branch +named as the repo does: `feature/{something}`, `fix/{something}`, `chore/{something}`. Both stay +fixed for the run. + +```bash +mkdir -p .claude/plans/{run-id} .claude/runs/{run-id}/findings +chmod 777 .claude/runs/{run-id} .claude/runs/{run-id}/findings +``` + +`.claude/plans/{run-id}` is what the container reads; `.claude/runs/{run-id}` is where the +reviewing agents write their findings and where you write what you decide. Both are gitignored, +and both are yours to read at any point. + +**Make every directory under `/runs` here, and make it `777`.** The container's `agent` is uid +1001 and your files are uid 1000, and a bind mount keeps host ownership, so the agents can only +write into a directory that grants it. Creating them on this side also keeps you able to delete +what they wrote — a directory the container creates is one you cannot remove. + +The container needs both mounts. Confirm before starting a run: + +```bash +docker exec crypter-pipeline test -d /plans && docker exec crypter-pipeline test -w /runs +``` + +A container created before these existed picks them up on +`docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate`. + +## 1. Plan + +Invoke `crypter-plan` with the requirement verbatim and the output path +`.claude/plans/{run-id}/plan.md`. + +It settles the plan with the user itself. **Do not continue until they have approved it.** + +## 2. Build + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/crypter-implement {run-id} {branch}" +``` + +Keep the title and description it reports; `crypter-publish` needs them. + +## 3. Examine + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/crypter-examine {run-id} {branch} /plans/{run-id}/plan.md" +``` + +It writes `.claude/runs/{run-id}/conformance.md` and `.claude/runs/{run-id}/findings/{lens}.md`. +Read the files, not the summary. + +## 4. Triage + +You decide what to act on. Read every finding against the code before accepting it — a reviewer +that has already been wrong once will happily be wrong again, and acting on a bad finding means +changing working code. + +Accept anything with a concrete failure behind it. Reject preferences, restatements of the plan +the user already chose against, and findings about code the diff did not touch. An unplanned +extra that contradicts the plan's non-goals is not a preference — accept it. + +Write what you accepted and what you rejected, with a reason for each rejection, to +`.claude/runs/{run-id}/triage.md`. The user reads this to check your judgement, so write it for +them. + +## 5. Remediate + +Where anything was accepted: + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/crypter-remediate {run-id} {branch} /runs/{run-id}/triage.md" +``` + +## 6. Publish + +Invoke `crypter-publish` with the run id and the branch. It fetches the commits out of the +container, pushes them, and opens or updates the draft pull request. + +## 7. Hold it against CI + +Invoke `ci-watcher` with the repository path, the branch, the fork, the pull request number, the +attempt number, and `.claude/runs/{run-id}/ci-{n}.md`. It runs one attempt and reports. + +The loop is yours: + +1. Green → go to stage 8. +2. A failure → run `crypter-remediate` with `/runs/{run-id}/ci-{n}.md`, invoke `crypter-publish` + again, then `ci-watcher` with the next attempt number. +3. **Three attempts is the ceiling.** Comment the state of play on the pull request and hand back + to the user. + +Stop earlier and ask the user whenever another attempt looks pointless — the same check failing +the same way twice, a failure the plan did not anticipate, or anything that reads as a wrong plan +rather than wrong code. Three attempts is a limit, not a quota to spend. + +Stop immediately, without spending an attempt, where `ci-watcher` reports that no run appeared +for the commit. Workflows stay disabled on a new fork until they are enabled once in its Actions +tab, and that is a setup problem. + +## 8. Report + +- The fork pull request URL and whether its checks are green. It is a draft; taking it out of + draft is the user's. +- What each fix attempt changed, where any ran. +- Anything the implementer could not do, and any drift the auditor flagged. +- What you rejected in triage that the user might disagree with, and where `triage.md` is. + +The upstream pull request is a separate one against `Crypter-File-Transfer/Crypter`, since the +base repository is fixed when a pull request is created. The description is ready to paste. diff --git a/.claude/skills/crypter-examine/SKILL.md b/.claude/skills/crypter-examine/SKILL.md new file mode 100644 index 00000000..0b017b3a --- /dev/null +++ b/.claude/skills/crypter-examine/SKILL.md @@ -0,0 +1,81 @@ +--- +name: crypter-examine +description: Review a diff in the pipeline container and write findings to the host. Invoked as /crypter-examine {run-id} {ref} [plan-path] by the crypter-change and crypter-review skills on the host. +--- + +# Crypter examine + +Review a diff and leave hard artifacts behind. You do not write code and you do not decide what +gets acted on; the host session triages what you find. + +**This runs inside the devcontainer**, against a ref that already exists in `/work/Crypter/.git`. + +## Setup + +You are given a run id, a ref, and optionally a plan path: +`/crypter-examine {run-id} {ref} [plan-path]`. + +Two review phases run here, and the plan path decides whether the first one applies: + +| Phase | Runs when | +|---|---| +| Plan adherence | A plan path is given | +| Code review | Always | + +A change built from a plan gets both. A pull request someone else raised gets the second alone, +since there is no plan to hold it against. + +Findings go to `/runs/{run-id}/`, a writable mount of the host's `.claude/runs`. Each agent +writes its own findings; nothing here rewrites or summarises them into a second copy. They are +the deliverable — the host reads these files to triage, the user reads them to check that +judgement, and a later pass can read them to verify the claims they make. + +`/runs/{run-id}/` and `/runs/{run-id}/findings/` already exist; the caller creates them. **If +either is missing, stop and say so** rather than creating it — a directory made on this side is +one the host cannot clean up. + +## 1. Worktree on the ref + +```bash +git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} {ref} +``` + +No `-b` — the ref is already there. **If this fails, stop and say so.** + +Every agent gets this worktree path and works by absolute path inside it. Never `cd`. + +## 2. Plan adherence + +Given a plan path, invoke `conformance-auditor` with it, the worktree, and +`/runs/{run-id}/conformance.md`. It reports where the diff and the plan diverge. + +## 3. Code review + +Invoke `reviewer` once per lens, in parallel — they do not interact. Each gets the worktree and +`/runs/{run-id}/findings/{lens}.md`. + +| Lens | Brief | +|---|---| +| correctness | Bugs, boundary conditions, error paths, and what happens when inputs are hostile or absent. | +| maintainability | Readability, scope creep, and the conventions in `CLAUDE.md` and the Coding Standard. | +| testability | What the tests pin down, what they leave unverified, and whether the change can be tested at all. | +| security | Crypto boundaries, input validation, authentication and authorisation paths, key handling, transfer integrity. | + +Adding a lens means adding a row here. The `reviewer` definition stays as it is; the lens comes +from the prompt. + +Run the phases in parallel with each other too. The auditor and the reviewers read the same diff +and never interact. + +## 4. Report + +Summarise for the host session: how many findings each lens raised, where the auditor found +drift, and which findings you would look at first. Name the files you wrote. + +Leave the judgement to the host. Reporting a finding is not accepting it. + +```bash +git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} +``` + +Remove it on every exit path. diff --git a/.claude/skills/crypter-implement/SKILL.md b/.claude/skills/crypter-implement/SKILL.md new file mode 100644 index 00000000..d30a6315 --- /dev/null +++ b/.claude/skills/crypter-implement/SKILL.md @@ -0,0 +1,64 @@ +--- +name: crypter-implement +description: Build an approved plan into commits on a new branch, inside the pipeline container. Invoked as /crypter-implement {run-id} {branch} by the crypter-change skill on the host. +--- + +# Crypter implement + +Turn an approved plan into commits on a branch. + +**This runs inside the devcontainer.** The workspace is an anonymous clone of the org repository +with a single remote, `upstream`, which has no push url. The container holds no credential and +reads public code. The host session publishes the branch once you return. + +The plan is the specification. An interactive session on the host wrote it and the user approved +it there. This runs unattended. + +## Setup + +You are given a run id and a branch name: `/crypter-implement {run-id} {branch}`. + +Read `/plans/{run-id}/plan.md` first. It is a read-only mount of the host's `.claude/plans`. +**If it is absent, stop and say so** — the host session owns that file. + +## 1. Sync and branch + +Build on current code: + +```bash +git -C /work/Crypter fetch upstream +git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} -b {branch} upstream/stable +``` + +**If either fails, stop and say so.** A quietly skipped sync leaves the diff and the eventual +pull request on the wrong base, and nothing downstream will notice. + +Work by absolute path inside the worktree. Never `cd`. + +## 2. Implement + +Invoke `implementer` with `/plans/{run-id}/plan.md` and the worktree path. Give it nothing about +how the plan was reached — the plan is the specification. + +Read its report. If it says a step could not be done, that is not a failure to paper over: +say so plainly in your own report. + +## 3. Hand off + +```bash +git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} +``` + +Remove it on every exit path. The branch ref lives in `/work/Crypter/.git` and survives, which +is what the host fetches. + +Then report back to the host session: + +- The branch name and the commits on it. +- A title and description for the pull request. Title reads like a commit subject: imperative, + capitalized, no trailing period. Description is a few sentences of plain English saying what + changed and why, written for the org repository's reviewers. **Do not argue the case** — no + justifying the approach, no pre-empting objections, no listing rejected alternatives. Call out + what a reviewer would otherwise have to discover: migrations, breaking API changes, + deliberately held-back dependencies. That is information, not argument. +- Anything the implementer could not do. diff --git a/.claude/skills/crypter-plan-author/SKILL.md b/.claude/skills/crypter-plan-author/SKILL.md deleted file mode 100644 index d074b32e..00000000 --- a/.claude/skills/crypter-plan-author/SKILL.md +++ /dev/null @@ -1,96 +0,0 @@ ---- -name: crypter-plan-author -description: Draft an implementation plan for Crypter interactively, then hand it to the pipeline container to build. Use when asked to plan a change, or invoked as /crypter-plan-author "". ---- - -# Crypter plan author - -You turn a requirement into a plan the pipeline's agents implement. They see the plan and -nothing else. Write for that reader. - -This runs on the host, with the web, the user's tooling, and the user available to you. Settle -anything that needs them here, and write the answer into the plan. - -## 1. Sync - -```bash -git fetch upstream -git fetch origin -``` - -Read the code at `upstream/stable`, the commit the container branches from. - -## 2. Pick a run id and a branch - -A short run id from the requirement — `transfer-limits`, `fix-expiry-tz`. Name the branch as -the repo does: `feature/{something}`, `fix/{something}`, `chore/{something}`. - -Both are passed to the pipeline in stage 6, so decide them now and keep them stable. - -## 3. Understand before deciding - -Read `CLAUDE.md` and `Documentation/Development/Coding Standard.md` first. Then read the code -the requirement touches, and the code around it — the existing patterns are the ones the -implementation matches. - -Prefer reusing what exists. If a monad, primitive, service, or extension already does most of -the job, name it in the plan with its path. - -## 4. Write the plan - -Write it to `.claude/plans/{run-id}/plan.md`, the host side of the container's read-only -`/plans` mount. Create the directory as needed. - -- **Goal** — one paragraph. What changes for a user of Crypter, and why. -- **Non-goals** — what this change deliberately leaves alone. Be specific; this keeps the - implementer in scope, and the conformance auditor checks against it. -- **Approach** — the design, in prose. Name the types and methods to add or change. Explain - anything non-obvious, especially where a constraint forced the shape. -- **Steps** — numbered and ordered, each naming the files it touches. A step should be small - enough that its result is obvious. -- **Tests** — what to add to `Crypter.Test` or `Crypter.Test.Web` and what each case pins - down. CI is where the suite runs, so tests are what verify behaviour. -- **Risks** — what could break, and what a reviewer should look at hardest. - -### Crypter's idioms are part of the plan - -Express the plan in the conventions the code already uses, so the implementer inherits them: - -- `Maybe` and `Either` from `Crypter.Common/Monads` for expected failures. -- Validated types from `Crypter.Common/Primitives` rather than raw strings. -- `Async` suffix on async methods, and async all the way for database, file, and network IO. -- Constructors over object initializers. Enums over magic strings. -- Any change to an entity under `Crypter.DataAccess/Entities` needs an EF Core migration in - `Crypter.DataAccess/Migrations`. Say so explicitly, and say whether it also needs a - companion script in `Crypter.DataAccess/Scripts`. - -### Scope - -One pull request does one thing. Put drive-by refactors and cleanups under non-goals. - -## 5. Settle it with the user - -Show the user the plan and wait. This is the gate; the pipeline runs unattended once it opens. - -Ask when two readings give materially different work. Decide the routine calls yourself and -say which way you went. Revise the plan in place until the user approves it. - -## 6. Hand it to the pipeline - -The mount is present on containers created from the current -`.devcontainer/docker-compose.yml`. Confirm the plan is visible, then start the run: - -```bash -docker exec crypter-pipeline test -f /plans/{run-id}/plan.md -docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/pipeline {run-id} {branch}" -``` - -Recreate the container to pick up the mount: - -```bash -docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate -``` - -The pipeline returns a branch, a title and description for the pull request, and anything it -flagged. Publish it with `/crypter-publish {run-id} {branch}`. diff --git a/.claude/skills/crypter-plan/SKILL.md b/.claude/skills/crypter-plan/SKILL.md new file mode 100644 index 00000000..ff0e7fc1 --- /dev/null +++ b/.claude/skills/crypter-plan/SKILL.md @@ -0,0 +1,77 @@ +--- +name: crypter-plan +description: Draft an implementation plan for a change to Crypter, interactively. Use when asked to plan a change, or invoked as /crypter-plan "" [output-path]. +--- + +# Crypter plan + +You turn a requirement into a plan someone else implements from. They see the plan and nothing +else — not your reasoning, not the files you read, not the alternatives you rejected. Write for +that reader. + +This runs on the host, with the web, the user's tooling, and the user available to you. Settle +anything that needs them here, and write the answer into the plan. + +A plan stands on its own. Writing one commits you to nothing: the plan is worth having whether +it goes to `crypter-change`, to a person, or nowhere. + +## 1. Sync + +```bash +git fetch upstream +git fetch origin +``` + +Read the code at `upstream/stable`, the commit a build branches from. + +## 2. Understand before deciding + +Read `CLAUDE.md` and `Documentation/Development/Coding Standard.md` first. Then read the code +the requirement touches, and the code around it — the existing patterns are the ones the +implementation matches. + +Prefer reusing what exists. If a monad, primitive, service, or extension already does most of +the job, name it in the plan with its path. + +## 3. Write the plan + +Write it to the output path you were given. Absent one, use +`.claude/plans/{short-name}/plan.md`, taking a short name from the requirement — +`transfer-limits`, `fix-expiry-tz`. Create the directory as needed. + +- **Goal** — one paragraph. What changes for a user of Crypter, and why. +- **Non-goals** — what this change deliberately leaves alone. Be specific; this keeps the + implementer in scope, and the conformance auditor checks against it. +- **Approach** — the design, in prose. Name the types and methods to add or change. Explain + anything non-obvious, especially where a constraint forced the shape. +- **Steps** — numbered and ordered, each naming the files it touches. A step should be small + enough that its result is obvious. +- **Tests** — what to add to `Crypter.Test` or `Crypter.Test.Web` and what each case pins down. + CI is where the suite runs, so tests are what verify behaviour. +- **Risks** — what could break, and what a reviewer should look at hardest. + +### Crypter's idioms are part of the plan + +Express the plan in the conventions the code already uses, so the implementer inherits them: + +- `Maybe` and `Either` from `Crypter.Common/Monads` for expected failures. +- Validated types from `Crypter.Common/Primitives` rather than raw strings. +- `Async` suffix on async methods, and async all the way for database, file, and network IO. +- Constructors over object initializers. Enums over magic strings. +- Any change to an entity under `Crypter.DataAccess/Entities` needs an EF Core migration in + `Crypter.DataAccess/Migrations`. Say so explicitly, and say whether it also needs a companion + script in `Crypter.DataAccess/Scripts`. + +### Scope + +One pull request does one thing. Put drive-by refactors and cleanups under non-goals. + +## 4. Settle it with the user + +Show the user the plan and wait. + +Ask when two readings give materially different work. Being able to ask is why this runs on the +host; use it. Decide the routine calls yourself and say which way you went. Revise the plan in +place until the user approves it. + +Report the path you wrote and what the user settled. diff --git a/.claude/skills/crypter-publish/SKILL.md b/.claude/skills/crypter-publish/SKILL.md index 24d776e5..e62740c5 100644 --- a/.claude/skills/crypter-publish/SKILL.md +++ b/.claude/skills/crypter-publish/SKILL.md @@ -1,15 +1,19 @@ --- name: crypter-publish -description: Take a branch the pipeline built in the container, push it to the fork, open a pull request, and hold it against CI. Use when the pipeline has finished, or invoked as /crypter-publish {run-id} {branch}. +description: Push a branch the pipeline built in the container to the fork and open or update its pull request. Use when a branch is ready to publish, or invoked as /crypter-publish {run-id} {branch}. --- # Crypter publish -Take the branch the pipeline built and turn it into a pull request with green checks. +Take the branch the container built and put it on the fork, with a pull request open against it. **This runs on the host.** The container holds no credential, so every authenticated GitHub operation happens here, with yours. +Safe to run repeatedly on the same branch. Each run pushes whatever commits the container has +added and updates the existing pull request, which is what a caller looping over CI attempts +needs from it. + You are given a run id and a branch name: `/crypter-publish {run-id} {branch}`. ## 1. Fetch the branch out of the container @@ -29,58 +33,26 @@ and say so** — the branch is the whole deliverable. ```bash git fetch upstream git push origin upstream/stable:refs/heads/stable -git push -u origin {branch} +git push origin {branch} ``` The first push keeps the fork's `stable` level with the org repository, so the pull request compares against current code. -## 3. Open the pull request - -Open it against the fork, base `stable`, as a draft, using whatever GitHub access this session -has — the `gh` CLI, or the GitHub MCP server's `create_pull_request`. - -Take the title and description from the pipeline's report. Write the description for the org -repository's reviewers, since it carries over when the upstream pull request is opened. - -## 4. Hold it against CI - -Invoke `ci-watcher` with the pull request number, the attempt number, and -`.claude/plans/{run-id}/ci-{n}.md`. It runs **one attempt**: it reads the checks for the head -commit, watches them, and reports. - -You own the loop: - -1. `ci-watcher` reports green → go to stage 5. -2. `ci-watcher` reports a failure → run the fix in the container with the report it wrote: - - ```bash - docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/pipeline-fix {run-id} {branch} /plans/{run-id}/ci-{n}.md" - ``` - - Then fetch the new commits as in stage 1, push them, and invoke `ci-watcher` again with the - next attempt number. -3. **Stop after three attempts.** Comment the state of play on the pull request and hand back - to the user. +## 3. Open or update the pull request -Stop earlier and ask the user whenever another attempt looks pointless — the same check failing -the same way twice, a failure the plan did not anticipate, or anything that reads as a wrong -plan rather than wrong code. Three attempts is the ceiling, not a quota to spend. +Where a pull request for `{branch}` is already open, the push has updated it and there is +nothing more to do. Say which one it was. -Stop immediately, without spending an attempt, if `ci-watcher` reports that no run appeared for -the commit. Workflows stay disabled on a new fork until they are enabled once in its Actions -tab, and that is a setup problem. +Otherwise open it against the fork, base `stable`, as a draft, using whatever GitHub access this +session has — the `gh` CLI, or the GitHub MCP server's `create_pull_request`. -## 5. Report +Take the title and description from the report of whoever built the branch. Write the +description for the org repository's reviewers, since it carries over when the upstream pull +request is opened. -Tell the user: +## 4. Report -- The fork pull request URL and whether its checks are green. It is a draft; taking it out of - draft is theirs. -- What each fix attempt changed, if any ran. -- Anything the pipeline could not do, and what it rejected in triage. -- If the loop gave up: which check failed and what the last attempt tried. +The pull request URL, whether it was opened or updated, and the head commit now on it. -The upstream pull request is a separate one against `Crypter-File-Transfer/Crypter`, since the -base repository is fixed when a pull request is created. The description is ready to paste. +Checks start on the push. Watching them belongs to the caller. diff --git a/.claude/skills/crypter-remediate/SKILL.md b/.claude/skills/crypter-remediate/SKILL.md new file mode 100644 index 00000000..709ebc62 --- /dev/null +++ b/.claude/skills/crypter-remediate/SKILL.md @@ -0,0 +1,55 @@ +--- +name: crypter-remediate +description: Apply a report to a branch the pipeline already built, whether triaged review findings or a CI failure. Invoked as /crypter-remediate {run-id} {branch} {report-path} by the crypter-change skill on the host. +--- + +# Crypter remediate + +Take a report of what is wrong with a branch this container already built, and fix it. + +**This runs inside the devcontainer**, on a branch that exists in `/work/Crypter/.git`. The host +wrote the report and the host publishes the result. + +The report is triaged review findings or a CI failure. Both are the same job: a description of +what is wrong, an existing branch, and commits that address it. + +## Setup + +You are given a run id, a branch name, and a report path: +`/crypter-remediate {run-id} {branch} {report-path}`. + +Read the report first. It lives under `/runs/{run-id}/`, the mount the host shares with you. +**If it is absent, stop and say so.** + +Read `/plans/{run-id}/plan.md` too where one exists. The fix stays inside what the plan set out +to do; a repair that reaches into the plan's non-goals belongs in your report rather than in a +commit. + +## 1. Worktree on the existing branch + +```bash +git -C /work/Crypter fetch upstream +git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} {branch} +``` + +No `-b` — the branch is already there, carrying the commits the host has pushed. **If this +fails, stop and say so.** + +## 2. Fix + +Invoke `implementer` with the report path and the worktree path. Each fix is its own commit on +the branch. + +Read its report. If it says the failure could not be addressed, say so plainly in your own +report rather than reporting success. + +## 3. Hand off + +```bash +git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} +``` + +Remove it on every exit path. The branch keeps the new commits. + +Then report back to the host session: what the report described, what changed, and which commits +now sit on the branch. The host fetches those commits and pushes them. diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md new file mode 100644 index 00000000..107f92a6 --- /dev/null +++ b/.claude/skills/crypter-review/SKILL.md @@ -0,0 +1,73 @@ +--- +name: crypter-review +description: Review an existing pull request with the container's reviewer lenses and report what they found. Use when asked to scrutinise a pull request, or invoked as /crypter-review {pr-number}. +--- + +# Crypter review + +Put an existing pull request through the same lenses a change of your own goes through. + +**This runs on the host** and orchestrates one skill in the container. Use it on a pull request +that deserves more scrutiny than a read, and on pull requests other people raised. + +The findings come back to the user. Nothing is posted to GitHub. + +## Setup + +You are given a pull request number: `/crypter-review {pr-number}`. + +Use `pr-{number}` as the run id. + +```bash +mkdir -p .claude/runs/pr-{number}/findings +chmod 777 .claude/runs/pr-{number} .claude/runs/pr-{number}/findings +``` + +The container's `agent` is uid 1001 and your files are uid 1000, so the agents write into +directories this side creates and grants. Creating them here also keeps you able to delete what +they wrote. + +## 1. Read the pull request + +Read its title, description and diff with whatever GitHub access this session has — the `gh` +CLI, or the GitHub MCP server's `pull_request_read`. What the author says it does is context for +reading the diff, and worth carrying into your report where the two disagree. + +## 2. Fetch it into the container + +Pull request heads are public refs on the org repository, so the container reaches them +anonymously: + +```bash +docker exec crypter-pipeline \ + git -C /work/Crypter fetch upstream pull/{number}/head:pr-{number} +``` + +**If this fails, stop and say so.** + +## 3. Examine + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --dangerously-skip-permissions -p "/crypter-examine pr-{number} pr-{number}" +``` + +No plan path. A pull request raised elsewhere has no plan to hold it against, so the plan +adherence phase sits out and the lenses do the work. + +Findings land in `.claude/runs/pr-{number}/findings/{lens}.md`. + +## 4. Report + +Read the files and tell the user what is in them: + +- What each lens raised, and which findings you would act on first. +- Where a finding rests on an assumption about intent, say so — the lenses read a diff, not a + discussion. +- Which findings you checked against the code yourself and stand behind, separately from those + you are relaying. +- Where the artifacts are. + +Say plainly where the lenses found nothing. A quiet review is a result. + +Posting any of this to the pull request is the user's call, and theirs to do. diff --git a/.claude/skills/pipeline-fix/SKILL.md b/.claude/skills/pipeline-fix/SKILL.md deleted file mode 100644 index b18ffb9f..00000000 --- a/.claude/skills/pipeline-fix/SKILL.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -name: pipeline-fix -description: Apply a fix to a branch the pipeline already built, from a report the host wrote. Invoked as /pipeline-fix {run-id} {branch} {report-path} by the crypter-publish skill on the host. ---- - -# Pipeline fix - -Take a report of something wrong with a branch this container already built, and fix it. - -**This runs inside the devcontainer**, on a branch that exists in `/work/Crypter/.git` from an -earlier `/pipeline` run. The host wrote the report, and the host publishes the result. - -## Setup - -You are given a run id, a branch name, and a report path: `/pipeline-fix {run-id} {branch} -{report-path}`. - -Read the report first. It lives under `/plans/{run-id}/`, the read-only mount the host owns. -**If it is absent, stop and say so.** - -Read `/plans/{run-id}/plan.md` too. The fix stays inside what the plan set out to do. - -## 1. Worktree on the existing branch - -```bash -git -C /work/Crypter fetch upstream -git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} {branch} -``` - -No `-b` — the branch is already there, with the commits the host has pushed. **If this fails, -stop and say so.** - -## 2. Fix - -Invoke `implementer` with the report path and the worktree path. Each fix is its own commit on -the branch. - -Read its report. If it says the failure could not be addressed, say so plainly in your own -report rather than reporting success. - -## 3. Hand off - -```bash -git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} -``` - -Remove it on every exit path. The branch keeps the new commits. - -Then report back to the host session: what the failure was, what changed, and which commits -now sit on the branch. The host fetches those commits and pushes them. diff --git a/.claude/skills/pipeline/SKILL.md b/.claude/skills/pipeline/SKILL.md deleted file mode 100644 index 2039d23d..00000000 --- a/.claude/skills/pipeline/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: pipeline -description: Take an approved plan to a reviewed branch in the pipeline container, using a chain of subagents. Invoked as /pipeline {run-id} {branch} by the crypter-plan-author skill on the host. ---- - -# Pipeline - -Turn an approved plan into a reviewed branch, in stages, each run by a subagent with its own -context. A later stage that starts fresh actually re-examines the work; one that inherits the -reasoning behind it rubber-stamps it. - -**This runs inside the devcontainer.** The workspace is an anonymous clone of the org -repository with a single remote, `upstream`, which has no push url. The container holds no -credential and reads public code. The host session pushes the branch and opens the pull -request once you return. - -The plan is the specification. An interactive session on the host wrote it under the -`crypter-plan-author` skill and the user approved it there. This runs unattended, to a branch -the host can publish. - -## Setup - -You are given a run id and a branch name: `/pipeline {run-id} {branch}`. - -Read `/plans/{run-id}/plan.md` first. It is a read-only mount of the host's `.claude/plans`. -**If it is absent, stop and say so** — the host session owns that file. - -Run state goes in the workspace, which is writable: - -```bash -mkdir -p /work/Crypter/.claude/pipeline/{run-id} -``` - -`conformance.md` and `findings/` go there. It is gitignored. - -## 1. Sync and branch - -Build on current code: - -```bash -git -C /work/Crypter fetch upstream -git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} -b {branch} upstream/stable -``` - -**If either fails, stop and say so.** A quietly skipped sync leaves the diff and the eventual -pull request on the wrong base, and nothing downstream will notice. - -Every later stage gets this worktree path and works by absolute path inside it. Never `cd`. - -## 2. Implement - -Invoke `implementer` with `/plans/{run-id}/plan.md` and the worktree path. The plan is the -specification. - -Read its report. If it says a step could not be done, that is not a failure to paper over: -surface it to the user with the rest of the results at the end, and let the auditor record it. - -## 3. Examine - -Run these in parallel — they do not interact: - -- `conformance-auditor` with `/plans/{run-id}/plan.md`, the worktree, and - `/work/Crypter/.claude/pipeline/{run-id}/conformance.md`. -- `reviewer`, once per lens, with the worktree and - `/work/Crypter/.claude/pipeline/{run-id}/findings/{lens}.md`. - -The lens list is currently one entry: - -| Lens | Brief | -|---|---| -| general | Correctness and edge cases first, then scope creep, then the conventions in `CLAUDE.md`. | - -Adding lenses later — security, simplicity, test coverage — means adding rows here. The -`reviewer` definition does not change; the lens comes from the prompt. - -## 4. Triage - -You decide what to act on. Read every finding against the code before accepting it — a reviewer -that has already been wrong once will happily be wrong again, and acting on a bad finding means -changing working code. - -Accept anything with a concrete failure behind it. Reject preferences, restatements of the plan -you already chose against, and findings about code the diff did not touch. An unplanned extra -that contradicts the plan's non-goals is not a preference — accept it. - -Write what you accepted and what you rejected, with a reason for each rejection, into -`/work/Crypter/.claude/pipeline/{run-id}/findings/triage.md`. The user reads this to check your -judgement. - -## 5. Remediate - -If anything was accepted, invoke `implementer` with the accepted findings and the worktree -path. Each fix is its own commit on the existing branch. - -## 6. Hand off - -```bash -git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} -``` - -Remove it on every exit path, including when the pipeline stopped early. The branch ref lives -in `/work/Crypter/.git` and survives, which is what the host fetches. - -Then report back to the host session, in a few sentences: - -- The branch name, and a title and description for the pull request. Title reads like a commit - subject: imperative, capitalized, no trailing period. Description is a few sentences of plain - English saying what changed and why, written for the org repository's reviewers. **Do not - argue the case** — no justifying the approach, no pre-empting objections, no listing rejected - alternatives. Call out what a reviewer would otherwise have to discover: migrations, breaking - API changes, deliberately held-back dependencies. That is information, not argument. -- Anything the implementer could not do, and any deviation the auditor flagged as drift. -- What you rejected in triage that the user might disagree with. - -The host session pushes the branch and opens the pull request from there. diff --git a/.devcontainer/clone-upstream.sh b/.devcontainer/clone-upstream.sh index e7b8bd13..8bd0f8d1 100644 --- a/.devcontainer/clone-upstream.sh +++ b/.devcontainer/clone-upstream.sh @@ -13,7 +13,7 @@ set -euo pipefail upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" -# Has to match the workspace path the pipeline skill and docker-compose.yml use. +# Has to match the workspace path the container skills and docker-compose.yml use. workspace="/work/Crypter" git config --global user.name "${CRYPTER_GIT_NAME}" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index 2c095e38..b0cfd68d 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -17,6 +17,8 @@ services: - claude:/home/agent/.claude # Plans are authored on the host and read from /plans. - ../.claude/plans:/plans:ro + # Findings, conformance and triage are artifacts on the host, written from /runs. + - ../.claude/runs:/runs # /work/Crypter does not exist until crypter-clone-upstream has run, so the container starts # one level up. Open a shell with `exec -w /work/Crypter`. working_dir: /work diff --git a/.gitignore b/.gitignore index 3c655648..d5b52fda 100644 --- a/.gitignore +++ b/.gitignore @@ -468,5 +468,8 @@ Crypter.Web/pnpm-lock.yaml # Plans authored on the host and mounted into the pipeline container .claude/plans/ +# Findings and triage written back from the pipeline container +.claude/runs/ + # Devcontainer configuration, copied from .devcontainer/.env.example .devcontainer/.env diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 213a6927..87cd21c2 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -1,12 +1,20 @@ # Agentic Development Pipeline -A change goes through three skills: +Two orchestrators compose a set of task skills. | Skill | Runs | Does | |---|---|---| -| `/crypter-plan-author` | Host | Drafts the plan interactively, with the web, your tooling and you available to it | -| `/pipeline` | Container | Implements the plan, reviews it, and leaves a branch | -| `/crypter-publish` | Host | Pushes the branch, opens the pull request, holds it against CI | +| `/crypter-change` | Host | Carries a requirement to a green draft pull request | +| `/crypter-review` | Host | Puts an existing pull request through the reviewer lenses | +| `/crypter-plan` | Host | Drafts the plan interactively, with the web, your tooling and you available to it | +| `/crypter-implement` | Container | Builds the plan into commits on a new branch | +| `/crypter-examine` | Container | Reviews a diff for plan adherence and code quality | +| `/crypter-remediate` | Container | Applies triaged findings or a CI failure to an existing branch | +| `/crypter-publish` | Host | Pushes the branch and opens or updates the pull request | + +The task skills stand alone. `/crypter-plan` is worth running on its own when you want a plan +and nothing else, and `/crypter-publish` is safe to run repeatedly, which is how the CI loop +uses it. **The container holds no credential.** Its workspace is an anonymous clone of the org repository with one remote, `upstream`, which has no push url, so the agents read public code @@ -14,28 +22,56 @@ and commit locally. Every authenticated GitHub operation happens on the host wit access. The workspace is a named Docker volume rather than a bind mount of your checkout, so the agents cannot touch uncommitted work on your machine. -When `/crypter-publish` finishes you have a fork pull request to read; opening one against the -org repository is something you do by hand afterwards. +When `/crypter-change` finishes you have a fork pull request to read; opening one against the org +repository is something you do by hand afterwards. This document covers the setup you need before the container will start. -## Planning and the plans mount +## The two mounts -`/crypter-plan-author` writes to `.claude/plans/{run-id}/plan.md` on your host, which is -gitignored. Compose mounts `.claude/plans` read-only at `/plans` in the container, so the -pipeline reads the plan where you wrote it and the agents write their run state to the workspace -instead. +Everything crossing the container boundary goes through one of two directories, both gitignored +and both on your disk: -You approve the plan in that host session. It then starts the pipeline itself: +| Host | Container | Direction | Holds | +|---|---|---|---| +| `.claude/plans/{run-id}` | `/plans` | Read-only | `plan.md` | +| `.claude/runs/{run-id}` | `/runs` | Writable | `conformance.md`, `findings/{lens}.md`, `triage.md`, `ci-{n}.md` | + +The plan goes in and cannot be rewritten by the agents. Findings come back out as files you can +open, grep and keep, rather than as text in a transcript, and each is written by the agent that +found it. `triage.md` is what `/crypter-change` decided to act on, and reading it is how you +check that judgement. + +The container's `agent` user is uid 1001, because the base image already has a user on 1000. A +bind mount keeps host ownership, so the orchestrators create every directory under `.claude/runs` +themselves and give it mode 777. Directories made on the host stay deletable from the host; a +directory the container creates is one you need `docker exec` to remove. + +The branch itself travels differently. It never passes through a mount: ```bash -docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/pipeline {run-id} {branch}" +git -c protocol.ext.allow=user fetch \ + "ext::docker exec -i crypter-pipeline git upload-pack /work/Crypter" {branch}:{branch} ``` -A container created before the mount existed picks it up on +`protocol.ext.allow` is passed per command, so it stays out of your git config. + +A container created before these mounts existed picks them up on `docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate`. +## Running a change + +```bash +/crypter-change "" +``` + +It plans, stops for your approval, then builds, examines, triages, remediates, publishes, and +holds the pull request against CI for at most three fix attempts. The approval is the only stop. + +`/crypter-review {pr-number}` is the other entry point. It fetches a pull request's head into the +container, runs the lenses against it with no plan to audit, and reports. It posts nothing to +GitHub. + ## Configuration `.devcontainer/.env` holds everything Compose substitutes when it creates the container. It is @@ -67,26 +103,10 @@ docker compose -f .devcontainer/docker-compose.yml exec -w /work/Crypter pipelin Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` reuses the workspace and your Claude Code credentials. -## Publishing - -`/crypter-publish {run-id} {branch}` reaches into the container for the branch, using git's -`ext` transport over `docker exec`: - -```bash -git -c protocol.ext.allow=user fetch \ - "ext::docker exec -i crypter-pipeline git upload-pack /work/Crypter" {branch}:{branch} -``` - -`protocol.ext.allow` is passed per command, so it stays out of your git config. From there the -host pushes the branch to your fork, opens the draft pull request, and runs the CI loop with -your own GitHub access — the `gh` CLI or the GitHub MCP server. Three fix attempts is the -ceiling; `/crypter-publish` writes each failure to `.claude/plans/{run-id}/ci-{n}.md` and runs -`/pipeline-fix` in the container to address it. - ## Enable Actions on your fork GitHub disables workflows on new forks. Until you turn them on, pushing a branch runs nothing, -and the pipeline stops at the CI stage reporting that no run ever appeared. +and `/crypter-change` stops at the CI stage reporting that no run ever appeared. Open the **Actions** tab on your fork and use the button confirming you want to run workflows. You only do this once. From f75eaaa7bc226c0c3ee56024a23ed518fb86a372 Mon Sep 17 00:00:00 2001 From: n Date: Tue, 4 Aug 2026 22:57:15 -0500 Subject: [PATCH 25/41] Take a detached worktree when examining a ref crypter-examine claimed the branch it reviewed, so it failed whenever anything else already held that ref. It only reads, and a detached worktree reads just as well. Co-Authored-By: Claude Opus 5 --- .claude/skills/crypter-examine/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/crypter-examine/SKILL.md b/.claude/skills/crypter-examine/SKILL.md index 0b017b3a..839e2b47 100644 --- a/.claude/skills/crypter-examine/SKILL.md +++ b/.claude/skills/crypter-examine/SKILL.md @@ -37,10 +37,11 @@ one the host cannot clean up. ## 1. Worktree on the ref ```bash -git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} {ref} +git -C /work/Crypter worktree add --detach /work/Crypter/.claude/worktrees/{run-id} {ref} ``` -No `-b` — the ref is already there. **If this fails, stop and say so.** +`--detach` because you only read. A worktree that claims the branch collides with anything else +holding it, and reviewing never needs it claimed. **If this fails, stop and say so.** Every agent gets this worktree path and works by absolute path inside it. Never `cd`. From 0f6bc8f4deccc77e13861b77c25690a1fdc882fd Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 10:56:32 -0500 Subject: [PATCH 26/41] Rename the publish skill and fix what the examine run found The pipeline reviewed its own branch. Acting on what it raised: crypter-publish becomes crypter-open-pull-request, since the skill only ever opens a draft and taking a pull request out of draft is the user's call. ci-watcher loses the fork parameter it was never given and resolves the repository from the path it already has, and reads the branch's version of a file rather than whatever the working tree is checked out at. crypter-review forces its fetch refspec, so a pull request that was rebased or amended can be reviewed again. The mount table named the run-id directory as the mount source; the mounts are the parents. The pre-flight check tested the parent of /runs, which uid 1001 cannot write to by design, so it now tests the run directory the caller just created. The launch steps create both mount sources, because Docker creates a missing bind-mount source as root. The documentation credited a fork-scoped token with bounding the container and claimed it holds no credential; the Claude Code credential is still there, so it says GitHub credential and names the container as a trust boundary. The orchestrators state that they run from the main checkout, which is where the mounts resolve. --- .claude/agents/ci-watcher.md | 42 ++++++++----- .claude/skills/crypter-change/SKILL.md | 32 +++++----- .claude/skills/crypter-examine/SKILL.md | 4 +- .claude/skills/crypter-implement/SKILL.md | 9 ++- .../SKILL.md | 19 +++--- .claude/skills/crypter-plan/SKILL.md | 4 +- .claude/skills/crypter-remediate/SKILL.md | 4 +- .claude/skills/crypter-review/SKILL.md | 12 +++- .gitignore | 3 - .../Agentic Development Pipeline.md | 61 +++++++++++++------ 10 files changed, 114 insertions(+), 76 deletions(-) rename .claude/skills/{crypter-publish => crypter-open-pull-request}/SKILL.md (75%) diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index f9fc21ff..a2f19b16 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -13,16 +13,20 @@ You find out whether CI accepts the pull request as it currently stands. You do code. When checks fail you produce a description of the failure precise enough that an implementer who has never seen this pull request can fix it. -You are given a repository path, a branch name, a fork as `/`, a pull request -number, an attempt number, and the path to `ci-{n}.md`. You run **one attempt**. The skill -counts attempts, runs the fix between them, and calls you again — so you always start from a -clean read of the current state rather than from your own last guess. +You are given a repository path, a branch name, a pull request number, an attempt number, and +the path to `ci-{n}.md`. You run **one attempt**. The skill counts attempts, runs the fix +between them, and calls you again — so you always start from a clean read of the current state +rather than from your own last guess. -**You run on the host**, with the session's own GitHub access. Use -`mcp__github__pull_request_read` with `method: "get_check_runs"` for the head commit's checks -and `method: "get_status"` for the combined status. Where the `gh` CLI is installed, its -`gh pr checks --watch` and `gh run view --log-failed` give more detail; use them when they are -there. +The pull request is on the repository the branch was pushed to: + +```bash +git -C remote get-url origin +``` + +Use `mcp__github__pull_request_read` with `method: "get_check_runs"` for the head commit's +checks. Where the `gh` CLI is installed, `gh pr checks --watch` and `gh run list --commit ` +followed by `gh run view --log-failed` give more detail; use them when they are there. ## Find the run @@ -70,11 +74,17 @@ not have caught locally shows up. ## On failure Get the real error. The check run's `output` summary and annotations carry the diagnostic; -where `gh` is installed, `gh run view --repo --log-failed` carries more. +where `gh` is installed, the failed job's log carries more. + +Then read the code the failure points at. The repository's working tree is on whatever the user +last checked out, so read the branch's version: + +```bash +git -C show : +``` -Then read the code the failure points at, in the repository at that branch. A stack trace names -a file and a line; open it. The difference between a useful report and a useless one is whether -you found the cause or just copied the symptom. +A stack trace names a file and a line; open it. The difference between a useful report and a +useless one is whether you found the cause or just copied the symptom. Write the attempt to `ci-{n}.md`: @@ -85,8 +95,8 @@ Write the attempt to `ci-{n}.md`: - Whether it looks like a code defect, a wrong test, or something environmental. Say which, and say when you are unsure. -This file is what the container reads, through its `/runs` mount, so it has to stand on its own. Then report the same thing back. Do not propose a patch; the implementer decides -the fix. +This file is what the container reads, through its `/runs` mount, so it has to stand on its +own. Then report the same thing back. Do not propose a patch; the implementer decides the fix. If the failure looks like the plan itself was wrong — the tests encode behaviour the change contradicts — say so plainly. That is the signal for a human to step in, and it is worth more @@ -94,5 +104,5 @@ than another attempt. ## On success -Append the result to `ci-{n}.md`, and report the pull request URL, the checks that passed, and +Write the result to `ci-{n}.md`, and report the pull request URL, the checks that passed, and the mergeable state. Say nothing about quality; that was the pipeline's review stage. diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index 1f5375bd..9170c517 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -1,15 +1,17 @@ --- name: crypter-change -description: Take a requirement to an open, CI-green draft pull request, orchestrating the plan, build, review and publish skills. Use when asked to make a change to Crypter, or invoked as /crypter-change "". +description: Take a requirement to an open, CI-green draft pull request, orchestrating the plan, implement, examine and pull request skills. Use when asked to make a change to Crypter, or invoked as /crypter-change "". --- # Crypter change Carry a requirement from a sentence to a draft pull request whose checks pass. -**This runs on the host** and owns the whole run. The work happens in skills below you: planning -here, building and reviewing in the container, publishing here. You hold the plan, the findings -and every CI attempt, which is why the judgement calls are yours. +You own the whole run. Building and reviewing happen in the container; you hold the plan, the +findings and every CI attempt, which is why the judgement calls are yours. + +Run from the root of the main checkout. The container's mounts resolve against it, so a run +started from a worktree writes its plan where the container cannot read it. There is one gate: the user approves the plan. Everything after it runs to a green draft pull request, or to a written account of why CI would not take it. @@ -34,10 +36,12 @@ and both are yours to read at any point. write into a directory that grants it. Creating them on this side also keeps you able to delete what they wrote — a directory the container creates is one you cannot remove. -The container needs both mounts. Confirm before starting a run: +The container needs both mounts, and the run directory has to be writable from inside it. +Confirm before starting: ```bash -docker exec crypter-pipeline test -d /plans && docker exec crypter-pipeline test -w /runs +docker exec crypter-pipeline test -d /plans/{run-id} && \ + docker exec crypter-pipeline test -w /runs/{run-id}/findings ``` A container created before these existed picks them up on @@ -57,7 +61,7 @@ docker exec -w /work/Crypter crypter-pipeline \ claude --dangerously-skip-permissions -p "/crypter-implement {run-id} {branch}" ``` -Keep the title and description it reports; `crypter-publish` needs them. +Keep the title and description it reports; `crypter-open-pull-request` needs them. ## 3. Examine @@ -92,21 +96,21 @@ docker exec -w /work/Crypter crypter-pipeline \ claude --dangerously-skip-permissions -p "/crypter-remediate {run-id} {branch} /runs/{run-id}/triage.md" ``` -## 6. Publish +## 6. Open the pull request -Invoke `crypter-publish` with the run id and the branch. It fetches the commits out of the -container, pushes them, and opens or updates the draft pull request. +Invoke `crypter-open-pull-request` with the run id and the branch. It fetches the commits out of +the container, pushes them, and opens or updates the draft pull request. ## 7. Hold it against CI -Invoke `ci-watcher` with the repository path, the branch, the fork, the pull request number, the -attempt number, and `.claude/runs/{run-id}/ci-{n}.md`. It runs one attempt and reports. +Invoke `ci-watcher` with the repository path, the branch, the pull request number, the attempt +number, and `.claude/runs/{run-id}/ci-{n}.md`. It runs one attempt and reports. The loop is yours: 1. Green → go to stage 8. -2. A failure → run `crypter-remediate` with `/runs/{run-id}/ci-{n}.md`, invoke `crypter-publish` - again, then `ci-watcher` with the next attempt number. +2. A failure → run `crypter-remediate` with `/runs/{run-id}/ci-{n}.md`, invoke + `crypter-open-pull-request` again, then `ci-watcher` with the next attempt number. 3. **Three attempts is the ceiling.** Comment the state of play on the pull request and hand back to the user. diff --git a/.claude/skills/crypter-examine/SKILL.md b/.claude/skills/crypter-examine/SKILL.md index 839e2b47..540aec92 100644 --- a/.claude/skills/crypter-examine/SKILL.md +++ b/.claude/skills/crypter-examine/SKILL.md @@ -6,9 +6,9 @@ description: Review a diff in the pipeline container and write findings to the h # Crypter examine Review a diff and leave hard artifacts behind. You do not write code and you do not decide what -gets acted on; the host session triages what you find. +gets acted on; your caller triages what you find. -**This runs inside the devcontainer**, against a ref that already exists in `/work/Crypter/.git`. +The ref already exists in `/work/Crypter/.git`. ## Setup diff --git a/.claude/skills/crypter-implement/SKILL.md b/.claude/skills/crypter-implement/SKILL.md index d30a6315..af4aaa4b 100644 --- a/.claude/skills/crypter-implement/SKILL.md +++ b/.claude/skills/crypter-implement/SKILL.md @@ -7,12 +7,11 @@ description: Build an approved plan into commits on a new branch, inside the pip Turn an approved plan into commits on a branch. -**This runs inside the devcontainer.** The workspace is an anonymous clone of the org repository -with a single remote, `upstream`, which has no push url. The container holds no credential and -reads public code. The host session publishes the branch once you return. +The workspace is an anonymous clone of the org repository with a single remote, `upstream`, +which has no push url. Commit locally and stop there; the branch is fetched out and pushed once +you return. -The plan is the specification. An interactive session on the host wrote it and the user approved -it there. This runs unattended. +The plan is the specification. The user approved it before this ran, and this runs unattended. ## Setup diff --git a/.claude/skills/crypter-publish/SKILL.md b/.claude/skills/crypter-open-pull-request/SKILL.md similarity index 75% rename from .claude/skills/crypter-publish/SKILL.md rename to .claude/skills/crypter-open-pull-request/SKILL.md index e62740c5..11ab0468 100644 --- a/.claude/skills/crypter-publish/SKILL.md +++ b/.claude/skills/crypter-open-pull-request/SKILL.md @@ -1,20 +1,17 @@ --- -name: crypter-publish -description: Push a branch the pipeline built in the container to the fork and open or update its pull request. Use when a branch is ready to publish, or invoked as /crypter-publish {run-id} {branch}. +name: crypter-open-pull-request +description: Push a branch the pipeline built in the container to the fork and open or update its draft pull request. Use when a branch is ready for a pull request, or invoked as /crypter-open-pull-request {run-id} {branch}. --- -# Crypter publish +# Crypter open pull request -Take the branch the container built and put it on the fork, with a pull request open against it. - -**This runs on the host.** The container holds no credential, so every authenticated GitHub -operation happens here, with yours. +Take the branch the container built and put it on the fork, with a draft pull request open +against it. Safe to run repeatedly on the same branch. Each run pushes whatever commits the container has -added and updates the existing pull request, which is what a caller looping over CI attempts -needs from it. +added and updates the existing pull request. -You are given a run id and a branch name: `/crypter-publish {run-id} {branch}`. +You are given a run id and a branch name: `/crypter-open-pull-request {run-id} {branch}`. ## 1. Fetch the branch out of the container @@ -47,6 +44,8 @@ nothing more to do. Say which one it was. Otherwise open it against the fork, base `stable`, as a draft, using whatever GitHub access this session has — the `gh` CLI, or the GitHub MCP server's `create_pull_request`. +It stays a draft. Taking it out of draft is the user's. + Take the title and description from the report of whoever built the branch. Write the description for the org repository's reviewers, since it carries over when the upstream pull request is opened. diff --git a/.claude/skills/crypter-plan/SKILL.md b/.claude/skills/crypter-plan/SKILL.md index ff0e7fc1..541fd3bb 100644 --- a/.claude/skills/crypter-plan/SKILL.md +++ b/.claude/skills/crypter-plan/SKILL.md @@ -9,8 +9,8 @@ You turn a requirement into a plan someone else implements from. They see the pl else — not your reasoning, not the files you read, not the alternatives you rejected. Write for that reader. -This runs on the host, with the web, the user's tooling, and the user available to you. Settle -anything that needs them here, and write the answer into the plan. +The web, the user's tooling, and the user are available to you. Settle anything that needs them +here, and write the answer into the plan. A plan stands on its own. Writing one commits you to nothing: the plan is worth having whether it goes to `crypter-change`, to a person, or nowhere. diff --git a/.claude/skills/crypter-remediate/SKILL.md b/.claude/skills/crypter-remediate/SKILL.md index 709ebc62..a010df7b 100644 --- a/.claude/skills/crypter-remediate/SKILL.md +++ b/.claude/skills/crypter-remediate/SKILL.md @@ -7,8 +7,8 @@ description: Apply a report to a branch the pipeline already built, whether tria Take a report of what is wrong with a branch this container already built, and fix it. -**This runs inside the devcontainer**, on a branch that exists in `/work/Crypter/.git`. The host -wrote the report and the host publishes the result. +The branch exists in `/work/Crypter/.git`. Commit locally; the result is fetched out and pushed +once you return. The report is triaged review findings or a CI failure. Both are the same job: a description of what is wrong, an existing branch, and commits that address it. diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index 107f92a6..bef6668e 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -7,8 +7,9 @@ description: Review an existing pull request with the container's reviewer lense Put an existing pull request through the same lenses a change of your own goes through. -**This runs on the host** and orchestrates one skill in the container. Use it on a pull request -that deserves more scrutiny than a read, and on pull requests other people raised. +Use it on a pull request that deserves more scrutiny than a read, and on pull requests other +people raised. The lenses run in the container, against a copy of the pull request fetched into +its clone. The findings come back to the user. Nothing is posted to GitHub. @@ -16,6 +17,8 @@ The findings come back to the user. Nothing is posted to GitHub. You are given a pull request number: `/crypter-review {pr-number}`. +Run from the root of the main checkout. The container's mounts resolve against it. + Use `pr-{number}` as the run id. ```bash @@ -40,9 +43,12 @@ anonymously: ```bash docker exec crypter-pipeline \ - git -C /work/Crypter fetch upstream pull/{number}/head:pr-{number} + git -C /work/Crypter fetch upstream +pull/{number}/head:pr-{number} ``` +The refspec is forced, so reviewing a pull request again after its author rebased or amended +picks up the new head instead of being rejected. + **If this fails, stop and say so.** ## 3. Examine diff --git a/.gitignore b/.gitignore index d5b52fda..cf594157 100644 --- a/.gitignore +++ b/.gitignore @@ -462,9 +462,6 @@ Crypter.Web/pnpm-lock.yaml # Claude Code worktrees .claude/worktrees/ -# Agentic pipeline run state -.claude/pipeline/ - # Plans authored on the host and mounted into the pipeline container .claude/plans/ diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 87cd21c2..907a8294 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -1,26 +1,43 @@ # Agentic Development Pipeline -Two orchestrators compose a set of task skills. +Two orchestrators compose a set of task skills. You invoke an orchestrator in your own session, +and it invokes the rest. -| Skill | Runs | Does | +| Orchestrator | Does | +|---|---| +| `/crypter-change ""` | Carries a requirement to a green draft pull request | +| `/crypter-review {pr-number}` | Puts an existing pull request through the reviewer lenses | + +| Task skill | Executes in | Does | |---|---|---| -| `/crypter-change` | Host | Carries a requirement to a green draft pull request | -| `/crypter-review` | Host | Puts an existing pull request through the reviewer lenses | -| `/crypter-plan` | Host | Drafts the plan interactively, with the web, your tooling and you available to it | +| `/crypter-plan` | Your session | Drafts the plan interactively, with the web, your tooling and you available to it | | `/crypter-implement` | Container | Builds the plan into commits on a new branch | | `/crypter-examine` | Container | Reviews a diff for plan adherence and code quality | | `/crypter-remediate` | Container | Applies triaged findings or a CI failure to an existing branch | -| `/crypter-publish` | Host | Pushes the branch and opens or updates the pull request | +| `/crypter-open-pull-request` | Your session | Pushes the branch and opens or updates the draft pull request | + +Every skill that reads or writes code runs in the container, against the container's own clone. +Your session plans, decides what to act on, and talks to GitHub. `/crypter-review` reviews +nothing itself: it fetches the pull request into the container and runs `/crypter-examine` +there. The task skills stand alone. `/crypter-plan` is worth running on its own when you want a plan -and nothing else, and `/crypter-publish` is safe to run repeatedly, which is how the CI loop -uses it. +and nothing else, and `/crypter-open-pull-request` is safe to run repeatedly, which is how the +CI loop uses it. -**The container holds no credential.** Its workspace is an anonymous clone of the org +**Run the orchestrators from the root of your main checkout.** The container's mounts are +relative to `.devcontainer/`, so `.claude/plans` and `.claude/runs` resolve against that one +directory. Started from a worktree, a run writes its plan somewhere the container cannot read. + +**The container holds no GitHub credential.** Its workspace is an anonymous clone of the org repository with one remote, `upstream`, which has no push url, so the agents read public code -and commit locally. Every authenticated GitHub operation happens on the host with your own -access. The workspace is a named Docker volume rather than a bind mount of your checkout, so -the agents cannot touch uncommitted work on your machine. +and commit locally. Every authenticated GitHub operation happens in your session with your own +access, and `/crypter-change` pushes and re-pushes without stopping to ask. The workspace is a +named Docker volume rather than a bind mount of your checkout, so the agents cannot touch +uncommitted work on your machine. + +The container does hold your Claude Code credential, in the `crypter-pipeline-claude` volume, +and its network egress is open. Treat it as a trust boundary rather than a sandbox. When `/crypter-change` finishes you have a fork pull request to read; opening one against the org repository is something you do by hand afterwards. @@ -34,8 +51,8 @@ and both on your disk: | Host | Container | Direction | Holds | |---|---|---|---| -| `.claude/plans/{run-id}` | `/plans` | Read-only | `plan.md` | -| `.claude/runs/{run-id}` | `/runs` | Writable | `conformance.md`, `findings/{lens}.md`, `triage.md`, `ci-{n}.md` | +| `.claude/plans` | `/plans` | Read-only | `{run-id}/plan.md` | +| `.claude/runs` | `/runs` | Writable | `{run-id}/conformance.md`, `{run-id}/findings/{lens}.md`, `{run-id}/triage.md`, `{run-id}/ci-{n}.md` | The plan goes in and cannot be rewritten by the agents. Findings come back out as files you can open, grep and keep, rather than as text in a transcript, and each is written by the agent that @@ -65,8 +82,9 @@ A container created before these mounts existed picks them up on /crypter-change "" ``` -It plans, stops for your approval, then builds, examines, triages, remediates, publishes, and -holds the pull request against CI for at most three fix attempts. The approval is the only stop. +It plans, stops for your approval, then builds, examines, triages, remediates, opens the draft +pull request, and holds it against CI for at most three fix attempts. The approval is the only +stop, and the pull request stays a draft until you take it out of one. `/crypter-review {pr-number}` is the other entry point. It fetches a pull request's head into the container, runs the lenses against it with no plan to audit, and reports. It posts nothing to @@ -96,10 +114,15 @@ Compose project from the application stack at the repository root, so `docker co `docker compose down` there never touch it, and the two share no network. ```bash +mkdir -p .claude/plans .claude/runs docker compose -f .devcontainer/docker-compose.yml up -d docker compose -f .devcontainer/docker-compose.yml exec -w /work/Crypter pipeline bash ``` +Create the two mount sources first. They are gitignored, so a fresh clone has neither, and +Docker creates a missing bind-mount source as root — which the orchestrators then cannot write +into. + Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` reuses the workspace and your Claude Code credentials. @@ -155,9 +178,9 @@ Credentials live in `/home/agent/.claude`, which is the `crypter-pipeline-claude they survive container rebuilds. You only do this again after removing that volume. Run the agents with `--dangerously-skip-permissions`. A pipeline that stops to approve every -file write is not a pipeline, and the fork-scoped token is what bounds the blast radius rather -than the permission prompts. That flag is also why the container runs as the unprivileged -`agent` user; Claude Code refuses it as root. +file write is not a pipeline. What bounds the blast radius is the container itself: a workspace +in a named volume, a remote with no push url, and no GitHub credential to push with. That flag +is also why the container runs as the unprivileged `agent` user; Claude Code refuses it as root. ## Changing the image From 3d9cc70760d3b20b844076cadf82ad1d85e3bf83 Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 10:59:01 -0500 Subject: [PATCH 27/41] Run the container agents in auto permission mode The agents run unattended, which is the whole reason permissions had to be resolved without a prompt. Auto mode does that without handing them the bypass, and the unprivileged user in the image stands on its own merits rather than on what the bypass refuses to do as root. --- .claude/skills/crypter-change/SKILL.md | 6 +++--- .claude/skills/crypter-review/SKILL.md | 2 +- .devcontainer/Dockerfile | 3 +-- .../Development/Agentic Development Pipeline.md | 9 ++++----- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index 9170c517..b85e91ad 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -58,7 +58,7 @@ It settles the plan with the user itself. **Do not continue until they have appr ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/crypter-implement {run-id} {branch}" + claude --permission-mode auto -p "/crypter-implement {run-id} {branch}" ``` Keep the title and description it reports; `crypter-open-pull-request` needs them. @@ -67,7 +67,7 @@ Keep the title and description it reports; `crypter-open-pull-request` needs the ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/crypter-examine {run-id} {branch} /plans/{run-id}/plan.md" + claude --permission-mode auto -p "/crypter-examine {run-id} {branch} /plans/{run-id}/plan.md" ``` It writes `.claude/runs/{run-id}/conformance.md` and `.claude/runs/{run-id}/findings/{lens}.md`. @@ -93,7 +93,7 @@ Where anything was accepted: ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/crypter-remediate {run-id} {branch} /runs/{run-id}/triage.md" + claude --permission-mode auto -p "/crypter-remediate {run-id} {branch} /runs/{run-id}/triage.md" ``` ## 6. Open the pull request diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index bef6668e..c2479c4c 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -55,7 +55,7 @@ picks up the new head instead of being rejected. ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --dangerously-skip-permissions -p "/crypter-examine pr-{number} pr-{number}" + claude --permission-mode auto -p "/crypter-examine pr-{number} pr-{number}" ``` No plan path. A pull request raised elsewhere has no plan to hold it against, so the plan diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 4ab95c70..63ba2156 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -12,8 +12,7 @@ ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ DOTNET_TOOLS=/usr/local/share/dotnet-tools ENV PATH="${PATH}:${DOTNET_TOOLS}" -# Claude Code refuses --dangerously-skip-permissions when running as root on Linux, -# so the agents need an unprivileged user to run as. +# The agents run unattended, so they run as an unprivileged user rather than root. RUN groupadd --gid $USER_GID $USERNAME \ && useradd --uid $USER_UID --gid $USER_GID --create-home --shell /bin/bash $USERNAME diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 907a8294..3f78ed43 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -141,7 +141,7 @@ the container pulls it for you. There is nothing to build unless you are changin itself. Built on `mcr.microsoft.com/dotnet/sdk:10.0`, running as an unprivileged user named `agent` -because Claude Code refuses `--dangerously-skip-permissions` as root: +rather than as root: - The .NET 10 SDK, the `wasm-tools` workload, and `dotnet-ef` - Node 22 and pnpm 11.18.0, which `Crypter.Web`'s PreBuild target needs @@ -177,10 +177,9 @@ your host and a code to paste back. Credentials live in `/home/agent/.claude`, which is the `crypter-pipeline-claude` volume, so they survive container rebuilds. You only do this again after removing that volume. -Run the agents with `--dangerously-skip-permissions`. A pipeline that stops to approve every -file write is not a pipeline. What bounds the blast radius is the container itself: a workspace -in a named volume, a remote with no push url, and no GitHub credential to push with. That flag -is also why the container runs as the unprivileged `agent` user; Claude Code refuses it as root. +Run the agents with `--permission-mode auto`. They work unattended, so a prompt they cannot +answer is a run that stalls. What bounds the blast radius is the container itself: a workspace +in a named volume, a remote with no push url, and no GitHub credential to push with. ## Changing the image From 320ce1521fd62e5c6075a3cc06cb99cb3d450650 Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 11:14:16 -0500 Subject: [PATCH 28/41] Refuse file operations that leave the project through a symlink .claude/runs is a writable mount into the pipeline container, and /crypter-review runs agents over diffs written by people outside this project. A symlink left in that directory resolves on the host side, so reading a findings file can read any file the user can, and writing triage.md can overwrite one. The hook blocks a path inside the project that resolves outside it. Paths that point outside to begin with are untouched, since asking for one is deliberate. It is written in Node because the build already depends on it, so it runs where a shell script would not. --- .claude/hooks/deny-symlink-escape.mjs | 58 +++++++++++++++++++++++++++ .claude/settings.json | 15 +++++++ 2 files changed, 73 insertions(+) create mode 100644 .claude/hooks/deny-symlink-escape.mjs create mode 100644 .claude/settings.json diff --git a/.claude/hooks/deny-symlink-escape.mjs b/.claude/hooks/deny-symlink-escape.mjs new file mode 100644 index 00000000..fe49ea02 --- /dev/null +++ b/.claude/hooks/deny-symlink-escape.mjs @@ -0,0 +1,58 @@ +// Refuse a file operation on a path inside the project that resolves outside it. +// +// A path pointing outside the project is left alone; asking for one is deliberate. What this +// blocks is a path that looks local and is not — a symlink in the working tree leading to a +// file elsewhere on the machine. The pipeline makes that reachable: .claude/runs is a writable +// mount into the container, and the agents writing there review diffs written by people +// outside this project. +import { readFileSync, realpathSync } from "node:fs"; +import { resolve, relative, isAbsolute } from "node:path"; + +const projectDir = realpathSync(process.env.CLAUDE_PROJECT_DIR ?? process.cwd()); + +const inside = (child) => { + const rel = relative(projectDir, child); + return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); +}; + +// The nearest ancestor that exists, so a file about to be created is judged by the directory +// it lands in. +const resolveExisting = (path) => { + for (let current = path; ; ) { + try { + return realpathSync(current); + } catch { + const parent = resolve(current, ".."); + if (parent === current) { + return null; + } + current = parent; + } + } +}; + +let input; +try { + input = JSON.parse(readFileSync(0, "utf8")); +} catch { + process.exit(0); +} + +const filePath = input?.tool_input?.file_path ?? input?.tool_input?.notebook_path; +if (!filePath) { + process.exit(0); +} + +const target = resolve(projectDir, filePath); +if (!inside(target)) { + process.exit(0); +} + +const resolved = resolveExisting(target); +if (resolved !== null && !inside(resolved)) { + console.error( + `${filePath} is inside the project but resolves to ${resolved}. ` + + "Refusing to follow it out." + ); + process.exit(2); +} diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..01ea193c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Edit|Write|NotebookEdit", + "hooks": [ + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/deny-symlink-escape.mjs\"" + } + ] + } + ] + } +} From ea67500f28c25d5e1d3a7623224738ca26b53cbf Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 11:21:53 -0500 Subject: [PATCH 29/41] Post the review lenses' findings to the pull request A review that lands only on the reviewer's disk asks the reviewer to retype it. crypter-review now triages what the lenses raised and posts a single review that comments, with a line comment for each finding that lands inside the diff and the rest in the body. It never approves and never requests changes. The pull request may belong to someone else, and either verdict is the user's to give. Triage comes first, and it is written to triage.md, so the findings that reach the author are the ones that survived a read against the code, and so a later remediation run has something to start from. --- .claude/skills/crypter-review/SKILL.md | 50 ++++++++++++++----- .../Agentic Development Pipeline.md | 4 +- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index c2479c4c..cff436a1 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -1,6 +1,6 @@ --- name: crypter-review -description: Review an existing pull request with the container's reviewer lenses and report what they found. Use when asked to scrutinise a pull request, or invoked as /crypter-review {pr-number}. +description: Review an existing pull request with the container's reviewer lenses and post what they found to the pull request. Use when asked to scrutinise a pull request, or invoked as /crypter-review {pr-number}. --- # Crypter review @@ -11,7 +11,8 @@ Use it on a pull request that deserves more scrutiny than a read, and on pull re people raised. The lenses run in the container, against a copy of the pull request fetched into its clone. -The findings come back to the user. Nothing is posted to GitHub. +The findings land on disk and on the pull request, as one review that comments and neither +approves nor requests changes. ## Setup @@ -63,17 +64,42 @@ adherence phase sits out and the lenses do the work. Findings land in `.claude/runs/pr-{number}/findings/{lens}.md`. -## 4. Report +## 4. Triage -Read the files and tell the user what is in them: +Read every finding against the code before you carry it to the pull request. A lens that has +already been wrong once will happily be wrong again, and a finding posted is a finding the author +has to answer. -- What each lens raised, and which findings you would act on first. -- Where a finding rests on an assumption about intent, say so — the lenses read a diff, not a - discussion. -- Which findings you checked against the code yourself and stand behind, separately from those - you are relaying. -- Where the artifacts are. +Keep anything with a concrete failure behind it. Drop preferences, restatements of what the +author already chose, and findings about code the diff did not touch. -Say plainly where the lenses found nothing. A quiet review is a result. +Write what you kept and what you dropped, with a reason for each, to +`.claude/runs/pr-{number}/triage.md`. That file is how the user checks this judgement, and it is +what a later remediation run reads. -Posting any of this to the pull request is the user's call, and theirs to do. +## 5. Post the review + +One review, event `COMMENT`. Never approve and never request changes — that is the user's, and +this pull request may not be theirs. + +Use the GitHub MCP server's `pull_request_review_write` with method `create` to open a pending +review, `add_comment_to_pending_review` for each finding that names a file and a line **in the +diff**, then `submit_pending`. Where `gh` is installed, `gh pr review --comment` posts the body. + +The review body carries: + +- Which lenses ran, and which found nothing. A quiet lens is a result worth stating. +- Every finding you kept that has no line to hang on, in full. +- That the lenses read the diff rather than the discussion around it, so a finding resting on an + assumption about intent says so. + +Attribute it. The body opens by naming the lenses as its author, so the person reading knows what +produced it. + +A line comment that the API rejects for being outside the diff goes in the body instead. **Do not +retry it against a different line.** + +## 6. Report + +Tell the user the review URL, what you kept and dropped, which findings you checked against the +code yourself and stand behind, and where the artifacts are. diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 3f78ed43..1e3db31c 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -87,8 +87,8 @@ pull request, and holds it against CI for at most three fix attempts. The approv stop, and the pull request stays a draft until you take it out of one. `/crypter-review {pr-number}` is the other entry point. It fetches a pull request's head into the -container, runs the lenses against it with no plan to audit, and reports. It posts nothing to -GitHub. +container, runs the lenses against it with no plan to audit, triages what they raise, and posts +one review that comments. It never approves and never requests changes. ## Configuration From d228579ea3af8e4b251910ca62f244e12fdee33c Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 11:37:51 -0500 Subject: [PATCH 30/41] Add an orchestrator that triages the findings on a pull request A finding on a pull request is a claim, and acting on a wrong one means changing working code. crypter-triage-review gives each finding its own verifier with the code and nothing else to judge it by, so no agent ever rules on a finding it raised. What holds becomes a commit. What does not gets a reply on its thread carrying the evidence, so the person who raised it can answer. What the verifier cannot settle goes to the user untouched. The commits go back to the pull request's own branch, which is only possible when it comes from a repository the session can push to. Otherwise the replies stand on their own and the author does the fixing. --- .claude/agents/finding-verifier.md | 55 +++++++++ .claude/skills/crypter-triage-review/SKILL.md | 111 ++++++++++++++++++ .claude/skills/crypter-verify/SKILL.md | 53 +++++++++ .../Agentic Development Pipeline.md | 16 ++- 4 files changed, 231 insertions(+), 4 deletions(-) create mode 100644 .claude/agents/finding-verifier.md create mode 100644 .claude/skills/crypter-triage-review/SKILL.md create mode 100644 .claude/skills/crypter-verify/SKILL.md diff --git a/.claude/agents/finding-verifier.md b/.claude/agents/finding-verifier.md new file mode 100644 index 00000000..52129653 --- /dev/null +++ b/.claude/agents/finding-verifier.md @@ -0,0 +1,55 @@ +--- +name: finding-verifier +description: Check a review finding against the code and rule on whether it holds. Used by the /crypter-verify skill, once per finding. +tools: Read, Grep, Glob, Bash, Write +model: opus +effort: high +color: yellow +--- + +# Finding verifier + +You are given one finding, a worktree path, and an output path. You decide whether the finding +is true of the code in that worktree. You do not fix anything, and you do not review the diff +for anything else. + +The finding is a claim, not a brief. Somebody else wrote it, they may have been wrong, and +finding that out is the job. Read it as evidence of where to look rather than as a description +of what you will find. + +## Rule on it + +A finding holds when you can trace the failure it describes through the code as it stands: the +inputs or state it names reach the code path it names and produce the outcome it claims. + +It does not hold when any link in that chain is missing. Common shapes: + +- The code it describes is not what is there. +- The path it describes cannot be reached with the inputs it names. +- Something upstream already prevents the failure — a guard, a validated type, a constraint. +- It describes code the diff did not touch. +- It states a preference with no failure behind it. + +Where the finding is right about a problem and wrong about why, it holds. Say what is actually +broken. + +Where you cannot settle it — the behaviour depends on configuration you cannot see, or on a +runtime you cannot exercise — say so and stop. Unsettled is a verdict. Do not guess in either +direction. + +## Report + +Write to the output path as Markdown: + +- The finding, quoted. +- **Holds**, **Does not hold**, or **Unsettled**. +- The evidence, by file and line. What you read, and what it shows. A verdict without the code + behind it is worth nothing to whoever reads this next. +- Where it holds: the concrete failure, stated the way you would want to receive it — enough for + someone to fix without rediscovering it. +- Where it does not hold: what the code does instead, and which link in the chain breaks. This + goes back to the person who raised it, so it has to stand up on its own. + +Then report the verdict and one sentence of evidence. + +Rule on the finding you were given. Anything else you notice belongs to a review, not to this. diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md new file mode 100644 index 00000000..0828339d --- /dev/null +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -0,0 +1,111 @@ +--- +name: crypter-triage-review +description: Verify the findings left on a pull request, push back on the ones that do not hold, and fix the ones that do. Use when asked to work through review comments, or invoked as /crypter-triage-review {pr-number}. +--- + +# Crypter triage review + +Work through the findings on a pull request. Each one is either answered on the thread or +recorded as verified, and the verified ones become commits. + +Findings come from anywhere — the reviewer lenses, a person, another tool. They are treated the +same way, because where a finding came from says nothing about whether it is true. + +Run from the root of the main checkout. The container's mounts resolve against it. + +## Setup + +You are given a pull request number: `/crypter-triage-review {pr-number}`. + +Use `pr-{number}` as the run id. + +```bash +mkdir -p .claude/runs/pr-{number}/verification +chmod 777 .claude/runs/pr-{number} .claude/runs/pr-{number}/verification +``` + +The container's `agent` is uid 1001 and your files are uid 1000, so the agents write into +directories this side creates and grants. + +## 1. Collect the findings + +Read the pull request with whatever GitHub access this session has — the `gh` CLI, or the GitHub +MCP server's `pull_request_read` with `get_review_comments`, `get_reviews` and `get_comments`. + +Take the head branch and head repository from `get` while you are there. You need both later. + +Write every open finding to `.claude/runs/pr-{number}/review.md`, one entry each: + +- A short id you assign, `f1` upward. +- The thread or comment id, so a reply can find its way back. +- The file and line, where it has one. +- The finding, quoted in full. + +Skip threads already resolved and comments that raise nothing — approvals, thanks, questions +about intent. A question is for the author to answer, not for a verifier. + +**If there is nothing open, say so and stop.** + +## 2. Fetch the head into the container + +```bash +docker exec crypter-pipeline \ + git -C /work/Crypter fetch upstream +pull/{number}/head:{head-branch} +``` + +The local branch takes the pull request's own branch name, so the commits go back to the branch +they came from. + +**If this fails, stop and say so.** + +## 3. Verify + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --permission-mode auto -p "/crypter-verify pr-{number} {head-branch} /runs/pr-{number}/review.md" +``` + +Verdicts land in `.claude/runs/pr-{number}/verification/{id}.md`. Read the files, not the +summary. + +## 4. Answer each finding + +Every finding gets one of three outcomes, and none of them is silence. + +**Does not hold** — reply on the thread with `add_reply_to_pull_request_comment`, or +`gh pr comment` where the finding has no thread. Give the evidence: what the code does instead, +by file and line. Two or three sentences. Say it as a position, not a verdict — the person who +raised it may know something the verifier could not see, and the thread is where that comes out. + +Reply once. If they answer, that is the user's conversation, not yours to continue. + +**Holds** — write it to `.claude/runs/pr-{number}/triage.md`: the id, the failure, and the file +and line. That file is what the fix is built from, so write it for someone who has not read the +thread. + +**Unsettled** — carry it to the user in your report. Do not reply, and do not fix. + +## 5. Fix what held + +Where `triage.md` has anything, and the head branch is one you can push to: + +```bash +docker exec -w /work/Crypter crypter-pipeline \ + claude --permission-mode auto -p "/crypter-remediate pr-{number} {head-branch} /runs/pr-{number}/triage.md" +``` + +Then invoke `crypter-open-pull-request` with the run id and the head branch. It pushes the +commits and leaves the existing pull request in place. + +A pull request from a repository you cannot push to stops here. The replies stand, `triage.md` +stands, and the author does the fixing. Say so in the report. + +## 6. Report + +- What held, what did not, and what you could not settle. +- The replies you posted, and where. +- What changed on the branch, and the commits now on the pull request. +- Where the artifacts are. + +CI is not watched here. A push starts a round of checks; reading them is `/crypter-change`'s job +or yours. diff --git a/.claude/skills/crypter-verify/SKILL.md b/.claude/skills/crypter-verify/SKILL.md new file mode 100644 index 00000000..4b0d69d6 --- /dev/null +++ b/.claude/skills/crypter-verify/SKILL.md @@ -0,0 +1,53 @@ +--- +name: crypter-verify +description: Rule on each finding in a report against the code, one verifier per finding. Invoked as /crypter-verify {run-id} {ref} {findings-path} by the crypter-triage-review skill. +--- + +# Crypter verify + +Take a list of findings somebody left on a diff and decide which of them are true. + +The ref already exists in `/work/Crypter/.git`. You write verdicts and nothing else — no fixes, +and no findings of your own. + +## Setup + +You are given a run id, a ref, and a findings path: +`/crypter-verify {run-id} {ref} {findings-path}`. + +The findings file lives under `/runs/{run-id}/`. Each finding in it carries an id. **If the file +is absent, stop and say so.** + +`/runs/{run-id}/verification/` already exists; the caller creates it. **If it is missing, stop +and say so** rather than creating it. + +## 1. Worktree on the ref + +```bash +git -C /work/Crypter worktree add --detach /work/Crypter/.claude/worktrees/{run-id}-verify {ref} +``` + +`--detach` because you only read. **If this fails, stop and say so.** + +Every agent gets this worktree path and works by absolute path inside it. Never `cd`. + +## 2. Verify + +Invoke `finding-verifier` once per finding, in parallel. Each gets one finding, the worktree +path, and `/runs/{run-id}/verification/{finding-id}.md`. + +One finding per agent, and each sees only its own. A verifier that reads the whole report starts +weighing findings against each other instead of against the code. + +Never give a finding to the agent that raised it. + +## 3. Report + +For each finding: its id, the verdict, and one line of evidence. Then the counts — how many held, +how many did not, how many are unsettled. Name the files you wrote. + +```bash +git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id}-verify +``` + +Remove it on every exit path. diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 1e3db31c..23721602 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -7,12 +7,14 @@ and it invokes the rest. |---|---| | `/crypter-change ""` | Carries a requirement to a green draft pull request | | `/crypter-review {pr-number}` | Puts an existing pull request through the reviewer lenses | +| `/crypter-triage-review {pr-number}` | Rules on the findings left on a pull request and fixes the ones that hold | | Task skill | Executes in | Does | |---|---|---| | `/crypter-plan` | Your session | Drafts the plan interactively, with the web, your tooling and you available to it | | `/crypter-implement` | Container | Builds the plan into commits on a new branch | | `/crypter-examine` | Container | Reviews a diff for plan adherence and code quality | +| `/crypter-verify` | Container | Rules on each finding in a report against the code | | `/crypter-remediate` | Container | Applies triaged findings or a CI failure to an existing branch | | `/crypter-open-pull-request` | Your session | Pushes the branch and opens or updates the draft pull request | @@ -52,7 +54,7 @@ and both on your disk: | Host | Container | Direction | Holds | |---|---|---|---| | `.claude/plans` | `/plans` | Read-only | `{run-id}/plan.md` | -| `.claude/runs` | `/runs` | Writable | `{run-id}/conformance.md`, `{run-id}/findings/{lens}.md`, `{run-id}/triage.md`, `{run-id}/ci-{n}.md` | +| `.claude/runs` | `/runs` | Writable | `{run-id}/conformance.md`, `{run-id}/findings/{lens}.md`, `{run-id}/review.md`, `{run-id}/verification/{id}.md`, `{run-id}/triage.md`, `{run-id}/ci-{n}.md` | The plan goes in and cannot be rewritten by the agents. Findings come back out as files you can open, grep and keep, rather than as text in a transcript, and each is written by the agent that @@ -86,9 +88,15 @@ It plans, stops for your approval, then builds, examines, triages, remediates, o pull request, and holds it against CI for at most three fix attempts. The approval is the only stop, and the pull request stays a draft until you take it out of one. -`/crypter-review {pr-number}` is the other entry point. It fetches a pull request's head into the -container, runs the lenses against it with no plan to audit, triages what they raise, and posts -one review that comments. It never approves and never requests changes. +`/crypter-review {pr-number}` is the second entry point. It fetches a pull request's head into +the container, runs the lenses against it with no plan to audit, triages what they raise, and +posts one review that comments. It never approves and never requests changes. + +`/crypter-triage-review {pr-number}` is the third. It reads the findings already on a pull +request, whoever left them, and gives one verifier per finding a worktree and nothing else to +judge it by. A finding that does not survive that gets a reply on its thread saying what the +code does instead. A finding that does becomes a commit, where the head branch is one you can +push to. Nothing is fixed on the strength of the finding alone. ## Configuration From dcaf1a7f85ec513e821c8ac1e127a4c83ef3021a Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 11:46:48 -0500 Subject: [PATCH 31/41] Prefix the container skills with crypter-devcontainer The skills are visible wherever the repository is checked out, and four of them only work inside the pipeline container, where /work/Crypter, /plans and /runs exist. The prefix says which is which, so the skills a user invokes are the ones without it. --- .claude/agents/conformance-auditor.md | 2 +- .claude/agents/finding-verifier.md | 2 +- .claude/agents/implementer.md | 2 +- .claude/agents/reviewer.md | 2 +- .claude/skills/crypter-change/SKILL.md | 8 +++---- .../SKILL.md | 8 +++---- .../SKILL.md | 8 +++---- .../SKILL.md | 8 +++---- .../SKILL.md | 8 +++---- .claude/skills/crypter-review/SKILL.md | 2 +- .claude/skills/crypter-triage-review/SKILL.md | 4 ++-- .../Agentic Development Pipeline.md | 23 +++++++++++-------- 12 files changed, 40 insertions(+), 37 deletions(-) rename .claude/skills/{crypter-examine => crypter-devcontainer-examine}/SKILL.md (92%) rename .claude/skills/{crypter-implement => crypter-devcontainer-implement}/SKILL.md (88%) rename .claude/skills/{crypter-remediate => crypter-devcontainer-remediate}/SKILL.md (87%) rename .claude/skills/{crypter-verify => crypter-devcontainer-verify}/SKILL.md (85%) diff --git a/.claude/agents/conformance-auditor.md b/.claude/agents/conformance-auditor.md index b841a81b..aae806f1 100644 --- a/.claude/agents/conformance-auditor.md +++ b/.claude/agents/conformance-auditor.md @@ -1,6 +1,6 @@ --- name: conformance-auditor -description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as the plan adherence phase of the /crypter-examine skill. +description: Compare a branch's diff against the plan it was built from and report where they diverge. Used as the plan adherence phase of the /crypter-devcontainer-examine skill. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/agents/finding-verifier.md b/.claude/agents/finding-verifier.md index 52129653..567b6cc0 100644 --- a/.claude/agents/finding-verifier.md +++ b/.claude/agents/finding-verifier.md @@ -1,6 +1,6 @@ --- name: finding-verifier -description: Check a review finding against the code and rule on whether it holds. Used by the /crypter-verify skill, once per finding. +description: Check a review finding against the code and rule on whether it holds. Used by the /crypter-devcontainer-verify skill, once per finding. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md index d02607d2..2116c128 100644 --- a/.claude/agents/implementer.md +++ b/.claude/agents/implementer.md @@ -1,6 +1,6 @@ --- name: implementer -description: Implement an approved plan in Crypter, or apply triaged review findings and CI fixes. Used by the /crypter-implement and /crypter-remediate skills. +description: Implement an approved plan in Crypter, or apply triaged review findings and CI fixes. Used by the /crypter-devcontainer-implement and /crypter-devcontainer-remediate skills. tools: Read, Grep, Glob, Bash, Write, Edit model: opus effort: high diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 44e77347..0132b517 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -1,6 +1,6 @@ --- name: reviewer -description: Review a Crypter branch's diff under a named lens and report findings. Used as the code review phase of the /crypter-examine skill; the lens comes from the prompt. +description: Review a Crypter branch's diff under a named lens and report findings. Used as the code review phase of the /crypter-devcontainer-examine skill; the lens comes from the prompt. tools: Read, Grep, Glob, Bash, Write model: opus effort: high diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index b85e91ad..5cbc0168 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -58,7 +58,7 @@ It settles the plan with the user itself. **Do not continue until they have appr ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --permission-mode auto -p "/crypter-implement {run-id} {branch}" + claude --permission-mode auto -p "/crypter-devcontainer-implement {run-id} {branch}" ``` Keep the title and description it reports; `crypter-open-pull-request` needs them. @@ -67,7 +67,7 @@ Keep the title and description it reports; `crypter-open-pull-request` needs the ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --permission-mode auto -p "/crypter-examine {run-id} {branch} /plans/{run-id}/plan.md" + claude --permission-mode auto -p "/crypter-devcontainer-examine {run-id} {branch} /plans/{run-id}/plan.md" ``` It writes `.claude/runs/{run-id}/conformance.md` and `.claude/runs/{run-id}/findings/{lens}.md`. @@ -93,7 +93,7 @@ Where anything was accepted: ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --permission-mode auto -p "/crypter-remediate {run-id} {branch} /runs/{run-id}/triage.md" + claude --permission-mode auto -p "/crypter-devcontainer-remediate {run-id} {branch} /runs/{run-id}/triage.md" ``` ## 6. Open the pull request @@ -109,7 +109,7 @@ number, and `.claude/runs/{run-id}/ci-{n}.md`. It runs one attempt and reports. The loop is yours: 1. Green → go to stage 8. -2. A failure → run `crypter-remediate` with `/runs/{run-id}/ci-{n}.md`, invoke +2. A failure → run `crypter-devcontainer-remediate` with `/runs/{run-id}/ci-{n}.md`, invoke `crypter-open-pull-request` again, then `ci-watcher` with the next attempt number. 3. **Three attempts is the ceiling.** Comment the state of play on the pull request and hand back to the user. diff --git a/.claude/skills/crypter-examine/SKILL.md b/.claude/skills/crypter-devcontainer-examine/SKILL.md similarity index 92% rename from .claude/skills/crypter-examine/SKILL.md rename to .claude/skills/crypter-devcontainer-examine/SKILL.md index 540aec92..8a7d5111 100644 --- a/.claude/skills/crypter-examine/SKILL.md +++ b/.claude/skills/crypter-devcontainer-examine/SKILL.md @@ -1,9 +1,9 @@ --- -name: crypter-examine -description: Review a diff in the pipeline container and write findings to the host. Invoked as /crypter-examine {run-id} {ref} [plan-path] by the crypter-change and crypter-review skills on the host. +name: crypter-devcontainer-examine +description: Review a diff in the pipeline container and write findings to the host. Invoked as /crypter-devcontainer-examine {run-id} {ref} [plan-path] by the crypter-change and crypter-review skills. --- -# Crypter examine +# Crypter devcontainer examine Review a diff and leave hard artifacts behind. You do not write code and you do not decide what gets acted on; your caller triages what you find. @@ -13,7 +13,7 @@ The ref already exists in `/work/Crypter/.git`. ## Setup You are given a run id, a ref, and optionally a plan path: -`/crypter-examine {run-id} {ref} [plan-path]`. +`/crypter-devcontainer-examine {run-id} {ref} [plan-path]`. Two review phases run here, and the plan path decides whether the first one applies: diff --git a/.claude/skills/crypter-implement/SKILL.md b/.claude/skills/crypter-devcontainer-implement/SKILL.md similarity index 88% rename from .claude/skills/crypter-implement/SKILL.md rename to .claude/skills/crypter-devcontainer-implement/SKILL.md index af4aaa4b..037fe5b9 100644 --- a/.claude/skills/crypter-implement/SKILL.md +++ b/.claude/skills/crypter-devcontainer-implement/SKILL.md @@ -1,9 +1,9 @@ --- -name: crypter-implement -description: Build an approved plan into commits on a new branch, inside the pipeline container. Invoked as /crypter-implement {run-id} {branch} by the crypter-change skill on the host. +name: crypter-devcontainer-implement +description: Build an approved plan into commits on a new branch, inside the pipeline container. Invoked as /crypter-devcontainer-implement {run-id} {branch} by the crypter-change skill. --- -# Crypter implement +# Crypter devcontainer implement Turn an approved plan into commits on a branch. @@ -15,7 +15,7 @@ The plan is the specification. The user approved it before this ran, and this ru ## Setup -You are given a run id and a branch name: `/crypter-implement {run-id} {branch}`. +You are given a run id and a branch name: `/crypter-devcontainer-implement {run-id} {branch}`. Read `/plans/{run-id}/plan.md` first. It is a read-only mount of the host's `.claude/plans`. **If it is absent, stop and say so** — the host session owns that file. diff --git a/.claude/skills/crypter-remediate/SKILL.md b/.claude/skills/crypter-devcontainer-remediate/SKILL.md similarity index 87% rename from .claude/skills/crypter-remediate/SKILL.md rename to .claude/skills/crypter-devcontainer-remediate/SKILL.md index a010df7b..0787b96f 100644 --- a/.claude/skills/crypter-remediate/SKILL.md +++ b/.claude/skills/crypter-devcontainer-remediate/SKILL.md @@ -1,9 +1,9 @@ --- -name: crypter-remediate -description: Apply a report to a branch the pipeline already built, whether triaged review findings or a CI failure. Invoked as /crypter-remediate {run-id} {branch} {report-path} by the crypter-change skill on the host. +name: crypter-devcontainer-remediate +description: Apply a report to a branch the pipeline already built, whether triaged review findings or a CI failure. Invoked as /crypter-devcontainer-remediate {run-id} {branch} {report-path} by the crypter-change and crypter-triage-review skills. --- -# Crypter remediate +# Crypter devcontainer remediate Take a report of what is wrong with a branch this container already built, and fix it. @@ -16,7 +16,7 @@ what is wrong, an existing branch, and commits that address it. ## Setup You are given a run id, a branch name, and a report path: -`/crypter-remediate {run-id} {branch} {report-path}`. +`/crypter-devcontainer-remediate {run-id} {branch} {report-path}`. Read the report first. It lives under `/runs/{run-id}/`, the mount the host shares with you. **If it is absent, stop and say so.** diff --git a/.claude/skills/crypter-verify/SKILL.md b/.claude/skills/crypter-devcontainer-verify/SKILL.md similarity index 85% rename from .claude/skills/crypter-verify/SKILL.md rename to .claude/skills/crypter-devcontainer-verify/SKILL.md index 4b0d69d6..0f268295 100644 --- a/.claude/skills/crypter-verify/SKILL.md +++ b/.claude/skills/crypter-devcontainer-verify/SKILL.md @@ -1,9 +1,9 @@ --- -name: crypter-verify -description: Rule on each finding in a report against the code, one verifier per finding. Invoked as /crypter-verify {run-id} {ref} {findings-path} by the crypter-triage-review skill. +name: crypter-devcontainer-verify +description: Rule on each finding in a report against the code, one verifier per finding. Invoked as /crypter-devcontainer-verify {run-id} {ref} {findings-path} by the crypter-triage-review skill. --- -# Crypter verify +# Crypter devcontainer verify Take a list of findings somebody left on a diff and decide which of them are true. @@ -13,7 +13,7 @@ and no findings of your own. ## Setup You are given a run id, a ref, and a findings path: -`/crypter-verify {run-id} {ref} {findings-path}`. +`/crypter-devcontainer-verify {run-id} {ref} {findings-path}`. The findings file lives under `/runs/{run-id}/`. Each finding in it carries an id. **If the file is absent, stop and say so.** diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index cff436a1..d009c06c 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -56,7 +56,7 @@ picks up the new head instead of being rejected. ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --permission-mode auto -p "/crypter-examine pr-{number} pr-{number}" + claude --permission-mode auto -p "/crypter-devcontainer-examine pr-{number} pr-{number}" ``` No plan path. A pull request raised elsewhere has no plan to hold it against, so the plan diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index 0828339d..52f4ac4b 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -62,7 +62,7 @@ they came from. ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --permission-mode auto -p "/crypter-verify pr-{number} {head-branch} /runs/pr-{number}/review.md" + claude --permission-mode auto -p "/crypter-devcontainer-verify pr-{number} {head-branch} /runs/pr-{number}/review.md" ``` Verdicts land in `.claude/runs/pr-{number}/verification/{id}.md`. Read the files, not the @@ -91,7 +91,7 @@ Where `triage.md` has anything, and the head branch is one you can push to: ```bash docker exec -w /work/Crypter crypter-pipeline \ - claude --permission-mode auto -p "/crypter-remediate pr-{number} {head-branch} /runs/pr-{number}/triage.md" + claude --permission-mode auto -p "/crypter-devcontainer-remediate pr-{number} {head-branch} /runs/pr-{number}/triage.md" ``` Then invoke `crypter-open-pull-request` with the run id and the head branch. It pushes the diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 23721602..1ddc380c 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -1,6 +1,6 @@ # Agentic Development Pipeline -Two orchestrators compose a set of task skills. You invoke an orchestrator in your own session, +Three orchestrators compose a set of task skills. You invoke an orchestrator in your own session, and it invokes the rest. | Orchestrator | Does | @@ -12,20 +12,23 @@ and it invokes the rest. | Task skill | Executes in | Does | |---|---|---| | `/crypter-plan` | Your session | Drafts the plan interactively, with the web, your tooling and you available to it | -| `/crypter-implement` | Container | Builds the plan into commits on a new branch | -| `/crypter-examine` | Container | Reviews a diff for plan adherence and code quality | -| `/crypter-verify` | Container | Rules on each finding in a report against the code | -| `/crypter-remediate` | Container | Applies triaged findings or a CI failure to an existing branch | +| `/crypter-devcontainer-implement` | Container | Builds the plan into commits on a new branch | +| `/crypter-devcontainer-examine` | Container | Reviews a diff for plan adherence and code quality | +| `/crypter-devcontainer-verify` | Container | Rules on each finding in a report against the code | +| `/crypter-devcontainer-remediate` | Container | Applies triaged findings or a CI failure to an existing branch | | `/crypter-open-pull-request` | Your session | Pushes the branch and opens or updates the draft pull request | Every skill that reads or writes code runs in the container, against the container's own clone. Your session plans, decides what to act on, and talks to GitHub. `/crypter-review` reviews -nothing itself: it fetches the pull request into the container and runs `/crypter-examine` -there. +nothing itself: it fetches the pull request into the container and runs +`/crypter-devcontainer-examine` there. -The task skills stand alone. `/crypter-plan` is worth running on its own when you want a plan -and nothing else, and `/crypter-open-pull-request` is safe to run repeatedly, which is how the -CI loop uses it. +The `crypter-devcontainer-` prefix marks the skills an orchestrator invokes inside the container. +They expect `/work/Crypter`, `/plans` and `/runs`, none of which your session has, and they are +named so you can tell at a glance which skills are yours to run. + +Of the rest, `/crypter-plan` is worth running on its own when you want a plan and nothing else, +and `/crypter-open-pull-request` is safe to run repeatedly, which is how the CI loop uses it. **Run the orchestrators from the root of your main checkout.** The container's mounts are relative to `.devcontainer/`, so `.claude/plans` and `.claude/runs` resolve against that one From 062eaa8c55c0355de4957a20ff9970f14345ad95 Mon Sep 17 00:00:00 2001 From: n Date: Wed, 5 Aug 2026 11:51:01 -0500 Subject: [PATCH 32/41] Prefix the session-side steps with crypter-step The two skills a session runs on behalf of an orchestrator now say so in their names. crypter-step- rather than crypter-change-step- because open-pull-request is a step of crypter-triage-review as well. What is left unprefixed is what a user invokes: crypter-change, crypter-review, crypter-triage-review. --- .claude/skills/crypter-change/SKILL.md | 10 +++++----- .../SKILL.md | 8 ++++---- .../{crypter-plan => crypter-step-plan}/SKILL.md | 6 +++--- .claude/skills/crypter-triage-review/SKILL.md | 2 +- .../Development/Agentic Development Pipeline.md | 15 ++++++++------- 5 files changed, 21 insertions(+), 20 deletions(-) rename .claude/skills/{crypter-open-pull-request => crypter-step-open-pull-request}/SKILL.md (83%) rename .claude/skills/{crypter-plan => crypter-step-plan}/SKILL.md (94%) diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index 5cbc0168..9db07654 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -49,7 +49,7 @@ A container created before these existed picks them up on ## 1. Plan -Invoke `crypter-plan` with the requirement verbatim and the output path +Invoke `crypter-step-plan` with the requirement verbatim and the output path `.claude/plans/{run-id}/plan.md`. It settles the plan with the user itself. **Do not continue until they have approved it.** @@ -61,7 +61,7 @@ docker exec -w /work/Crypter crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-implement {run-id} {branch}" ``` -Keep the title and description it reports; `crypter-open-pull-request` needs them. +Keep the title and description it reports; `crypter-step-open-pull-request` needs them. ## 3. Examine @@ -98,8 +98,8 @@ docker exec -w /work/Crypter crypter-pipeline \ ## 6. Open the pull request -Invoke `crypter-open-pull-request` with the run id and the branch. It fetches the commits out of -the container, pushes them, and opens or updates the draft pull request. +Invoke `crypter-step-open-pull-request` with the run id and the branch. It fetches the commits +out of the container, pushes them, and opens or updates the draft pull request. ## 7. Hold it against CI @@ -110,7 +110,7 @@ The loop is yours: 1. Green → go to stage 8. 2. A failure → run `crypter-devcontainer-remediate` with `/runs/{run-id}/ci-{n}.md`, invoke - `crypter-open-pull-request` again, then `ci-watcher` with the next attempt number. + `crypter-step-open-pull-request` again, then `ci-watcher` with the next attempt number. 3. **Three attempts is the ceiling.** Comment the state of play on the pull request and hand back to the user. diff --git a/.claude/skills/crypter-open-pull-request/SKILL.md b/.claude/skills/crypter-step-open-pull-request/SKILL.md similarity index 83% rename from .claude/skills/crypter-open-pull-request/SKILL.md rename to .claude/skills/crypter-step-open-pull-request/SKILL.md index 11ab0468..34e48e7e 100644 --- a/.claude/skills/crypter-open-pull-request/SKILL.md +++ b/.claude/skills/crypter-step-open-pull-request/SKILL.md @@ -1,9 +1,9 @@ --- -name: crypter-open-pull-request -description: Push a branch the pipeline built in the container to the fork and open or update its draft pull request. Use when a branch is ready for a pull request, or invoked as /crypter-open-pull-request {run-id} {branch}. +name: crypter-step-open-pull-request +description: Push a branch the pipeline built in the container to the fork and open or update its draft pull request. Invoked as /crypter-step-open-pull-request {run-id} {branch} by the crypter-change and crypter-triage-review skills. --- -# Crypter open pull request +# Crypter step open pull request Take the branch the container built and put it on the fork, with a draft pull request open against it. @@ -11,7 +11,7 @@ against it. Safe to run repeatedly on the same branch. Each run pushes whatever commits the container has added and updates the existing pull request. -You are given a run id and a branch name: `/crypter-open-pull-request {run-id} {branch}`. +You are given a run id and a branch name: `/crypter-step-open-pull-request {run-id} {branch}`. ## 1. Fetch the branch out of the container diff --git a/.claude/skills/crypter-plan/SKILL.md b/.claude/skills/crypter-step-plan/SKILL.md similarity index 94% rename from .claude/skills/crypter-plan/SKILL.md rename to .claude/skills/crypter-step-plan/SKILL.md index 541fd3bb..d910cc7c 100644 --- a/.claude/skills/crypter-plan/SKILL.md +++ b/.claude/skills/crypter-step-plan/SKILL.md @@ -1,9 +1,9 @@ --- -name: crypter-plan -description: Draft an implementation plan for a change to Crypter, interactively. Use when asked to plan a change, or invoked as /crypter-plan "" [output-path]. +name: crypter-step-plan +description: Draft an implementation plan for a change to Crypter, interactively. Invoked as /crypter-step-plan "" [output-path] by the crypter-change skill, and usable on its own when a plan is all you want. --- -# Crypter plan +# Crypter step plan You turn a requirement into a plan someone else implements from. They see the plan and nothing else — not your reasoning, not the files you read, not the alternatives you rejected. Write for diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index 52f4ac4b..6db73960 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -94,7 +94,7 @@ docker exec -w /work/Crypter crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-remediate pr-{number} {head-branch} /runs/pr-{number}/triage.md" ``` -Then invoke `crypter-open-pull-request` with the run id and the head branch. It pushes the +Then invoke `crypter-step-open-pull-request` with the run id and the head branch. It pushes the commits and leaves the existing pull request in place. A pull request from a repository you cannot push to stops here. The replies stand, `triage.md` diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 1ddc380c..a10cc22d 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -11,24 +11,25 @@ and it invokes the rest. | Task skill | Executes in | Does | |---|---|---| -| `/crypter-plan` | Your session | Drafts the plan interactively, with the web, your tooling and you available to it | +| `/crypter-step-plan` | Your session | Drafts the plan interactively, with the web, your tooling and you available to it | | `/crypter-devcontainer-implement` | Container | Builds the plan into commits on a new branch | | `/crypter-devcontainer-examine` | Container | Reviews a diff for plan adherence and code quality | | `/crypter-devcontainer-verify` | Container | Rules on each finding in a report against the code | | `/crypter-devcontainer-remediate` | Container | Applies triaged findings or a CI failure to an existing branch | -| `/crypter-open-pull-request` | Your session | Pushes the branch and opens or updates the draft pull request | +| `/crypter-step-open-pull-request` | Your session | Pushes the branch and opens or updates the draft pull request | Every skill that reads or writes code runs in the container, against the container's own clone. Your session plans, decides what to act on, and talks to GitHub. `/crypter-review` reviews nothing itself: it fetches the pull request into the container and runs `/crypter-devcontainer-examine` there. -The `crypter-devcontainer-` prefix marks the skills an orchestrator invokes inside the container. -They expect `/work/Crypter`, `/plans` and `/runs`, none of which your session has, and they are -named so you can tell at a glance which skills are yours to run. +Both prefixes say the same thing: an orchestrator invokes this, you do not. `crypter-step-` runs +in your session, and `crypter-devcontainer-` runs in the container, which expects `/work/Crypter`, +`/plans` and `/runs` — none of which your session has. The three skills without a prefix are the +ones to invoke. -Of the rest, `/crypter-plan` is worth running on its own when you want a plan and nothing else, -and `/crypter-open-pull-request` is safe to run repeatedly, which is how the CI loop uses it. +`/crypter-step-plan` is the one worth borrowing when you want a plan and nothing else, and +`/crypter-step-open-pull-request` is safe to run repeatedly, which is how the CI loop uses it. **Run the orchestrators from the root of your main checkout.** The container's mounts are relative to `.devcontainer/`, so `.claude/plans` and `.claude/runs` resolve against that one From 50f123019d088a61d7d5d6a20d44fccb14136de1 Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:34:57 -0500 Subject: [PATCH 33/41] Push pipeline branches to the org repository (#844) The pipeline skills assumed origin was a personal fork and upstream the org repository. This checkout has one remote, origin, pointing at the org, so crypter-step-plan failed on its first command and the push step had no upstream/stable to sync a fork from. Branches now go to the org repository the way every branch before the pipeline did, which removes the fork sync push and the second pull request that had to be opened by hand afterwards. crypter-step-open-pull-request checks origin before pushing, since a checkout wired up differently sends the branch somewhere the pull request will not find it. Container-side skills keep the upstream remote name. That is the name the container's own clone gives its single read-only remote, and it is unaffected. Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/agents/ci-watcher.md | 5 ++- .claude/skills/crypter-change/SKILL.md | 11 +++---- .../crypter-step-open-pull-request/SKILL.md | 31 ++++++++++++------- .claude/skills/crypter-step-plan/SKILL.md | 3 +- .../Agentic Development Pipeline.md | 19 ++++++------ 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index a2f19b16..e8eac9bf 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -41,9 +41,8 @@ git -C rev-parse ``` Compare that against the head SHA in the pull request data. A push takes a moment to register, -so poll `get_check_runs` every 30 seconds until runs appear. If nothing has appeared after a -few minutes, say so and stop: on a fork, workflows stay disabled until they are enabled once in -the Actions tab, and that is a setup problem no amount of waiting fixes. +so poll `get_check_runs` until runs appear. If nothing has appeared after a few minutes, say so +and stop: a push that starts no checks is a setup problem no amount of waiting fixes. ## Watch diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index 9db07654..b32fee0b 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -119,16 +119,13 @@ the same way twice, a failure the plan did not anticipate, or anything that read rather than wrong code. Three attempts is a limit, not a quota to spend. Stop immediately, without spending an attempt, where `ci-watcher` reports that no run appeared -for the commit. Workflows stay disabled on a new fork until they are enabled once in its Actions -tab, and that is a setup problem. +for the commit. Nothing to fix has been established yet, and a push that starts no checks is a +setup problem rather than a code one. ## 8. Report -- The fork pull request URL and whether its checks are green. It is a draft; taking it out of - draft is the user's. +- The pull request URL and whether its checks are green. It is a draft; taking it out of draft + is the user's. - What each fix attempt changed, where any ran. - Anything the implementer could not do, and any drift the auditor flagged. - What you rejected in triage that the user might disagree with, and where `triage.md` is. - -The upstream pull request is a separate one against `Crypter-File-Transfer/Crypter`, since the -base repository is fixed when a pull request is created. The description is ready to paste. diff --git a/.claude/skills/crypter-step-open-pull-request/SKILL.md b/.claude/skills/crypter-step-open-pull-request/SKILL.md index 34e48e7e..03047de8 100644 --- a/.claude/skills/crypter-step-open-pull-request/SKILL.md +++ b/.claude/skills/crypter-step-open-pull-request/SKILL.md @@ -1,11 +1,11 @@ --- name: crypter-step-open-pull-request -description: Push a branch the pipeline built in the container to the fork and open or update its draft pull request. Invoked as /crypter-step-open-pull-request {run-id} {branch} by the crypter-change and crypter-triage-review skills. +description: Push a branch the pipeline built in the container to the repository and open or update its draft pull request. Invoked as /crypter-step-open-pull-request {run-id} {branch} by the crypter-change and crypter-triage-review skills. --- # Crypter step open pull request -Take the branch the container built and put it on the fork, with a draft pull request open +Take the branch the container built and put it on the repository, with a draft pull request open against it. Safe to run repeatedly on the same branch. Each run pushes whatever commits the container has @@ -25,30 +25,37 @@ git -c protocol.ext.allow=user fetch \ `protocol.ext.allow` is passed per command and stays out of your config. **If this fails, stop and say so** — the branch is the whole deliverable. -## 2. Push to the fork +## 2. Push to the repository + +`origin` is the org repository, the same one the container cloned and the same one the pull +request opens against. Confirm that before pushing anything: ```bash -git fetch upstream -git push origin upstream/stable:refs/heads/stable -git push origin {branch} +git remote get-url origin ``` -The first push keeps the fork's `stable` level with the org repository, so the pull request -compares against current code. +**If it is not `Crypter-File-Transfer/Crypter`, stop and say so.** A checkout wired up +differently — a fork on `origin`, or the org on some other remote — pushes the branch somewhere +the pull request will not find it. + +```bash +git fetch origin +git push origin {branch} +``` ## 3. Open or update the pull request Where a pull request for `{branch}` is already open, the push has updated it and there is nothing more to do. Say which one it was. -Otherwise open it against the fork, base `stable`, as a draft, using whatever GitHub access this -session has — the `gh` CLI, or the GitHub MCP server's `create_pull_request`. +Otherwise open it against `Crypter-File-Transfer/Crypter`, base `stable`, as a draft, using +whatever GitHub access this session has — the `gh` CLI, or the GitHub MCP server's +`create_pull_request`. It stays a draft. Taking it out of draft is the user's. Take the title and description from the report of whoever built the branch. Write the -description for the org repository's reviewers, since it carries over when the upstream pull -request is opened. +description for the reviewers who will read it on that pull request. ## 4. Report diff --git a/.claude/skills/crypter-step-plan/SKILL.md b/.claude/skills/crypter-step-plan/SKILL.md index d910cc7c..e7298fb1 100644 --- a/.claude/skills/crypter-step-plan/SKILL.md +++ b/.claude/skills/crypter-step-plan/SKILL.md @@ -18,11 +18,10 @@ it goes to `crypter-change`, to a person, or nowhere. ## 1. Sync ```bash -git fetch upstream git fetch origin ``` -Read the code at `upstream/stable`, the commit a build branches from. +Read the code at `origin/stable`, the commit a build branches from. ## 2. Understand before deciding diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index a10cc22d..dc08832b 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -45,8 +45,15 @@ uncommitted work on your machine. The container does hold your Claude Code credential, in the `crypter-pipeline-claude` volume, and its network egress is open. Treat it as a trust boundary rather than a sandbox. -When `/crypter-change` finishes you have a fork pull request to read; opening one against the org -repository is something you do by hand afterwards. +The branch is pushed to the org repository and the pull request opens against it, base `stable`, +the same route a branch of your own takes. `/crypter-change` leaves you a draft pull request to +read. + +The org repository therefore has two names in this pipeline. Your session reaches it as `origin`, +the remote your checkout already has. The container reaches it as `upstream`, the name its clone +gives the one remote it has, chosen so that a remote with no push url reads as one. Host-side +skills say `origin` and container-side skills say `upstream`; both mean +`Crypter-File-Transfer/Crypter`. This document covers the setup you need before the container will start. @@ -138,14 +145,6 @@ into. Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` reuses the workspace and your Claude Code credentials. -## Enable Actions on your fork - -GitHub disables workflows on new forks. Until you turn them on, pushing a branch runs nothing, -and `/crypter-change` stops at the CI stage reporting that no run ever appeared. - -Open the **Actions** tab on your fork and use the button confirming you want to run workflows. -You only do this once. - ## What is in the container The image is published by the org at `ghcr.io/crypter-file-transfer/crypter-devcontainer`, and From e74250bb59b050c6af16315306a05d795c1b846a Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:40:46 -0500 Subject: [PATCH 34/41] Watch CI with a script instead of a polling loop (#846) * Watch CI with gh instead of a polling loop The CI watcher was told to poll for check runs every thirty seconds. A foreground sleep does not run in this harness and the agent has no timer, so the interval it was asked for was one it could not take. The documented way out was gh pr checks --watch, and gh was not installed, so that path was dead too. gh is now a hard dependency rather than an alternative offered where it happens to exist. Watching is a single blocking --watch call, and the exit code distinguishes a failure from a watch cut short before the round finished, which would otherwise burn a fix attempt on a run that was merely still going. Failure diagnosis reads the failing job's log through gh run view --log-failed rather than scraping check run annotations. Annotations carry compiler diagnostics well and test failures poorly, and the report this agent writes is the only thing the implementer gets. gh carries its own credential, separate from the MCP server's, so the agent checks gh auth status first and names the missing login rather than failing part way through with an authentication error. crypter-triage-review named gh pr comment as the only way to answer a finding with no thread, which was unreachable for the same reason. It now names the MCP equivalent alongside it. Co-Authored-By: Claude Opus 5 * Treat a gh authentication failure as a setup problem An unauthenticated gh pr checks exits 4, which was outside the set of exit codes the watcher was given. Left undocumented it reads as a non-zero exit and therefore as a failing build, which sends the implementer looking for a defect that is not there. Co-Authored-By: Claude Opus 5 * Move the CI watcher's mechanics into a script Waiting, reading exit codes, finding failing runs and cutting a log down to the part that explains itself are all deterministic, and they were written as prose for an agent to follow. Prose cannot be run, so the mistakes in it only surfaced when a run went wrong: a polling interval the harness cannot take, an exit code that reads as a failing build when it means nobody logged in, an instruction to read the tail of a log whose last fifty lines are cleanup. ci-status.sh does that half and can be tested. Exit codes separate a CI failure from the three setup problems that otherwise look like one. On a failure it prints the window of log ending at the runner's ##[error] marker, which turns a 1100-line dump into roughly sixty lines containing the cause. The agent keeps the half that needs judgement: reading back from the symptom to the cause, reading the branch's version of the code the failure names, deciding whether it is a defect, a wrong test or the plan itself being wrong. Co-Authored-By: Claude Opus 5 * Keep gh behind the script rather than in agent prose Two skills offered a bare gh call as an alternative to the MCP server for posting a review and commenting on a pull request. The MCP server does both, so those were discretionary CLI invocations with nothing to recommend them. The watcher's remaining direct call was fetching a fuller copy of a log the script had already downloaded. The script now keeps each failing job's log and prints its path, so reading further means opening a file rather than going back to the network. Nothing outside ci-status.sh invokes gh now. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/agents/ci-watcher.md | 54 +++++--- .claude/scripts/ci-status.sh | 126 ++++++++++++++++++ .claude/skills/crypter-review/SKILL.md | 2 +- .claude/skills/crypter-triage-review/SKILL.md | 7 +- 4 files changed, 165 insertions(+), 24 deletions(-) create mode 100755 .claude/scripts/ci-status.sh diff --git a/.claude/agents/ci-watcher.md b/.claude/agents/ci-watcher.md index e8eac9bf..4e5bb322 100644 --- a/.claude/agents/ci-watcher.md +++ b/.claude/agents/ci-watcher.md @@ -24,30 +24,31 @@ The pull request is on the repository the branch was pushed to: git -C remote get-url origin ``` -Use `mcp__github__pull_request_read` with `method: "get_check_runs"` for the head commit's -checks. Where the `gh` CLI is installed, `gh pr checks --watch` and `gh run list --commit ` -followed by `gh run view --log-failed` give more detail; use them when they are there. - -## Find the run - -The pull request is a draft and stays one; the user takes it out of draft when they are ready -to review it. Checks run on drafts, so pushing the branch starts a round of them, and a round -is already queued or finished by the time you are invoked. +## Watch -Confirm you are reading the round for the commit you were asked about: +`.claude/scripts/ci-status.sh` does the waiting and the log archaeology. Run it and read what it +gives you: ```bash -git -C rev-parse +.claude/scripts/ci-status.sh / ``` -Compare that against the head SHA in the pull request data. A push takes a moment to register, -so poll `get_check_runs` until runs appear. If nothing has appeared after a few minutes, say so -and stop: a push that starts no checks is a setup problem no amount of waiting fixes. +It blocks until every check concludes, so **never write a polling loop with `sleep` in it** — a +foreground `sleep` does not run here. Give the call a long timeout; a full round is several +minutes and the tool caps at ten. -## Watch +Its exit code is the outcome, and it separates cases you would otherwise confuse: -Poll until every check reaches a conclusion. Give it a generous timeout — a full build plus the -test suite is slow, and a watch you cut short looks exactly like a failure. +| Exit | Means | What to do | +|---|---|---| +| `0` | Every check passed | Report success | +| `1` | A check failed | The log extract is on stdout; diagnose it | +| `3` | No checks ever started | A setup problem. Stop and say so | +| `4` | `gh` is not authenticated | A setup problem. Say `gh auth login` has not been run | +| `8` | Still pending when the watch ended | The call was cut short. Run it again | + +`3`, `4` and `8` are **not** CI failures. Reporting any of them as one sends an implementer +hunting for a defect that does not exist. Five workflows run on a pull request, reported by job name rather than by workflow name. Expect these: @@ -57,8 +58,7 @@ these: | `changes / detect` | Never. Every workflow gates on `detect-code-changes`, so there are five of these. | | `build-and-test` | The diff is documentation only | | `build-and-test-web` | The diff is documentation only | -| `Analyze (csharp)` | The diff is documentation only | -| `Analyze (javascript)` | The diff is documentation only | +| `Analyze (csharp)` and `Analyze (javascript)` | The diff is documentation only | | `build-api` | The diff is documentation only | | `build-web` | The diff is documentation only | | `build-devcontainer` | The diff does not touch `.devcontainer/` | @@ -72,8 +72,20 @@ not have caught locally shows up. ## On failure -Get the real error. The check run's `output` summary and annotations carry the diagnostic; -where `gh` is installed, the failed job's log carries more. +The script has already found the failing runs and printed the window of log ending at the +runner's `##[error]` marker. That window is where the diagnosis is, and reading it is the job. + +**The marker line is the symptom, not the cause.** It says things like `buildx failed with: +ERROR: ... exit code: 1`. The thing that actually broke — a version mismatch, a compiler +diagnostic, a failing assertion — sits in the lines above it. Work upwards until you find +something that explains the failure rather than restating it. + +Where the extract leaves you short of the cause, read further. The script keeps each failing +job's full log and prints its path, so open that file and search it rather than fetching another +copy. + +Say so in the report if it still does not explain the failure, and give the run URL. Do not fill +the gap with a cause the log does not support. Then read the code the failure points at. The repository's working tree is on whatever the user last checked out, so read the branch's version: diff --git a/.claude/scripts/ci-status.sh b/.claude/scripts/ci-status.sh new file mode 100755 index 00000000..dc368d9d --- /dev/null +++ b/.claude/scripts/ci-status.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Watch a pull request's checks to a conclusion and, where they failed, print the part of the +# log that explains why. +# +# This is the mechanical half of watching CI: waiting, reading exit codes, finding the failing +# runs, and cutting the noise out of their logs. Deciding what the error means belongs to +# whoever reads the output. +# +# usage: ci-status.sh {pr-number} [owner/repo] +# +# Exit codes are the caller's signal and are deliberately distinct: +# 0 every check passed +# 1 a check failed; the log extract is on stdout +# 3 no checks have started for the head commit +# 4 gh is not authenticated +# 8 checks were still pending when the watch ended +set -uo pipefail + +pr_number="${1:-}" +repo="${2:-}" + +if [[ -z "${pr_number}" ]]; then + echo "usage: ci-status.sh {pr-number} [owner/repo]" >&2 + exit 64 +fi + +if ! gh auth status >/dev/null 2>&1; then + echo "gh is not authenticated. Run 'gh auth login'." >&2 + exit 4 +fi + +gh_args=(--repo "${repo}") +[[ -z "${repo}" ]] && gh_args=() + +head_sha=$(gh pr view "${pr_number}" "${gh_args[@]}" --json headRefOid --jq .headRefOid 2>/dev/null) +if [[ -z "${head_sha}" ]]; then + echo "Could not read pull request ${pr_number}." >&2 + exit 64 +fi + +echo "Head commit: ${head_sha}" + +checks=$(gh pr checks "${pr_number}" "${gh_args[@]}" --watch 2>&1) +watch_status=$? + +# A pull request whose checks never started reports this rather than an empty table, and it +# means a setup problem rather than a slow queue. +if grep -qi "no checks reported" <<<"${checks}"; then + echo "No checks have started for ${head_sha}." + exit 3 +fi + +echo "${checks}" + +case "${watch_status}" in + 0) + echo + echo "All checks passed." + exit 0 + ;; + 8) + echo + echo "Checks still pending when the watch ended. Run again." + exit 8 + ;; +esac + +# Strip the "jobsteptimestamp " prefix gh puts on every log line, which is most of the +# width and none of the information. +strip_prefix() { + sed -E 's/^[^\t]*\t[^\t]*\t[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z //' +} + +# The runner marks the failure with ##[error], but that line is the symptom — "the build +# failed". The cause sits above it, so print a window ending at the marker. +extract_failure() { + local log="$1" + local marker + marker=$(grep -n '##\[error\]' "${log}" | head -1 | cut -d: -f1) + + if [[ -z "${marker}" ]]; then + echo " No ##[error] marker found. Last 40 lines:" + tail -40 "${log}" | strip_prefix | sed 's/^/ /' + return + fi + + local from=$(( marker - 60 )) + (( from < 1 )) && from=1 + sed -n "${from},$(( marker + 2 ))p" "${log}" | strip_prefix | sed 's/^/ /' +} + +echo +echo "=== Failing runs for ${head_sha} ===" + +failed_runs=$(gh run list --commit "${head_sha}" "${gh_args[@]}" \ + --status failure --json databaseId,workflowName --jq '.[] | "\(.databaseId)\t\(.workflowName)"') + +if [[ -z "${failed_runs}" ]]; then + echo "A check failed but no failing workflow run was found for the commit." + echo "The failure may belong to a check that is not an Actions run." + exit 1 +fi + +# The full logs are kept rather than cleaned up. The extract below is a window, and whoever +# reads it may need more; leaving the files behind means they open a file instead of going back +# to the network for a second copy. +log_dir=$(mktemp -d -t ci-status-XXXXXX) + +while IFS=$'\t' read -r run_id workflow_name; do + [[ -z "${run_id}" ]] && continue + echo + echo "--- ${workflow_name} (run ${run_id}) ---" + echo " https://github.com/${repo:-$(gh repo view --json nameWithOwner --jq .nameWithOwner)}/actions/runs/${run_id}" + echo + + log="${log_dir}/${run_id}.log" + if gh run view "${run_id}" "${gh_args[@]}" --log-failed >"${log}" 2>/dev/null && [[ -s "${log}" ]]; then + extract_failure "${log}" + echo + echo " Full log: ${log}" + else + echo " Could not read the failed log for run ${run_id}." + fi +done <<<"${failed_runs}" + +exit 1 diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index d009c06c..2d547481 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -84,7 +84,7 @@ this pull request may not be theirs. Use the GitHub MCP server's `pull_request_review_write` with method `create` to open a pending review, `add_comment_to_pending_review` for each finding that names a file and a line **in the -diff**, then `submit_pending`. Where `gh` is installed, `gh pr review --comment` posts the body. +diff**, then `submit_pending`. The review body carries: diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index 6db73960..d390d577 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -72,8 +72,11 @@ summary. Every finding gets one of three outcomes, and none of them is silence. -**Does not hold** — reply on the thread with `add_reply_to_pull_request_comment`, or -`gh pr comment` where the finding has no thread. Give the evidence: what the code does instead, +**Does not hold** — reply on the thread with `add_reply_to_pull_request_comment`. Where the +finding has no thread to reply on, comment on the pull request itself with `add_issue_comment`, +quoting enough of the finding that the reply stands on its own. + +Give the evidence: what the code does instead, by file and line. Two or three sentences. Say it as a position, not a verdict — the person who raised it may know something the verifier could not see, and the thread is where that comes out. From de18bcb09488a68854af2a50269d091ee7b2dfec Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:06:48 -0500 Subject: [PATCH 35/41] Build and review in per-run container workspaces (#845) * Build and review in per-run container workspaces The container kept a long-lived clone of the org repository in a named volume. Nothing ever advanced its working tree, so the pipeline's own skills and agents were frozen at whatever commit the volume was created with. The rename in #842 made that visible: the host began invoking crypter-devcontainer-implement while the container still only knew crypter-implement, and both orchestrators broke. Workspaces are now created per run at /work/{run-id} and deleted when the run ends, cloned from a read-only mount of the host's .git. A copy of the code no longer outlives the run that made it, so it cannot drift. The mount being read-only is what makes sharing history safe: the container reads commits and cannot move a ref or add an object. The host working tree is not mounted, so uncommitted work stays invisible to the agents. The orchestrator owns the lifecycle and the container skills assume a workspace exists, mirroring how /runs already works. This also retires the worktree add/remove pairing in every container skill, along with the stale worktree a crashed run used to leave behind. The image is built locally instead of pulled from GHCR. It carries tooling and no source, so a change to workspace.sh or the Dockerfile is a rebuild rather than a publish waiting on an approval. NuGet and pnpm caches move to named volumes. Workspaces are ephemeral, so without them every run would restore from nothing. Co-Authored-By: Claude Opus 5 * Stop publishing the devcontainer image The image is built locally and carries tooling rather than source, so nothing consumes the published copy. Publishing it on every merge to stable left a registry image nobody pulled and an approval gate on changes that only ever affect the machine making them. pr-build-devcontainer stays. It pushes nothing and catches a Dockerfile that does not build, which is the only part of the publish path that was earning its keep. The devcontainer environment on the repository has no jobs left referencing it and can be deleted from the repository settings. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/skills/crypter-change/SKILL.md | 34 ++++- .../crypter-devcontainer-examine/SKILL.md | 25 ++-- .../crypter-devcontainer-implement/SKILL.md | 35 +++-- .../crypter-devcontainer-remediate/SKILL.md | 27 ++-- .../crypter-devcontainer-verify/SKILL.md | 20 +-- .claude/skills/crypter-review/SKILL.md | 26 ++-- .../crypter-step-open-pull-request/SKILL.md | 4 +- .claude/skills/crypter-triage-review/SKILL.md | 31 +++-- .devcontainer/Dockerfile | 24 +++- .devcontainer/clone-upstream.sh | 34 ----- .devcontainer/docker-compose.yml | 29 +++-- .devcontainer/workspace.sh | 83 ++++++++++++ .../workflows/build-and-push-devcontainer.yml | 56 -------- .github/workflows/detect-code-changes.yml | 2 +- .../Agentic Development Pipeline.md | 120 +++++++++++------- 15 files changed, 316 insertions(+), 234 deletions(-) delete mode 100644 .devcontainer/clone-upstream.sh create mode 100755 .devcontainer/workspace.sh delete mode 100644 .github/workflows/build-and-push-devcontainer.yml diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index b32fee0b..d491150f 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -47,6 +47,20 @@ docker exec crypter-pipeline test -d /plans/{run-id} && \ A container created before these existed picks them up on `docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate`. +Then make the workspace the container builds in. It is a clone of your repository, taken from +the read-only `/host-git` mount, and it lasts exactly as long as this run: + +```bash +git fetch origin +docker exec crypter-pipeline crypter-workspace create {run-id} +``` + +Fetch first — the workspace takes `upstream/stable` from your `origin/stable`, so a stale +remote-tracking ref puts the whole run on an old base. **If either fails, stop and say so.** + +The workspace holds only committed history. Uncommitted work in your checkout is not visible to +the container and never reaches the branch. + ## 1. Plan Invoke `crypter-step-plan` with the requirement verbatim and the output path @@ -57,7 +71,7 @@ It settles the plan with the user itself. **Do not continue until they have appr ## 2. Build ```bash -docker exec -w /work/Crypter crypter-pipeline \ +docker exec -w /work/{run-id} crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-implement {run-id} {branch}" ``` @@ -66,7 +80,7 @@ Keep the title and description it reports; `crypter-step-open-pull-request` need ## 3. Examine ```bash -docker exec -w /work/Crypter crypter-pipeline \ +docker exec -w /work/{run-id} crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-examine {run-id} {branch} /plans/{run-id}/plan.md" ``` @@ -92,7 +106,7 @@ them. Where anything was accepted: ```bash -docker exec -w /work/Crypter crypter-pipeline \ +docker exec -w /work/{run-id} crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-remediate {run-id} {branch} /runs/{run-id}/triage.md" ``` @@ -122,7 +136,19 @@ Stop immediately, without spending an attempt, where `ci-watcher` reports that n for the commit. Nothing to fix has been established yet, and a push that starts no checks is a setup problem rather than a code one. -## 8. Report +## 8. Tear down and report + +The branch is on the fork and the artifacts are on your disk, so the workspace has nothing left +to hold: + +```bash +docker exec crypter-pipeline crypter-workspace remove {run-id} +``` + +Remove it on every exit path, including the ones where you stopped early. Nothing under `/runs` +or `.claude/plans` is touched by this — those are the record of the run and they stay. + +Then report: - The pull request URL and whether its checks are green. It is a draft; taking it out of draft is the user's. diff --git a/.claude/skills/crypter-devcontainer-examine/SKILL.md b/.claude/skills/crypter-devcontainer-examine/SKILL.md index 8a7d5111..287dbbe4 100644 --- a/.claude/skills/crypter-devcontainer-examine/SKILL.md +++ b/.claude/skills/crypter-devcontainer-examine/SKILL.md @@ -8,7 +8,7 @@ description: Review a diff in the pipeline container and write findings to the h Review a diff and leave hard artifacts behind. You do not write code and you do not decide what gets acted on; your caller triages what you find. -The ref already exists in `/work/Crypter/.git`. +The ref already exists in the run's workspace at `/work/{run-id}`. ## Setup @@ -34,25 +34,28 @@ judgement, and a later pass can read them to verify the claims they make. either is missing, stop and say so** rather than creating it — a directory made on this side is one the host cannot clean up. -## 1. Worktree on the ref +## 1. Put the workspace on the ref + +The workspace at `/work/{run-id}` already exists; the host created it. **If it is missing, stop +and say so** rather than creating one. ```bash -git -C /work/Crypter worktree add --detach /work/Crypter/.claude/worktrees/{run-id} {ref} +git -C /work/{run-id} checkout --detach {ref} ``` -`--detach` because you only read. A worktree that claims the branch collides with anything else -holding it, and reviewing never needs it claimed. **If this fails, stop and say so.** +`--detach` because you only read. Leaving the branch unclaimed keeps a later stage free to check +it out and commit to it. **If this fails, stop and say so.** -Every agent gets this worktree path and works by absolute path inside it. Never `cd`. +Every agent gets the workspace path and works by absolute path inside it. Never `cd`. ## 2. Plan adherence -Given a plan path, invoke `conformance-auditor` with it, the worktree, and +Given a plan path, invoke `conformance-auditor` with it, the workspace, and `/runs/{run-id}/conformance.md`. It reports where the diff and the plan diverge. ## 3. Code review -Invoke `reviewer` once per lens, in parallel — they do not interact. Each gets the worktree and +Invoke `reviewer` once per lens, in parallel — they do not interact. Each gets the workspace and `/runs/{run-id}/findings/{lens}.md`. | Lens | Brief | @@ -75,8 +78,4 @@ drift, and which findings you would look at first. Name the files you wrote. Leave the judgement to the host. Reporting a finding is not accepting it. -```bash -git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} -``` - -Remove it on every exit path. +Leave the workspace as it is. The host removes it when the run ends. diff --git a/.claude/skills/crypter-devcontainer-implement/SKILL.md b/.claude/skills/crypter-devcontainer-implement/SKILL.md index 037fe5b9..546a4740 100644 --- a/.claude/skills/crypter-devcontainer-implement/SKILL.md +++ b/.claude/skills/crypter-devcontainer-implement/SKILL.md @@ -7,9 +7,9 @@ description: Build an approved plan into commits on a new branch, inside the pip Turn an approved plan into commits on a branch. -The workspace is an anonymous clone of the org repository with a single remote, `upstream`, -which has no push url. Commit locally and stop there; the branch is fetched out and pushed once -you return. +The workspace at `/work/{run-id}` is a clone of the host repository, taken from a read-only +mount. It has no push url and no credential. Commit locally and stop there; the branch is +fetched out and pushed once you return. The plan is the specification. The user approved it before this ran, and this runs unattended. @@ -20,23 +20,26 @@ You are given a run id and a branch name: `/crypter-devcontainer-implement {run- Read `/plans/{run-id}/plan.md` first. It is a read-only mount of the host's `.claude/plans`. **If it is absent, stop and say so** — the host session owns that file. -## 1. Sync and branch +`/work/{run-id}` already exists; the host created it. **If it is missing, stop and say so** +rather than creating one — the host owns the workspace for the whole run and removes it at the +end. -Build on current code: +## 1. Branch + +The workspace is checked out at `upstream/stable`, so build from there: ```bash -git -C /work/Crypter fetch upstream -git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} -b {branch} upstream/stable +git -C /work/{run-id} checkout -b {branch} refs/remotes/upstream/stable ``` -**If either fails, stop and say so.** A quietly skipped sync leaves the diff and the eventual -pull request on the wrong base, and nothing downstream will notice. +**If this fails, stop and say so.** A branch cut from the wrong base leaves the diff and the +eventual pull request on the wrong base, and nothing downstream will notice. -Work by absolute path inside the worktree. Never `cd`. +Work by absolute path inside the workspace. Never `cd`. ## 2. Implement -Invoke `implementer` with `/plans/{run-id}/plan.md` and the worktree path. Give it nothing about +Invoke `implementer` with `/plans/{run-id}/plan.md` and the workspace path. Give it nothing about how the plan was reached — the plan is the specification. Read its report. If it says a step could not be done, that is not a failure to paper over: @@ -44,14 +47,10 @@ say so plainly in your own report. ## 3. Hand off -```bash -git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} -``` - -Remove it on every exit path. The branch ref lives in `/work/Crypter/.git` and survives, which -is what the host fetches. +Leave the workspace as it is, with `{branch}` checked out and its commits on it. The host fetches +the branch out of it and removes it when the run ends. -Then report back to the host session: +Report back to the host session: - The branch name and the commits on it. - A title and description for the pull request. Title reads like a commit subject: imperative, diff --git a/.claude/skills/crypter-devcontainer-remediate/SKILL.md b/.claude/skills/crypter-devcontainer-remediate/SKILL.md index 0787b96f..e71b5b9a 100644 --- a/.claude/skills/crypter-devcontainer-remediate/SKILL.md +++ b/.claude/skills/crypter-devcontainer-remediate/SKILL.md @@ -7,8 +7,8 @@ description: Apply a report to a branch the pipeline already built, whether tria Take a report of what is wrong with a branch this container already built, and fix it. -The branch exists in `/work/Crypter/.git`. Commit locally; the result is fetched out and pushed -once you return. +The branch exists in the run's workspace at `/work/{run-id}`. Commit locally; the result is +fetched out and pushed once you return. The report is triaged review findings or a CI failure. Both are the same job: a description of what is wrong, an existing branch, and commits that address it. @@ -25,19 +25,21 @@ Read `/plans/{run-id}/plan.md` too where one exists. The fix stays inside what t to do; a repair that reaches into the plan's non-goals belongs in your report rather than in a commit. -## 1. Worktree on the existing branch +## 1. Claim the existing branch + +The workspace at `/work/{run-id}` already exists; the host created it. **If it is missing, stop +and say so** rather than creating one. ```bash -git -C /work/Crypter fetch upstream -git -C /work/Crypter worktree add /work/Crypter/.claude/worktrees/{run-id} {branch} +git -C /work/{run-id} checkout {branch} ``` -No `-b` — the branch is already there, carrying the commits the host has pushed. **If this -fails, stop and say so.** +No `-b` — the branch is already there, carrying the commits an earlier stage put on it. **If +this fails, stop and say so.** ## 2. Fix -Invoke `implementer` with the report path and the worktree path. Each fix is its own commit on +Invoke `implementer` with the report path and the workspace path. Each fix is its own commit on the branch. Read its report. If it says the failure could not be addressed, say so plainly in your own @@ -45,11 +47,8 @@ report rather than reporting success. ## 3. Hand off -```bash -git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id} -``` - -Remove it on every exit path. The branch keeps the new commits. +Leave the workspace as it is, with the new commits on `{branch}`. The host fetches them out and +removes the workspace when the run ends. -Then report back to the host session: what the report described, what changed, and which commits +Report back to the host session: what the report described, what changed, and which commits now sit on the branch. The host fetches those commits and pushes them. diff --git a/.claude/skills/crypter-devcontainer-verify/SKILL.md b/.claude/skills/crypter-devcontainer-verify/SKILL.md index 0f268295..c95fa34d 100644 --- a/.claude/skills/crypter-devcontainer-verify/SKILL.md +++ b/.claude/skills/crypter-devcontainer-verify/SKILL.md @@ -7,7 +7,8 @@ description: Rule on each finding in a report against the code, one verifier per Take a list of findings somebody left on a diff and decide which of them are true. -The ref already exists in `/work/Crypter/.git`. You write verdicts and nothing else — no fixes, +The ref already exists in the run's workspace at `/work/{run-id}`. You write verdicts and +nothing else — no fixes, and no findings of your own. ## Setup @@ -21,19 +22,22 @@ is absent, stop and say so.** `/runs/{run-id}/verification/` already exists; the caller creates it. **If it is missing, stop and say so** rather than creating it. -## 1. Worktree on the ref +## 1. Put the workspace on the ref + +The workspace at `/work/{run-id}` already exists; the host created it. **If it is missing, stop +and say so** rather than creating one. ```bash -git -C /work/Crypter worktree add --detach /work/Crypter/.claude/worktrees/{run-id}-verify {ref} +git -C /work/{run-id} checkout --detach {ref} ``` `--detach` because you only read. **If this fails, stop and say so.** -Every agent gets this worktree path and works by absolute path inside it. Never `cd`. +Every agent gets the workspace path and works by absolute path inside it. Never `cd`. ## 2. Verify -Invoke `finding-verifier` once per finding, in parallel. Each gets one finding, the worktree +Invoke `finding-verifier` once per finding, in parallel. Each gets one finding, the workspace path, and `/runs/{run-id}/verification/{finding-id}.md`. One finding per agent, and each sees only its own. A verifier that reads the whole report starts @@ -46,8 +50,4 @@ Never give a finding to the agent that raised it. For each finding: its id, the verdict, and one line of evidence. Then the counts — how many held, how many did not, how many are unsettled. Name the files you wrote. -```bash -git -C /work/Crypter worktree remove /work/Crypter/.claude/worktrees/{run-id}-verify -``` - -Remove it on every exit path. +Leave the workspace as it is. The host removes it when the run ends. diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index 2d547481..2c61f5b2 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -37,25 +37,28 @@ Read its title, description and diff with whatever GitHub access this session ha CLI, or the GitHub MCP server's `pull_request_read`. What the author says it does is context for reading the diff, and worth carrying into your report where the two disagree. -## 2. Fetch it into the container +## 2. Fetch it into a workspace -Pull request heads are public refs on the org repository, so the container reaches them -anonymously: +The container has no network remote. It clones from your repository through a read-only mount, +so the pull request head goes into your repository first and travels across from there: ```bash -docker exec crypter-pipeline \ - git -C /work/Crypter fetch upstream +pull/{number}/head:pr-{number} +git fetch origin +refs/pull/{number}/head:refs/pr/{number} +docker exec crypter-pipeline crypter-workspace create pr-{number} \ + '+refs/pr/{number}:refs/heads/pr-{number}' ``` The refspec is forced, so reviewing a pull request again after its author rebased or amended picks up the new head instead of being rejected. -**If this fails, stop and say so.** +**If either fails, stop and say so.** + +The workspace lasts for this review and no longer. ## 3. Examine ```bash -docker exec -w /work/Crypter crypter-pipeline \ +docker exec -w /work/pr-{number} crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-examine pr-{number} pr-{number}" ``` @@ -99,7 +102,14 @@ produced it. A line comment that the API rejects for being outside the diff goes in the body instead. **Do not retry it against a different line.** -## 6. Report +## 6. Tear down and report + +```bash +docker exec crypter-pipeline crypter-workspace remove pr-{number} +``` + +Remove it on every exit path, including the ones where you stopped early. The findings under +`.claude/runs/pr-{number}` are the record and they stay. Tell the user the review URL, what you kept and dropped, which findings you checked against the code yourself and stand behind, and where the artifacts are. diff --git a/.claude/skills/crypter-step-open-pull-request/SKILL.md b/.claude/skills/crypter-step-open-pull-request/SKILL.md index 03047de8..b130708a 100644 --- a/.claude/skills/crypter-step-open-pull-request/SKILL.md +++ b/.claude/skills/crypter-step-open-pull-request/SKILL.md @@ -15,11 +15,11 @@ You are given a run id and a branch name: `/crypter-step-open-pull-request {run- ## 1. Fetch the branch out of the container -The branch lives in the container's clone. `git` reaches it over `docker exec`: +The branch lives in the run's workspace. `git` reaches it over `docker exec`: ```bash git -c protocol.ext.allow=user fetch \ - "ext::docker exec -i crypter-pipeline git upload-pack /work/Crypter" {branch}:{branch} + "ext::docker exec -i crypter-pipeline git upload-pack /work/{run-id}" {branch}:{branch} ``` `protocol.ext.allow` is passed per command and stays out of your config. **If this fails, stop diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index d390d577..34420ee4 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -46,22 +46,26 @@ about intent. A question is for the author to answer, not for a verifier. **If there is nothing open, say so and stop.** -## 2. Fetch the head into the container +## 2. Fetch the head into a workspace + +The container has no network remote. It clones from your repository through a read-only mount, +so the head goes into your repository first and travels across from there: ```bash -docker exec crypter-pipeline \ - git -C /work/Crypter fetch upstream +pull/{number}/head:{head-branch} +git fetch origin +refs/pull/{number}/head:refs/pr/{number} +docker exec crypter-pipeline crypter-workspace create pr-{number} \ + '+refs/pr/{number}:refs/heads/{head-branch}' ``` -The local branch takes the pull request's own branch name, so the commits go back to the branch -they came from. +The branch in the workspace takes the pull request's own branch name, so the commits go back to +the branch they came from. -**If this fails, stop and say so.** +**If either fails, stop and say so.** ## 3. Verify ```bash -docker exec -w /work/Crypter crypter-pipeline \ +docker exec -w /work/pr-{number} crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-verify pr-{number} {head-branch} /runs/pr-{number}/review.md" ``` @@ -93,7 +97,7 @@ thread. Where `triage.md` has anything, and the head branch is one you can push to: ```bash -docker exec -w /work/Crypter crypter-pipeline \ +docker exec -w /work/pr-{number} crypter-pipeline \ claude --permission-mode auto -p "/crypter-devcontainer-remediate pr-{number} {head-branch} /runs/pr-{number}/triage.md" ``` @@ -103,7 +107,16 @@ commits and leaves the existing pull request in place. A pull request from a repository you cannot push to stops here. The replies stand, `triage.md` stands, and the author does the fixing. Say so in the report. -## 6. Report +## 6. Tear down and report + +```bash +docker exec crypter-pipeline crypter-workspace remove pr-{number} +``` + +Remove it on every exit path, including the ones where you stopped early. The verdicts under +`.claude/runs/pr-{number}` are the record and they stay. + +Then report: - What held, what did not, and what you could not settle. - The replies you posted, and where. diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 63ba2156..98fc8031 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -12,6 +12,11 @@ ENV DOTNET_CLI_TELEMETRY_OPTOUT=1 \ DOTNET_TOOLS=/usr/local/share/dotnet-tools ENV PATH="${PATH}:${DOTNET_TOOLS}" +# Workspaces are ephemeral, so the package caches have to live outside them or every run pays a +# full restore. Both paths are named volumes in docker-compose.yml. +ENV NUGET_PACKAGES=/caches/nuget \ + npm_config_store_dir=/caches/pnpm + # The agents run unattended, so they run as an unprivileged user rather than root. RUN groupadd --gid $USER_GID $USERNAME \ && useradd --uid $USER_UID --gid $USER_GID --create-home --shell /bin/bash $USERNAME @@ -40,14 +45,19 @@ RUN dotnet workload install wasm-tools RUN dotnet tool install dotnet-ef --version '10.0.*' --tool-path "${DOTNET_TOOLS}" -COPY .devcontainer/clone-upstream.sh /usr/local/bin/crypter-clone-upstream -RUN chmod +x /usr/local/bin/crypter-clone-upstream +COPY .devcontainer/workspace.sh /usr/local/bin/crypter-workspace +RUN chmod +x /usr/local/bin/crypter-workspace + +# /host-git is the host's repository, owned by the host user. Git refuses to read a repository +# owned by anyone else until it is named as safe, and the whole point of the mount is that it +# belongs to somebody else. +RUN git config --system --add safe.directory /host-git -# The workspace and the agent's Claude Code state are both named volumes. Docker creates a -# mount point that the image does not already contain as root, so creating these here is -# what gives the volumes the right ownership. -RUN mkdir -p /work /home/$USERNAME/.claude \ - && chown $USER_UID:$USER_GID /work /home/$USERNAME/.claude +# The caches and the agent's Claude Code state are named volumes. Docker creates a mount point +# that the image does not already contain as root, so creating these here is what gives the +# volumes the right ownership. /work holds the ephemeral workspaces and is not a volume. +RUN mkdir -p /work /caches/nuget /caches/pnpm /home/$USERNAME/.claude \ + && chown -R $USER_UID:$USER_GID /work /caches /home/$USERNAME/.claude USER $USERNAME WORKDIR /work diff --git a/.devcontainer/clone-upstream.sh b/.devcontainer/clone-upstream.sh deleted file mode 100644 index 8bd0f8d1..00000000 --- a/.devcontainer/clone-upstream.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env bash -# Prepare the pipeline workspace: a clone of the org repository. The container runs this on -# every start; an existing workspace is left alone. -# -# The workspace is a named volume rather than a bind mount of a host checkout. The agents get -# their own clone, so they cannot touch uncommitted work on the host. The clone is anonymous -# and the remote has no push url, so the agents read public code and commit locally. Pushing -# and opening pull requests happen on the host. -set -euo pipefail - -: "${CRYPTER_GIT_NAME:?Set CRYPTER_GIT_NAME in .devcontainer/.env to the author name on the commits}" -: "${CRYPTER_GIT_EMAIL:?Set CRYPTER_GIT_EMAIL in .devcontainer/.env to the author email on the commits}" - -upstream_repo="${CRYPTER_UPSTREAM:-Crypter-File-Transfer/Crypter}" - -# Has to match the workspace path the container skills and docker-compose.yml use. -workspace="/work/Crypter" - -git config --global user.name "${CRYPTER_GIT_NAME}" -git config --global user.email "${CRYPTER_GIT_EMAIL}" - -if [[ -d "${workspace}/.git" ]]; then - echo "Workspace already present at ${workspace}" -else - git clone --origin upstream "https://github.com/${upstream_repo}.git" "${workspace}" -fi - -# A push from the container fails here rather than at a credential prompt. -git -C "${workspace}" remote set-url --push upstream no-push - -git -C "${workspace}" fetch --quiet upstream - -echo "Workspace ready at ${workspace}" -git -C "${workspace}" remote -v diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index b0cfd68d..f6c9407f 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -3,31 +3,38 @@ name: crypter-pipeline services: pipeline: container_name: crypter-pipeline - image: ghcr.io/crypter-file-transfer/crypter-devcontainer:latest - pull_policy: missing + # Built here rather than pulled. The image carries tooling and nothing else, so a change to + # it is a local rebuild instead of a publish someone has to approve. + image: crypter-devcontainer:local build: context: .. dockerfile: .devcontainer/Dockerfile environment: - CRYPTER_UPSTREAM: Crypter-File-Transfer/Crypter CRYPTER_GIT_NAME: ${CRYPTER_GIT_NAME} CRYPTER_GIT_EMAIL: ${CRYPTER_GIT_EMAIL} volumes: - - workspace:/work - claude:/home/agent/.claude + # The host repository, read-only. Workspaces are cloned from it and the container writes + # nothing back. The host working tree is deliberately not mounted, so uncommitted work is + # not visible in here. + - ../.git:/host-git:ro # Plans are authored on the host and read from /plans. - ../.claude/plans:/plans:ro # Findings, conformance and triage are artifacts on the host, written from /runs. - ../.claude/runs:/runs - # /work/Crypter does not exist until crypter-clone-upstream has run, so the container starts - # one level up. Open a shell with `exec -w /work/Crypter`. + # Package caches are the one thing that outlives a run. Workspaces are ephemeral, so + # without these every run restores NuGet and pnpm from scratch. + - nuget:/caches/nuget + - pnpm:/caches/pnpm + # Workspaces are created per run at /work/{run-id}. Open a shell on one with + # `exec -w /work/{run-id}`. working_dir: /work - # crypter-clone-upstream leaves an existing workspace alone and only refetches, so running - # it on every start is safe. - command: bash -lc "crypter-clone-upstream && sleep infinity" + command: sleep infinity volumes: - workspace: - name: crypter-pipeline-workspace claude: name: crypter-pipeline-claude + nuget: + name: crypter-pipeline-nuget + pnpm: + name: crypter-pipeline-pnpm diff --git a/.devcontainer/workspace.sh b/.devcontainer/workspace.sh new file mode 100755 index 00000000..1097dfbf --- /dev/null +++ b/.devcontainer/workspace.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Create and remove the per-run workspaces the agents build and review in. +# +# A workspace is a clone of the host repository taken from the read-only /host-git mount. It +# belongs to one run and is removed with it, so no copy of the repository outlives the state it +# was made from. +# +# The mount is read-only, so the container reads committed history and writes nothing back. The +# host's working tree is not mounted at all, which is what keeps uncommitted work invisible here. +set -euo pipefail + +host_git="/host-git" + +usage() { + echo "usage: crypter-workspace create {run-id} [refspec]" >&2 + echo " crypter-workspace remove {run-id}" >&2 + exit 64 +} + +subcommand="${1:-}" +run_id="${2:-}" +[[ -n "${subcommand}" && -n "${run_id}" ]] || usage + +# The run id becomes a path under /work that `remove` deletes recursively, so it has to be a +# plain name before it is used as one. +if [[ ! "${run_id}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then + echo "Run id '${run_id}' is not a plain name" >&2 + exit 64 +fi + +workspace="/work/${run_id}" + +case "${subcommand}" in + create) + : "${CRYPTER_GIT_NAME:?Set CRYPTER_GIT_NAME in .devcontainer/.env to the author name on the commits}" + : "${CRYPTER_GIT_EMAIL:?Set CRYPTER_GIT_EMAIL in .devcontainer/.env to the author email on the commits}" + + if [[ ! -d "${host_git}" ]]; then + echo "No host repository at ${host_git}. The container was started without its mount." >&2 + exit 1 + fi + + if [[ -e "${workspace}" ]]; then + echo "A workspace already exists at ${workspace}. Remove it or use another run id." >&2 + exit 1 + fi + + # --no-hardlinks because the mount is read-only and owned by another uid, which is exactly + # the case where git's hardlink optimisation is unavailable. Copying is predictable. + git clone --quiet --no-hardlinks "${host_git}" "${workspace}" + + # The agents diff against upstream/stable. Take it from the host's own remote-tracking ref + # so it reflects the org repository rather than whatever branch the host has checked out. + git -C "${workspace}" fetch --quiet origin \ + '+refs/remotes/origin/stable:refs/remotes/upstream/stable' + + if [[ -n "${3:-}" ]]; then + git -C "${workspace}" fetch --quiet origin "${3}" + fi + + git -C "${workspace}" checkout --quiet -B stable refs/remotes/upstream/stable + + git -C "${workspace}" config user.name "${CRYPTER_GIT_NAME}" + git -C "${workspace}" config user.email "${CRYPTER_GIT_EMAIL}" + + echo "Workspace ready at ${workspace}" + git -C "${workspace}" log --oneline -1 refs/remotes/upstream/stable + ;; + + remove) + if [[ ! -d "${workspace}" ]]; then + echo "No workspace at ${workspace}" + exit 0 + fi + + rm -rf "${workspace}" + echo "Removed ${workspace}" + ;; + + *) + usage + ;; +esac diff --git a/.github/workflows/build-and-push-devcontainer.yml b/.github/workflows/build-and-push-devcontainer.yml deleted file mode 100644 index c0e9bda9..00000000 --- a/.github/workflows/build-and-push-devcontainer.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Build and push an image of the Crypter devcontainer to GitHub Container Registry - -on: - push: - branches: - - stable - paths: - - '.devcontainer/**' - - '.github/workflows/build-and-push-devcontainer.yml' - - workflow_dispatch: - -env: - registry: ghcr.io/${{ github.repository_owner }} - -jobs: - build-and-push-devcontainer-image: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - environment: - name: devcontainer - - steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - - name: Log in to the Container registry - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ${{ env.registry }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 - with: - images: ${{ env.registry }}/crypter-devcontainer - tags: | - type=raw,value=latest - type=sha,format=short - - - name: Build and push Docker image - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - file: ./.devcontainer/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/detect-code-changes.yml b/.github/workflows/detect-code-changes.yml index 7abb3b5e..aa688bb0 100644 --- a/.github/workflows/detect-code-changes.yml +++ b/.github/workflows/detect-code-changes.yml @@ -33,7 +33,7 @@ jobs: base_sha: ${{ github.event.pull_request.base.sha }} head_sha: ${{ github.event.pull_request.head.sha }} documentation: '(\.md$|^Documentation/|^\.github/ISSUE_TEMPLATE/)' - devcontainer: '(^\.devcontainer/|^\.github/workflows/(pr-build|build-and-push)-devcontainer\.yml$)' + devcontainer: '(^\.devcontainer/|^\.github/workflows/pr-build-devcontainer\.yml$)' run: | set -euo pipefail diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index dc08832b..6c5252fc 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -24,7 +24,7 @@ nothing itself: it fetches the pull request into the container and runs `/crypter-devcontainer-examine` there. Both prefixes say the same thing: an orchestrator invokes this, you do not. `crypter-step-` runs -in your session, and `crypter-devcontainer-` runs in the container, which expects `/work/Crypter`, +in your session, and `crypter-devcontainer-` runs in the container, which expects a workspace, `/plans` and `/runs` — none of which your session has. The three skills without a prefix are the ones to invoke. @@ -35,38 +35,57 @@ ones to invoke. relative to `.devcontainer/`, so `.claude/plans` and `.claude/runs` resolve against that one directory. Started from a worktree, a run writes its plan somewhere the container cannot read. -**The container holds no GitHub credential.** Its workspace is an anonymous clone of the org -repository with one remote, `upstream`, which has no push url, so the agents read public code -and commit locally. Every authenticated GitHub operation happens in your session with your own -access, and `/crypter-change` pushes and re-pushes without stopping to ask. The workspace is a -named Docker volume rather than a bind mount of your checkout, so the agents cannot touch -uncommitted work on your machine. - -The container does hold your Claude Code credential, in the `crypter-pipeline-claude` volume, -and its network egress is open. Treat it as a trust boundary rather than a sandbox. +**The container holds no GitHub credential and no network remote.** Every authenticated GitHub +operation happens in your session with your own access, and `/crypter-change` pushes and +re-pushes without stopping to ask. The branch is pushed to the org repository and the pull request opens against it, base `stable`, the same route a branch of your own takes. `/crypter-change` leaves you a draft pull request to read. -The org repository therefore has two names in this pipeline. Your session reaches it as `origin`, -the remote your checkout already has. The container reaches it as `upstream`, the name its clone -gives the one remote it has, chosen so that a remote with no push url reads as one. Host-side -skills say `origin` and container-side skills say `upstream`; both mean -`Crypter-File-Transfer/Crypter`. +The container does hold your Claude Code credential, in the `crypter-pipeline-claude` volume, +and its network egress is open. Treat it as a trust boundary rather than a sandbox. + +## Workspaces + +The agents build and review in a **workspace**: a clone of your repository at `/work/{run-id}`, +made when a run starts and deleted when it ends. Nothing that holds a copy of the code outlives +the run that made it, so there is no second checkout drifting away from yours. + +Workspaces are cloned from `/host-git`, a read-only mount of your repository's `.git`. Read-only +is what makes this safe to share: the container reads committed history and cannot move a ref, +add an object, or touch anything in your repository. Your working tree is not mounted at all, so +uncommitted work is invisible in there and cannot reach a branch. + +The orchestrator owns the lifecycle. It creates the workspace in its setup and removes it when +the run ends; the container skills use it and never create or destroy one. + +```bash +docker exec crypter-pipeline crypter-workspace create {run-id} [refspec] +docker exec crypter-pipeline crypter-workspace remove {run-id} +``` + +The org repository has two names as a result. Your session reaches it as `origin`, the remote +your checkout already has. Inside a workspace it is `upstream/stable`, a ref the create step +copies from your `origin/stable` so the agents always diff against the org's current code rather +than whatever branch you have checked out. Fetch before creating a workspace, or the run starts +on a stale base. This document covers the setup you need before the container will start. -## The two mounts +## The mounts -Everything crossing the container boundary goes through one of two directories, both gitignored -and both on your disk: +Everything crossing the container boundary goes through one of these: | Host | Container | Direction | Holds | |---|---|---|---| +| `.git` | `/host-git` | Read-only | Your committed history, which workspaces are cloned from | | `.claude/plans` | `/plans` | Read-only | `{run-id}/plan.md` | | `.claude/runs` | `/runs` | Writable | `{run-id}/conformance.md`, `{run-id}/findings/{lens}.md`, `{run-id}/review.md`, `{run-id}/verification/{id}.md`, `{run-id}/triage.md`, `{run-id}/ci-{n}.md` | +`.claude/plans` and `.claude/runs` are gitignored and live on your disk. Only `/runs` is +writable; the other two the container can read and nothing more. + The plan goes in and cannot be rewritten by the agents. Findings come back out as files you can open, grep and keep, rather than as text in a transcript, and each is written by the agent that found it. `triage.md` is what `/crypter-change` decided to act on, and reading it is how you @@ -81,7 +100,7 @@ The branch itself travels differently. It never passes through a mount: ```bash git -c protocol.ext.allow=user fetch \ - "ext::docker exec -i crypter-pipeline git upload-pack /work/Crypter" {branch}:{branch} + "ext::docker exec -i crypter-pipeline git upload-pack /work/{run-id}" {branch}:{branch} ``` `protocol.ext.allow` is passed per command, so it stays out of your git config. @@ -123,8 +142,8 @@ cp .devcontainer/.env.example .devcontainer/.env | `CRYPTER_GIT_NAME` | Author name on the agents' commits. | | `CRYPTER_GIT_EMAIL` | Author email on the agents' commits. | -Both are required. Leaving one empty fails the container's startup script with a message naming -the variable. +Both are required. Leaving one empty fails workspace creation with a message naming the +variable. ## Launching the container @@ -134,22 +153,25 @@ Compose project from the application stack at the repository root, so `docker co ```bash mkdir -p .claude/plans .claude/runs -docker compose -f .devcontainer/docker-compose.yml up -d -docker compose -f .devcontainer/docker-compose.yml exec -w /work/Crypter pipeline bash +docker compose -f .devcontainer/docker-compose.yml up -d --build +docker compose -f .devcontainer/docker-compose.yml exec pipeline bash ``` Create the two mount sources first. They are gitignored, so a fresh clone has neither, and Docker creates a missing bind-mount source as root — which the orchestrators then cannot write into. +The first `--build` takes a few minutes, mostly installing the `wasm-tools` workload. After +that Docker's layer cache makes it quick, and a change to `workspace.sh` rebuilds only the last +couple of layers. Use `--build` whenever `.devcontainer/` has changed; plain `up -d` otherwise. + Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` -reuses the workspace and your Claude Code credentials. +keeps your Claude Code credentials and your package caches. ## What is in the container -The image is published by the org at `ghcr.io/crypter-file-transfer/crypter-devcontainer`, and -the container pulls it for you. There is nothing to build unless you are changing the image -itself. +The image is built locally from `.devcontainer/Dockerfile` and tagged `crypter-devcontainer:local`. +It carries tooling and no source, so it only changes when the tooling does. Built on `mcr.microsoft.com/dotnet/sdk:10.0`, running as an unprivileged user named `agent` rather than as root: @@ -162,21 +184,26 @@ There is **no Docker in the container**, so `Crypter.Test` cannot run there — Testcontainers to start PostgreSQL. The agents build but never test locally; the test suite runs in CI once the pull request exists, and failures come back to the implementer from there. -Two named volumes survive rebuilds: `crypter-pipeline-workspace` holds the workspace at -`/work/Crypter`, and `crypter-pipeline-claude` holds the agent's Claude Code state. +Three named volumes survive rebuilds, and none of them holds source: -## First start +| Volume | Holds | +|---|---| +| `crypter-pipeline-claude` | The agent's Claude Code state and credentials | +| `crypter-pipeline-nuget` | The NuGet package cache | +| `crypter-pipeline-pnpm` | The pnpm store | + +The two caches exist because workspaces are ephemeral. Without them every run would restore +NuGet and pnpm from nothing, which is most of a build. -Every `up` runs `crypter-clone-upstream`, which clones the org repository to `/work/Crypter` as -the `upstream` remote, clears that remote's push url, and fetches. If it already finds a -workspace there it leaves it alone and only refetches, so restarting the container does not -discard work in progress. +`/work` is the container's own filesystem rather than a volume, so live workspaces do not +survive a `down`. That is the intent: a run that was interrupted leaves nothing behind to +collide with the next one. To start over from nothing, take the container down and remove the volumes: ```bash docker compose -f .devcontainer/docker-compose.yml down -docker volume rm crypter-pipeline-workspace crypter-pipeline-claude +docker volume rm crypter-pipeline-claude crypter-pipeline-nuget crypter-pipeline-pnpm ``` ## Authenticate Claude Code @@ -190,22 +217,21 @@ they survive container rebuilds. You only do this again after removing that volu Run the agents with `--permission-mode auto`. They work unattended, so a prompt they cannot answer is a run that stalls. What bounds the blast radius is the container itself: a workspace -in a named volume, a remote with no push url, and no GitHub credential to push with. +that is thrown away at the end of the run, a read-only view of your repository, and no GitHub +credential to push with. ## Changing the image -Only needed if your change requires a different image — a new tool the agents need, a runtime -version bump. Otherwise skip this; the published image is what the container runs. - -Build your change locally to try it: +Needed when the tooling changes — a new tool the agents need, a runtime version bump. Source +changes never require it, because the image carries no source. ```bash -docker compose -f .devcontainer/docker-compose.yml build -docker compose -f .devcontainer/docker-compose.yml up -d +docker compose -f .devcontainer/docker-compose.yml up -d --build ``` -Open a pull request for `.devcontainer/` once it works. `pr-build-devcontainer` builds the image -on the pull request, and merging to `stable` runs -`.github/workflows/build-and-push-devcontainer.yml`, which pushes to -`ghcr.io/crypter-file-transfer/crypter-devcontainer`. That job runs in the `devcontainer` -environment, so it waits for a reviewer to approve it before anything is published. +That is the whole loop. The image is local to your machine — it is never published, and nobody +else consumes it — so a change to `workspace.sh` or the Dockerfile takes effect on your next +`up` and affects nothing but your own container. + +`pr-build-devcontainer` builds the image on a pull request that touches `.devcontainer/`. It +pushes nothing; it is there to catch a Dockerfile that does not build. From 2091907f1ec26e9f92d94354de14b217a5c9c99b Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:25:13 -0500 Subject: [PATCH 36/41] Verify the pipeline container before the skills use it (#848) The skills open at `docker exec crypter-pipeline` and assume the container is running and belongs to this checkout. Neither holds by itself. The Compose mounts are relative paths, so they resolve against whichever checkout launched the container, and a second checkout finds a container by name whose /runs writes land somewhere it never looks and whose /host-git is a different history. The trap is that an exited container looks repairable. `docker start` reuses the mounts and image fixed at creation, so it brings the wrong container back and the run fails two steps later at workspace creation, reading as a missing executable rather than as the wrong container. crypter-review and crypter-triage-review gain the preflight crypter-change already had, and all three now also check the image carries crypter-workspace, which the mount checks alone let through. The checks are `test` rather than `command -v` because docker exec runs a binary and not a shell, so a builtin exits 127 either way. The remedy is `up -d --build`, not `--force-recreate`, which recreates against new mounts but keeps a stale image. It takes over the one crypter-pipeline on the machine, so the skills ask before running it. Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/skills/crypter-change/SKILL.md | 26 +++++++++++++---- .claude/skills/crypter-review/SKILL.md | 29 +++++++++++++++++++ .claude/skills/crypter-triage-review/SKILL.md | 29 +++++++++++++++++++ .../Agentic Development Pipeline.md | 19 ++++++++++-- 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index d491150f..ba547c09 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -36,16 +36,32 @@ and both are yours to read at any point. write into a directory that grants it. Creating them on this side also keeps you able to delete what they wrote — a directory the container creates is one you cannot remove. -The container needs both mounts, and the run directory has to be writable from inside it. -Confirm before starting: +The container needs both mounts, the run directory has to be writable from inside it, and the +image has to carry the current tooling. Confirm before starting: ```bash docker exec crypter-pipeline test -d /plans/{run-id} && \ - docker exec crypter-pipeline test -w /runs/{run-id}/findings + docker exec crypter-pipeline test -w /runs/{run-id}/findings && \ + docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace ``` -A container created before these existed picks them up on -`docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate`. +The mount checks also settle which checkout the container belongs to: one created against a +different one reaches neither directory. The last check is separate because an older image +passes the first two and then fails at workspace creation with nothing but a missing executable +to go on. All three are `test` because `docker exec` runs a binary and not a shell, so a builtin +like `command -v` exits 127 whether or not the thing it was looking for is there. + +**Do not `docker start` an exited container to fix any of this.** Mounts and image are fixed +when a container is created, so starting one built from another checkout, or from an older +image, brings back the same wrong container. Bring it up from here instead: + +```bash +docker compose -f .devcontainer/docker-compose.yml up -d --build +``` + +That rebuilds the image and recreates the container against this checkout's mounts. It replaces +any container of the same name, so **ask the user before running it** — theirs may belong to +another checkout and hold work you cannot see. Then make the workspace the container builds in. It is a clone of your repository, taken from the read-only `/host-git` mount, and it lasts exactly as long as this run: diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index 2c61f5b2..30a25983 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -31,6 +31,35 @@ The container's `agent` is uid 1001 and your files are uid 1000, so the agents w directories this side creates and grants. Creating them here also keeps you able to delete what they wrote. +Then confirm the running container is the one this checkout describes, before anything depends +on it: + +```bash +docker exec crypter-pipeline test -w /runs/pr-{number}/findings && \ + docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace +``` + +The first proves the `/runs` mount reaches the directory you just made, which a container +created against a different checkout will not. The second proves the image carries the current +tooling. A container that fails either is not this checkout's, and every later step fails +against it in a way that reads like something else — a missing executable, findings written +somewhere you never look. + +Both are `test` because `docker exec` runs a binary and not a shell, so a builtin like +`command -v` exits 127 whether or not the thing it was looking for is there. + +**Do not `docker start` an exited container to fix this.** Mounts and image are fixed when a +container is created, so starting one built from another checkout, or from an older image, +brings back the same wrong container. Bring it up from here instead: + +```bash +docker compose -f .devcontainer/docker-compose.yml up -d --build +``` + +That rebuilds the image and recreates the container against this checkout's mounts. It replaces +any container of the same name, so **ask the user before running it** — theirs may belong to +another checkout and hold work you cannot see. + ## 1. Read the pull request Read its title, description and diff with whatever GitHub access this session has — the `gh` diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index 34420ee4..e4af386c 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -27,6 +27,35 @@ chmod 777 .claude/runs/pr-{number} .claude/runs/pr-{number}/verification The container's `agent` is uid 1001 and your files are uid 1000, so the agents write into directories this side creates and grants. +Then confirm the running container is the one this checkout describes, before anything depends +on it: + +```bash +docker exec crypter-pipeline test -w /runs/pr-{number}/verification && \ + docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace +``` + +The first proves the `/runs` mount reaches the directory you just made, which a container +created against a different checkout will not. The second proves the image carries the current +tooling. A container that fails either is not this checkout's, and every later step fails +against it in a way that reads like something else — a missing executable, verification written +somewhere you never look. + +Both are `test` because `docker exec` runs a binary and not a shell, so a builtin like +`command -v` exits 127 whether or not the thing it was looking for is there. + +**Do not `docker start` an exited container to fix this.** Mounts and image are fixed when a +container is created, so starting one built from another checkout, or from an older image, +brings back the same wrong container. Bring it up from here instead: + +```bash +docker compose -f .devcontainer/docker-compose.yml up -d --build +``` + +That rebuilds the image and recreates the container against this checkout's mounts. It replaces +any container of the same name, so **ask the user before running it** — theirs may belong to +another checkout and hold work you cannot see. + ## 1. Collect the findings Read the pull request with whatever GitHub access this session has — the `gh` CLI, or the GitHub diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 6c5252fc..0a19a8d7 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -105,8 +105,23 @@ git -c protocol.ext.allow=user fetch \ `protocol.ext.allow` is passed per command, so it stays out of your git config. -A container created before these mounts existed picks them up on -`docker compose -f .devcontainer/docker-compose.yml up -d --force-recreate`. +Because the mounts are relative paths in the Compose file, they resolve against the checkout you +launch from, and a container is stuck with whatever they resolved to when it was created. Run +the pipeline from a second checkout and the container it finds by name is the first one's: +`/runs` writes land under a repository you are not looking at, and `/host-git` clones a history +that is not the one you are reviewing. + +Starting an exited container does not repair this, and neither does it pick up a newer image — +`docker start` reuses what the container was created with. Recreate it from the checkout you +mean to work in: + +```bash +docker compose -f .devcontainer/docker-compose.yml up -d --build +``` + +That rebuilds the image and recreates the container against the mounts as they resolve here. +There is one `crypter-pipeline` on the machine, so this takes it over from whichever checkout +held it. ## Running a change From 6def2e859e5628086c793c0a95195629a696fd5f Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:02:07 -0500 Subject: [PATCH 37/41] Observe a pull request's base branch instead of assuming stable (#849) crypter-review reviewed every pull request against stable. A release pull request targets main, so the diff it handed the lenses was stable against itself: empty. Four lenses read nothing and reported nothing wrong, which is indistinguishable from a clean review. The skill now takes the base branch from the pull request and passes it down, and crypter-workspace takes it as --base, defaulting to stable for work built here. A base that does not resolve is caught before the clone, so a corrected retry is not refused by the half-built workspace of the failed one. The workspace's second name for the org repository goes with it. A clone maps the source's local branches into origin/*, which is what upstream/stable existed to work around; pointing remote.origin.fetch at the host's remote-tracking refs gets the same guarantee under one name, and keeps it against a later bare fetch. Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/agents/conformance-auditor.md | 17 +++--- .claude/agents/reviewer.md | 9 +-- .claude/skills/crypter-change/SKILL.md | 7 ++- .../crypter-devcontainer-examine/SKILL.md | 18 ++++-- .../crypter-devcontainer-implement/SKILL.md | 4 +- .claude/skills/crypter-review/SKILL.md | 15 +++-- .devcontainer/workspace.sh | 56 +++++++++++++++---- .../Agentic Development Pipeline.md | 15 +++-- 8 files changed, 98 insertions(+), 43 deletions(-) diff --git a/.claude/agents/conformance-auditor.md b/.claude/agents/conformance-auditor.md index aae806f1..838cf93b 100644 --- a/.claude/agents/conformance-auditor.md +++ b/.claude/agents/conformance-auditor.md @@ -12,19 +12,22 @@ color: yellow You answer one question: **does the diff match the plan?** Not whether the code is good, not whether the plan was a good plan. Fidelity, and nothing else. -You are given a worktree path, a plan file, and an output path. Read both, read the diff, write -your report to the output path, and report a short summary. You do not repair what you find, -and that is deliberate — a deviation you quietly repair is a deviation nobody ever sees. -Report it. +You are given a worktree path, a plan file, a base ref, and an output path. Read both, read the +diff, write your report to the output path, and report a short summary. You do not repair what +you find, and that is deliberate — a deviation you quietly repair is a deviation nobody ever +sees. Report it. ## Getting the diff +The base ref is the branch this change is proposed against, and it is given to you — do not +assume it: + ```bash -git -C diff upstream/stable...HEAD -git -C log --oneline upstream/stable..HEAD +git -C diff ...HEAD +git -C log --oneline ..HEAD ``` -Three dots. You want what the branch added, not what `stable` moved on to. Read the changed +Three dots. You want what the branch added, not what the base moved on to. Read the changed files themselves where the diff alone does not tell you whether a step was really done — a plan step that says "return `Maybe` instead of null" is not satisfied by a signature change if the call sites still null-check. diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index 0132b517..ada4f1ce 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -13,15 +13,16 @@ You review a diff under a **lens** given in your prompt — a name and a descrip look for. One definition serves every lens; the prompt decides which one you are. If no lens is given, review generally: correctness first, then everything else. -You are given a worktree path, a lens, and an output path. Write your findings to the output -path and report a short summary. Report what is wrong; someone else fixes it. +You are given a worktree path, a lens, a base ref, and an output path. Write your findings to the +output path and report a short summary. Report what is wrong; someone else fixes it. ## Scope -Review the diff, not the repository: +Review the diff, not the repository. The base ref is the branch this change is proposed against, +and it is given to you — do not assume it: ```bash -git -C diff upstream/stable...HEAD +git -C diff ...HEAD ``` Read the surrounding code freely — you cannot judge a change without it — but a problem that diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index ba547c09..cc22ddc1 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -71,8 +71,9 @@ git fetch origin docker exec crypter-pipeline crypter-workspace create {run-id} ``` -Fetch first — the workspace takes `upstream/stable` from your `origin/stable`, so a stale -remote-tracking ref puts the whole run on an old base. **If either fails, stop and say so.** +Fetch first — the workspace takes its `origin/stable` from yours, so a stale remote-tracking ref +puts the whole run on an old base. A change of your own targets `stable`, which is what `create` +uses when no `--base` is given. **If either fails, stop and say so.** The workspace holds only committed history. Uncommitted work in your checkout is not visible to the container and never reaches the branch. @@ -97,7 +98,7 @@ Keep the title and description it reports; `crypter-step-open-pull-request` need ```bash docker exec -w /work/{run-id} crypter-pipeline \ - claude --permission-mode auto -p "/crypter-devcontainer-examine {run-id} {branch} /plans/{run-id}/plan.md" + claude --permission-mode auto -p "/crypter-devcontainer-examine {run-id} {branch} origin/stable /plans/{run-id}/plan.md" ``` It writes `.claude/runs/{run-id}/conformance.md` and `.claude/runs/{run-id}/findings/{lens}.md`. diff --git a/.claude/skills/crypter-devcontainer-examine/SKILL.md b/.claude/skills/crypter-devcontainer-examine/SKILL.md index 287dbbe4..df2dbe59 100644 --- a/.claude/skills/crypter-devcontainer-examine/SKILL.md +++ b/.claude/skills/crypter-devcontainer-examine/SKILL.md @@ -1,6 +1,6 @@ --- name: crypter-devcontainer-examine -description: Review a diff in the pipeline container and write findings to the host. Invoked as /crypter-devcontainer-examine {run-id} {ref} [plan-path] by the crypter-change and crypter-review skills. +description: Review a diff in the pipeline container and write findings to the host. Invoked as /crypter-devcontainer-examine {run-id} {ref} {base-ref} [plan-path] by the crypter-change and crypter-review skills. --- # Crypter devcontainer examine @@ -12,8 +12,14 @@ The ref already exists in the run's workspace at `/work/{run-id}`. ## Setup -You are given a run id, a ref, and optionally a plan path: -`/crypter-devcontainer-examine {run-id} {ref} [plan-path]`. +You are given a run id, a ref, a base ref, and optionally a plan path: +`/crypter-devcontainer-examine {run-id} {ref} {base-ref} [plan-path]`. + +The base ref is the branch the change is proposed against, named as the workspace knows it — +`origin/stable` for work built here, `origin/main` for a pull request that targets `main`. Every +agent below diffs against it. **Pass it on as given; never substitute a default.** A base that +does not match the pull request produces a diff nobody asked about, and the emptiest version of +that failure — a base identical to the ref — reads as four lenses finding nothing wrong. Two review phases run here, and the plan path decides whether the first one applies: @@ -50,13 +56,13 @@ Every agent gets the workspace path and works by absolute path inside it. Never ## 2. Plan adherence -Given a plan path, invoke `conformance-auditor` with it, the workspace, and +Given a plan path, invoke `conformance-auditor` with it, the workspace, the base ref, and `/runs/{run-id}/conformance.md`. It reports where the diff and the plan diverge. ## 3. Code review -Invoke `reviewer` once per lens, in parallel — they do not interact. Each gets the workspace and -`/runs/{run-id}/findings/{lens}.md`. +Invoke `reviewer` once per lens, in parallel — they do not interact. Each gets the workspace, the +base ref, and `/runs/{run-id}/findings/{lens}.md`. | Lens | Brief | |---|---| diff --git a/.claude/skills/crypter-devcontainer-implement/SKILL.md b/.claude/skills/crypter-devcontainer-implement/SKILL.md index 546a4740..53090e79 100644 --- a/.claude/skills/crypter-devcontainer-implement/SKILL.md +++ b/.claude/skills/crypter-devcontainer-implement/SKILL.md @@ -26,10 +26,10 @@ end. ## 1. Branch -The workspace is checked out at `upstream/stable`, so build from there: +The workspace is checked out at `origin/stable`, so build from there: ```bash -git -C /work/{run-id} checkout -b {branch} refs/remotes/upstream/stable +git -C /work/{run-id} checkout -b {branch} refs/remotes/origin/stable ``` **If this fails, stop and say so.** A branch cut from the wrong base leaves the diff and the diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index 30a25983..445dc719 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -66,19 +66,26 @@ Read its title, description and diff with whatever GitHub access this session ha CLI, or the GitHub MCP server's `pull_request_read`. What the author says it does is context for reading the diff, and worth carrying into your report where the two disagree. +Take its **base branch** from the same read — `base.ref` from `pull_request_read` with method +`get`, or `.baseRefName` from `gh pr view`. Most pull requests here target `stable`, but a +release targets `main`, and nothing about the number tells you which. Everything below diffs +against the branch the pull request actually names. + ## 2. Fetch it into a workspace The container has no network remote. It clones from your repository through a read-only mount, so the pull request head goes into your repository first and travels across from there: ```bash -git fetch origin +refs/pull/{number}/head:refs/pr/{number} +git fetch origin +refs/pull/{number}/head:refs/pr/{number} {base-branch} docker exec crypter-pipeline crypter-workspace create pr-{number} \ - '+refs/pr/{number}:refs/heads/pr-{number}' + --base {base-branch} '+refs/pr/{number}:refs/heads/pr-{number}' ``` The refspec is forced, so reviewing a pull request again after its author rebased or amended -picks up the new head instead of being rejected. +picks up the new head instead of being rejected. The base branch is fetched alongside it because +the workspace clones your repository, and a base you have never fetched is not there to diff +against. **If either fails, stop and say so.** @@ -88,7 +95,7 @@ The workspace lasts for this review and no longer. ```bash docker exec -w /work/pr-{number} crypter-pipeline \ - claude --permission-mode auto -p "/crypter-devcontainer-examine pr-{number} pr-{number}" + claude --permission-mode auto -p "/crypter-devcontainer-examine pr-{number} pr-{number} origin/{base-branch}" ``` No plan path. A pull request raised elsewhere has no plan to hold it against, so the plan diff --git a/.devcontainer/workspace.sh b/.devcontainer/workspace.sh index 1097dfbf..69a4ad77 100755 --- a/.devcontainer/workspace.sh +++ b/.devcontainer/workspace.sh @@ -12,7 +12,7 @@ set -euo pipefail host_git="/host-git" usage() { - echo "usage: crypter-workspace create {run-id} [refspec]" >&2 + echo "usage: crypter-workspace create {run-id} [--base {branch}] [refspec]" >&2 echo " crypter-workspace remove {run-id}" >&2 exit 64 } @@ -20,6 +20,7 @@ usage() { subcommand="${1:-}" run_id="${2:-}" [[ -n "${subcommand}" && -n "${run_id}" ]] || usage +shift 2 # The run id becomes a path under /work that `remove` deletes recursively, so it has to be a # plain name before it is used as one. @@ -28,6 +29,22 @@ if [[ ! "${run_id}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]]; then exit 64 fi +# The branch the run is built or reviewed against. A pull request states its own, so the caller +# passes what it read rather than letting this default stand in for it. +base="stable" +if [[ "${1:-}" == "--base" ]]; then + base="${2:-}" + [[ -n "${base}" ]] || usage + shift 2 +fi + +# The base reaches git as a ref, where a leading dash would be read as an option instead. +if [[ ! "${base}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]]; then + echo "Base branch '${base}' is not a plain branch name" >&2 + exit 64 +fi + +refspec="${1:-}" workspace="/work/${run_id}" case "${subcommand}" in @@ -45,26 +62,43 @@ case "${subcommand}" in exit 1 fi + # Resolve the base before anything is created, so a base that is not there leaves nothing + # behind to remove first. + if ! git -C "${host_git}" rev-parse --verify --quiet "refs/remotes/origin/${base}" >/dev/null + then + echo "The host repository has no origin/${base}. Fetch it there and try again." >&2 + exit 1 + fi + # --no-hardlinks because the mount is read-only and owned by another uid, which is exactly # the case where git's hardlink optimisation is unavailable. Copying is predictable. git clone --quiet --no-hardlinks "${host_git}" "${workspace}" - # The agents diff against upstream/stable. Take it from the host's own remote-tracking ref - # so it reflects the org repository rather than whatever branch the host has checked out. - git -C "${workspace}" fetch --quiet origin \ - '+refs/remotes/origin/stable:refs/remotes/upstream/stable' - - if [[ -n "${3:-}" ]]; then - git -C "${workspace}" fetch --quiet origin "${3}" + # A clone maps the source's local branches into origin/*, so origin/stable here would mean + # whatever the host has checked out rather than what the org repository holds. Point the + # remote at the host's own remote-tracking refs instead, so origin/{branch} means the same + # thing in a workspace as it does on the host. Configuring the refspec rather than fetching + # it once keeps a later bare `git fetch` from putting the host's local branches back. + git -C "${workspace}" config remote.origin.fetch \ + '+refs/remotes/origin/*:refs/remotes/origin/*' + git -C "${workspace}" fetch --quiet --prune origin + + if [[ -n "${refspec}" ]]; then + git -C "${workspace}" fetch --quiet origin "${refspec}" fi - git -C "${workspace}" checkout --quiet -B stable refs/remotes/upstream/stable + # The clone takes origin/HEAD from the host's checked-out branch, which is the one thing in + # the origin namespace that would still mean the host rather than the org. Point it at the + # base, so a bare `origin` resolves to what the run is measured against. + git -C "${workspace}" remote set-head origin "${base}" + + git -C "${workspace}" checkout --quiet -B "${base}" "refs/remotes/origin/${base}" git -C "${workspace}" config user.name "${CRYPTER_GIT_NAME}" git -C "${workspace}" config user.email "${CRYPTER_GIT_EMAIL}" - echo "Workspace ready at ${workspace}" - git -C "${workspace}" log --oneline -1 refs/remotes/upstream/stable + echo "Workspace ready at ${workspace}, based on ${base}" + git -C "${workspace}" log --oneline -1 "refs/remotes/origin/${base}" ;; remove) diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 0a19a8d7..255b6110 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -61,15 +61,18 @@ The orchestrator owns the lifecycle. It creates the workspace in its setup and r the run ends; the container skills use it and never create or destroy one. ```bash -docker exec crypter-pipeline crypter-workspace create {run-id} [refspec] +docker exec crypter-pipeline crypter-workspace create {run-id} [--base {branch}] [refspec] docker exec crypter-pipeline crypter-workspace remove {run-id} ``` -The org repository has two names as a result. Your session reaches it as `origin`, the remote -your checkout already has. Inside a workspace it is `upstream/stable`, a ref the create step -copies from your `origin/stable` so the agents always diff against the org's current code rather -than whatever branch you have checked out. Fetch before creating a workspace, or the run starts -on a stale base. +The org repository is `origin` on both sides. A clone would otherwise map your local branches +into the workspace's `origin/*`, so the create step points the remote at your remote-tracking +refs instead, and `origin/stable` in a workspace means what it means in your checkout. Fetch +before creating a workspace, or the run starts on a stale base. + +`--base` is the branch the run is built or reviewed against, `stable` when it is not given. A +pull request states its own base, and a release states `main`, so the review skills pass what +they read rather than assuming. This document covers the setup you need before the container will start. From 0c0e514630613d8edc5cf6a53930e7aa54776d13 Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:21:02 -0500 Subject: [PATCH 38/41] Split the reviewer conventions across the lenses that own them (#850) Every lens was handed the same list of Crypter conventions while also being told to stay inside its lens, so the security lens was primed to report a missing Async suffix and forbidden from reporting it in the same breath. Which way a reviewer resolved that was left to chance. The conventions are now split by what goes wrong when they are broken, and each half lives in the lens brief that can judge the consequence: monads, sync IO, constructors, enums and missing migrations under correctness; history-narrating comments and Async naming under maintainability; validated primitives under security. Testability claims none, rather than being given one to justify the symmetry. That moves them out of the shared agent definition and into the prompt, where the skill already said the lens belongs. The briefs must now be passed in full for the conventions to be reviewed at all. Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/agents/reviewer.md | 15 ++----- .../crypter-devcontainer-examine/SKILL.md | 41 +++++++++++++++---- 2 files changed, 37 insertions(+), 19 deletions(-) diff --git a/.claude/agents/reviewer.md b/.claude/agents/reviewer.md index ada4f1ce..d2bdf974 100644 --- a/.claude/agents/reviewer.md +++ b/.claude/agents/reviewer.md @@ -47,17 +47,10 @@ nits, and the nits make the real ones harder to see. ## Crypter's conventions are in scope -A change that ignores them is a legitimate finding for any lens: - -- Nulls or exceptions where `Maybe` or `Either` from `Crypter.Common/Monads` - belongs. -- Raw strings where a validated type from `Crypter.Common/Primitives` exists. -- Sync IO on a database, file, or network path; a missing `Async` suffix. -- Object initializers where a constructor belongs; magic strings where an enum belongs. -- An entity change under `Crypter.DataAccess/Entities` with no migration in - `Crypter.DataAccess/Migrations` — and whether it needs a companion script in - `Crypter.DataAccess/Scripts`. -- Comments narrating history rather than explaining the code as it stands. +A change that ignores the conventions in `CLAUDE.md` and the Coding Standard is a legitimate +finding, and your lens names the ones that are yours. They are split across the lenses by what +goes wrong when they are broken, so a convention yours does not name is another lens's — leave +it, the same as anything else outside your brief. ## Report diff --git a/.claude/skills/crypter-devcontainer-examine/SKILL.md b/.claude/skills/crypter-devcontainer-examine/SKILL.md index df2dbe59..c22924ea 100644 --- a/.claude/skills/crypter-devcontainer-examine/SKILL.md +++ b/.claude/skills/crypter-devcontainer-examine/SKILL.md @@ -62,16 +62,41 @@ Given a plan path, invoke `conformance-auditor` with it, the workspace, the base ## 3. Code review Invoke `reviewer` once per lens, in parallel — they do not interact. Each gets the workspace, the -base ref, and `/runs/{run-id}/findings/{lens}.md`. +base ref, `/runs/{run-id}/findings/{lens}.md`, and its brief below, in full. -| Lens | Brief | -|---|---| -| correctness | Bugs, boundary conditions, error paths, and what happens when inputs are hostile or absent. | -| maintainability | Readability, scope creep, and the conventions in `CLAUDE.md` and the Coding Standard. | -| testability | What the tests pin down, what they leave unverified, and whether the change can be tested at all. | -| security | Crypto boundaries, input validation, authentication and authorisation paths, key handling, transfer integrity. | +A brief is the whole of what its lens covers, conventions included. The conventions are split by +what goes wrong when they are broken rather than kept as one list, so that a lens is told the +ones it can judge the consequences of and left ignorant of the rest. + +**correctness** — Bugs, boundary conditions, error paths, and what happens when inputs are +hostile or absent. Including: + +- Nulls or exceptions where `Maybe` or `Either` from `Crypter.Common/Monads` + belongs, and the crash or swallowed failure that follows. +- Sync IO on a database, file, or network path. +- Object initializers where a constructor belongs, leaving an object usable before it is whole. +- Magic strings where an enum belongs. +- An entity change under `Crypter.DataAccess/Entities` with no migration in + `Crypter.DataAccess/Migrations`, and whether it needs a companion script in + `Crypter.DataAccess/Scripts`. + +**maintainability** — Readability, scope creep, and the conventions in `CLAUDE.md` and the Coding +Standard that no other lens claims. Including: + +- Comments narrating history rather than explaining the code as it stands. +- A missing `Async` suffix on an async method. The naming is yours; sync IO on a path that should + be async belongs to correctness. + +**testability** — What the tests pin down, what they leave unverified, and whether the change can +be tested at all. + +**security** — Crypto boundaries, input validation, authentication and authorisation paths, key +handling, transfer integrity. Including: + +- Raw strings where a validated type from `Crypter.Common/Primitives` exists, and the unchecked + value that reaches past a boundary as a result. -Adding a lens means adding a row here. The `reviewer` definition stays as it is; the lens comes +Adding a lens means adding a brief here. The `reviewer` definition stays as it is; the lens comes from the prompt. Run the phases in parallel with each other too. The auditor and the reviewers read the same diff From 81e3e3de26c9e3a6f583cd66f5e74bb159c7268a Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Thu, 6 Aug 2026 08:05:37 -0500 Subject: [PATCH 39/41] Verify review findings in the container instead of on the host (#851) The host skill read every lens finding against the code to decide what to post, which put a judgement about code in the session that has no lens discipline and no worktree. In practice it drifted further than that: with an examine run that produced nothing, reading the diff and forming findings directly looked like the reasonable way to fill the gap. Findings now go to /crypter-devcontainer-verify, which already rules on findings from anywhere else and already rejects preferences and findings about untouched code. It grows a second input shape for this: given the lenses' own directory it assigns ids per lens rather than expecting a collected report, so the host hands over a path and reads nothing until verdicts exist. Only findings that hold reach the pull request. One that a verifier ruled against is the pipeline checking itself, and it stays in .claude/runs where a later pass can read it, rather than costing the author a read to reach a conclusion the verifier already reached. Both container calls exit 0 whichever way they go, so each step now checks what was written instead of trusting the status, and distinguishes an empty result that means success from one that means the run never happened. Co-authored-by: n Co-authored-by: Claude Opus 5 --- .../crypter-devcontainer-verify/SKILL.md | 21 +++- .claude/skills/crypter-review/SKILL.md | 105 ++++++++++++++---- .../Agentic Development Pipeline.md | 18 ++- 3 files changed, 115 insertions(+), 29 deletions(-) diff --git a/.claude/skills/crypter-devcontainer-verify/SKILL.md b/.claude/skills/crypter-devcontainer-verify/SKILL.md index c95fa34d..db52c5fb 100644 --- a/.claude/skills/crypter-devcontainer-verify/SKILL.md +++ b/.claude/skills/crypter-devcontainer-verify/SKILL.md @@ -1,6 +1,6 @@ --- name: crypter-devcontainer-verify -description: Rule on each finding in a report against the code, one verifier per finding. Invoked as /crypter-devcontainer-verify {run-id} {ref} {findings-path} by the crypter-triage-review skill. +description: Rule on each finding in a report against the code, one verifier per finding. Invoked as /crypter-devcontainer-verify {run-id} {ref} {findings-path} by the crypter-triage-review and crypter-review skills. --- # Crypter devcontainer verify @@ -16,8 +16,23 @@ and no findings of your own. You are given a run id, a ref, and a findings path: `/crypter-devcontainer-verify {run-id} {ref} {findings-path}`. -The findings file lives under `/runs/{run-id}/`. Each finding in it carries an id. **If the file -is absent, stop and say so.** +The findings path lives under `/runs/{run-id}/` and is either a file or a directory. **If it is +absent, stop and say so.** + +**A file** is a report someone collected, and each finding in it already carries an id. Use those +ids. + +**A directory** is the lenses' own output, one report per lens and no ids in it. Every `.md` in +it is a lens report named for its lens. Read each one, split it into its individual findings, and +give each an id of `{lens}-{n}` numbered from 1 in the order the lens reported them — the lenses +rank most severe first, so that order is information worth keeping. + +A lens that found nothing still writes its file saying so. It contributes no findings and no ids, +which is a result rather than a problem. Where every lens reported that way there is nothing to +verify, and that is the answer — say so and stop. + +**A directory holding no files at all is a different thing:** whatever should have filled it did +not run. Stop and say so, and do not report it as lenses finding nothing. `/runs/{run-id}/verification/` already exists; the caller creates it. **If it is missing, stop and say so** rather than creating it. diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index 445dc719..92d5cd10 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -1,6 +1,6 @@ --- name: crypter-review -description: Review an existing pull request with the container's reviewer lenses and post what they found to the pull request. Use when asked to scrutinise a pull request, or invoked as /crypter-review {pr-number}. +description: Review an existing pull request with the container's reviewer lenses, verify what they found, and post the findings that hold. Use when asked to scrutinise a pull request, or invoked as /crypter-review {pr-number}. --- # Crypter review @@ -11,8 +11,16 @@ Use it on a pull request that deserves more scrutiny than a read, and on pull re people raised. The lenses run in the container, against a copy of the pull request fetched into its clone. -The findings land on disk and on the pull request, as one review that comments and neither -approves nor requests changes. +Every finding lands on disk. The ones that survive verification also land on the pull request, as +one review that comments and neither approves nor requests changes. + +The lenses do the reviewing and the verifiers rule on what they found. Both run in the container. +You start them, carry what survives to the pull request, and report the rest — **you never review +the diff yourself, and you never decide whether a finding is true.** + +Everything that reaches the author came from a lens that read the code in the container and a +verifier that checked it there. A review posted from here is attributed to them, so anything of +your own inside it is a claim made in someone else's name. ## Setup @@ -23,8 +31,9 @@ Run from the root of the main checkout. The container's mounts resolve against i Use `pr-{number}` as the run id. ```bash -mkdir -p .claude/runs/pr-{number}/findings -chmod 777 .claude/runs/pr-{number} .claude/runs/pr-{number}/findings +mkdir -p .claude/runs/pr-{number}/findings .claude/runs/pr-{number}/verification +chmod 777 .claude/runs/pr-{number} .claude/runs/pr-{number}/findings \ + .claude/runs/pr-{number}/verification ``` The container's `agent` is uid 1001 and your files are uid 1000, so the agents write into @@ -36,6 +45,7 @@ on it: ```bash docker exec crypter-pipeline test -w /runs/pr-{number}/findings && \ + docker exec crypter-pipeline test -w /runs/pr-{number}/verification && \ docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace ``` @@ -66,6 +76,10 @@ Read its title, description and diff with whatever GitHub access this session ha CLI, or the GitHub MCP server's `pull_request_read`. What the author says it does is context for reading the diff, and worth carrying into your report where the two disagree. +Read for orientation and for the base branch, not for defects. This read is how you follow the +lenses later, not a first pass at the review. Whatever you notice here is not a finding, and +noticing it is not a reason to go looking for more. + Take its **base branch** from the same read — `base.ref` from `pull_request_read` with method `get`, or `.baseRefName` from `gh pr view`. Most pull requests here target `stable`, but a release targets `main`, and nothing about the number tells you which. Everything below diffs @@ -103,18 +117,47 @@ adherence phase sits out and the lenses do the work. Findings land in `.claude/runs/pr-{number}/findings/{lens}.md`. -## 4. Triage +`claude -p` exits 0 whether or not the run worked. An unknown command, an expired session and a +clean review all come back as success, so the exit code tells you nothing. Look at what it wrote +instead: + +```bash +ls .claude/runs/pr-{number}/findings/ +``` + +An empty directory is a failed run, not four quiet lenses — a lens with nothing to say still +writes its file. **Stop and say so.** + +Do not stand in for the lenses that did not run. Reading the diff and writing up what you would +have found produces a review with nothing behind it, wearing their name, at exactly the moment +there is nothing to post and the pull request looks unreviewed. The run failing is the result; +report that instead. + +## 4. Verify + +A lens that has already been wrong once will happily be wrong again, and a finding posted is a +finding the author has to answer. So every finding is ruled on against the code before it goes +anywhere — by a verifier in the container, one per finding, not by you. -Read every finding against the code before you carry it to the pull request. A lens that has -already been wrong once will happily be wrong again, and a finding posted is a finding the author -has to answer. +```bash +docker exec -w /work/pr-{number} crypter-pipeline \ + claude --permission-mode auto -p "/crypter-devcontainer-verify pr-{number} pr-{number} /runs/pr-{number}/findings/" +``` + +Given the findings directory, verify treats every file in it as a lens report, assigns each +finding an id, and gives one verifier the finding and nothing else. + +Verdicts land in `.claude/runs/pr-{number}/verification/{id}.md`, each with the evidence behind +it. -Keep anything with a concrete failure behind it. Drop preferences, restatements of what the -author already chose, and findings about code the diff did not touch. +An empty `verification/` means one of two things and they are not the same. Either every lens +found nothing, which verify reports and which ends in no review — a real and welcome result — or +the run failed the silent way step 3 describes. Verify's own report distinguishes them. **If it +failed, stop and say so**; do not read the absence of verdicts as a clean diff. -Write what you kept and what you dropped, with a reason for each, to -`.claude/runs/pr-{number}/triage.md`. That file is how the user checks this judgement, and it is -what a later remediation run reads. +**Do not read the findings before the verdicts exist.** A finding you have already formed a view +on is one you will post or bury on your own authority, which is the whole thing this step moves +into the container. Wait for the verdict and route by it. ## 5. Post the review @@ -125,15 +168,26 @@ Use the GitHub MCP server's `pull_request_review_write` with method `create` to review, `add_comment_to_pending_review` for each finding that names a file and a line **in the diff**, then `submit_pending`. +**Only findings that held go up.** A finding the verifier ruled against is the process working, +and the pull request is not where that belongs — it would cost the author a read to reach the +same conclusion the verifier already reached with the code in front of it. Unsettled findings do +not go up either; nothing unverified reaches the author. + +They are not lost. The verdicts and their evidence stay under `.claude/runs/pr-{number}/`, which +is where a later pass over this pipeline reads what the lenses claimed and how it turned out. + The review body carries: - Which lenses ran, and which found nothing. A quiet lens is a result worth stating. -- Every finding you kept that has no line to hang on, in full. +- Every held finding that has no line to hang on, in full. - That the lenses read the diff rather than the discussion around it, so a finding resting on an assumption about intent says so. -Attribute it. The body opens by naming the lenses as its author, so the person reading knows what -produced it. +Attribute it. The body opens by naming the lenses as its author and the verifiers as what ruled +on them, so the person reading knows what produced it. **Nothing in the review is yours.** + +**If nothing held, post no review.** Say so to the user instead. A review that reports only that +it found nothing still costs everyone subscribed a notification. A line comment that the API rejects for being outside the diff goes in the body instead. **Do not retry it against a different line.** @@ -144,8 +198,17 @@ retry it against a different line.** docker exec crypter-pipeline crypter-workspace remove pr-{number} ``` -Remove it on every exit path, including the ones where you stopped early. The findings under -`.claude/runs/pr-{number}` are the record and they stay. +Remove it on every exit path, including the ones where you stopped early. The findings and +verdicts under `.claude/runs/pr-{number}` are the record and they stay. + +Then report: + +- The review URL, and what went up. +- The counts: how many findings each lens raised, how many held, how many did not, how many are + unsettled. +- The unsettled ones in full. They reached nobody else, so this is the only place they surface. +- Which lenses found nothing. +- Where the artifacts are. -Tell the user the review URL, what you kept and dropped, which findings you checked against the -code yourself and stand behind, and where the artifacts are. +A lens that raised plenty and had none of it hold is worth a sentence of its own. That is the +pipeline telling you something about the lens rather than about the pull request. diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 255b6110..9b16f614 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -19,9 +19,12 @@ and it invokes the rest. | `/crypter-step-open-pull-request` | Your session | Pushes the branch and opens or updates the draft pull request | Every skill that reads or writes code runs in the container, against the container's own clone. -Your session plans, decides what to act on, and talks to GitHub. `/crypter-review` reviews -nothing itself: it fetches the pull request into the container and runs -`/crypter-devcontainer-examine` there. +Your session plans, decides what to act on, and talks to GitHub. It does not read code to form a +view on it — reviewing a diff and ruling on a finding are both judgements made in the container, +by an agent with the code in front of it. `/crypter-review` reviews nothing itself and settles +nothing itself: it fetches the pull request into the container, runs +`/crypter-devcontainer-examine` there, has `/crypter-devcontainer-verify` rule on what came back, +and carries the survivors to GitHub. Both prefixes say the same thing: an orchestrator invokes this, you do not. `crypter-step-` runs in your session, and `crypter-devcontainer-` runs in the container, which expects a workspace, @@ -137,8 +140,13 @@ pull request, and holds it against CI for at most three fix attempts. The approv stop, and the pull request stays a draft until you take it out of one. `/crypter-review {pr-number}` is the second entry point. It fetches a pull request's head into -the container, runs the lenses against it with no plan to audit, triages what they raise, and -posts one review that comments. It never approves and never requests changes. +the container, runs the lenses against it with no plan to audit, then gives one verifier per +finding the same treatment `/crypter-triage-review` gives findings from anywhere else. Only the +findings that survive that are posted, as one review that comments. It never approves and never +requests changes. + +What a lens raised and a verifier then ruled against stays in `.claude/runs`. It is a record of +the pipeline checking itself, and not something the pull request has to carry. `/crypter-triage-review {pr-number}` is the third. It reads the findings already on a pull request, whoever left them, and gives one verifier per finding a worktree and nothing else to From 17e170842951c79aaebc7c4bcdfa4d31fdaeeb00 Mon Sep 17 00:00:00 2001 From: Jack Edwards <37938228+Jack-Edwards@users.noreply.github.com> Date: Thu, 6 Aug 2026 09:41:02 -0500 Subject: [PATCH 40/41] Clone workspaces from the repository and name containers per checkout (#852) The pipeline container was welded to one checkout. Its mounts are relative paths resolved at creation, and both the container and Compose project names were hardcoded, so a second checkout running the pipeline reached the first one's container: findings written under a repository nobody was looking at, and workspaces cloned from a history nobody was reviewing. Workspaces now clone from CRYPTER_REPO_URL rather than from a read-only mount of the host's .git. A run sees the branch as the repository holds it, so the launching checkout no longer decides what gets built or reviewed, and a stale or unfetched host is no longer a way to start a run on the wrong base. Pull request heads come from refs/pull/{n}/head directly, which removes the host-side fetch that staged them. The origin/* fixup in workspace.sh goes with it: it existed only because cloning a checkout maps that checkout's local branches into origin/*, which cloning the real remote does not. Dropping the mount also makes a git worktree a usable checkout for the pipeline. Its .git is a file rather than a directory, so mounting it gave git clone something it could not read. Containers are now named crypter-pipeline-{slug}-{hash}, derived by pipeline.sh from the checkout it sits in. A checkout resolves only to its own name, so which container an orchestrator reaches is settled by where it runs rather than by probing mounts, and recreating one leaves the others alone. The three volumes stay shared machine-wide, so Claude Code is still authenticated once and the caches warmed once; they are external because a Compose-owned volume refuses to mount into a second project. Compose requires the container name rather than defaulting it, so invoking docker compose against the file directly now fails instead of quietly creating a container whose identity says nothing about its mounts. The container needs outbound network for source as well as for the API, so the pipeline no longer works offline. It clones anonymously over https and holds no GitHub credential; branches still leave over the ext:: transport and are pushed from the host. Co-authored-by: n Co-authored-by: Claude Opus 5 --- .claude/skills/crypter-change/SKILL.md | 62 +++---- .claude/skills/crypter-review/SKILL.md | 63 +++---- .../crypter-step-open-pull-request/SKILL.md | 2 +- .claude/skills/crypter-triage-review/SKILL.md | 52 +++--- .devcontainer/.env.example | 3 + .devcontainer/Dockerfile | 5 - .devcontainer/docker-compose.yml | 23 ++- .devcontainer/pipeline.sh | 103 ++++++++++++ .devcontainer/workspace.sh | 50 ++---- .../Agentic Development Pipeline.md | 159 ++++++++++++------ 10 files changed, 332 insertions(+), 190 deletions(-) create mode 100755 .devcontainer/pipeline.sh diff --git a/.claude/skills/crypter-change/SKILL.md b/.claude/skills/crypter-change/SKILL.md index cc22ddc1..80f4172b 100644 --- a/.claude/skills/crypter-change/SKILL.md +++ b/.claude/skills/crypter-change/SKILL.md @@ -10,8 +10,9 @@ Carry a requirement from a sentence to a draft pull request whose checks pass. You own the whole run. Building and reviewing happen in the container; you hold the plan, the findings and every CI attempt, which is why the judgement calls are yours. -Run from the root of the main checkout. The container's mounts resolve against it, so a run -started from a worktree writes its plan where the container cannot read it. +Run from the root of a checkout — a worktree does as well as a main one. `/plans` and `/runs` +resolve against it, and the container is named after it, so the one you reach is always the one +reading the plan you wrote. There is one gate: the user approves the plan. Everything after it runs to a green draft pull request, or to a written account of why CI would not take it. @@ -40,43 +41,44 @@ The container needs both mounts, the run directory has to be writable from insid image has to carry the current tooling. Confirm before starting: ```bash -docker exec crypter-pipeline test -d /plans/{run-id} && \ - docker exec crypter-pipeline test -w /runs/{run-id}/findings && \ - docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace +.devcontainer/pipeline.sh exec -- test -d /plans/{run-id} && \ + .devcontainer/pipeline.sh exec -- test -w /runs/{run-id}/findings && \ + .devcontainer/pipeline.sh exec -- test -x /usr/local/bin/crypter-workspace ``` -The mount checks also settle which checkout the container belongs to: one created against a -different one reaches neither directory. The last check is separate because an older image -passes the first two and then fails at workspace creation with nothing but a missing executable -to go on. All three are `test` because `docker exec` runs a binary and not a shell, so a builtin -like `command -v` exits 127 whether or not the thing it was looking for is there. +`pipeline.sh` resolves the container from the checkout it sits in, so which container you get is +settled by where you are rather than by anything you check. The mount checks prove `/plans` and +`/runs` are both there and that uid 1001 can write the run directory. The last check is separate +because an older image passes the first two and then fails at workspace creation with nothing but +a missing executable to go on. All three are `test` because `docker exec` runs a binary and not a +shell, so a builtin like `command -v` exits 127 whether or not the thing it was looking for is +there. -**Do not `docker start` an exited container to fix any of this.** Mounts and image are fixed -when a container is created, so starting one built from another checkout, or from an older -image, brings back the same wrong container. Bring it up from here instead: +**Do not `docker start` an exited container to fix any of this.** The image is fixed when a +container is created, so starting an old one brings back the old tooling. Bring it up from here +instead: ```bash -docker compose -f .devcontainer/docker-compose.yml up -d --build +.devcontainer/pipeline.sh up ``` -That rebuilds the image and recreates the container against this checkout's mounts. It replaces -any container of the same name, so **ask the user before running it** — theirs may belong to -another checkout and hold work you cannot see. +That rebuilds the image and recreates this checkout's container. It cannot touch another +checkout's. What it does destroy is every workspace under `/work` in *this* container, because +`/work` is the container's own filesystem and not a volume — so **ask the user before running it +if another run may be live here.** -Then make the workspace the container builds in. It is a clone of your repository, taken from -the read-only `/host-git` mount, and it lasts exactly as long as this run: +Then make the workspace the container builds in. It is a clone of the repository taken from +GitHub, and it lasts exactly as long as this run: ```bash -git fetch origin -docker exec crypter-pipeline crypter-workspace create {run-id} +.devcontainer/pipeline.sh exec -- crypter-workspace create {run-id} ``` -Fetch first — the workspace takes its `origin/stable` from yours, so a stale remote-tracking ref -puts the whole run on an old base. A change of your own targets `stable`, which is what `create` -uses when no `--base` is given. **If either fails, stop and say so.** +A change of your own targets `stable`, which is what `create` uses when no `--base` is given. +**If it fails, stop and say so.** -The workspace holds only committed history. Uncommitted work in your checkout is not visible to -the container and never reaches the branch. +The workspace takes `stable` as the repository holds it, so nothing about your checkout — what +it is on, how stale it is, what is uncommitted in it — reaches the branch. ## 1. Plan @@ -88,7 +90,7 @@ It settles the plan with the user itself. **Do not continue until they have appr ## 2. Build ```bash -docker exec -w /work/{run-id} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/{run-id} -- \ claude --permission-mode auto -p "/crypter-devcontainer-implement {run-id} {branch}" ``` @@ -97,7 +99,7 @@ Keep the title and description it reports; `crypter-step-open-pull-request` need ## 3. Examine ```bash -docker exec -w /work/{run-id} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/{run-id} -- \ claude --permission-mode auto -p "/crypter-devcontainer-examine {run-id} {branch} origin/stable /plans/{run-id}/plan.md" ``` @@ -123,7 +125,7 @@ them. Where anything was accepted: ```bash -docker exec -w /work/{run-id} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/{run-id} -- \ claude --permission-mode auto -p "/crypter-devcontainer-remediate {run-id} {branch} /runs/{run-id}/triage.md" ``` @@ -159,7 +161,7 @@ The branch is on the fork and the artifacts are on your disk, so the workspace h to hold: ```bash -docker exec crypter-pipeline crypter-workspace remove {run-id} +.devcontainer/pipeline.sh exec -- crypter-workspace remove {run-id} ``` Remove it on every exit path, including the ones where you stopped early. Nothing under `/runs` diff --git a/.claude/skills/crypter-review/SKILL.md b/.claude/skills/crypter-review/SKILL.md index 92d5cd10..d9036575 100644 --- a/.claude/skills/crypter-review/SKILL.md +++ b/.claude/skills/crypter-review/SKILL.md @@ -26,7 +26,8 @@ your own inside it is a claim made in someone else's name. You are given a pull request number: `/crypter-review {pr-number}`. -Run from the root of the main checkout. The container's mounts resolve against it. +Run from the root of a checkout. `/plans` and `/runs` resolve against it, and the container is +named after it, so the one you reach is always the one whose artifacts you are reading. Use `pr-{number}` as the run id. @@ -40,35 +41,35 @@ The container's `agent` is uid 1001 and your files are uid 1000, so the agents w directories this side creates and grants. Creating them here also keeps you able to delete what they wrote. -Then confirm the running container is the one this checkout describes, before anything depends -on it: +Then confirm the container is up and current, before anything depends on it: ```bash -docker exec crypter-pipeline test -w /runs/pr-{number}/findings && \ - docker exec crypter-pipeline test -w /runs/pr-{number}/verification && \ - docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace +.devcontainer/pipeline.sh exec -- test -w /runs/pr-{number}/findings && \ + .devcontainer/pipeline.sh exec -- test -w /runs/pr-{number}/verification && \ + .devcontainer/pipeline.sh exec -- test -x /usr/local/bin/crypter-workspace ``` -The first proves the `/runs` mount reaches the directory you just made, which a container -created against a different checkout will not. The second proves the image carries the current -tooling. A container that fails either is not this checkout's, and every later step fails -against it in a way that reads like something else — a missing executable, findings written -somewhere you never look. +`pipeline.sh` resolves the container from the checkout it sits in, so which container you get is +settled by where you are rather than by anything you check. What the probes are for is the rest: +the first two prove `/runs` is mounted and that uid 1001 can write the directories you just +made, and the third proves the image carries the current tooling. Without them a later step +fails in a way that reads like something else — a missing executable, findings written somewhere +you never look. -Both are `test` because `docker exec` runs a binary and not a shell, so a builtin like +All three are `test` because `docker exec` runs a binary and not a shell, so a builtin like `command -v` exits 127 whether or not the thing it was looking for is there. -**Do not `docker start` an exited container to fix this.** Mounts and image are fixed when a -container is created, so starting one built from another checkout, or from an older image, -brings back the same wrong container. Bring it up from here instead: +**Do not `docker start` an exited container to fix this.** The image is fixed when a container is +created, so starting an old one brings back the old tooling. Bring it up from here instead: ```bash -docker compose -f .devcontainer/docker-compose.yml up -d --build +.devcontainer/pipeline.sh up ``` -That rebuilds the image and recreates the container against this checkout's mounts. It replaces -any container of the same name, so **ask the user before running it** — theirs may belong to -another checkout and hold work you cannot see. +That rebuilds the image and recreates this checkout's container. It cannot touch another +checkout's. What it does destroy is every workspace under `/work` in *this* container, because +`/work` is the container's own filesystem and not a volume — so **ask the user before running it +if another run may be live here.** ## 1. Read the pull request @@ -87,28 +88,28 @@ against the branch the pull request actually names. ## 2. Fetch it into a workspace -The container has no network remote. It clones from your repository through a read-only mount, -so the pull request head goes into your repository first and travels across from there: +The container clones from the repository itself, so the pull request head comes straight from +GitHub and nothing has to be staged in your checkout first: ```bash -git fetch origin +refs/pull/{number}/head:refs/pr/{number} {base-branch} -docker exec crypter-pipeline crypter-workspace create pr-{number} \ - --base {base-branch} '+refs/pr/{number}:refs/heads/pr-{number}' +.devcontainer/pipeline.sh exec -- crypter-workspace create pr-{number} \ + --base {base-branch} '+refs/pull/{number}/head:refs/heads/pr-{number}' ``` The refspec is forced, so reviewing a pull request again after its author rebased or amended -picks up the new head instead of being rejected. The base branch is fetched alongside it because -the workspace clones your repository, and a base you have never fetched is not there to diff -against. +picks up the new head instead of being rejected. The base branch needs no fetching of its own — +the clone brings every branch the repository has. -**If either fails, stop and say so.** +Whatever your checkout is on, and however stale it is, does not reach the review. + +**If it fails, stop and say so.** The workspace lasts for this review and no longer. ## 3. Examine ```bash -docker exec -w /work/pr-{number} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/pr-{number} -- \ claude --permission-mode auto -p "/crypter-devcontainer-examine pr-{number} pr-{number} origin/{base-branch}" ``` @@ -140,7 +141,7 @@ finding the author has to answer. So every finding is ruled on against the code anywhere — by a verifier in the container, one per finding, not by you. ```bash -docker exec -w /work/pr-{number} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/pr-{number} -- \ claude --permission-mode auto -p "/crypter-devcontainer-verify pr-{number} pr-{number} /runs/pr-{number}/findings/" ``` @@ -195,7 +196,7 @@ retry it against a different line.** ## 6. Tear down and report ```bash -docker exec crypter-pipeline crypter-workspace remove pr-{number} +.devcontainer/pipeline.sh exec -- crypter-workspace remove pr-{number} ``` Remove it on every exit path, including the ones where you stopped early. The findings and diff --git a/.claude/skills/crypter-step-open-pull-request/SKILL.md b/.claude/skills/crypter-step-open-pull-request/SKILL.md index b130708a..529e0f8b 100644 --- a/.claude/skills/crypter-step-open-pull-request/SKILL.md +++ b/.claude/skills/crypter-step-open-pull-request/SKILL.md @@ -19,7 +19,7 @@ The branch lives in the run's workspace. `git` reaches it over `docker exec`: ```bash git -c protocol.ext.allow=user fetch \ - "ext::docker exec -i crypter-pipeline git upload-pack /work/{run-id}" {branch}:{branch} + "ext::docker exec -i $(.devcontainer/pipeline.sh name) git upload-pack /work/{run-id}" {branch}:{branch} ``` `protocol.ext.allow` is passed per command and stays out of your config. **If this fails, stop diff --git a/.claude/skills/crypter-triage-review/SKILL.md b/.claude/skills/crypter-triage-review/SKILL.md index e4af386c..d2a02cb4 100644 --- a/.claude/skills/crypter-triage-review/SKILL.md +++ b/.claude/skills/crypter-triage-review/SKILL.md @@ -11,7 +11,8 @@ recorded as verified, and the verified ones become commits. Findings come from anywhere — the reviewer lenses, a person, another tool. They are treated the same way, because where a finding came from says nothing about whether it is true. -Run from the root of the main checkout. The container's mounts resolve against it. +Run from the root of a checkout. `/plans` and `/runs` resolve against it, and the container is +named after it, so the one you reach is always the one whose artifacts you are reading. ## Setup @@ -27,34 +28,34 @@ chmod 777 .claude/runs/pr-{number} .claude/runs/pr-{number}/verification The container's `agent` is uid 1001 and your files are uid 1000, so the agents write into directories this side creates and grants. -Then confirm the running container is the one this checkout describes, before anything depends -on it: +Then confirm the container is up and current, before anything depends on it: ```bash -docker exec crypter-pipeline test -w /runs/pr-{number}/verification && \ - docker exec crypter-pipeline test -x /usr/local/bin/crypter-workspace +.devcontainer/pipeline.sh exec -- test -w /runs/pr-{number}/verification && \ + .devcontainer/pipeline.sh exec -- test -x /usr/local/bin/crypter-workspace ``` -The first proves the `/runs` mount reaches the directory you just made, which a container -created against a different checkout will not. The second proves the image carries the current -tooling. A container that fails either is not this checkout's, and every later step fails -against it in a way that reads like something else — a missing executable, verification written -somewhere you never look. +`pipeline.sh` resolves the container from the checkout it sits in, so which container you get is +settled by where you are rather than by anything you check. What the probes are for is the rest: +the first proves `/runs` is mounted and that uid 1001 can write the directory you just made, and +the second proves the image carries the current tooling. Without them a later step fails in a way +that reads like something else — a missing executable, verification written somewhere you never +look. Both are `test` because `docker exec` runs a binary and not a shell, so a builtin like `command -v` exits 127 whether or not the thing it was looking for is there. -**Do not `docker start` an exited container to fix this.** Mounts and image are fixed when a -container is created, so starting one built from another checkout, or from an older image, -brings back the same wrong container. Bring it up from here instead: +**Do not `docker start` an exited container to fix this.** The image is fixed when a container is +created, so starting an old one brings back the old tooling. Bring it up from here instead: ```bash -docker compose -f .devcontainer/docker-compose.yml up -d --build +.devcontainer/pipeline.sh up ``` -That rebuilds the image and recreates the container against this checkout's mounts. It replaces -any container of the same name, so **ask the user before running it** — theirs may belong to -another checkout and hold work you cannot see. +That rebuilds the image and recreates this checkout's container. It cannot touch another +checkout's. What it does destroy is every workspace under `/work` in *this* container, because +`/work` is the container's own filesystem and not a volume — so **ask the user before running it +if another run may be live here.** ## 1. Collect the findings @@ -77,24 +78,23 @@ about intent. A question is for the author to answer, not for a verifier. ## 2. Fetch the head into a workspace -The container has no network remote. It clones from your repository through a read-only mount, -so the head goes into your repository first and travels across from there: +The container clones from the repository itself, so the head comes straight from GitHub and +nothing has to be staged in your checkout first: ```bash -git fetch origin +refs/pull/{number}/head:refs/pr/{number} -docker exec crypter-pipeline crypter-workspace create pr-{number} \ - '+refs/pr/{number}:refs/heads/{head-branch}' +.devcontainer/pipeline.sh exec -- crypter-workspace create pr-{number} \ + '+refs/pull/{number}/head:refs/heads/{head-branch}' ``` The branch in the workspace takes the pull request's own branch name, so the commits go back to the branch they came from. -**If either fails, stop and say so.** +**If it fails, stop and say so.** ## 3. Verify ```bash -docker exec -w /work/pr-{number} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/pr-{number} -- \ claude --permission-mode auto -p "/crypter-devcontainer-verify pr-{number} {head-branch} /runs/pr-{number}/review.md" ``` @@ -126,7 +126,7 @@ thread. Where `triage.md` has anything, and the head branch is one you can push to: ```bash -docker exec -w /work/pr-{number} crypter-pipeline \ +.devcontainer/pipeline.sh exec -w /work/pr-{number} -- \ claude --permission-mode auto -p "/crypter-devcontainer-remediate pr-{number} {head-branch} /runs/pr-{number}/triage.md" ``` @@ -139,7 +139,7 @@ stands, and the author does the fixing. Say so in the report. ## 6. Tear down and report ```bash -docker exec crypter-pipeline crypter-workspace remove pr-{number} +.devcontainer/pipeline.sh exec -- crypter-workspace remove pr-{number} ``` Remove it on every exit path, including the ones where you stopped early. The verdicts under diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index c10ed1a3..554041de 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,2 +1,5 @@ CRYPTER_GIT_EMAIL="" CRYPTER_GIT_NAME="" + +# The repository workspaces are cloned from. Set it to work against a fork. +#CRYPTER_REPO_URL="https://github.com/Crypter-File-Transfer/Crypter.git" diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 98fc8031..e9cf0728 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -48,11 +48,6 @@ RUN dotnet tool install dotnet-ef --version '10.0.*' --tool-path "${DOTNET_TOOLS COPY .devcontainer/workspace.sh /usr/local/bin/crypter-workspace RUN chmod +x /usr/local/bin/crypter-workspace -# /host-git is the host's repository, owned by the host user. Git refuses to read a repository -# owned by anyone else until it is named as safe, and the whole point of the mount is that it -# belongs to somebody else. -RUN git config --system --add safe.directory /host-git - # The caches and the agent's Claude Code state are named volumes. Docker creates a mount point # that the image does not already contain as root, so creating these here is what gives the # volumes the right ownership. /work holds the ephemeral workspaces and is not a volume. diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index f6c9407f..fa48c29b 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -1,23 +1,26 @@ -name: crypter-pipeline - services: pipeline: - container_name: crypter-pipeline + # Named per checkout by pipeline.sh, so two checkouts get two containers instead of fighting + # over one. Required rather than defaulted: a bare `docker compose` here would create a + # container whose name says nothing about which checkout's mounts it holds. + container_name: ${CRYPTER_PIPELINE_CONTAINER:?run .devcontainer/pipeline.sh up instead} # Built here rather than pulled. The image carries tooling and nothing else, so a change to # it is a local rebuild instead of a publish someone has to approve. image: crypter-devcontainer:local build: context: .. dockerfile: .devcontainer/Dockerfile + labels: + # What `pipeline.sh list` reads to say which checkout a container belongs to. + com.crypter.pipeline.checkout: ${CRYPTER_PIPELINE_CHECKOUT:?run .devcontainer/pipeline.sh up instead} environment: CRYPTER_GIT_NAME: ${CRYPTER_GIT_NAME} CRYPTER_GIT_EMAIL: ${CRYPTER_GIT_EMAIL} + # Workspaces are cloned from here rather than from the host, so a run sees the branch as + # the repository holds it. Override for a fork. + CRYPTER_REPO_URL: ${CRYPTER_REPO_URL:-https://github.com/Crypter-File-Transfer/Crypter.git} volumes: - claude:/home/agent/.claude - # The host repository, read-only. Workspaces are cloned from it and the container writes - # nothing back. The host working tree is deliberately not mounted, so uncommitted work is - # not visible in here. - - ../.git:/host-git:ro # Plans are authored on the host and read from /plans. - ../.claude/plans:/plans:ro # Findings, conformance and triage are artifacts on the host, written from /runs. @@ -31,10 +34,16 @@ services: working_dir: /work command: sleep infinity +# External so every instance on the machine shares them: Claude Code is authenticated once and +# the caches are warmed once. A Compose-owned volume carries the project it was created for, and +# a second project mounting it fails on the mismatch. `pipeline.sh up` creates them. volumes: claude: + external: true name: crypter-pipeline-claude nuget: + external: true name: crypter-pipeline-nuget pnpm: + external: true name: crypter-pipeline-pnpm diff --git a/.devcontainer/pipeline.sh b/.devcontainer/pipeline.sh new file mode 100755 index 00000000..0c287fe8 --- /dev/null +++ b/.devcontainer/pipeline.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Resolve which pipeline container belongs to this checkout, and drive its lifecycle. +# +# /plans and /runs are relative mounts, so a container is welded to the checkout it was created +# from. Naming the container after that checkout is what lets several exist at once: the name a +# checkout resolves to is its own, so an orchestrator can never reach another checkout's mounts. +# +# The name is derived rather than configured. There is nothing to set, and nothing that can drift +# out of step with where the checkout actually is. +# +# Parallel runs within one checkout need none of this. They are already separated by the per-run +# workspaces at /work/{run-id}. +set -euo pipefail + +script_dir="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +checkout="$(dirname "${script_dir}")" +compose_file="${script_dir}/docker-compose.yml" + +# Shared by every instance on the machine, which is why Claude Code is authenticated once and the +# package caches are warmed once. Declared external in the Compose file, so nothing creates them +# but this script. +volumes=(crypter-pipeline-claude crypter-pipeline-nuget crypter-pipeline-pnpm) + +usage() { + cat >&2 <<'EOF' +usage: pipeline.sh name print this checkout's container name + pipeline.sh exec [flags] -- {cmd} run a command in this checkout's container + pipeline.sh up create or recreate it, rebuilding the image + pipeline.sh down stop and remove it + pipeline.sh list every pipeline container, and its checkout +EOF + exit 64 +} + +# The name is both a container name and a Compose project name. Compose is the stricter of the +# two: lowercase, and no dots. The slug keeps `docker ps` readable and the hash of the real path +# separates two checkouts that share a basename. +container_name() { + local slug hash + slug="$(basename "${checkout}" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-')" + slug="${slug#-}" + slug="${slug%-}" + slug="${slug:0:20}" + slug="${slug:-checkout}" + + hash="$(printf '%s' "${checkout}" | sha256sum | cut -c1-8)" + + printf 'crypter-pipeline-%s-%s\n' "${slug}" "${hash}" +} + +compose() { + local name + name="$(container_name)" + CRYPTER_PIPELINE_CONTAINER="${name}" \ + CRYPTER_PIPELINE_CHECKOUT="${checkout}" \ + docker compose --project-name "${name}" --file "${compose_file}" "$@" +} + +case "${1:-}" in + name) + container_name + ;; + + exec) + shift + # Flags for docker exec come first, then `--`, then the command. The separator is what keeps + # a command's own flags from being read as docker's. + flags=() + while [[ $# -gt 0 && "${1}" != "--" ]]; do + flags+=("${1}") + shift + done + [[ "${1:-}" == "--" ]] || usage + shift + [[ $# -gt 0 ]] || usage + + docker exec "${flags[@]}" "$(container_name)" "$@" + ;; + + up) + # Compose will not create an external volume, and a missing one fails the `up` rather than + # being made on the fly. Creating is idempotent, so this is safe on every run. + for volume in "${volumes[@]}"; do + docker volume create "${volume}" >/dev/null + done + + compose up --detach --build + ;; + + down) + compose down + ;; + + list) + docker ps --all \ + --filter 'label=com.crypter.pipeline.checkout' \ + --format 'table {{.Names}}\t{{.Status}}\t{{.Label "com.crypter.pipeline.checkout"}}' + ;; + + *) + usage + ;; +esac diff --git a/.devcontainer/workspace.sh b/.devcontainer/workspace.sh index 69a4ad77..98590295 100755 --- a/.devcontainer/workspace.sh +++ b/.devcontainer/workspace.sh @@ -1,16 +1,17 @@ #!/usr/bin/env bash # Create and remove the per-run workspaces the agents build and review in. # -# A workspace is a clone of the host repository taken from the read-only /host-git mount. It -# belongs to one run and is removed with it, so no copy of the repository outlives the state it -# was made from. +# A workspace is a clone of the repository taken from CRYPTER_REPO_URL. It belongs to one run and +# is removed with it, so no copy of the repository outlives the state it was made from. # -# The mount is read-only, so the container reads committed history and writes nothing back. The -# host's working tree is not mounted at all, which is what keeps uncommitted work invisible here. +# Cloning from the remote rather than from the host means a run sees the branch as the repository +# holds it, not as some checkout happens to have fetched it. Nothing on the host is mounted here, +# so neither uncommitted work nor a stale checkout can reach a run. +# +# The clone is anonymous and read-only. Branches leave a workspace by the host fetching from it +# over `docker exec ... git upload-pack`, so no credential is needed in here. set -euo pipefail -host_git="/host-git" - usage() { echo "usage: crypter-workspace create {run-id} [--base {branch}] [refspec]" >&2 echo " crypter-workspace remove {run-id}" >&2 @@ -51,47 +52,28 @@ case "${subcommand}" in create) : "${CRYPTER_GIT_NAME:?Set CRYPTER_GIT_NAME in .devcontainer/.env to the author name on the commits}" : "${CRYPTER_GIT_EMAIL:?Set CRYPTER_GIT_EMAIL in .devcontainer/.env to the author email on the commits}" - - if [[ ! -d "${host_git}" ]]; then - echo "No host repository at ${host_git}. The container was started without its mount." >&2 - exit 1 - fi + : "${CRYPTER_REPO_URL:?Set CRYPTER_REPO_URL to the repository the workspaces are cloned from}" if [[ -e "${workspace}" ]]; then echo "A workspace already exists at ${workspace}. Remove it or use another run id." >&2 exit 1 fi - # Resolve the base before anything is created, so a base that is not there leaves nothing - # behind to remove first. - if ! git -C "${host_git}" rev-parse --verify --quiet "refs/remotes/origin/${base}" >/dev/null + git clone --quiet "${CRYPTER_REPO_URL}" "${workspace}" + + # The base has to exist before the run is measured against it, and the clone is the first + # place that can be checked. A workspace without its base is no use, so it goes. + if ! git -C "${workspace}" rev-parse --verify --quiet "refs/remotes/origin/${base}" >/dev/null then - echo "The host repository has no origin/${base}. Fetch it there and try again." >&2 + echo "${CRYPTER_REPO_URL} has no ${base} branch." >&2 + rm -rf "${workspace}" exit 1 fi - # --no-hardlinks because the mount is read-only and owned by another uid, which is exactly - # the case where git's hardlink optimisation is unavailable. Copying is predictable. - git clone --quiet --no-hardlinks "${host_git}" "${workspace}" - - # A clone maps the source's local branches into origin/*, so origin/stable here would mean - # whatever the host has checked out rather than what the org repository holds. Point the - # remote at the host's own remote-tracking refs instead, so origin/{branch} means the same - # thing in a workspace as it does on the host. Configuring the refspec rather than fetching - # it once keeps a later bare `git fetch` from putting the host's local branches back. - git -C "${workspace}" config remote.origin.fetch \ - '+refs/remotes/origin/*:refs/remotes/origin/*' - git -C "${workspace}" fetch --quiet --prune origin - if [[ -n "${refspec}" ]]; then git -C "${workspace}" fetch --quiet origin "${refspec}" fi - # The clone takes origin/HEAD from the host's checked-out branch, which is the one thing in - # the origin namespace that would still mean the host rather than the org. Point it at the - # base, so a bare `origin` resolves to what the run is measured against. - git -C "${workspace}" remote set-head origin "${base}" - git -C "${workspace}" checkout --quiet -B "${base}" "refs/remotes/origin/${base}" git -C "${workspace}" config user.name "${CRYPTER_GIT_NAME}" diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index 9b16f614..f7c00f6b 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -34,13 +34,14 @@ ones to invoke. `/crypter-step-plan` is the one worth borrowing when you want a plan and nothing else, and `/crypter-step-open-pull-request` is safe to run repeatedly, which is how the CI loop uses it. -**Run the orchestrators from the root of your main checkout.** The container's mounts are -relative to `.devcontainer/`, so `.claude/plans` and `.claude/runs` resolve against that one -directory. Started from a worktree, a run writes its plan somewhere the container cannot read. +**Run the orchestrators from the root of a checkout**, main or worktree. `.claude/plans` and +`.claude/runs` are mounts relative to `.devcontainer/`, so they resolve against whichever checkout +you launch from, and the container is named after that checkout — so the one you reach is always +the one holding the artifacts you are reading. -**The container holds no GitHub credential and no network remote.** Every authenticated GitHub -operation happens in your session with your own access, and `/crypter-change` pushes and -re-pushes without stopping to ask. +**The container holds no GitHub credential.** It clones anonymously over https and can only read +a public repository. Every authenticated GitHub operation happens in your session with your own +access, and `/crypter-change` pushes and re-pushes without stopping to ask. The branch is pushed to the org repository and the pull request opens against it, base `stable`, the same route a branch of your own takes. `/crypter-change` leaves you a draft pull request to @@ -51,28 +52,29 @@ and its network egress is open. Treat it as a trust boundary rather than a sandb ## Workspaces -The agents build and review in a **workspace**: a clone of your repository at `/work/{run-id}`, +The agents build and review in a **workspace**: a clone of the repository at `/work/{run-id}`, made when a run starts and deleted when it ends. Nothing that holds a copy of the code outlives the run that made it, so there is no second checkout drifting away from yours. -Workspaces are cloned from `/host-git`, a read-only mount of your repository's `.git`. Read-only -is what makes this safe to share: the container reads committed history and cannot move a ref, -add an object, or touch anything in your repository. Your working tree is not mounted at all, so -uncommitted work is invisible in there and cannot reach a branch. +Workspaces are cloned from `CRYPTER_REPO_URL` — the org repository on GitHub, not your checkout. +Nothing on the host is mounted for them to read. **Your checkout is therefore irrelevant to what +a run builds or reviews**: it sees the branch as the repository holds it, whatever yours is on, +however stale it is, and whatever is uncommitted in it. A pull request head is fetched straight +from `refs/pull/{number}/head`, so nothing has to be staged on your side first. + +Because each workspace is a full clone taken when it is created, and nothing re-fetches +afterwards, a run is pinned to the commit it started from. Merge to `stable` while a run is going +and it keeps building against what it cloned; the next run gets the new commit. Two runs at +different bases are no trouble. The orchestrator owns the lifecycle. It creates the workspace in its setup and removes it when the run ends; the container skills use it and never create or destroy one. ```bash -docker exec crypter-pipeline crypter-workspace create {run-id} [--base {branch}] [refspec] -docker exec crypter-pipeline crypter-workspace remove {run-id} +.devcontainer/pipeline.sh exec -- crypter-workspace create {run-id} [--base {branch}] [refspec] +.devcontainer/pipeline.sh exec -- crypter-workspace remove {run-id} ``` -The org repository is `origin` on both sides. A clone would otherwise map your local branches -into the workspace's `origin/*`, so the create step points the remote at your remote-tracking -refs instead, and `origin/stable` in a workspace means what it means in your checkout. Fetch -before creating a workspace, or the run starts on a stale base. - `--base` is the branch the run is built or reviewed against, `stable` when it is not given. A pull request states its own base, and a release states `main`, so the review skills pass what they read rather than assuming. @@ -85,12 +87,14 @@ Everything crossing the container boundary goes through one of these: | Host | Container | Direction | Holds | |---|---|---|---| -| `.git` | `/host-git` | Read-only | Your committed history, which workspaces are cloned from | | `.claude/plans` | `/plans` | Read-only | `{run-id}/plan.md` | | `.claude/runs` | `/runs` | Writable | `{run-id}/conformance.md`, `{run-id}/findings/{lens}.md`, `{run-id}/review.md`, `{run-id}/verification/{id}.md`, `{run-id}/triage.md`, `{run-id}/ci-{n}.md` | -`.claude/plans` and `.claude/runs` are gitignored and live on your disk. Only `/runs` is -writable; the other two the container can read and nothing more. +Both are gitignored and live on your disk. Only `/runs` is writable; `/plans` the container can +read and nothing more. + +Source is not among them. Nothing of your repository is mounted, so a run cannot read your +working tree, your local branches, or a ref you have not pushed — it clones from GitHub instead. The plan goes in and cannot be rewritten by the agents. Findings come back out as files you can open, grep and keep, rather than as text in a transcript, and each is written by the agent that @@ -102,32 +106,52 @@ bind mount keeps host ownership, so the orchestrators create every directory und themselves and give it mode 777. Directories made on the host stay deletable from the host; a directory the container creates is one you need `docker exec` to remove. -The branch itself travels differently. It never passes through a mount: +A branch the agents built travels back out the same way it would from any remote, over a git +transport that runs `docker exec` instead of opening a socket: ```bash git -c protocol.ext.allow=user fetch \ - "ext::docker exec -i crypter-pipeline git upload-pack /work/{run-id}" {branch}:{branch} + "ext::docker exec -i $(.devcontainer/pipeline.sh name) git upload-pack /work/{run-id}" {branch}:{branch} +``` + +`protocol.ext.allow` is passed per command, so it stays out of your git config. Pushing is then +yours, with your credentials — which is what keeps a write token out of a container running +unattended agents. + +## One container per checkout + +`/plans` and `/runs` are relative paths in the Compose file, so they resolve against the checkout +you launch from and a container is stuck with whatever they resolved to when it was created. Each +checkout therefore gets its own container, named after it: + +```bash +.devcontainer/pipeline.sh name # crypter-pipeline-crypter-4f3a9c21 ``` -`protocol.ext.allow` is passed per command, so it stays out of your git config. +The name is derived from the checkout's real path — a readable slug, and a hash to separate two +clones that share a basename. Nothing to configure, and nothing that can drift out of step with +where the checkout actually is. Because a checkout resolves only to its own name, a run can never +reach another checkout's mounts, and recreating one container leaves the others alone. + +```bash +.devcontainer/pipeline.sh list # every instance, and the checkout it belongs to +``` -Because the mounts are relative paths in the Compose file, they resolve against the checkout you -launch from, and a container is stuck with whatever they resolved to when it was created. Run -the pipeline from a second checkout and the container it finds by name is the first one's: -`/runs` writes land under a repository you are not looking at, and `/host-git` clones a history -that is not the one you are reviewing. +This is not what makes runs parallel. Several runs share one container quite happily — they are +separated by their workspaces at `/work/{run-id}` and their artifacts at `/runs/{run-id}`, and +concurrent `docker exec` calls do not queue. Per-checkout naming is about which host directories +a container is wired to, nothing more. -Starting an exited container does not repair this, and neither does it pick up a newer image — -`docker start` reuses what the container was created with. Recreate it from the checkout you -mean to work in: +Starting an exited container does not pick up a newer image — `docker start` reuses what the +container was created with. Recreate it instead: ```bash -docker compose -f .devcontainer/docker-compose.yml up -d --build +.devcontainer/pipeline.sh up ``` -That rebuilds the image and recreates the container against the mounts as they resolve here. -There is one `crypter-pipeline` on the machine, so this takes it over from whichever checkout -held it. +That rebuilds the image and recreates this checkout's container. `/work` is the container's own +filesystem rather than a volume, so this destroys any workspace a run in this checkout is still +using. ## Running a change @@ -163,36 +187,43 @@ ignored by git. Copy the template and fill it in before the first `up`. cp .devcontainer/.env.example .devcontainer/.env ``` -| Variable | Value | -|---|---| -| `CRYPTER_GIT_NAME` | Author name on the agents' commits. | -| `CRYPTER_GIT_EMAIL` | Author email on the agents' commits. | +| Variable | Value | | +|---|---|---| +| `CRYPTER_GIT_NAME` | Author name on the agents' commits. | Required | +| `CRYPTER_GIT_EMAIL` | Author email on the agents' commits. | Required | +| `CRYPTER_REPO_URL` | The repository workspaces are cloned from. | Defaults to the org repository; set it for a fork | + +Both required ones fail workspace creation with a message naming the variable when left empty. -Both are required. Leaving one empty fails workspace creation with a message naming the -variable. +The container's name needs no configuration. It is derived from where the checkout is. ## Launching the container -The container is a Compose service in `.devcontainer/docker-compose.yml`. That is a separate -Compose project from the application stack at the repository root, so `docker compose up` and +The container is a Compose service in `.devcontainer/docker-compose.yml`, driven through +`pipeline.sh` — which supplies the per-checkout name Compose needs. That is a separate Compose +project from the application stack at the repository root, so `docker compose up` and `docker compose down` there never touch it, and the two share no network. ```bash mkdir -p .claude/plans .claude/runs -docker compose -f .devcontainer/docker-compose.yml up -d --build -docker compose -f .devcontainer/docker-compose.yml exec pipeline bash +.devcontainer/pipeline.sh up +.devcontainer/pipeline.sh exec -- bash # add -it for an interactive shell ``` Create the two mount sources first. They are gitignored, so a fresh clone has neither, and Docker creates a missing bind-mount source as root — which the orchestrators then cannot write into. -The first `--build` takes a few minutes, mostly installing the `wasm-tools` workload. After -that Docker's layer cache makes it quick, and a change to `workspace.sh` rebuilds only the last -couple of layers. Use `--build` whenever `.devcontainer/` has changed; plain `up -d` otherwise. +Running `docker compose` against this file directly fails, on purpose: the container name is a +required variable, and a container created without it would say nothing about which checkout's +mounts it holds. -Swap `up -d` for `down` to stop it. The named volumes outlive the container, so the next `up` -keeps your Claude Code credentials and your package caches. +The first `up` takes a few minutes, mostly installing the `wasm-tools` workload. After that +Docker's layer cache makes it quick, and a change to `workspace.sh` rebuilds only the last couple +of layers. + +`pipeline.sh down` stops it. The named volumes outlive the container, so the next `up` keeps your +Claude Code credentials and your package caches. ## What is in the container @@ -210,6 +241,9 @@ There is **no Docker in the container**, so `Crypter.Test` cannot run there — Testcontainers to start PostgreSQL. The agents build but never test locally; the test suite runs in CI once the pull request exists, and failures come back to the implementer from there. +It does need **outbound network**, both for the Anthropic API and now for cloning workspaces. +The pipeline does not work offline. + Three named volumes survive rebuilds, and none of them holds source: | Volume | Holds | @@ -218,6 +252,11 @@ Three named volumes survive rebuilds, and none of them holds source: | `crypter-pipeline-nuget` | The NuGet package cache | | `crypter-pipeline-pnpm` | The pnpm store | +**Every instance on the machine shares all three**, which is why Claude Code is authenticated +once rather than once per checkout, and why a second checkout's first build is not a cold +restore. They are declared external so that several Compose projects can mount them; `pipeline.sh +up` creates them. + The two caches exist because workspaces are ephemeral. Without them every run would restore NuGet and pnpm from nothing, which is most of a build. @@ -228,10 +267,14 @@ collide with the next one. To start over from nothing, take the container down and remove the volumes: ```bash -docker compose -f .devcontainer/docker-compose.yml down +.devcontainer/pipeline.sh down docker volume rm crypter-pipeline-claude crypter-pipeline-nuget crypter-pipeline-pnpm ``` +The `down` is per-checkout, but removing the volumes is not — it takes the credentials and caches +away from **every** instance. Use `pipeline.sh list` to see what else is on the machine first, +including containers left behind by checkouts that no longer exist. + ## Authenticate Claude Code The image ships Claude Code but no credentials. Run `claude` once inside the container and @@ -243,8 +286,9 @@ they survive container rebuilds. You only do this again after removing that volu Run the agents with `--permission-mode auto`. They work unattended, so a prompt they cannot answer is a run that stalls. What bounds the blast radius is the container itself: a workspace -that is thrown away at the end of the run, a read-only view of your repository, and no GitHub -credential to push with. +that is thrown away at the end of the run, no access to your repository at all, and no GitHub +credential to push with — its only reach into the repository is an anonymous read of what is +already public. ## Changing the image @@ -252,12 +296,15 @@ Needed when the tooling changes — a new tool the agents need, a runtime versio changes never require it, because the image carries no source. ```bash -docker compose -f .devcontainer/docker-compose.yml up -d --build +.devcontainer/pipeline.sh up ``` That is the whole loop. The image is local to your machine — it is never published, and nobody else consumes it — so a change to `workspace.sh` or the Dockerfile takes effect on your next -`up` and affects nothing but your own container. +`up`. + +The image tag is shared, so the rebuild is machine-wide; other instances pick the new image up +when they are next recreated, not before. `pr-build-devcontainer` builds the image on a pull request that touches `.devcontainer/`. It pushes nothing; it is there to catch a Dockerfile that does not build. From 40c60594747bb9e63da5a1b090fa4e2dacfe943b Mon Sep 17 00:00:00 2001 From: n Date: Thu, 6 Aug 2026 19:49:57 -0500 Subject: [PATCH 41/41] Authenticate the pipeline container by login or by token Claude Code in the container authenticates from an interactive login held in the crypter-pipeline-claude volume, shared by every checkout on the machine. That login lapses on its own schedule, and renewing it meant knowing to open a shell in the container and run `claude` there. `pipeline.sh login` makes that a command. CLAUDE_CODE_OAUTH_TOKEN is an alternative for a machine that would rather configure the credential than open a browser: Compose reads it into the container's environment, where Claude Code uses it in place of the stored login. Replacing an expired token means editing .env and running `up` again, since the environment is fixed when the container is created, so the stored login remains the better default. `up` warns when neither is set rather than refusing, because either one on its own is enough. Co-Authored-By: Claude Opus 5 --- .devcontainer/.env.example | 3 ++ .devcontainer/docker-compose.yml | 1 + .devcontainer/pipeline.sh | 20 +++++++++++-- .../Agentic Development Pipeline.md | 30 ++++++++++++++----- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 554041de..321f8b28 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,5 +1,8 @@ CRYPTER_GIT_EMAIL="" CRYPTER_GIT_NAME="" +# Claude Code's credential in the container. Generate one on the host with `claude setup-token`. +CLAUDE_CODE_OAUTH_TOKEN="" + # The repository workspaces are cloned from. Set it to work against a fork. #CRYPTER_REPO_URL="https://github.com/Crypter-File-Transfer/Crypter.git" diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml index fa48c29b..4f6bf341 100644 --- a/.devcontainer/docker-compose.yml +++ b/.devcontainer/docker-compose.yml @@ -19,6 +19,7 @@ services: # Workspaces are cloned from here rather than from the host, so a run sees the branch as # the repository holds it. Override for a fork. CRYPTER_REPO_URL: ${CRYPTER_REPO_URL:-https://github.com/Crypter-File-Transfer/Crypter.git} + CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN:-} volumes: - claude:/home/agent/.claude # Plans are authored on the host and read from /plans. diff --git a/.devcontainer/pipeline.sh b/.devcontainer/pipeline.sh index 0c287fe8..f2b01714 100755 --- a/.devcontainer/pipeline.sh +++ b/.devcontainer/pipeline.sh @@ -15,10 +15,10 @@ set -euo pipefail script_dir="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" checkout="$(dirname "${script_dir}")" compose_file="${script_dir}/docker-compose.yml" +env_file="${script_dir}/.env" # Shared by every instance on the machine, which is why Claude Code is authenticated once and the -# package caches are warmed once. Declared external in the Compose file, so nothing creates them -# but this script. +# package caches are warmed once. Declared external, so nothing creates them but this script. volumes=(crypter-pipeline-claude crypter-pipeline-nuget crypter-pipeline-pnpm) usage() { @@ -26,6 +26,7 @@ usage() { usage: pipeline.sh name print this checkout's container name pipeline.sh exec [flags] -- {cmd} run a command in this checkout's container pipeline.sh up create or recreate it, rebuilding the image + pipeline.sh login authenticate Claude Code interactively pipeline.sh down stop and remove it pipeline.sh list every pipeline container, and its checkout EOF @@ -78,6 +79,15 @@ case "${1:-}" in ;; up) + token="${CLAUDE_CODE_OAUTH_TOKEN:-}" + if [[ -z "${token}" && -f "${env_file}" ]]; then + token="$(. "${env_file}" >/dev/null 2>&1; printf '%s' "${CLAUDE_CODE_OAUTH_TOKEN:-}")" + fi + if [[ -z "${token}" ]]; then + echo "No CLAUDE_CODE_OAUTH_TOKEN in .devcontainer/.env." >&2 + echo "Claude Code will use the login in the volume; 'pipeline.sh login' renews it." >&2 + fi + # Compose will not create an external volume, and a missing one fails the `up` rather than # being made on the fly. Creating is idempotent, so this is safe on every run. for volume in "${volumes[@]}"; do @@ -87,6 +97,12 @@ case "${1:-}" in compose up --detach --build ;; + login) + # Credentials land in /home/agent/.claude, which is a volume, so the login outlives the + # container and is shared by every checkout on the machine. + docker exec -it "$(container_name)" claude + ;; + down) compose down ;; diff --git a/Documentation/Development/Agentic Development Pipeline.md b/Documentation/Development/Agentic Development Pipeline.md index f7c00f6b..93b710c4 100644 --- a/Documentation/Development/Agentic Development Pipeline.md +++ b/Documentation/Development/Agentic Development Pipeline.md @@ -47,8 +47,9 @@ The branch is pushed to the org repository and the pull request opens against it the same route a branch of your own takes. `/crypter-change` leaves you a draft pull request to read. -The container does hold your Claude Code credential, in the `crypter-pipeline-claude` volume, -and its network egress is open. Treat it as a trust boundary rather than a sandbox. +The container does hold your Claude Code credential, in the `crypter-pipeline-claude` volume or as +`CLAUDE_CODE_OAUTH_TOKEN` in its environment, and its network egress is open. Treat it as a trust +boundary rather than a sandbox. ## Workspaces @@ -191,9 +192,10 @@ cp .devcontainer/.env.example .devcontainer/.env |---|---|---| | `CRYPTER_GIT_NAME` | Author name on the agents' commits. | Required | | `CRYPTER_GIT_EMAIL` | Author email on the agents' commits. | Required | +| `CLAUDE_CODE_OAUTH_TOKEN` | Claude Code's credential. | Optional; an alternative to `pipeline.sh login` | | `CRYPTER_REPO_URL` | The repository workspaces are cloned from. | Defaults to the org repository; set it for a fork | -Both required ones fail workspace creation with a message naming the variable when left empty. +The two git variables fail workspace creation with a message naming the variable when left empty. The container's name needs no configuration. It is derived from where the checkout is. @@ -277,12 +279,24 @@ including containers left behind by checkouts that no longer exist. ## Authenticate Claude Code -The image ships Claude Code but no credentials. Run `claude` once inside the container and -follow the login prompt. The container has no browser, so the flow gives you a URL to open on -your host and a code to paste back. +The image ships Claude Code but no credential. Log in once: -Credentials live in `/home/agent/.claude`, which is the `crypter-pipeline-claude` volume, so -they survive container rebuilds. You only do this again after removing that volume. +```bash +.devcontainer/pipeline.sh login +``` + +Type `/login` and follow the prompt. The container has no browser, so the flow gives you a URL to +open on your host and a code to paste back. Credentials live in `/home/agent/.claude`, which is +the `crypter-pipeline-claude` volume, so they survive rebuilds and are shared by every checkout +on the machine. Run this again when the login lapses. + +A machine that would rather configure the credential than open a browser can set +`CLAUDE_CODE_OAUTH_TOKEN` in `.devcontainer/.env` instead, generated on the host with `claude +setup-token`. Compose reads it into the container's environment and Claude Code uses it in place +of the stored login. Replacing an expired one means editing `.env` and running `up` again, since +the environment is fixed when the container is created — a stored login renews without that. + +`up` warns when neither is set. Either one on its own is enough. Run the agents with `--permission-mode auto`. They work unattended, so a prompt they cannot answer is a run that stalls. What bounds the blast radius is the container itself: a workspace