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 d009c06c..d10794c6 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 6db73960..734b031a 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" ``` @@ -90,7 +94,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" ``` @@ -100,7 +104,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.