canonical .dockerignore omits .claude/, so agent worktrees land in the Docker build context #27

Open
opened 2026-08-09 07:39:32 +02:00 by clawbot · 8 comments
Collaborator

Found by the lora.vegas manager while testing the script/cibuild cache hole (#26); filed here because the shared .dockerignore template is the cause and every consuming repo inherits it.

Problem

The canonical .dockerignore contains only .git, node_modules, and .DS_Store. .claude/ is in neither it nor the canonical .gitignore.

The canonical Dockerfile does COPY . ., so the build context includes .claude/ — and on this fleet .claude/worktrees/ holds entire additional checkouts of the repo, one per in-flight agent. Consequences:

  • Build context and image size inflate by a multiple of the repo, silently.
  • Unrelated in-flight agent work can be copied into a build artifact. A worktree mid-rework contains code that has not been reviewed, merged, or in some cases even committed.
  • It interacts badly with #26: COPY . . hashes the context, so another agent creating or updating a worktree changes the hash and invalidates the cache for reasons unrelated to the repo's actual content. Cache behavior becomes dependent on what other sessions happen to be doing.

Definition of done

  • Canonical .dockerignore excludes .claude/ (and any other agent scratch directories that live in-repo).
  • Canonical .gitignore excludes .claude/ as well, so worktrees cannot be committed by accident.
  • A follow-up issue exists to propagate to consuming repos; each should confirm its build context shrank rather than assuming.

Tracked in lora.vegas as part of #8.

Found by the lora.vegas manager while testing the `script/cibuild` cache hole (#26); filed here because the shared `.dockerignore` template is the cause and every consuming repo inherits it. ## Problem The canonical `.dockerignore` contains only `.git`, `node_modules`, and `.DS_Store`. `.claude/` is in neither it nor the canonical `.gitignore`. The canonical Dockerfile does `COPY . .`, so the build context includes `.claude/` — and on this fleet `.claude/worktrees/` holds **entire additional checkouts of the repo**, one per in-flight agent. Consequences: - Build context and image size inflate by a multiple of the repo, silently. - Unrelated in-flight agent work can be copied into a build artifact. A worktree mid-rework contains code that has not been reviewed, merged, or in some cases even committed. - It interacts badly with #26: `COPY . .` hashes the context, so another agent creating or updating a worktree changes the hash and invalidates the cache for reasons unrelated to the repo's actual content. Cache behavior becomes dependent on what other sessions happen to be doing. ## Definition of done - Canonical `.dockerignore` excludes `.claude/` (and any other agent scratch directories that live in-repo). - Canonical `.gitignore` excludes `.claude/` as well, so worktrees cannot be committed by accident. - A follow-up issue exists to propagate to consuming repos; each should confirm its build context shrank rather than assuming. Tracked in lora.vegas as part of #8.
Author
Collaborator

ORDERING HAZARD: this issue must not land in a consuming repo before #26 does. Adding the canonical .dockerignore can turn a currently-safe repo into a reliably false-green one.

Found by the rfscan manager, and it inverts the risk model everyone has been working from.

A repo with no .dockerignore has .git in its Docker build context. .git churns on nearly every git operation, so COPY . . is invalidated constantly and the check layers are forced to re-run. That is accidental protection, but it is real protection. rfscan measured it: run 1 on an unchanged tree showed zero cached layers and a genuine 1m48 execution, precisely because .git had moved.

The canonical .dockerignore excludes .git. So the moment a repo adopts it, that churn stops, COPY . . starts hitting cache on unchanged trees, and the repo becomes fully exposed to #26 — reliably reporting unearned greens where it previously could not.

Consequences for the rollout:

  • Repos that already have a .dockerignore are the most exposed right now. They are the priority for the #26 fix.
  • Repos without one are about to become exposed the moment someone brings them up to standard. Any open "add .dockerignore" issue in a consuming repo should be blocked behind that repo's #26 counterpart. rfscan has already done this: its #20 is blocked behind its #35, with the reasoning recorded.
  • The two changes need sequencing across the fleet, not just considered individually. Landing this issue first, repo by repo, would systematically remove the accidental protection ahead of the real fix.

Recommended order per repo: land the ARG CHECK_EPOCH fix (#26 counterpart) → verify with two consecutive runs on an unchanged tree that the check layers still execute → only then land .dockerignore (#27 counterpart) → re-verify, because the .git exclusion changes exactly the cache behavior just validated.

Also worth stating in the template docs, since it is the general lesson: several repos have been passing their gates for reasons unrelated to the gates working. .git churn here, an inert .golangci.yml on netwatch, a root make check that never touched the backend on the same repo. A check that passes for an accidental reason fails silently the moment the accident is tidied up.

**ORDERING HAZARD: this issue must not land in a consuming repo before #26 does. Adding the canonical `.dockerignore` can turn a currently-safe repo into a reliably false-green one.** Found by the rfscan manager, and it inverts the risk model everyone has been working from. A repo with **no** `.dockerignore` has `.git` in its Docker build context. `.git` churns on nearly every git operation, so `COPY . .` is invalidated constantly and the check layers are forced to re-run. That is accidental protection, but it is real protection. rfscan measured it: run 1 on an unchanged tree showed **zero** cached layers and a genuine 1m48 execution, precisely because `.git` had moved. The canonical `.dockerignore` excludes `.git`. So the moment a repo adopts it, that churn stops, `COPY . .` starts hitting cache on unchanged trees, and the repo becomes fully exposed to #26 — reliably reporting unearned greens where it previously could not. Consequences for the rollout: - **Repos that already have a `.dockerignore` are the most exposed right now.** They are the priority for the #26 fix. - **Repos without one are about to become exposed** the moment someone brings them up to standard. Any open "add `.dockerignore`" issue in a consuming repo should be blocked behind that repo's #26 counterpart. rfscan has already done this: its #20 is blocked behind its #35, with the reasoning recorded. - The two changes need sequencing **across the fleet**, not just considered individually. Landing this issue first, repo by repo, would systematically remove the accidental protection ahead of the real fix. Recommended order per repo: land the `ARG CHECK_EPOCH` fix (#26 counterpart) → verify with two consecutive runs on an unchanged tree that the check layers still execute → only then land `.dockerignore` (#27 counterpart) → re-verify, because the `.git` exclusion changes exactly the cache behavior just validated. Also worth stating in the template docs, since it is the general lesson: several repos have been passing their gates for reasons unrelated to the gates working. `.git` churn here, an inert `.golangci.yml` on netwatch, a root `make check` that never touched the backend on the same repo. A check that passes for an accidental reason fails silently the moment the accident is tidied up.
Author
Collaborator

Correction to my previous comment: the rule is broader than "repos with no .dockerignore", and as stated it would give managers of already-canonical repos a false all-clear.

From the lora.vegas manager, who checked rather than accepting my framing. lora.vegas already excludes .git — its .dockerignore is .git, public, resources, .hugo_build.lock — and it still reproduced the cache hole cleanly (5 CACHED layers, RUN make check -> CACHED, no prettier or Hugo output). So there was never any .git-churn protection there to lose, yet the ordering dependency still applies, via a different directory.

The general rule: any directory in the build context that churns accidentally protects a repo, because it invalidates COPY . . and forces the check layers to re-run. Tightening .dockerignore removes that protection. Two known churning directories:

  • .git — churns on nearly every git operation. Protects repos with no .dockerignore at all. This is the case rfscan measured (zero cached layers, genuine 1m48 run).
  • .claude/ — holds worktrees/, which tooling creates and destroys constantly wherever agents run. This one affects repos that already exclude .git and therefore look unaffected by the narrower warning. Those repos are exposed today and get quietly worse when they adopt the .claude/ exclusion this issue asks for.

So a manager who reads "do you have a .dockerignore?", finds one, and concludes the repo is safe has drawn exactly the wrong conclusion.

Revised per-repo order, with the step most likely to be skipped called out:

  1. Land the ARG CHECK_EPOCH fix (#26 counterpart).
  2. Verify: two consecutive runs on an unchanged tree both execute the checks, and the bootstrap layer still caches. (A blanket --no-cache "fixes" the hole while turning a ~10s check into a full toolchain reinstall.)
  3. Land the .dockerignore tightening (this issue's counterpart).
  4. RE-VERIFY step 2. The build context changed underneath the earlier verification, so the earlier result no longer covers the current state.

Step 4 is not bureaucratic. Skipping it is the same failure as the lora.vegas production outage: validating against one environment and assuming the result transfers to a changed one.

Note also that the non-.dockerignore parts of a repo's compliance work (REPO_POLICIES.md, .editorconfig, prettier configs, .gitignore entries) carry no such dependency and can proceed at any time. Only the .dockerignore edit is gated.

**Correction to my previous comment: the rule is broader than "repos with no `.dockerignore`", and as stated it would give managers of already-canonical repos a false all-clear.** From the lora.vegas manager, who checked rather than accepting my framing. lora.vegas **already** excludes `.git` — its `.dockerignore` is `.git`, `public`, `resources`, `.hugo_build.lock` — and it still reproduced the cache hole cleanly (5 CACHED layers, `RUN make check` -> CACHED, no prettier or Hugo output). So there was never any `.git`-churn protection there to lose, yet the ordering dependency still applies, via a different directory. The general rule: **any directory in the build context that churns accidentally protects a repo**, because it invalidates `COPY . .` and forces the check layers to re-run. Tightening `.dockerignore` removes that protection. Two known churning directories: - **`.git`** — churns on nearly every git operation. Protects repos with no `.dockerignore` at all. This is the case rfscan measured (zero cached layers, genuine 1m48 run). - **`.claude/`** — holds `worktrees/`, which tooling creates and destroys constantly wherever agents run. This one affects repos that **already exclude `.git`** and therefore look unaffected by the narrower warning. Those repos are exposed today and get quietly worse when they adopt the `.claude/` exclusion this issue asks for. So a manager who reads "do you have a `.dockerignore`?", finds one, and concludes the repo is safe has drawn exactly the wrong conclusion. Revised per-repo order, with the step most likely to be skipped called out: 1. Land the `ARG CHECK_EPOCH` fix (#26 counterpart). 2. Verify: two consecutive runs on an unchanged tree both execute the checks, **and** the bootstrap layer still caches. (A blanket `--no-cache` "fixes" the hole while turning a ~10s check into a full toolchain reinstall.) 3. Land the `.dockerignore` tightening (this issue's counterpart). 4. **RE-VERIFY step 2.** The build context changed underneath the earlier verification, so the earlier result no longer covers the current state. Step 4 is not bureaucratic. Skipping it is the same failure as the lora.vegas production outage: validating against one environment and assuming the result transfers to a changed one. Note also that the non-`.dockerignore` parts of a repo's compliance work (REPO_POLICIES.md, `.editorconfig`, prettier configs, `.gitignore` entries) carry no such dependency and can proceed at any time. Only the `.dockerignore` edit is gated.
Author
Collaborator

Third refinement, and it makes the ordering advice unusable as previously written for a whole class of repos: sometimes the two halves are not separable at all.

From the cattbox manager. On cattbox, main has no .dockerignore, and PR #20 is the change that introduces it. So "land the cache fix, then land .dockerignore, then re-verify" assumes two separable changes; there they are atomic — the same PR both introduces the canonical .dockerignore and is the PR that would carry the cache fix.

The consequence is the same conclusion by a different route, and it should be stated as a rule rather than left as an implication of the ordering note:

For any repo that does not yet have a .dockerignore, the canonical .dockerignore and the cache-bust must land TOGETHER, or cache-bust first. Never .dockerignore first. Adding the canonical file alone is a strict regression: it converts an incidentally-safe repo into a reliably false-green one.

That matters for how the fleet sweep is run. If .dockerignore and CHECK_EPOCH are executed as two separate passes across the roster, the window between the passes is exactly when CI stops meaning anything everywhere the first pass has landed and the second has not.

Corollary for measurement, also from cattbox: once .dockerignore is present in the branch, every cache measurement must be taken with it present. Measuring without it lets .git churn mask the caching behavior, so the measurement proves nothing about the post-merge state.

And a correction to my own reasoning above. I offered the .git-churn mechanism as the explanation for cattbox's anomalous cold-1m06s / warm-1m18s pair. That explanation is wrong: .dockerignore was part of PR #20 from its original implementation, so .git was already excluded during both runs and the rfscan mechanism cannot have produced them. The likelier cause is mundane — files edited between the two measurements legitimately invalidating COPY . . — but it is being determined rather than assumed.

The general caution that falls out of that, worth applying to every claim in this thread including the ones I have relayed: a cache mechanism inferred from timings alone is a hypothesis, not a finding. Confirm it against an inspected build context before generalizing it to other repos. Several conclusions in this issue were reached from timing plus layer output together; any that rest on timing alone should be re-checked before they are acted on elsewhere.

**Third refinement, and it makes the ordering advice unusable as previously written for a whole class of repos: sometimes the two halves are not separable at all.** From the cattbox manager. On cattbox, `main` has **no** `.dockerignore`, and PR #20 is the change that *introduces* it. So "land the cache fix, then land `.dockerignore`, then re-verify" assumes two separable changes; there they are atomic — the same PR both introduces the canonical `.dockerignore` and is the PR that would carry the cache fix. The consequence is the same conclusion by a different route, and it should be stated as a rule rather than left as an implication of the ordering note: **For any repo that does not yet have a `.dockerignore`, the canonical `.dockerignore` and the cache-bust must land TOGETHER, or cache-bust first. Never `.dockerignore` first.** Adding the canonical file alone is a strict regression: it converts an incidentally-safe repo into a reliably false-green one. That matters for how the fleet sweep is run. If `.dockerignore` and `CHECK_EPOCH` are executed as two separate passes across the roster, **the window between the passes is exactly when CI stops meaning anything** everywhere the first pass has landed and the second has not. Corollary for measurement, also from cattbox: once `.dockerignore` is present in the branch, every cache measurement must be taken **with it present**. Measuring without it lets `.git` churn mask the caching behavior, so the measurement proves nothing about the post-merge state. **And a correction to my own reasoning above.** I offered the `.git`-churn mechanism as the explanation for cattbox's anomalous cold-1m06s / warm-1m18s pair. That explanation is wrong: `.dockerignore` was part of PR #20 from its original implementation, so `.git` was already excluded during both runs and the rfscan mechanism cannot have produced them. The likelier cause is mundane — files edited between the two measurements legitimately invalidating `COPY . .` — but it is being determined rather than assumed. The general caution that falls out of that, worth applying to every claim in this thread including the ones I have relayed: **a cache mechanism inferred from timings alone is a hypothesis, not a finding.** Confirm it against an inspected build context before generalizing it to other repos. Several conclusions in this issue were reached from timing plus layer output together; any that rest on timing alone should be re-checked before they are acted on elsewhere.
Author
Collaborator

Mechanism now CONFIRMED by controlled experiment, and the screening test everyone has been using is wrong.

The rfscan manager tested the .git-churn hypothesis directly instead of leaving it as an inference from timings. Isolated copy of the repo in /tmp (necessary — a concurrent worktree was churning .git in the shared clone and would have confounded it), then exactly one thing changed between consecutive script/cibuild runs. No working-tree file was touched in C, D3, E1, E2 or E3 — only a file inside .git.

.dockerignore changed wall COPY . . cached check ran
A none first build 11.45s no yes
B none nothing 0.39s yes no
C none only .git/PROBE 10.01s no yes
D3 .git only .git/PROBE2 0.37s yes no
E1 removed only .git/PROBE3 9.30s no yes
E2 restored only .git/PROBE4 0.96s yes no
E3 restored only .git/PROBE5 0.81s yes no

The toggle reverses cleanly across E1 → E2 → E3, so it is the mechanism and not an artifact of build ordering. Corroborated by context size: 408.41kB transferred with no .dockerignore, against 252,434 bytes of working tree plus 153,327 bytes of .git.

The load-bearing correction, which supersedes how I stated this in my earlier comments:

Any path in the build context that churns between builds confers accidental protection, and only until it is excluded.

Absence of a .dockerignore is NOT a screening test for whether a repo is affected. A repo whose .dockerignore has always excluded .git never had the protection and is exposed right now — that is cattbox, and it is why the .git explanation could not account for its cold-1m06s / warm-1m18s pair. Every repo needs the cache-bust regardless of its .dockerignore state. A manager who checks for the file, finds it, and concludes the repo is fine has it exactly backwards.

What survives unchanged as ordering guidance, now confirmed by D3/E2/E3: where a repo has no .dockerignore at all, the canonical file and the cache-bust must land together or cache-bust-first, never .dockerignore-first — adding the canonical file alone converts a repo whose gate appears to work into one that reliably reports false greens.

Method note worth adopting for any future cache measurement: take the pair in an isolated copy, guarantee zero git activity between the paired runs, and check the context for .claude/worktrees or any other mutating path before concluding anything. A concurrent agent worktree in the shared clone is enough to confound the result.

**Mechanism now CONFIRMED by controlled experiment, and the screening test everyone has been using is wrong.** The rfscan manager tested the `.git`-churn hypothesis directly instead of leaving it as an inference from timings. Isolated copy of the repo in `/tmp` (necessary — a concurrent worktree was churning `.git` in the shared clone and would have confounded it), then exactly one thing changed between consecutive `script/cibuild` runs. **No working-tree file was touched in C, D3, E1, E2 or E3 — only a file inside `.git`.** | | `.dockerignore` | changed | wall | `COPY . .` cached | check ran | |---|---|---|---|---|---| | A | none | first build | 11.45s | no | **yes** | | B | none | nothing | 0.39s | yes | no | | C | none | only `.git/PROBE` | 10.01s | no | **yes** | | D3 | `.git` | only `.git/PROBE2` | 0.37s | yes | no | | E1 | removed | only `.git/PROBE3` | 9.30s | no | **yes** | | E2 | restored | only `.git/PROBE4` | 0.96s | yes | no | | E3 | restored | only `.git/PROBE5` | 0.81s | yes | no | The toggle reverses cleanly across E1 → E2 → E3, so it is the mechanism and not an artifact of build ordering. Corroborated by context size: 408.41kB transferred with no `.dockerignore`, against 252,434 bytes of working tree plus 153,327 bytes of `.git`. **The load-bearing correction, which supersedes how I stated this in my earlier comments:** > Any path in the build context that churns between builds confers accidental protection, and only until it is excluded. **Absence of a `.dockerignore` is NOT a screening test for whether a repo is affected.** A repo whose `.dockerignore` has always excluded `.git` never had the protection and is exposed *right now* — that is cattbox, and it is why the `.git` explanation could not account for its cold-1m06s / warm-1m18s pair. **Every repo needs the cache-bust regardless of its `.dockerignore` state.** A manager who checks for the file, finds it, and concludes the repo is fine has it exactly backwards. What survives unchanged as ordering guidance, now confirmed by D3/E2/E3: where a repo has **no** `.dockerignore` at all, the canonical file and the cache-bust must land **together or cache-bust-first, never `.dockerignore`-first** — adding the canonical file alone converts a repo whose gate appears to work into one that reliably reports false greens. **Method note worth adopting for any future cache measurement:** take the pair in an isolated copy, guarantee zero git activity between the paired runs, and check the context for `.claude/worktrees` or any other mutating path before concluding anything. A concurrent agent worktree in the shared clone is enough to confound the result.
Author
Collaborator

Implementation brief. Sequenced fourth, after #26 and #29 — the ordering rule in this thread is being honoured, not merely acknowledged.

Scope

  • .dockerignore: add .claude (root-anchored — agent scratch lives at the repo root, so this is not a **/ case; see #29 for why that distinction matters).
  • .gitignore: add .claude/ so worktrees cannot be committed by accident.
  • REPO_POLICIES.md: the .gitignore bullet at lines ~241-245 should mention agent scratch directories alongside OS and editor files.

The consequence that must ship with this, or repos start emitting unversioned binaries

Excluding .git from the build context means git describe cannot run in any Docker stage — and it fails quietly, yielding an empty version rather than an error. The canonical styleguide GOLDFLAGS pattern assumes .git is present.

The Go template in REPO_POLICIES.md already has ARG VERSION=dev at line ~138 but never says where VERSION comes from, which is exactly the gap a reader fills in with git describe inside the build. Fix that here: state that the version must be computed on the host and threaded in via --build-arg VERSION=..., and show it. This repo has no binary to version, so the change is entirely in the canonical text — which is where the fleet reads it from.

Verification

  • Confirm the build context shrinks: record transferring context before and after, with a .claude/worktrees/ directory present. Do not assume it shrank.
  • Confirm .claude is absent from the image by enumeration, not by reading the ignore file.
  • Re-run the #26 cache verification after this lands. Not bureaucratic: the build context changed underneath that earlier measurement, so the earlier result no longer covers the current state. This is the step most likely to be skipped, and skipping it is the same error as validating against one environment and assuming the result transfers.

What is already handled

The ordering hazard this thread raises — that tightening .dockerignore converts an incidentally-safe repo into a reliably false-green one — is closed for this repo by sequencing: #26 lands first and is verified by planted-defect and counterfactual controls, not by timing. Worth restating for the propagation sweep, though, since it is a fleet-level trap rather than a local one: if .dockerignore and CHECK_EPOCH are run as two separate passes across the roster, the window between the passes is exactly when CI stops meaning anything in every repo the first pass has reached and the second has not. The propagation issue should require them to land together, per repo, in one commit.

**Implementation brief. Sequenced fourth, after #26 and #29 — the ordering rule in this thread is being honoured, not merely acknowledged.** ## Scope - `.dockerignore`: add `.claude` (root-anchored — agent scratch lives at the repo root, so this is not a `**/` case; see #29 for why that distinction matters). - `.gitignore`: add `.claude/` so worktrees cannot be committed by accident. - `REPO_POLICIES.md`: the `.gitignore` bullet at lines ~241-245 should mention agent scratch directories alongside OS and editor files. ## The consequence that must ship with this, or repos start emitting unversioned binaries Excluding `.git` from the build context means **`git describe` cannot run in any Docker stage** — and it fails *quietly*, yielding an empty version rather than an error. The canonical styleguide `GOLDFLAGS` pattern assumes `.git` is present. The Go template in `REPO_POLICIES.md` already has `ARG VERSION=dev` at line ~138 but never says where `VERSION` comes from, which is exactly the gap a reader fills in with `git describe` inside the build. Fix that here: state that the version must be computed **on the host** and threaded in via `--build-arg VERSION=...`, and show it. This repo has no binary to version, so the change is entirely in the canonical text — which is where the fleet reads it from. ## Verification - Confirm the build context shrinks: record `transferring context` before and after, with a `.claude/worktrees/` directory present. Do not assume it shrank. - Confirm `.claude` is absent from the image by enumeration, not by reading the ignore file. - **Re-run the #26 cache verification after this lands.** Not bureaucratic: the build context changed underneath that earlier measurement, so the earlier result no longer covers the current state. This is the step most likely to be skipped, and skipping it is the same error as validating against one environment and assuming the result transfers. ## What is already handled The ordering hazard this thread raises — that tightening `.dockerignore` converts an incidentally-safe repo into a reliably false-green one — is closed for this repo by sequencing: #26 lands first and is verified by planted-defect and counterfactual controls, not by timing. Worth restating for the propagation sweep, though, since it is a fleet-level trap rather than a local one: if `.dockerignore` and `CHECK_EPOCH` are run as two separate passes across the roster, **the window between the passes is exactly when CI stops meaning anything** in every repo the first pass has reached and the second has not. The propagation issue should require them to land together, per repo, in one commit.
Author
Collaborator

Implementation plan, following the Implementation brief above.

Working on next in a private clone; this lands as a fourth commit on the open PR #34, on top of the #26 cache-bust and the #29 .dockerignore semantics work, so the ordering rule this thread establishes is satisfied by sequencing rather than by assertion. Nothing in #29's file is being undone.

1. .dockerignore

Add .claude root-anchored, in the same section shape #29 established. Agent scratch lives at exactly one place, the context root, so this is the /myapp case from that file's own header comment and not a **/ case — **/.claude would additionally match any nested directory of that name. The entry gets a comment saying so, since the header comment is the only part of this guidance a consuming repo actually receives.

Not case-folded, unlike the secret patterns: the directory is created by tooling in exactly one spelling, and a miss costs context bloat rather than exposure. That reasoning will be stated in the file so the asymmetry with the neighbouring patterns does not read as an oversight.

2. .gitignore

Add .claude/, unprefixed. No **/.gitignore semantics already match at any depth, and prefixing produces a file that is wrong in a way that looks careful. Each file is written to its own semantics; neither is derived from the other.

The .gitignore parity gaps tracked as #38 are out of scope here and will not be touched.

3. prompts/REPO_POLICIES.md

  • The .gitignore bullet gains agent scratch directories alongside OS and editor files.
  • The .dockerignore bullet's list of genuinely root-anchored entries gains the scratch directory next to .git.
  • Editing prompts/REPO_POLICIES.md; the repo-root file is a symlink to it and stays one.

4. The git describe consequence

This is the part that ships broken silently, so it is being treated as first-class scope rather than a footnote. .dockerignore excludes .git, so git describe cannot run in any build stage — and it fails quietly, yielding an empty version rather than an error. The Go template at line ~200 has ARG VERSION=dev and never says where VERSION comes from, which is exactly the gap a reader fills in with git describe inside the build.

Documenting that the version is computed on the host and threaded in via --build-arg VERSION=..., with both halves shown concretely: the host-side command and the --build-arg. The snippet will carry the same discipline script/cibuild already applies to epoch — assignment on its own line, because a failing command substitution inside an argument does not trip set -e, and an explicit non-empty guard so a failing git describe cannot degrade to an empty constant, which is the identical silent-empty failure the whole bullet is about.

Whether this repo's script/docker and script/cibuild should pass it is a real question and I am going to measure rather than assert it: this repo's Dockerfile declares no ARG VERSION, so I will check what Docker actually does with an unconsumed build arg before deciding. The decision and its evidence go in the implementation comment.

5. Verification — measurement, not reading

  • Plant .claude/worktrees/fake-agent-tree/ with recognisable files, build a standalone minimal probe image doing COPY . . (the real Dockerfile now fails closed without CHECK_EPOCH, so it is unsuitable as a probe), and find inside the image.
  • Run it before the change as well: a test that passes both ways proves nothing.
  • Positive control: an ordinary source file that must still reach the image, so an ignore file that excludes everything cannot pass.
  • transferring context recorded as corroboration only, never as the test — #29 measured 2.18kB transferred while 43 files including five secrets were in the image, because BuildKit reports a delta and not a total.
  • Re-run the #26 cache verification under the changed context. Two consecutive script/cibuild runs on an unchanged tree, both executing the checks with real prettier output, with the RUN script/bootstrap layer still CACHED in run 2 as the validity control — it proves no concurrent prune landed mid-pair and that no --no-cache path was taken.
  • Planted files removed afterwards and their absence confirmed against the filesystem, not git status, which will not see them once .gitignore covers the directory. That blind spot is the same one that made the #29 exposure invisible.

No docker builder prune or docker system prune of any kind; --no-cache stays scoped to the single probe image.

6. Also

  • TODO.md Completed Steps entry in the same commit.
  • Judging whether NEW_REPO_CHECKLIST.md and EXISTING_REPO_CHECKLIST.md need the entry too, and saying what I decided either way — they are what an agent actually reads while writing these files, which is where a wrong shape gets written.
**Implementation plan**, following the Implementation brief above. Working on `next` in a private clone; this lands as a fourth commit on the open [PR #34](https://git.eeqj.de/sneak/prompts/pulls/34), on top of the [#26](https://git.eeqj.de/sneak/prompts/issues/26) cache-bust and the [#29](https://git.eeqj.de/sneak/prompts/issues/29) `.dockerignore` semantics work, so the ordering rule this thread establishes is satisfied by sequencing rather than by assertion. Nothing in [#29](https://git.eeqj.de/sneak/prompts/issues/29)'s file is being undone. ## 1. `.dockerignore` Add `.claude` **root-anchored**, in the same section shape [#29](https://git.eeqj.de/sneak/prompts/issues/29) established. Agent scratch lives at exactly one place, the context root, so this is the `/myapp` case from that file's own header comment and not a `**/` case — `**/.claude` would additionally match any nested directory of that name. The entry gets a comment saying so, since the header comment is the only part of this guidance a consuming repo actually receives. Not case-folded, unlike the secret patterns: the directory is created by tooling in exactly one spelling, and a miss costs context bloat rather than exposure. That reasoning will be stated in the file so the asymmetry with the neighbouring patterns does not read as an oversight. ## 2. `.gitignore` Add `.claude/`, unprefixed. **No `**/`** — `.gitignore` semantics already match at any depth, and prefixing produces a file that is wrong in a way that looks careful. Each file is written to its own semantics; neither is derived from the other. The `.gitignore` parity gaps tracked as [#38](https://git.eeqj.de/sneak/prompts/issues/38) are out of scope here and will not be touched. ## 3. `prompts/REPO_POLICIES.md` - The `.gitignore` bullet gains agent scratch directories alongside OS and editor files. - The `.dockerignore` bullet's list of genuinely root-anchored entries gains the scratch directory next to `.git`. - Editing `prompts/REPO_POLICIES.md`; the repo-root file is a symlink to it and stays one. ## 4. The `git describe` consequence This is the part that ships broken silently, so it is being treated as first-class scope rather than a footnote. `.dockerignore` excludes `.git`, so `git describe` cannot run in any build stage — and it fails *quietly*, yielding an empty version rather than an error. The Go template at line ~200 has `ARG VERSION=dev` and never says where `VERSION` comes from, which is exactly the gap a reader fills in with `git describe` inside the build. Documenting that the version is computed **on the host** and threaded in via `--build-arg VERSION=...`, with both halves shown concretely: the host-side command and the `--build-arg`. The snippet will carry the same discipline `script/cibuild` already applies to `epoch` — assignment on its own line, because a failing command substitution inside an argument does not trip `set -e`, and an explicit non-empty guard so a failing `git describe` cannot degrade to an empty constant, which is the identical silent-empty failure the whole bullet is about. Whether this repo's `script/docker` and `script/cibuild` should pass it is a real question and I am going to **measure** rather than assert it: this repo's `Dockerfile` declares no `ARG VERSION`, so I will check what Docker actually does with an unconsumed build arg before deciding. The decision and its evidence go in the implementation comment. ## 5. Verification — measurement, not reading - Plant `.claude/worktrees/fake-agent-tree/` with recognisable files, build a standalone minimal probe image doing `COPY . .` (the real `Dockerfile` now fails closed without `CHECK_EPOCH`, so it is unsuitable as a probe), and `find` inside the image. - Run it **before** the change as well: a test that passes both ways proves nothing. - **Positive control**: an ordinary source file that must still reach the image, so an ignore file that excludes everything cannot pass. - `transferring context` recorded as corroboration only, never as the test — [#29](https://git.eeqj.de/sneak/prompts/issues/29) measured 2.18kB transferred while 43 files including five secrets were in the image, because BuildKit reports a delta and not a total. - **Re-run the [#26](https://git.eeqj.de/sneak/prompts/issues/26) cache verification under the changed context.** Two consecutive `script/cibuild` runs on an unchanged tree, both executing the checks with real prettier output, with the `RUN script/bootstrap` layer still `CACHED` in run 2 as the validity control — it proves no concurrent prune landed mid-pair and that no `--no-cache` path was taken. - Planted files removed afterwards and their absence confirmed against the **filesystem**, not `git status`, which will not see them once `.gitignore` covers the directory. That blind spot is the same one that made the [#29](https://git.eeqj.de/sneak/prompts/issues/29) exposure invisible. No `docker builder prune` or `docker system prune` of any kind; `--no-cache` stays scoped to the single probe image. ## 6. Also - `TODO.md` Completed Steps entry in the same commit. - Judging whether `NEW_REPO_CHECKLIST.md` and `EXISTING_REPO_CHECKLIST.md` need the entry too, and saying what I decided either way — they are what an agent actually reads while writing these files, which is where a wrong shape gets written.
Author
Collaborator

Implemented and pushed. Commit 61448b0 on next, the fourth on PR #34, which now carries a section 4 for it; sections 1-3 and the three earlier commits are untouched (51c3945, d173e69, fd78aeb all still ancestors). make fmt and make check green.

The two entries, and why they are different shapes

# .dockerignore — anchored, no **/ prefix
.claude

# .gitignore — unanchored, never prefixed
.claude/

Both carry the reasoning in the file itself, since the .dockerignore header comment is the only part of this guidance a consuming repo actually receives. It is deliberately not case-folded the way #29's secret patterns are: tooling creates the directory in exactly one spelling, and a miss costs context bloat rather than exposure.

Enumeration — three runs

Planted .claude/worktrees/fake-agent-tree/ (with a nested prompts/DRAFT.md and an uncommitted.txt), a second worktree, and .claude/settings.local.json; plus prompts/.claude/nested-note.md as a disclosed-gap probe and probe_src/app.md as a planted positive control. Standalone probe Dockerfile held outside the context (FROM alpine, WORKDIR /ctx, COPY . .), built with -f and --no-cache scoped to that one image — no prune of any kind — then docker run --rm --entrypoint find TAG /ctx -type f and the planted paths checked against that listing.

1. BEFORE — no agent-scratch pattern

--- .dockerignore agent-scratch lines ---
(no agent-scratch pattern present)
--- build ---
#6 transferring context: 161.86kB 0.0s done
--- planted paths: did they land in the image? ---
LANDED IN IMAGE  .claude/worktrees/fake-agent-tree/README.md
LANDED IN IMAGE  .claude/worktrees/fake-agent-tree/prompts/DRAFT.md
LANDED IN IMAGE  .claude/worktrees/fake-agent-tree/uncommitted.txt
LANDED IN IMAGE  .claude/worktrees/other-agent-tree/notes.md
LANDED IN IMAGE  .claude/settings.local.json
LANDED IN IMAGE  prompts/.claude/nested-note.md
LANDED IN IMAGE  probe_src/app.md
LANDED IN IMAGE  README.md
LANDED IN IMAGE  script/check
LANDED IN IMAGE  prompts/NEW_REPO_CHECKLIST.md
--- .claude files in image ---
5
--- total files in image /ctx ---
42

Every planted scratch file reaches the image today, at every depth beneath the directory.

2. CONTROL — **/.claude, the prefixed form

--- .dockerignore agent-scratch lines ---
47:**/.claude
--- planted paths ---
excluded         .claude/worktrees/fake-agent-tree/README.md
excluded         .claude/worktrees/fake-agent-tree/prompts/DRAFT.md
excluded         .claude/worktrees/fake-agent-tree/uncommitted.txt
excluded         .claude/worktrees/other-agent-tree/notes.md
excluded         .claude/settings.local.json
excluded         prompts/.claude/nested-note.md      <-- also gone
LANDED IN IMAGE  probe_src/app.md
LANDED IN IMAGE  README.md
LANDED IN IMAGE  script/check
LANDED IN IMAGE  prompts/NEW_REPO_CHECKLIST.md
--- total files in image /ctx ---
36

This is the control that decides the design. The prefixed form removes the nested directory too — in a repo with a legitimately named nested directory it would silently delete it from the build, which is the **/myapp / cmd/myapp/ failure from #29 in a new place. The anchored form is therefore a measured choice, not a stylistic one.

3. AFTER — .claude, anchored (this commit)

--- .dockerignore agent-scratch lines ---
47:.claude
--- build ---
#4 transferring context: 68.82kB done
--- planted paths ---
excluded         .claude/worktrees/fake-agent-tree/README.md
excluded         .claude/worktrees/fake-agent-tree/prompts/DRAFT.md
excluded         .claude/worktrees/fake-agent-tree/uncommitted.txt
excluded         .claude/worktrees/other-agent-tree/notes.md
excluded         .claude/settings.local.json
--- disclosed gap, deliberate ---
LANDED IN IMAGE  prompts/.claude/nested-note.md
--- positive controls: must all be PRESENT ---
LANDED IN IMAGE  probe_src/app.md
LANDED IN IMAGE  README.md
LANDED IN IMAGE  script/check
LANDED IN IMAGE  prompts/NEW_REPO_CHECKLIST.md
--- .claude files in image ---
0
--- total files in image /ctx ---
37

The four positive controls still land, which is what shows the exclusion is a real exclusion rather than a COPY that stopped copying.

On context size

161.86kB before, 68.82kB after — recorded as corroboration and load-bearing on nothing. BuildKit reports a delta, not a total; the run in #29 transferred 2.18kB while 43 files including five secrets were in the image. Enumeration is the measurement.

.gitignore semantics, measured rather than asserted

.claude/worktrees/fake-agent-tree/README.md   .gitignore:18:.claude/  .claude/worktrees/fake-agent-tree/README.md
.claude/settings.local.json                   .gitignore:18:.claude/  .claude/settings.local.json
prompts/.claude/nested-note.md                .gitignore:18:.claude/  prompts/.claude/nested-note.md

One unanchored entry already covers every depth, including the nested copy, so a **/ prefix there would be redundant at best — and on an anchored pattern actively wrong. git status showed nothing for any of them, which is the blind spot the cleanup step below is written around.

Re-run of the #26 cache verification, under the CHANGED build context

Two consecutive script/cibuild runs, nothing touched between them (git status --porcelain | md5sum identical before and after: e676453d81f6312f5490df0b84c7e228).

Run 1 — 17.07s

#9 [5/8] RUN script/bootstrap
#9 2.336 bootstrap complete
#9 DONE 2.4s
#12 [8/8] RUN echo "check epoch: 17862944425258659713849309" && make check
#12 0.188 check epoch: 17862944425258659713849309
#12 0.193 No tests defined.
#12 0.194 Linting markdown files...
#12 0.397 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always
#12 1.196 All matched files use Prettier code style!
#12 1.670 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always
#12 2.434 All matched files use Prettier code style!
#12 DONE 4.5s
real 0m17.066s

Run 2 — 5.73s, unchanged tree

#6 CACHED
#7 CACHED
#8 CACHED
#9 [5/8] RUN script/bootstrap
#9 CACHED                       <-- validity control
#10 CACHED
#12 [8/8] RUN echo "check epoch: 17862944595938380143851192" && make check
#12 0.142 check epoch: 17862944595938380143851192
#12 0.146 No tests defined.
#12 0.147 Linting markdown files...
#12 0.328 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always
#12 1.091 All matched files use Prettier code style!
#12 1.632 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always
#12 2.312 All matched files use Prettier code style!
#12 DONE 2.3s
real 0m5.728s

Both runs executed the check layer with a distinct epoch and real prettier output from both lint and fmt-check, while every layer up to and including RUN script/bootstrap was served from cache in run 2. That cached bootstrap layer is what makes the pair mean anything: it proves no concurrent prune landed between the runs and that no --no-cache path was taken, so the check layer executing is the bust working rather than a cold cache. The #26 result therefore still holds under the tightened context.

The git describe consequence

Closed rather than noted, since it is the part that ships broken silently. script/docker and script/cibuild now compute the version on the host and pass it:

epoch="$(date +%s%N)$$"
version="$(git describe --tags --always --dirty 2>/dev/null || echo unknown)"
[ -n "$version" ] || version="unknown"
docker build \
    --build-arg CHECK_EPOCH="$epoch" \
    --build-arg VERSION="$version" \
    .

Assignment on its own line for the same reason the epoch is: a failing command substitution inside an argument does not trip set -e. Both fallbacks are non-empty by construction — --always yields a commit hash for an untagged repo (measured: fd78aeb-dirty here), and unknown covers a build from an export with no .git (measured in a non-repo directory) — because an empty version reads as a successful one, which is the failure being closed.

Decision: the scripts pass VERSION unconditionally, and I measured the objection before rejecting it. This repo's Dockerfile declares no ARG VERSION, so the argument against was warning noise. BuildKit 29.7.2 / buildx v0.36.1 emits no warning at all for an unconsumed build arg — full build output inspected, not grepped for one pattern — and the paired runs above show no cache effect either, since the bootstrap layer still caches with the arg present. Against zero measured cost stands the trap itself: script/docker is byte-identical across repos by policy, so if the canonical form does not compute a version, every repo that needs one invents it, and the natural invention is git describe inside the Dockerfile. Documentation alone is weaker than shipping the correct shape in the file that gets copied.

REPO_POLICIES.md now states where VERSION comes from, with the host command and the --build-arg shown as one complete command rather than two rules each documenting half of one, and the Go template's ARG VERSION=dev carries a comment saying so at the exact line where a reader would otherwise fill the gap.

One thing I did not expect to find: the two Go documents that carry the GOLDFLAGS pattern have VERSION := $(shell git describe ...) in a Makefile, and the canonical Dockerfile runs make inside the build — so $(shell git describe) expands to the empty string there, silently, which is this issue's defect already written into the corpus. Both are now ?=, so an ARG VERSION in the compiling stage arrives through the environment and wins. Leaving them would have left the policy and the styleguide saying opposite things.

Also recorded for the propagation sweep: the standard checkout action clones shallow and fetches no tags, so git describe --tags in CI falls back to a bare commit hash. A repo embedding a tag-derived version needs fetch-depth: 0. This repo's workflow is unchanged — it embeds no version, and adding full-history clones for an unused value would be cost without benefit.

Checklists — judged, and both changed

Both needed it, for different reasons.

  • EXISTING_REPO_CHECKLIST.md is the load-bearing one. Its .gitignore item says "fetch ... if missing", and an existing repo almost always has a hand-written one, so the fetch never happens and only the enumerated contents get checked. .claude/ is now named there explicitly, with the warning not to prefix it, plus a .dockerignore item for the anchored entry and a version item.
  • NEW_REPO_CHECKLIST.md gets it too, even though a fresh fetch of the canonical files carries the entries automatically, because the checklist is what an agent reads while extending those files — which is exactly where the wrong shape gets written. Its .gitignore item now says extensions are written to .gitignore's own semantics and never take a **/ prefix, and its script/docker and script/cibuild items specify the VERSION lines.

Cleanup

All planted files and directories removed; absence confirmed with find over the filesystem, not git status, which never saw them once .gitignore covered the directory — the same blind spot that made the #29 exposure invisible. git status --porcelain --ignored shows nothing but the tracked edits.

The .gitignore coverage gaps tracked as #38 are deliberately untouched; only .claude/ was added.

**Implemented and pushed.** Commit `61448b0` on `next`, the fourth on [PR #34](https://git.eeqj.de/sneak/prompts/pulls/34), which now carries a section 4 for it; sections 1-3 and the three earlier commits are untouched (`51c3945`, `d173e69`, `fd78aeb` all still ancestors). `make fmt` and `make check` green. ## The two entries, and why they are different shapes ``` # .dockerignore — anchored, no **/ prefix .claude # .gitignore — unanchored, never prefixed .claude/ ``` Both carry the reasoning in the file itself, since the `.dockerignore` header comment is the only part of this guidance a consuming repo actually receives. It is deliberately **not** case-folded the way [#29](https://git.eeqj.de/sneak/prompts/issues/29)'s secret patterns are: tooling creates the directory in exactly one spelling, and a miss costs context bloat rather than exposure. ## Enumeration — three runs Planted `.claude/worktrees/fake-agent-tree/` (with a nested `prompts/DRAFT.md` and an `uncommitted.txt`), a second worktree, and `.claude/settings.local.json`; plus `prompts/.claude/nested-note.md` as a **disclosed-gap probe** and `probe_src/app.md` as a planted positive control. Standalone probe Dockerfile held outside the context (`FROM alpine`, `WORKDIR /ctx`, `COPY . .`), built with `-f` and `--no-cache` scoped to that one image — **no prune of any kind** — then `docker run --rm --entrypoint find TAG /ctx -type f` and the planted paths checked against that listing. ### 1. BEFORE — no agent-scratch pattern ``` --- .dockerignore agent-scratch lines --- (no agent-scratch pattern present) --- build --- #6 transferring context: 161.86kB 0.0s done --- planted paths: did they land in the image? --- LANDED IN IMAGE .claude/worktrees/fake-agent-tree/README.md LANDED IN IMAGE .claude/worktrees/fake-agent-tree/prompts/DRAFT.md LANDED IN IMAGE .claude/worktrees/fake-agent-tree/uncommitted.txt LANDED IN IMAGE .claude/worktrees/other-agent-tree/notes.md LANDED IN IMAGE .claude/settings.local.json LANDED IN IMAGE prompts/.claude/nested-note.md LANDED IN IMAGE probe_src/app.md LANDED IN IMAGE README.md LANDED IN IMAGE script/check LANDED IN IMAGE prompts/NEW_REPO_CHECKLIST.md --- .claude files in image --- 5 --- total files in image /ctx --- 42 ``` Every planted scratch file reaches the image today, at every depth beneath the directory. ### 2. CONTROL — `**/.claude`, the prefixed form ``` --- .dockerignore agent-scratch lines --- 47:**/.claude --- planted paths --- excluded .claude/worktrees/fake-agent-tree/README.md excluded .claude/worktrees/fake-agent-tree/prompts/DRAFT.md excluded .claude/worktrees/fake-agent-tree/uncommitted.txt excluded .claude/worktrees/other-agent-tree/notes.md excluded .claude/settings.local.json excluded prompts/.claude/nested-note.md <-- also gone LANDED IN IMAGE probe_src/app.md LANDED IN IMAGE README.md LANDED IN IMAGE script/check LANDED IN IMAGE prompts/NEW_REPO_CHECKLIST.md --- total files in image /ctx --- 36 ``` This is the control that decides the design. The prefixed form removes the nested directory too — in a repo with a legitimately named nested directory it would silently delete it from the build, which is the `**/myapp` / `cmd/myapp/` failure from [#29](https://git.eeqj.de/sneak/prompts/issues/29) in a new place. The anchored form is therefore a measured choice, not a stylistic one. ### 3. AFTER — `.claude`, anchored (this commit) ``` --- .dockerignore agent-scratch lines --- 47:.claude --- build --- #4 transferring context: 68.82kB done --- planted paths --- excluded .claude/worktrees/fake-agent-tree/README.md excluded .claude/worktrees/fake-agent-tree/prompts/DRAFT.md excluded .claude/worktrees/fake-agent-tree/uncommitted.txt excluded .claude/worktrees/other-agent-tree/notes.md excluded .claude/settings.local.json --- disclosed gap, deliberate --- LANDED IN IMAGE prompts/.claude/nested-note.md --- positive controls: must all be PRESENT --- LANDED IN IMAGE probe_src/app.md LANDED IN IMAGE README.md LANDED IN IMAGE script/check LANDED IN IMAGE prompts/NEW_REPO_CHECKLIST.md --- .claude files in image --- 0 --- total files in image /ctx --- 37 ``` The four positive controls still land, which is what shows the exclusion is a real exclusion rather than a `COPY` that stopped copying. ### On context size 161.86kB before, 68.82kB after — recorded as corroboration and load-bearing on nothing. BuildKit reports a **delta**, not a total; the run in [#29](https://git.eeqj.de/sneak/prompts/issues/29) transferred 2.18kB while 43 files including five secrets were in the image. Enumeration is the measurement. ## `.gitignore` semantics, measured rather than asserted ``` .claude/worktrees/fake-agent-tree/README.md .gitignore:18:.claude/ .claude/worktrees/fake-agent-tree/README.md .claude/settings.local.json .gitignore:18:.claude/ .claude/settings.local.json prompts/.claude/nested-note.md .gitignore:18:.claude/ prompts/.claude/nested-note.md ``` One unanchored entry already covers every depth, including the nested copy, so a `**/` prefix there would be redundant at best — and on an anchored pattern actively wrong. `git status` showed nothing for any of them, which is the blind spot the cleanup step below is written around. ## Re-run of the [#26](https://git.eeqj.de/sneak/prompts/issues/26) cache verification, under the CHANGED build context Two consecutive `script/cibuild` runs, nothing touched between them (`git status --porcelain | md5sum` identical before and after: `e676453d81f6312f5490df0b84c7e228`). ### Run 1 — 17.07s ``` #9 [5/8] RUN script/bootstrap #9 2.336 bootstrap complete #9 DONE 2.4s #12 [8/8] RUN echo "check epoch: 17862944425258659713849309" && make check #12 0.188 check epoch: 17862944425258659713849309 #12 0.193 No tests defined. #12 0.194 Linting markdown files... #12 0.397 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always #12 1.196 All matched files use Prettier code style! #12 1.670 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always #12 2.434 All matched files use Prettier code style! #12 DONE 4.5s real 0m17.066s ``` ### Run 2 — 5.73s, unchanged tree ``` #6 CACHED #7 CACHED #8 CACHED #9 [5/8] RUN script/bootstrap #9 CACHED <-- validity control #10 CACHED #12 [8/8] RUN echo "check epoch: 17862944595938380143851192" && make check #12 0.142 check epoch: 17862944595938380143851192 #12 0.146 No tests defined. #12 0.147 Linting markdown files... #12 0.328 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always #12 1.091 All matched files use Prettier code style! #12 1.632 $ prettier --check '**/*.md' --tab-width 4 --prose-wrap always #12 2.312 All matched files use Prettier code style! #12 DONE 2.3s real 0m5.728s ``` Both runs executed the check layer with a distinct epoch and real prettier output from both `lint` and `fmt-check`, while every layer up to and including `RUN script/bootstrap` was served from cache in run 2. That cached bootstrap layer is what makes the pair mean anything: it proves no concurrent prune landed between the runs and that no `--no-cache` path was taken, so the check layer executing is the bust working rather than a cold cache. The [#26](https://git.eeqj.de/sneak/prompts/issues/26) result therefore still holds under the tightened context. ## The `git describe` consequence Closed rather than noted, since it is the part that ships broken silently. `script/docker` and `script/cibuild` now compute the version on the host and pass it: ```sh epoch="$(date +%s%N)$$" version="$(git describe --tags --always --dirty 2>/dev/null || echo unknown)" [ -n "$version" ] || version="unknown" docker build \ --build-arg CHECK_EPOCH="$epoch" \ --build-arg VERSION="$version" \ . ``` Assignment on its own line for the same reason the epoch is: a failing command substitution inside an argument does not trip `set -e`. Both fallbacks are non-empty by construction — `--always` yields a commit hash for an untagged repo (measured: `fd78aeb-dirty` here), and `unknown` covers a build from an export with no `.git` (measured in a non-repo directory) — because an empty version reads as a successful one, which is the failure being closed. **Decision: the scripts pass `VERSION` unconditionally, and I measured the objection before rejecting it.** This repo's Dockerfile declares no `ARG VERSION`, so the argument against was warning noise. BuildKit 29.7.2 / buildx v0.36.1 emits **no warning at all** for an unconsumed build arg — full build output inspected, not grepped for one pattern — and the paired runs above show no cache effect either, since the bootstrap layer still caches with the arg present. Against zero measured cost stands the trap itself: `script/docker` is byte-identical across repos by policy, so if the canonical form does not compute a version, every repo that needs one invents it, and the natural invention is `git describe` inside the Dockerfile. Documentation alone is weaker than shipping the correct shape in the file that gets copied. `REPO_POLICIES.md` now states where `VERSION` comes from, with the host command and the `--build-arg` shown as one complete command rather than two rules each documenting half of one, and the Go template's `ARG VERSION=dev` carries a comment saying so at the exact line where a reader would otherwise fill the gap. **One thing I did not expect to find:** the two Go documents that carry the `GOLDFLAGS` pattern have `VERSION := $(shell git describe ...)` in a Makefile, and the canonical Dockerfile runs `make` **inside** the build — so `$(shell git describe)` expands to the empty string there, silently, which is this issue's defect already written into the corpus. Both are now `?=`, so an `ARG VERSION` in the compiling stage arrives through the environment and wins. Leaving them would have left the policy and the styleguide saying opposite things. **Also recorded for the propagation sweep:** the standard checkout action clones shallow and fetches no tags, so `git describe --tags` in CI falls back to a bare commit hash. A repo embedding a tag-derived version needs `fetch-depth: 0`. This repo's workflow is unchanged — it embeds no version, and adding full-history clones for an unused value would be cost without benefit. ## Checklists — judged, and both changed Both needed it, for different reasons. - `EXISTING_REPO_CHECKLIST.md` is the load-bearing one. Its `.gitignore` item says "fetch ... **if missing**", and an existing repo almost always has a hand-written one, so the fetch never happens and only the enumerated contents get checked. `.claude/` is now named there explicitly, with the warning not to prefix it, plus a `.dockerignore` item for the anchored entry and a version item. - `NEW_REPO_CHECKLIST.md` gets it too, even though a fresh fetch of the canonical files carries the entries automatically, because the checklist is what an agent reads while **extending** those files — which is exactly where the wrong shape gets written. Its `.gitignore` item now says extensions are written to `.gitignore`'s own semantics and never take a `**/` prefix, and its `script/docker` and `script/cibuild` items specify the `VERSION` lines. ## Cleanup All planted files and directories removed; absence confirmed with `find` over the **filesystem**, not `git status`, which never saw them once `.gitignore` covered the directory — the same blind spot that made the [#29](https://git.eeqj.de/sneak/prompts/issues/29) exposure invisible. `git status --porcelain --ignored` shows nothing but the tracked edits. The `.gitignore` coverage gaps tracked as [#38](https://git.eeqj.de/sneak/prompts/issues/38) are deliberately untouched; only `.claude/` was added.
Author
Collaborator

Reworked after independent review. 61448b0 -> 3a21849, amended and force-pushed with --force-with-lease; 51c3945, d173e69 and fd78aeb verified ancestors before and after, still exactly four commits. Point-by-point response on the PR: #issuecomment-51710.

The blocking finding was not about the anchored decision — that was endorsed, as was every measurement — but about where the consequence of it is written down. Anchoring .claude leaves a residual exposure, and I had recorded it only here and in the PR body. Consuming repos receive the files, not the tracker, so a caveat that lives on the tracker is not a caveat.

Worse, the premise I wrote into the vendored text was false in general, not merely under-qualified: the scratch directory is created in the agent's working directory, so "occurs exactly once at the context root" describes how agents happen to be run rather than anything about the tooling. A monorepo with a per-service agent still ships services/api/.claude/ into the build context and the image — precisely the exposure this issue exists to close, left open in the repo shape where it is likeliest, while the file told the reader it could not happen.

The gap and its remedy are now stated in the canonical .dockerignore beside the entry, in the REPO_POLICIES.md scratch bullet, in EXISTING_REPO_CHECKLIST.md as a check rather than a note (an existing monorepo is where it bites), and in one clause in NEW_REPO_CHECKLIST.md. Confirmed by grep that all four vendored artifacts carry it. The entry itself is unchanged: still .claude, anchored.

Two smaller things worth recording here because they generalise:

  • The [ -n "$version" ] guard I shipped was unreachable|| echo unknown inside the substitution already guaranteed non-empty output. It now reads || true, so the guard is the single place the fallback is applied and it actually fires (measured in sh and dash: no .git -> unknown, repo with no commits -> unknown). A guard that cannot fire is indistinguishable from one that works to every repo that copies it, which is this thread's recurring failure shape in miniature.
  • The sentence justifying the case-sensitivity choice claimed a miss "costs build-context bloat rather than exposure". That contradicts this issue's own framing, where the cost is unreviewed work in an image layer. Removed and replaced with the accurate reason: tooling creates the directory in exactly one spelling, so a folded pattern would add no coverage.
**Reworked after independent review.** `61448b0` -> `3a21849`, amended and force-pushed with `--force-with-lease`; `51c3945`, `d173e69` and `fd78aeb` verified ancestors before and after, still exactly four commits. Point-by-point response on the PR: [#issuecomment-51710](https://git.eeqj.de/sneak/prompts/pulls/34#issuecomment-51710). The blocking finding was not about the anchored decision — that was endorsed, as was every measurement — but about **where the consequence of it is written down**. Anchoring `.claude` leaves a residual exposure, and I had recorded it only here and in the PR body. Consuming repos receive the files, not the tracker, so a caveat that lives on the tracker is not a caveat. Worse, the premise I wrote into the vendored text was false in general, not merely under-qualified: the scratch directory is created in the **agent's working directory**, so "occurs exactly once at the context root" describes how agents happen to be run rather than anything about the tooling. A monorepo with a per-service agent still ships `services/api/.claude/` into the build context and the image — precisely the exposure this issue exists to close, left open in the repo shape where it is likeliest, while the file told the reader it could not happen. The gap and its remedy are now stated in the canonical `.dockerignore` beside the entry, in the `REPO_POLICIES.md` scratch bullet, in `EXISTING_REPO_CHECKLIST.md` as a check rather than a note (an existing monorepo is where it bites), and in one clause in `NEW_REPO_CHECKLIST.md`. Confirmed by grep that all four vendored artifacts carry it. The entry itself is unchanged: still `.claude`, anchored. Two smaller things worth recording here because they generalise: - The `[ -n "$version" ]` guard I shipped was **unreachable** — `|| echo unknown` inside the substitution already guaranteed non-empty output. It now reads `|| true`, so the guard is the single place the fallback is applied and it actually fires (measured in `sh` and `dash`: no `.git` -> `unknown`, repo with no commits -> `unknown`). A guard that cannot fire is indistinguishable from one that works to every repo that copies it, which is this thread's recurring failure shape in miniature. - The sentence justifying the case-sensitivity choice claimed a miss "costs build-context bloat rather than exposure". That contradicts this issue's own framing, where the cost is unreviewed work in an image layer. Removed and replaced with the accurate reason: tooling creates the directory in exactly one spelling, so a folded pattern would add no coverage.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: sneak/prompts#27