Definition-of-done runner. One command runs every quality gate a repo declares
— typecheck, lint, tests, build, plus external gates like peep, looksy,
and other content-QA tools — and prints a compact pass/fail matrix. Failures are
trimmed to the lines you'd act on, never full runner output. Be "up to snuff"
before you ship.
Built so an AI session (or a human) never has to rediscover how a repo verifies itself, and so the full gate actually runs every time.
snuff init # seed snuff.yaml from package.json/Makefile/…; --suggest previews, writes nothing
snuff # run all gates from snuff.yaml (parallel, needs: respected)
snuff test build # run only these gates (plus what they need)
snuff --changed # skip gates whose paths: have no diff vs HEAD
snuff --skip build # leave named gates out of this run (repeatable, comma-separated)
snuff --only build # run only these gates (+ their needs:) (repeatable, comma-separated)
snuff --check-config # parse + validate snuff.yaml and gate cwds, run nothing
snuff --require # exit 2 (not the usual 1) when no manifest is found —
# for a CI/done-gates step that must tell "repo has no
# gates yet" apart from "gates ran and failed";
# rejects --hook (hooks stay non-blocking on no manifest)
snuff -j1 # sequential
snuff init --claude # + wire a Claude Code Stop hook (`snuff --hook`: only gates whose files moved since they last ran, one ✓ line when green) and CLAUDE.md note
snuff init --claude --hook-timeout 900
# override the Stop hook's timeout (default 600s) — bump for a slow suite
snuff init --suggest # print the seeded manifest, write nothing — dry-run before adopting
snuff init --pre-commit --pre-push
# wire the repo's pre-commit (snuff --changed) and pre-push (snuff) hooks
# (via `git rev-parse --git-path hooks`, so worktrees/submodules work);
# a non-snuff hook is left untouched → exit 1, unless --force replaces it
snuff init --reseed # overwrite an existing snuff.yaml with a fresh seed (--force never does)
snuff --quiet # print nothing when all green (red: matrix as usual)
snuff --verbose # + manifest path line and, per gate, the dirty files its paths: cover
snuff --json # machine-readable results
snuff --rerun-failed # run only what was red last time (plus what it needs)
snuff --show test # print the full saved output of one gate, no re-run
snuff --last # re-print the last run's matrix, no re-run
snuff --list # print gates (name · tags · needs · when · timeout), no re-run
snuff doctor # compare each gate's configured timeout against its recent real
# durations (.snuff/last.json history, last 5 runs per gate); flags
# a gate whose max recent run is >= 80% of its timeout — a drift risk
# even though it currently passes when run directly; silent when every
# gate has headroom; exit 1 if anything is flagged; --json supported
snuff --lines 5 # cap failure excerpts at 5 lines (default 15)
snuff --fix # on failure, run a gate's fix: command, then re-check once
snuff --fail-fast # stop launching new gates after the first hard failure
snuff --tag fast # run only gates tagged fast (plus what they need); repeatable
snuff --gha # print GitHub Actions ::error annotations; appends a step
# summary table when $GITHUB_STEP_SUMMARY is set
snuff --all ~/git # run every child repo's manifest under a root dir; one
# line per repo (`✗ name — <error>` for one that couldn't run),
# exit 1 if any repo is red; --tag/--changed/--fix/--fail-fast/
# --quiet are forwarded to every child run, --lines shapes --json
Every run saves full output to .snuff/last/<gate>.log + .snuff/last.json
(gitignored by snuff init) — the excerpt on screen is trimmed, the saved
log isn't. A partial run (named gates, --rerun-failed) updates only the
gates it ran; the rest of the last full picture stays put.
The matrix and excerpt symbols colour on a TTY stdout (never in --json or
--hook); NO_COLOR=1 disables it, FORCE_COLOR=1 forces it even when
piped (and wins over NO_COLOR when both are set). A gate that got
meaningfully slower than its last saved run (≥ 2s and ≥ 50%) gets a timing-Δ
suffix: ✓ test 14.3s (+6.1s).
A non-TTY run (piped, CI, background) streams per-gate ▶ name / ✓ name (2.1s)
progress lines to stderr as gates start and finish (skipped for --json,
--hook, and --quiet, which stay silent or pure), plus a ⚠ name ran at N% of its Ts timeout nudge for any gate that passed within 80% of its timeout
(skipped for --json/--hook only — it still fires under --quiet).
Typical output is one line per gate plus one summary line (~10–15 lines) — safe to read raw, no tail/squirt needed.
Output:
✓ typecheck 2.1s ✓ lint 0.8s ✗ test 14.3s ✓ build 9.0s
✗ test → exit 1
AssertionError: expected 402, got 200
2 passing, 1 failing
The summary line ends with @<sha> — the commit a green run vouches for (@e0cad9c+dirty when the tree has uncommitted changes); --json carries it as summary.rev.
A gate that rewrites a tracked file adds · wrote N files to the summary (names under --verbose; summary.wrote/summary.wroteFiles in --json) — so a definition-of-done run that dirties the tree it just certified never does so silently.
{ok, findings[], gates[], summary}. gates[] is the full per-gate record
(name, ok, skipped, allowFail, exitCode, timedOut, durationMs, attempts, fixed,
warnings, excerpt) — everything snuff knows.
findings[] is the fleet finding schema ({id, scope, severity, title, detail?, hint}), so a generic reader joins snuff with pulse/peep/looksy/tally without
knowing anything about gates:
{"id":"gate:acme/lint","scope":"gate","severity":"crit",
"title":"lint ✗ exit 1","detail":"src/x.ts(3,1): error TS2304: …",
"hint":"snuff --show lint"}Ids are gate:<repo dir>/<gate> — two repos both failing lint must not
collide in a fleet-wide join. The repo is its directory name, never the
~/.snuff/ path slug: an id is published output, and no home path belongs in
one.
Red gates only — a green gate is the normal state of a repo, and one ok row
per gate would drown a fleet-wide join. A failing allowFail gate is warn
(it cannot block the run, so it must not read as a stop-the-line); skipped
gates emit nothing, since not-run is not a verdict and --changed skips by
design. A timed-out gate reports elapsed time, not the configured timeout —
that value is absent whenever the gate falls back to snuff's default.
snuff.yaml in the repo root:
gates:
- name: typecheck
run: npm run lint
- name: test
run: npm test
needs: [typecheck] # runs after; skipped (–) if typecheck fails
paths: [src/**, test/**] # for --changed
env:
CI: "1"
- name: opsec
run: peep check example.com
allowFail: true # report ⚠ but don't fail the run
timeout: 120 # seconds (default 600)
cwd: packages/web # relative to the manifest| key | meaning |
|---|---|
needs |
gates that must finish first; a failed (non-allowFail) need skips this gate — seeded automatically for test when a build gate exists; snuff doctor flags a missing link |
requires |
external tools that must be on PATH; missing → clean missing: <tool>, no spawn |
requiresEnv |
env var names checked before the gate runs (after env: is merged in); missing → fail fast with a worktree hint (env missing: … — gitignored .env files don't follow git worktree add), no spawn |
paths |
globs (**, *, ?, {a,b}, dir/); with --changed, no matching change → skipped |
commentOnly |
opt-in; with --changed/--hook, also skip when every matched file's diff touches only blank lines and inert comments. Comments are not inert for every gate — eslint-disable, @ts-expect-error, # noqa and friends steer the tools themselves, so those are excluded automatically, but a project's own marker comment (a test that greps for foo-exempt) is not. Opt in a bundler build or a docs render; never lint, never a suite that reads its own markers |
env |
extra environment for the command; a relative path used as a value isn't resolved by snuff — it's passed through as-is to the child process, so it resolves (if the command treats it as a path) against the gate's cwd:, not necessarily the repo root |
dotenv |
file paths to load as env vars (relative to the manifest); lowest precedence — env: wins, then the process environment, then dotenv |
cwd |
working directory, relative to the manifest |
timeout |
seconds, default 600; the whole process tree is killed |
allowFail |
failure shows as ⚠ and doesn't affect the exit code or dependents; a passing advisory gate renders (✓) — parens mark it non-binding, distinct from a strict ✓ |
match |
regexes; when set, only matching excerpt lines survive (escape hatch for tools with no built-in trimmer) |
ignore |
regexes; matching lines are dropped from the excerpt before anything else runs |
warn |
regex; on a passing gate, output lines matching it are counted and shown as a ⚠N cell suffix (also in --json as warnings) — a green run that still has something worth a look (e.g. review comments a tool never flagged as failing) |
excerptLines |
max excerpt lines for this gate; overrides the global default (15) / --lines |
fix |
command to run on failure, before one re-check of run — only with --fix |
retries |
extra attempts on failure (flaky gates); marks the matrix (passed on retry N) |
tags |
free-form labels; snuff --tag <t> runs only gates carrying one of the given tags |
when (alias if) |
shell condition checked before run; false → skipped, doesn't block dependents. Env checks: when: 'test -n "$VAR"' |
each |
directory glob (e.g. sites/*); expands this one stanza into N gates, one per matching dir — see below |
Quoting: double-quoted scalars honour YAML escapes (\\, \", \n, \t),
single-quoted ones are literal ('' → '). So a regex in match:/ignore:
is "\\d+" or '\d+', and a shell test with quotes is when: "test -n \"$X\""
or when: 'test -n "$X"' — snuff init writes whichever needs no escaping.
A top-level defaults: block supplies fallbacks — timeout/env apply to
any gate lacking its own (a gate's own env key still wins over a
same-named default), jobs is a run-level default below -j:
defaults:
timeout: 120
jobs: 2
env:
CI: "1"
gates:
- name: typecheck
run: npm run linteach: expands one stanza into N gates, one per matching directory — for
repos with a pile of near-identical per-site/per-overlay gates that would
otherwise be hand-copied:
gates:
- name: build-{name}
run: npm run build
each: sites/*Matches directories only (readdirSync, not the changed-files list); one
path segment per *, ** for arbitrary depth. {dir} (the matched
directory, relative to the manifest) and {name} (its basename) are
interpolated into name/run/cwd/paths. cwd defaults to {dir} and
paths to ['{dir}/**'] when the gate doesn't set its own. Zero matches is
a load error; a name that doesn't vary per match (missing {name}) hits
the normal duplicate-gate-name error once expanded.
run:/fix: accept a YAML block scalar for a multi-line command — |
joins lines with \n (literal), > joins with a space (folded); |-/>-
are accepted but behave the same as |/> here (a shell command string
doesn't care about one trailing newline either way). Blank lines and
#-only lines inside the block are dropped, same as everywhere else in
this parser — don't rely on them for shell script structure:
gates:
- name: multi
run: |
for f in src/*.ts; do
echo "checking $f"
doneAdd a dotenv: list (per gate, or under defaults:) to load KEY=VALUE
vars from files — lowest precedence (env: wins, then the process
environment, then dotenv fills whatever's left). A path missing in a linked
git worktree add checkout falls back to the same relative path in the
main worktree, since gitignored dotenv files don't follow git worktree add:
gates:
- name: build
run: npm run build
dotenv: [.env.local]include: pulls another repo's gates into this run — for a deploy command
that ships another repo's files, so that repo's own coverage still gates it,
instead of shipping ungated:
include: [../other-repo]
gates:
- name: deploy
run: ./deploy.shEach path resolves relative to this manifest's dir and must itself hold a
snuff.yaml/.json; a missing or unreadable one is a load error naming the
path. That repo's gates merge into the same matrix named <label>/<name>
(label = the include path's basename, e.g. other-repo/lint) and default to
running in that repo's dir, not this one — a gate with its own cwd: still
resolves relative to its own repo. A red gate anywhere in an included repo
fails this run too, same as a red gate of its own.
Gates run in parallel up to the CPU count — concurrency resolves as
-j > SNUFF_JOBS > defaults.jobs > CPU count. Skipped gates never
fail the run on their own — the gate that caused the skip already did.
snuff.json with the same shape also works. Exit code is 0 only when every
gate passes (allowFail gates excepted) — safe to use as a hook or CI step.
SNUFF_JOBS/SNUFF_TIMEOUT env vars set machine/CI-level fallbacks below
-j/a gate's own timeout: (and above defaults.jobs) — no manifest edit
needed.
Repos with no tests, no build, no lint — scene generators, one-off scripts,
artifact-producing tooling — often ship with zero snuff.yaml coverage
because nothing shows what a "gate" looks like without lint/build. A
syntax check, an import-time sanity check, and a small smoke run cover most
of it:
gates:
- name: py_compile
run: python3 -m py_compile $(find . -name '*.py')
- name: import_check # replace scene_generators
run: python3 -c "import scene_generators" # with your repo's module
needs: [py_compile]
- name: smoke_render
run: python3 render.py --samples 3 --out /tmp/snuff-smoke
needs: [import_check]
timeout: 120py_compile is a fast syntax-only pass. import_check exercises whatever
the module asserts at import time (config loads, registries populate).
smoke_render is the real signal — a tiny N-sample run standing in for
"does this still produce output" — left as a hard failure, not
allowFail, because for an artifact-producing repo that's the actual
definition of done.
# docs-opsec: fail when tracked markdown mentions a private name.
# List lives OUTSIDE the repo (the names are the secret). One name per
# line; matched case-insensitively as whole words (-w bounds each name,
# so a short name won't match inside a longer word). Gate skips where
# the list file doesn't exist.
- name: docs-opsec
run: '! git grep -nIiwF -f "$HOME/.config/snuff/opsec-names.txt" -- "*.md"'
when: test -s "$HOME/.config/snuff/opsec-names.txt"
paths: ['**/*.md']! inverts git grep, so a grep error (exit 2) also reads as pass —
acceptable for an advisory tripwire.
snuff init --claude wires a Stop hook (snuff --hook) into the repo's
.claude/settings.json (merged, idempotent) and appends a bullet under a
## Definition of done heading in CLAUDE.md (added if missing). The hook
contract: all gates green → one snuff ✓ N/N gates line on stdout, exit 0, the session stops normally;
anything red → matrix + excerpts on stderr, exit 2, which blocks the stop
and feeds the failures back — the session keeps fixing until the gate is
green. Enforcement lives in the harness, not in the model's memory.
The generated command is pinned to the project directory (cd "${CLAUDE_PROJECT_DIR:-.}" && snuff --hook) — Claude Code runs Stop hooks in
the session's current cwd, which can drift into a sub-repo or worktree
with no manifest of its own. Re-running snuff init --claude upgrades an
older un-pinned hook in place, preserving any --tag/--only customization
already on the command. A cwd with no manifest is not an error under
--hook — it exits 0 with a one-line skip note on stderr, never blocking
the Stop.
The hook's timeout defaults to 600s; pass --hook-timeout N alongside
--claude to scale it for a slower suite (snuff init --claude --hook-timeout 900). A pre-existing hook without a timeout is upgraded to
whatever --hook-timeout says (or the 600s default); one that already has a
timeout is left alone.
--hook re-runs only what moved: each gate remembers when it last actually
ran (ranAt in .snuff/last.json) and is skipped when none of its paths:
files has an mtime newer than that (– name unchanged since last run), so a
tree that stays dirty for days does not re-run every gate on every Stop.
Hard-red gates always re-run; a gate with no baseline yet falls back to the
plain --changed rule (vs HEAD).
The hook blocks once per distinct red: the first red Stop prints the
matrix and exits 2; a Stop with the same red again (same gates, same output)
prints one snuff: still red (review) — … not blocking again line and exits
0, and so does any Stop where Claude Code reports stop_hook_active. A
different red blocks again; green clears the memory
(.snuff/last-block.json). Otherwise a red that needs a human step —
baselines, verdicts, a box that is down — loops the session forever. Slow, box-dependent gates belong behind a
tag (tags: [full]) with the hook wired as snuff --hook --tag fast.
Every run (hook or plain) also writes a global last-result file:
<home>/<repo-slug>.json under SNUFF_HOME (default ~/.snuff/), keyed by
the repo's absolute path with every / and . turned into -
(/Users/me/git/x → -Users-me-git-x.json). It's a low-detail export for
cross-tool consumers — {ts, head, cwd, ok, gates:[{name, ok, skipped, allowFail, durationMs}]}, no output — so a tool like brief can show
gates ✗ lint on its radar without reading .snuff/last.json or any log.
Best-effort: write failures are swallowed and never affect the run's exit code.
Bounding wall-clock. Gates already carry per-gate timeout: (default
600s, whole process tree killed) — do NOT wrap snuff itself in a shell
timeout: stock macOS ships no timeout/gtimeout binary (Homebrew's
coreutils adds one), and an outer kill loses the per-gate attribution the
matrix gives you. To bound a run instead, lower a gate's timeout:, or
select less of it with --tag/--only/--changed.
Core + execution phases done: parallel runner with needs:, --changed,
per-gate env/cwd, live matrix on a TTY. Gate recipes for composing
external tools are under Gate recipes above (docs-opsec so
far) — see PLAN.md for what's next.
npm install
npm run build # tsc → dist/
npm test # node --test
npm link # install the `snuff` command globally
No runtime dependencies so far — Node ≥ 20 built-ins. The YAML support is a constrained parser for exactly the manifest shape above, not general YAML.
skills/ship/SKILL.md — /ship: snuff gates → the repo's deploy command → post-deploy checks (snuff --profile prod or peep/looksy) → pulse diff. Symlink into ~/.claude/skills/ship.