Re-vendor the canonical files from sneak/prompts at dd4027b (closes #171)
check / check (push) Successful in 3m59s

.dockerignore, .gitignore, .prettierignore and REPO_POLICIES.md are
copied from sneak/prompts at dd4027b; .editorconfig, check.yml and
.prettierrc already matched. This repository's build artifacts follow
the copied content in .gitignore and, anchored, in .dockerignore. The
scripts follow the new policy: check runs fmt-check, cibuild bootstraps
and runs check before the image build, fmt and fmt-check find the
pinned yarn under nvm, and the image's last stage marks /app safe for
git. The build-context test expects the new pattern forms.

Judgement call: script/precommit runs lint and fmt-check without the tests, so the README's red-phase test commit can still land; the policy allows that form only where local testing is impossible.
Not applicable: the issue's .golangci.yml entries; this TypeScript repository has none.

Model: opus-5-5
This commit is contained in:
2026-10-06 06:18:50 +00:00
parent 2483c85321
commit 30540e99e9
14 changed files with 334 additions and 167 deletions
+75 -42
View File
@@ -1,53 +1,86 @@
# Mirrors .gitignore, with one deliberate exception: .gitignore itself stays # .dockerignore does NOT use .gitignore semantics. Docker matches with
# in the build context, because prettier 3 reads it as a default ignore file # moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# and dropping it would change what the lint phase's prettier check sees. # `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
# #
# .git is deliberately NOT excluded: the build derives the version it stamps # Matching is case-sensitive, so secrets use character ranges rather
# from it (script/version). It is sent without its config, which holds the # than an ALL-CAPS twin, which would still miss `Server.Key`.
# clone's remote URL and any credential in it, and which the build stage, the #
# final image, would otherwise carry. git describe does not need it. # Extend with this repo's own host-built artifacts, written anchored:
.git/config # `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# OS # .git is sent without its config. Without a VERSION build argument the
.DS_Store # stage that compiles runs `git describe --tags --always` on .git, which
Thumbs.db # does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Editors # Agent scratch: one full checkout of the repo per in-flight agent.
*.swp # Anchored because it occurs once where agents run at the repo root.
*.swo # KNOWN GAP: a repo running agents in subdirectories still ships
*~ # `services/api/.claude/` and must add its own anchored entry.
*.bak .claude
.idea/
.vscode/
*.sublime-*
# Node # Environment files. `*.env` covers bare `.env` and the `prod.env`
node_modules # convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# TypeScript / build artifacts # Private keys and the bundles carrying them. Public certificates
dist # (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
build **/*.[pP][eE][mM]
*.tsbuildinfo **/*.[kK][eE][yY]
coverage **/*.[pP]12
.nyc_output/ **/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Vitest # Dependencies: restored inside the image, never copied in.
.vitest-cache/ **/node_modules
# Environment / secrets # OS metadata.
.env **/.DS_Store
.env.* **/Thumbs.db
*.pem
*.key # Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# TypeScript / build artifacts: the image compiles its own.
/dist
/build
/*.tsbuildinfo
/coverage
/.nyc_output
/.vitest-cache
# Compiled binary (built by make build-bin); around 100 MB # Compiled binary (built by make build-bin); around 100 MB
bin/quak /bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo) # quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/ /.quak
# Local per-developer tool state, including agent worktrees. Correctness,
# not context size: a worktree copied in here has its own test/ tree, which
# vitest globs alongside the real one, so the containerised suite runs N+1
# times over and still reports success.
.claude/
+32 -10
View File
@@ -11,9 +11,41 @@ Thumbs.db
.vscode/ .vscode/
*.sublime-* *.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node # Node
node_modules/ node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# TypeScript / build artifacts # TypeScript / build artifacts
dist/ dist/
build/ build/
@@ -24,18 +56,8 @@ coverage/
# Vitest # Vitest
.vitest-cache/ .vitest-cache/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Compiled binary (built by make build-bin) # Compiled binary (built by make build-bin)
bin/quak bin/quak
# quak runtime data (in case anyone runs the CLI from inside the repo) # quak runtime data (in case anyone runs the CLI from inside the repo)
.quak/ .quak/
# Local per-developer tool settings and scratch state, including the
# worktrees agents check out under this directory
.claude/
-3
View File
@@ -1,5 +1,2 @@
node_modules/ node_modules/
yarn.lock yarn.lock
dist/
build/
coverage/
+2
View File
@@ -58,6 +58,8 @@ COPY --from=test /app/package.json /dev/null
COPY script/ script/ COPY script/ script/
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN script/bootstrap RUN script/bootstrap
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /app
COPY . . COPY . .
+25 -32
View File
@@ -137,16 +137,15 @@ alpine. We provide:
- `script/lint` — run eslint and a prettier check, by building the `lint` phase - `script/lint` — run eslint and a prettier check, by building the `lint` phase
of the `Dockerfile`; requires docker (see Linting and testing below) of the `Dockerfile`; requires docker (see Linting and testing below)
- `script/fmt` — format all files with prettier (writes) - `script/fmt` — format all files with prettier (writes)
- `script/fmt-check` — check formatting on the host (read-only); standalone, and - `script/fmt-check` — check formatting on the host (read-only)
not called by `script/check` or `script/precommit`, because `script/lint` - `script/check` — run all checks: `test`, `lint`, `fmt-check` (our own
already checks formatting in the container extension)
- `script/check` — run all checks: `test`, `lint` (our own extension)
- `script/docker` — build the image, tagged via `script/projectname` - `script/docker` — build the image, tagged via `script/projectname`
- `script/cibuild` — build the image (what CI runs); its last stage depends on - `script/cibuild` — what CI runs: `script/bootstrap`, then `script/check`, then
the `lint` and `test` phases, so this one build lints, tests and compiles the image build
- `script/precommit` — run by the git pre-commit hook (our own extension); runs - `script/precommit` — run by the git pre-commit hook (our own extension); runs
`script/lint`, which checks both lint and formatting, but deliberately not the `script/lint` and `script/fmt-check` but deliberately not the tests, so the
tests, so the TDD red-phase commit can land TDD red-phase commit can land
- `script/install-precommit` — installs the git pre-commit hook (our own - `script/install-precommit` — installs the git pre-commit hook (our own
extension); `make hooks` shims to it extension); `make hooks` shims to it
@@ -162,22 +161,19 @@ script runs the tools on the host: docker is required, and that also works where
the docker daemon is remote and bind mounts are impossible. the docker daemon is remote and bind mounts are impossible.
The last stage of the `Dockerfile` compiles the package, and it copies a file The last stage of the `Dockerfile` compiles the package, and it copies a file
from each phase, so it cannot be built unless lint and the tests pass. That is from each phase, so it cannot be built unless lint and the tests pass. The image
why `script/cibuild` is a single `docker build`: it runs lint and the tests once build in `script/cibuild` therefore runs lint and the tests a second time, after
each and then compiles. `script/check` has run them.
Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree Every `docker build` in `script/` passes `--no-cache`. On an unchanged tree
Docker would otherwise serve the lint and test steps from cache, nothing would Docker would otherwise serve the lint and test steps from cache, nothing would
run, and the build would still exit 0. run, and the build would still exit 0.
The formatting check is part of the `lint` phase, not a step beside it, so `script/fmt-check` runs prettier on the host. Its verdict matches the `lint`
`script/check` and `script/precommit` do not call `script/fmt-check` as well; phase's: prettier is pinned to an exact version, installed from `yarn.lock`
that would run prettier a second time over the same tree for the same verdict. under `--frozen-lockfile` in both places, and reads `.gitignore` as its default
`script/fmt-check` remains as a standalone entrypoint for asking the formatting ignore file — which is why `.dockerignore` keeps `.gitignore` in the build
question on the host. Its verdict matches the container's: prettier is pinned to context.
an exact version, installed from `yarn.lock` under `--frozen-lockfile` in both
places, and reads `.gitignore` as its default ignore file — which is why
`.dockerignore` keeps `.gitignore` in the build context.
### Version ### Version
@@ -259,11 +255,10 @@ All work on quak is test-driven. No exceptions.
history must still show tests landing before (or with) the matching history must still show tests landing before (or with) the matching
implementation. implementation.
8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which 8. The pre-commit hook installed by `make hooks` runs `script/precommit`, which
runs `script/lint` — eslint and the prettier check, in the container — but runs `script/lint` and `script/fmt-check` but not the tests, and so not the
not the tests, and so not the full `make check`. This is deliberate so the full `make check`. This is deliberate so the TDD red-phase commit (failing
TDD red-phase commit (failing tests, no implementation yet) can land. The tests, no implementation yet) can land. CI executes `script/cibuild`, which
`test` phase is part of the image build, which is what CI executes via runs the tests, so a red branch still cannot reach `next`.
`script/cibuild`, so a red branch still cannot reach `next`.
## Design ## Design
@@ -995,14 +990,12 @@ documents:
before the implementation. Tests are the canonical API documentation and must before the implementation. Tests are the canonical API documentation and must
be commented thoroughly. `main` and `next` are always green. be commented thoroughly. `main` and `next` are always green.
- **Required checks before every commit:** `make lint` must pass — that is - **Required checks before every commit:** `make lint` and `make fmt-check` must
eslint plus the prettier check, and it builds the `lint` phase of the pass. `make lint` is eslint plus the prettier check, and it builds the `lint`
`Dockerfile`, so it needs docker. The pre-commit hook enforces exactly that. phase of the `Dockerfile`, so it needs docker. The pre-commit hook enforces
`make check` (which also runs the tests) must pass before merging into `next`. exactly that. `make check` (which also runs the tests) must pass before
`make fmt-check` is available for a host-side formatting check on its own, but merging into `next`. Never invoke eslint or prettier directly; linting runs in
it is not a separate requirement: `make lint` already covers it, and running the container only.
both would check formatting twice. Never invoke eslint or prettier directly;
linting runs in the container only.
- **Formatting:** prettier with 4-space indents and `proseWrap: always` for - **Formatting:** prettier with 4-space indents and `proseWrap: always` for
markdown. Use `make fmt` to format. Use `yarn` not `npm`. markdown. Use `make fmt` to format. Use `yarn` not `npm`.
+120 -44
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-09-08 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -104,10 +104,14 @@ style conventions are in separate documents:
`lint` phase and a `test` phase, with the final stage depending on both so the `lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image. brings up a development environment; for server repos it is the runtime image.
Dockerfiles install development prerequisites by running `script/bootstrap` The gate phases and the build stage start from their pinned base images and
rather than duplicating installs inline; COPY `script/` and the dependency install what those images lack either inline, as the canonical Go `Dockerfile`
manifests (`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before below does for `git`, or by running `script/bootstrap`, as the `prompts`
running it. repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is - **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase no separate lint file. `script/lint` and `script/test` each build one phase
@@ -156,11 +160,14 @@ style conventions are in separate documents:
not evidence that anything ran: a sub-second build reporting success is a not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune` cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host. and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile, hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Go image. The canonical Go repo and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
@@ -173,8 +180,9 @@ style conventions are in separate documents:
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# Test phase # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# golang:1.x-alpine, YYYY-MM-DD # image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
@@ -191,15 +199,29 @@ style conventions are in separate documents:
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
ARG VERSION=dev # The VERSION build arg when one is given, otherwise
RUN CGO_ENABLED=0 go build -trimpath \ # `git describe --tags --always` on the .git in the build context. With
-ldflags="-s -w -X main.Version=${VERSION}" \ # .git present, a version that is still empty, dev or unknown fails the
-o /app ./cmd/app/ # build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
@@ -221,10 +243,41 @@ style conventions are in separate documents:
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting (e.g. - If the project requires CGO or system libraries for linting, install them
`vips-dev`), install them in the lint phase with `apk add`. in the lint phase. The `golangci/golangci-lint` image is Debian-based and
- `ARG VERSION=dev` is declared in the stage that compiles and supplied by has no `apk`, so install with `apt-get` under the Debian package name
`script/docker` and `script/cibuild`; no stage may call `git describe`. (`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` on push, and checks out the repo as its only other step.
@@ -233,7 +286,12 @@ style conventions are in separate documents:
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -286,17 +344,19 @@ style conventions are in separate documents:
``` ```
`-count=1` is required on both invocations: it defeats Go's test _result_ `-count=1` is required on both invocations: it defeats Go's test _result_
cache, so the target cannot report a pass it did not earn, and the rerun cache, so neither run can report a stored pass in place of running the
reproduces a failure instead of replaying it. It leaves the build cache tests. It leaves the build cache alone, so it costs the runtime of the suite
alone, so it costs the runtime of the suite and no recompilation. and no recompilation.
Note that this is a second, independent cache, stacked below the Docker That cache is Go's own, separate from Docker's layer cache. Go stores a
layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) passing result in its cache directory (`GOCACHE`), and when the same tests
addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; run again on unchanged code it prints that result, marked `(cached)`,
it does not guarantee `go test` inside that step does any work, because the without running them. That matters on a developer's machine, where this
`GOCACHE` baked into earlier image layers survives into the re-executed target runs and the directory lasts from one run to the next. The `test`
step. They are two separate defects requiring two separate fixes, and a fix phase of the `Dockerfile` needs no `-count=1`: its base image holds no
for one must not be recorded as covering the other. result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -340,7 +400,7 @@ style conventions are in separate documents:
— which is more dangerous than a short file with no secret patterns at all, — which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.git`, and the repo's own host-built root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is `cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
@@ -365,12 +425,13 @@ style conventions are in separate documents:
directory, so a repo running agents in subdirectories still ships directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there. `services/api/.claude/` and must add its own anchored entry there.
- **Excluding `.git` means `git describe` cannot run inside any build stage, and - **A plain `docker build .` of a clone stamps the version that
it fails quietly there.** In a build stage there is no repository, so `git describe --tags --always` gives**, derived from the `.git` in the build
`git describe` writes nothing to stdout, `-X main.Version=` comes out empty, context as the canonical `Dockerfile` above shows. Without its failure check,
the binary reports no version at all, and the build still exits 0. Compute the a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
version on the host and thread it in as a build arg. `script/docker` and and the build would still exit 0. `script/docker` and `script/cibuild` pass
`script/cibuild` do this, byte-identically across repos: the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh ```sh
# Own line: a failing command substitution inside an argument does not # Own line: a failing command substitution inside an argument does not
@@ -387,7 +448,7 @@ style conventions are in separate documents:
fallback is applied — a live check that fires on a build from an export with fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
@@ -426,12 +487,18 @@ style conventions are in separate documents:
`test-support` depguard rule, where a repo names its own test-support packages `test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is re-vendor carries its entries forward. The canonical golangci-lint version is
v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image image
(`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, (`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
only pin, since no repo installs golangci-lint on the host: bumping the directive must not name a newer Go minor version than the one golangci-lint
version means changing it and nothing else. was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by - **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
@@ -455,6 +522,11 @@ style conventions are in separate documents:
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
@@ -567,10 +639,10 @@ style conventions are in separate documents:
settings. settings.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and `Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
language-specific config). Everything else goes in a subdirectory. Canonical and language-specific config). Everything else goes in a subdirectory.
subdirectory names: Canonical subdirectory names:
- `bin/` — executable scripts and tools - `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose - `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in body is a single call into `internal/` or `pkg/`, no project logic in
@@ -601,3 +673,7 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml` - Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+8
View File
@@ -25,6 +25,14 @@ declares one.
# Completed Steps # Completed Steps
- 2026-10-06: The files this repository copies from `sneak/prompts` are copied
again from its commit `dd4027b` (issue 171). `.gitignore` and `.dockerignore`
keep out more secret files, and this repository's build artifacts follow the
copied content. `script/check` runs `script/fmt-check` again, `script/cibuild`
runs `script/bootstrap` and `script/check` before the image build,
`script/fmt` and `script/fmt-check` find the pinned yarn under nvm, and the
image's last stage marks `/app` safe for git.
- 2026-10-06: `quak backup` writes the account and album records - 2026-10-06: `quak backup` writes the account and album records
`backup-metadata` writes (issue 166): `account.json` with the account's `backup-metadata` writes (issue 166): `account.json` with the account's
`email` and `userID`, and in each album's JSON its `ownerID`, `isShared`, `email` and `userID`, and in each album's JSON its `ownerID`, `isShared`,
+5 -7
View File
@@ -1,11 +1,8 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint). Our own extension to # script/check: run all checks (test, lint, fmt-check). Our own
# scripts-to-rule-them-all. Both are Docker phases. Must not modify any # extension to scripts-to-rule-them-all. test and lint are Docker
# files. # phases; fmt-check is native, because a formatter writes the working
# # tree. Must not modify any files.
# script/fmt-check is not called here, unlike the template: the lint
# phase already runs `prettier --check .`, so calling it would run
# prettier a second time over the same tree for the same verdict.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -13,6 +10,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"
+7 -7
View File
@@ -1,8 +1,7 @@
#!/bin/sh #!/bin/sh
# script/cibuild: run the CI build. The image's last stage depends on the # script/cibuild: run the CI build. It bootstraps first: a CI runner
# lint and test phases, so this one build runs eslint, prettier and the # checks out and runs this and nothing else, and script/fmt-check runs
# suite once each and then compiles. Unlike the template it does not run # the formatter on the host, which a pristine checkout cannot do.
# script/check first, which would run lint and the tests a second time.
# --no-cache for the same reason as script/docker: the gate phases the # --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that # final stage depends on are RUN steps, and a cached one is a check that
# did not run. # did not run.
@@ -13,11 +12,12 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The version resolved here goes in as the VERSION # empty constant. The VERSION build argument takes precedence over
# build arg, which takes precedence over what the build would derive # the version a build stage derives from the .git in the context.
# from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+2 -3
View File
@@ -12,9 +12,8 @@ main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an # not trip `set -e`, so the inline form degrades silently to an
# empty constant. The version resolved here goes in as the VERSION # empty constant. The VERSION build argument takes precedence over
# build arg, which takes precedence over what the build would derive # the version a build stage derives from the .git in the context.
# from the .git in its context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ docker build --no-cache \
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn run prettier --write . run_yarn run prettier --write .
} }
main "$@" main "$@"
+20 -1
View File
@@ -4,9 +4,28 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn run prettier --check . run_yarn run prettier --check .
} }
main "$@" main "$@"
+5 -5
View File
@@ -2,17 +2,17 @@
# script/precommit: run by the git pre-commit hook; fails the commit if # script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. # checks fail. Our own extension to scripts-to-rule-them-all.
# #
# Runs lint but deliberately NOT the tests, so the TDD red-phase commit # Runs lint and fmt-check but deliberately NOT the tests, so the TDD
# (failing tests, no implementation yet) can land. CI runs # red-phase commit (failing tests, no implementation yet) can land. CI
# script/cibuild, whose image build includes the test phase, and so # runs script/cibuild, which runs the tests, and so catches any branch
# catches any branch that ships red. The lint phase includes the # that ships red.
# prettier check, so a badly formatted tree still fails the commit.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
} }
main "$@" main "$@"
+13 -12
View File
@@ -29,18 +29,19 @@ const patterns = (name: string): string[] =>
const dockerignore = patterns(".dockerignore"); const dockerignore = patterns(".dockerignore");
describe(".dockerignore", () => { describe(".dockerignore", () => {
// Everything here is either generated, enormous, or secret. `.claude/` is // Everything here is either generated, enormous, or secret. `.claude` is
// the correctness one: see the header comment and issue #25. // the correctness one: see the header comment and issue #25. The leading
// `/` anchors an entry at the root of the context.
it.each([ it.each([
".claude/", ".claude",
".quak/", "/.quak",
"bin/quak", "/bin/quak",
"node_modules", "**/node_modules",
"coverage", "/coverage",
"dist", "/dist",
".vitest-cache/", "/.vitest-cache",
".nyc_output/", "/.nyc_output",
"*.tsbuildinfo", "/*.tsbuildinfo",
])("keeps %s out of the build context", (pattern) => { ])("keeps %s out of the build context", (pattern) => {
expect(dockerignore).toContain(pattern); expect(dockerignore).toContain(pattern);
}); });
@@ -57,7 +58,7 @@ describe(".dockerignore", () => {
// The build stage is the final image, so a .git/config sent in would // The build stage is the final image, so a .git/config sent in would
// ship the clone's remote URL and any credential in it. // ship the clone's remote URL and any credential in it.
it("sends .git without its config", () => { it("sends .git without its config", () => {
expect(dockerignore).toContain(".git/config"); expect(dockerignore).toContain("**/.git/config");
}); });
// BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a // BuildKit lets a `Dockerfile.dockerignore` shadow the root one; such a