Skip to content
atrePublic

About

Definition-of-done runner — one command, all quality gates, a compact pass/fail matrix

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

snuff

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.

Usage

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.

--json shape

{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.

Manifest

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 lint

each: 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"
      done

Add 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.sh

Each 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.

Worked example: scripts-only repo

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: 120

py_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.

Gate recipes

# 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.

Claude Code integration

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.

Status

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.

Development

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

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.

About

Definition-of-done runner — one command, all quality gates, a compact pass/fail matrix

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages