diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml
new file mode 100644
index 0000000..eb984cd
--- /dev/null
+++ b/.github/actionlint.yaml
@@ -0,0 +1,34 @@
+# actionlint configuration.
+#
+# Like zizmor.yml, every entry here is a documented policy decision, not a
+# convenience mute. Findings that reflect a real gap stay visible.
+
+paths:
+ .github/workflows/standards-check.yml:
+ ignore:
+ # `job.workflow_sha` is the documented GitHub context property for "the
+ # commit SHA of the workflow file that defines the current job" — which
+ # is exactly what a reusable workflow needs in order to check out its
+ # OWN standards/ and configs at the same commit as the workflow being
+ # run. actionlint 1.7.12 has not added it to the `job` context schema
+ # (it is absent from the schema and from docs/checks.md), so it reports
+ # the property as undefined. GitHub populates it at runtime.
+ #
+ # Last verified against actionlint 1.7.12 and the upstream context
+ # schema reference at
+ # https://github.com/rhysd/actionlint/blob/main/docs/checks.md — whoever
+ # bumps actionlint should re-check that reference and remove this ignore
+ # once `workflow_sha` is recognized.
+ #
+ # The older spelling `github.job_workflow_sha` is NOT the fix: it is
+ # absent from the current contexts reference and evaluates to an empty
+ # string (actions/runner#2417). Empty is the dangerous value here,
+ # because actions/checkout treats an empty `ref` as the default branch
+ # — the check would go green having linted a different commit than the
+ # one under review. standards-check.yml therefore resolves the SHA in
+ # its own step and exits 2 when it is empty, so a future runner-side
+ # regression fails loudly instead of silently linting the wrong tree.
+ #
+ # Scoped to this one message in this one file: any other expression
+ # error, in this or any other workflow, still fires.
+ - 'property "workflow_sha" is not defined in object type'
diff --git a/.github/workflows/claude-blocking-review.yml b/.github/workflows/claude-blocking-review.yml
index efdaec7..0afcdde 100644
--- a/.github/workflows/claude-blocking-review.yml
+++ b/.github/workflows/claude-blocking-review.yml
@@ -568,10 +568,16 @@ jobs:
# `secrets: claude_oauth_token: required: true` on this
# workflow_call only guarantees the caller supplied a `secrets:`
# block — it does NOT guarantee the underlying repo secret it
- # references is actually set. A caller passing
- # `${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}` from a repo where that
- # secret was never added still satisfies the `required: true`
- # check while passing an empty string through.
+ # references is actually set. A caller passing its
+ # CLAUDE_CODE_OAUTH_TOKEN secret from a repo where that secret
+ # was never added still satisfies the `required: true` check
+ # while passing an empty string through.
+ #
+ # (Written without the ${_{ }} expression syntax on purpose:
+ # GitHub interpolates expressions inside `run:` blocks even in
+ # shell comments, and actionlint resolves them against this
+ # workflow's own `secrets:` — which declares claude_oauth_token,
+ # not CLAUDE_CODE_OAUTH_TOKEN.)
#
# Gated behind the same doc-only/Dependabot skip outputs as the
# review step itself (rather than running unconditionally right
diff --git a/.github/workflows/self-standards-check.yml b/.github/workflows/self-standards-check.yml
new file mode 100644
index 0000000..a4b47a2
--- /dev/null
+++ b/.github/workflows/self-standards-check.yml
@@ -0,0 +1,16 @@
+name: Self Standards Check
+
+# Self-applying caller: runs this repo's reusable standards-check.yml on its
+# own PRs via a local path, so a PR that changes the check dogfoods itself.
+# Produces the `standards-check / run-standards-check` status check.
+
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+
+permissions:
+ contents: read
+
+jobs:
+ standards-check:
+ uses: ./.github/workflows/standards-check.yml
diff --git a/.github/workflows/standards-check.yml b/.github/workflows/standards-check.yml
new file mode 100644
index 0000000..ed71281
--- /dev/null
+++ b/.github/workflows/standards-check.yml
@@ -0,0 +1,178 @@
+name: Standards Check
+
+# Reusable, deterministic, secret-free standards check. Replaces the CI
+# judgment reviewer as the fleet's required check (dev-env#60,
+# github-workflows#154). Installs pinned linters and runs
+# standards/run-standards.sh from THIS repo at the SHA of this workflow file
+# (job.workflow_sha), so the script, the configs, and the workflow always
+# agree.
+#
+# Caller stub (name the job `standards-check`; the required check is then
+# `standards-check / run-standards-check`):
+#
+# name: Standards Check
+# on:
+# pull_request:
+# types: [opened, synchronize, ready_for_review, reopened]
+# permissions:
+# contents: read
+# jobs:
+# standards-check:
+# uses: smartwatermelon/github-workflows/.github/workflows/standards-check.yml@standards-check-v1
+#
+# Tool versions are pinned here and verified by checksum. Bump them by PR.
+
+on:
+ workflow_call:
+ inputs:
+ shellcheck:
+ description: Run shellcheck -S info over shell files
+ type: boolean
+ default: true
+ yamllint:
+ description: Run yamllint over YAML files
+ type: boolean
+ default: true
+ actionlint:
+ description: Run actionlint over .github/workflows
+ type: boolean
+ default: true
+ zizmor:
+ description: Run zizmor over .github/workflows
+ type: boolean
+ default: true
+ markdownlint:
+ description: Run markdownlint-cli2 over Markdown files
+ type: boolean
+ default: true
+ node_floor_check:
+ description: Fail on Node.js pins below node_floor
+ type: boolean
+ default: true
+ node_floor:
+ description: Lowest supported Node.js major
+ type: string
+ default: "22"
+
+permissions: {}
+
+env:
+ SHELLCHECK_VERSION: "0.11.0"
+ SHELLCHECK_SHA256: "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198"
+ ACTIONLINT_VERSION: "1.7.12"
+ ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8"
+ ZIZMOR_VERSION: "1.30.0"
+ ZIZMOR_SHA256: "ec8c95cd800845abb9bbc5f377ec7c57d2eb8e2386a00a201d3a74ee4092e5ed"
+ YAMLLINT_VERSION: "1.38.0"
+ MARKDOWNLINT_CLI2_VERSION: "0.23.2"
+
+jobs:
+ run-standards-check:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ steps:
+ - name: Checkout caller repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ fetch-depth: 1
+ path: repo
+
+ # Resolve the SHA of THIS reusable workflow file, and fail if it is
+ # empty. `job.workflow_sha` is the documented property (the older
+ # `github.job_workflow_sha` spelling evaluates empty — actions/runner#2417).
+ # An empty ref is the dangerous case: actions/checkout would silently
+ # take the default branch, so the check would go green having linted a
+ # different commit than the one under review. Fail loudly instead.
+ - name: Resolve the standards SHA
+ id: standards_sha
+ env:
+ JOB_WORKFLOW_SHA: ${{ job.workflow_sha }}
+ run: |
+ set -euo pipefail
+ if [ -z "${JOB_WORKFLOW_SHA}" ]; then
+ echo "::error::job.workflow_sha is empty; refusing to check out an ambiguous ref"
+ exit 2
+ fi
+ echo "sha=${JOB_WORKFLOW_SHA}" >> "${GITHUB_OUTPUT}"
+ echo "standards SHA: ${JOB_WORKFLOW_SHA}"
+
+ - name: Checkout standards at the called-workflow SHA
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: smartwatermelon/github-workflows
+ ref: ${{ steps.standards_sha.outputs.sha }}
+ persist-credentials: false
+ fetch-depth: 1
+ path: standards-src
+ sparse-checkout: |
+ standards
+ zizmor.yml
+
+ - name: Install pinned linters
+ env:
+ SKIP_SHELLCHECK: ${{ inputs.shellcheck == false }}
+ SKIP_ACTIONLINT: ${{ inputs.actionlint == false }}
+ SKIP_ZIZMOR: ${{ inputs.zizmor == false }}
+ SKIP_YAMLLINT: ${{ inputs.yamllint == false }}
+ SKIP_MARKDOWNLINT: ${{ inputs.markdownlint == false }}
+ run: |
+ set -euo pipefail
+ mkdir -p "${HOME}/.local/bin"; echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"
+ if [ "${SKIP_SHELLCHECK}" != "true" ]; then
+ curl -Lfso /tmp/shellcheck.tar.xz \
+ "https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VERSION}/shellcheck-v${SHELLCHECK_VERSION}.linux.x86_64.tar.xz"
+ echo "${SHELLCHECK_SHA256} /tmp/shellcheck.tar.xz" | sha256sum -c -
+ tar -xJf /tmp/shellcheck.tar.xz -C /tmp
+ install -m 0755 "/tmp/shellcheck-v${SHELLCHECK_VERSION}/shellcheck" "${HOME}/.local/bin/shellcheck"
+ fi
+ if [ "${SKIP_ACTIONLINT}" != "true" ]; then
+ curl -Lfso /tmp/actionlint.tar.gz \
+ "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz"
+ echo "${ACTIONLINT_SHA256} /tmp/actionlint.tar.gz" | sha256sum -c -
+ tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint
+ install -m 0755 /tmp/actionlint "${HOME}/.local/bin/actionlint"
+ fi
+ if [ "${SKIP_ZIZMOR}" != "true" ]; then
+ curl -Lfso /tmp/zizmor.tar.gz \
+ "https://github.com/zizmorcore/zizmor/releases/download/v${ZIZMOR_VERSION}/zizmor-x86_64-unknown-linux-gnu.tar.gz"
+ echo "${ZIZMOR_SHA256} /tmp/zizmor.tar.gz" | sha256sum -c -
+ mkdir -p /tmp/zizmor && tar -xzf /tmp/zizmor.tar.gz -C /tmp/zizmor
+ install -m 0755 "$(find /tmp/zizmor -type f -name zizmor | head -1)" "${HOME}/.local/bin/zizmor"
+ fi
+ if [ "${SKIP_YAMLLINT}" != "true" ]; then
+ pipx install "yamllint==${YAMLLINT_VERSION}"
+ fi
+ if [ "${SKIP_MARKDOWNLINT}" != "true" ]; then
+ npm install -g "markdownlint-cli2@${MARKDOWNLINT_CLI2_VERSION}"
+ fi
+
+ # The skip list is assembled from one env var per linter rather than one
+ # long concatenated expression, so every line stays under the shared
+ # 120-column yamllint limit. Each var holds the linter's name when the
+ # input is false and an empty string otherwise; the shell joins them.
+ - name: Run standards
+ env:
+ SKIP_SHELLCHECK: ${{ inputs.shellcheck == false && 'shellcheck' || '' }}
+ SKIP_YAMLLINT: ${{ inputs.yamllint == false && 'yamllint' || '' }}
+ SKIP_ACTIONLINT: ${{ inputs.actionlint == false && 'actionlint' || '' }}
+ SKIP_ZIZMOR: ${{ inputs.zizmor == false && 'zizmor' || '' }}
+ SKIP_MARKDOWNLINT: ${{ inputs.markdownlint == false && 'markdownlint' || '' }}
+ SKIP_NODE_FLOOR: ${{ inputs.node_floor_check == false && 'node-floor' || '' }}
+ NODE_FLOOR: ${{ inputs.node_floor }}
+ run: |
+ set -euo pipefail
+ [[ "${NODE_FLOOR}" =~ ^[0-9]+$ ]] || { echo "::error::node_floor must be an integer major"; exit 2; }
+ skips=()
+ for s in "${SKIP_SHELLCHECK}" "${SKIP_YAMLLINT}" "${SKIP_ACTIONLINT}" \
+ "${SKIP_ZIZMOR}" "${SKIP_MARKDOWNLINT}" "${SKIP_NODE_FLOOR}"; do
+ [ -n "${s}" ] && skips+=("${s}")
+ done
+ skip_list="$(IFS=,; echo "${skips[*]-}")"
+ echo "skip list: '${skip_list}'"
+ bash standards-src/standards/run-standards.sh \
+ --repo repo \
+ --config-dir standards-src/standards \
+ --skip "${skip_list}" \
+ --node-floor "${NODE_FLOOR}"
diff --git a/CLAUDE.md b/CLAUDE.md
index e69de29..a5696c1 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -0,0 +1,12 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Testing and linting
+
+- `bash tests/run-tests.sh` runs the standards suites (`test-run-standards.sh`, `test-check-node-floor.sh`).
+ Requires `shellcheck`, `yamllint`, `actionlint`, `zizmor`, and `markdownlint-cli2` (all via Homebrew), plus `jq`.
+- `bash standards/run-standards.sh --repo
` runs the exact same standards
+ check CI runs, against a local checkout — this is the command
+ `standards-check.yml` calls in CI, so a clean local run predicts a clean
+ self-check.
diff --git a/README.md b/README.md
index 0aecd51..653843b 100644
--- a/README.md
+++ b/README.md
@@ -392,6 +392,131 @@ work.
---
+## `standards-check.yml`
+
+Reusable, deterministic, secret-free standards check. Per
+[dev-env#60](https://github.com/smartwatermelon/dev-env/issues/60) and
+[github-workflows#154](https://github.com/smartwatermelon/github-workflows/issues/154),
+decided 2026-09-08, this check replaces `claude-blocking-review.yml` as the
+fleet's required check, with no judgment reviewer kept in CI. Rollout (W2)
+and retirement of the old required check (W3) follow as separate phases; see
+[`smartwatermelon/dev-env` `docs/superpowers/plans/2026-09-08-w1-standards-check.md`](https://github.com/smartwatermelon/dev-env/blob/main/docs/superpowers/plans/2026-09-08-w1-standards-check.md)
+for the full plan.
+
+It installs pinned linters and runs `standards/run-standards.sh` from this
+repo, checked out at the SHA of the workflow file itself (`job.workflow_sha`),
+so the script, the configs, and the running workflow always agree.
+
+### Setup
+
+`.github/workflows/standards-check.yml` in your repo:
+
+```yaml
+name: Standards Check
+on:
+ pull_request:
+ types: [opened, synchronize, ready_for_review, reopened]
+permissions:
+ contents: read
+jobs:
+ standards-check:
+ uses: smartwatermelon/github-workflows/.github/workflows/standards-check.yml@standards-check-v1
+```
+
+Name the caller job `standards-check`; the required check is then
+`standards-check / run-standards-check` (caller job `standards-check`, inner
+job `run-standards-check`). With reusable workflows, GitHub reports the
+**inner job** as the status check, so the check name follows the caller job
+name regardless of which repo you're in.
+
+### Inputs
+
+| Input | Type | Default | Description |
+| ----- | ---- | ------- | ----------- |
+| `shellcheck` | boolean | `true` | Run `shellcheck -S info` over shell files |
+| `yamllint` | boolean | `true` | Run yamllint over YAML files |
+| `actionlint` | boolean | `true` | Run actionlint over `.github/workflows` |
+| `zizmor` | boolean | `true` | Run zizmor over `.github/workflows` |
+| `markdownlint` | boolean | `true` | Run markdownlint-cli2 over Markdown files |
+| `node_floor_check` | boolean | `true` | Fail on Node.js pins below `node_floor` |
+| `node_floor` | string | `"22"` | Lowest supported Node.js major |
+
+### Config precedence
+
+Per linter, a config at the caller repo's root wins. Otherwise the canonical
+file from **this** repo is used — `standards/markdownlint.json`,
+`standards/yamllint.yml`, or root `zizmor.yml` — fetched at
+`job.workflow_sha` (the SHA of the called workflow file), so the configs
+always match the workflow version that's running. A dedicated step resolves
+that SHA and fails loudly if it is empty, rather than letting
+`actions/checkout` silently fall back to the default branch and lint the
+wrong commit. This makes root [`zizmor.yml`](./zizmor.yml) the fleet-wide CI
+fallback policy for any consuming repo that has no `zizmor.yml` of its own —
+its `dependabot-cooldown` ignore, for example, applies fleet-wide by design.
+
+`.github/actionlint.yaml` in this repo carries a scoped ignore for
+`job.workflow_sha` on `standards-check.yml`, because actionlint 1.7.12's
+context schema hasn't caught up to that (documented, GitHub-populated)
+property yet. Remove the ignore once actionlint recognizes it.
+
+### Tool versions
+
+Pinned and checksum-verified in the workflow's `env:` block:
+
+| Tool | Version | Verification |
+| ---- | ------- | ------------- |
+| shellcheck | 0.11.0 | SHA-256 |
+| actionlint | 1.7.12 | SHA-256 |
+| zizmor | 1.30.0 | SHA-256 |
+| yamllint | 1.38.0 | pinned via `pipx install` |
+| markdownlint-cli2 | 0.23.2 | pinned via `npm install -g` |
+
+Bumps are manual PRs to this repo — Dependabot does not track these
+versions (they're plain strings in a workflow `env:` block, not a manifest
+Dependabot understands).
+
+### Local command
+
+```bash
+bash standards/run-standards.sh --repo
+```
+
+Runs the same checks CI runs, against a local checkout.
+
+### Tests
+
+```bash
+bash tests/run-tests.sh
+```
+
+Requires `shellcheck`, `yamllint`, `actionlint`, `zizmor`, and
+`markdownlint-cli2` (all via Homebrew), plus `jq`.
+
+### Validation
+
+On 2026-09-08, a throwaway PR
+([#163](https://github.com/smartwatermelon/github-workflows/pull/163)) with
+a real `SC2086` was pushed against the caller workflow to confirm the gate
+actually fails on a known-bad case. It did:
+`standards-check / run-standards-check` failed in
+[run 34267112924](https://github.com/smartwatermelon/github-workflows/actions/runs/34267112924),
+with shellcheck reporting the injected problem. PR #163 was then closed
+without merging.
+
+### Versioning
+
+| Tag | Meaning |
+| ----- | --------- |
+| `standards-check-v1` | **Recommended for callers.** Floating major — moved manually and human-authorized, exactly like `@v3` above. |
+| `standards-check-vX.Y.Z` | Exact release. Pin only with a specific reason to freeze; see the "Prefer floating `@v3`" discussion above — the same trade-off applies here. |
+
+This workflow uses its own prefixed tag namespace for the same reason
+`dependabot-auto-merge` does: git tags in this repo are repo-scoped, not
+per-file, so a fourth workflow starting a bare `v1` would collide with the
+existing `claude-blocking-review`/`claude-assistant` `v1` line.
+
+---
+
## `claude-assistant`
Reusable workflow that invokes Claude Code Action. The caller handles triggers
diff --git a/standards/check-node-floor.sh b/standards/check-node-floor.sh
new file mode 100755
index 0000000..7552376
--- /dev/null
+++ b/standards/check-node-floor.sh
@@ -0,0 +1,105 @@
+#!/usr/bin/env bash
+# check-node-floor.sh
+#
+# Fails (exit 1) if any Node.js version pin in the repo names a major below
+# . Sources scanned:
+# - .github/workflows/*.yml|*.yaml : `node-version:` literals and
+# `node-version-file:` indirections
+# - .nvmrc, .node-version : bare versions ("20", "20.19.4", "v18")
+# - package.json : engines.node lower bound (">=14.0.0")
+# Named aliases (lts/*, node, latest, current) are accepted: they float and
+# are never below the floor. Expressions (${{ ... }}) are skipped with a
+# notice because they cannot be resolved statically.
+set -euo pipefail
+
+repo="${1:?usage: check-node-floor.sh }"
+floor="${2:?usage: check-node-floor.sh }"
+[[ "${floor}" =~ ^[0-9]+$ ]] || { echo "::error::floor must be an integer major, got '${floor}'"; exit 2; }
+
+errors=0
+
+# _major -> prints the major, or nothing for aliases/unparseable.
+# Always returns 0: "no major here" is a valid answer (lts/*, node, latest),
+# not an error, and a nonzero status would be masked inside "$(_major ...)".
+_major() {
+ local v="${1}"
+ v="${v#v}"; v="${v%%.*}"
+ if [[ "${v}" =~ ^[0-9]+$ ]]; then printf '%s' "${v}"; fi
+ return 0
+}
+
+# _check
+_check() {
+ local major="${1}" where="${2}"
+ [[ -z "${major}" ]] && return 0
+ if (( major < floor )); then
+ echo "::error::${where}: Node ${major} is below the supported floor (${floor})"
+ errors=$((errors + 1))
+ fi
+}
+
+# _version_from_file -> first non-empty, non-comment line, trimmed
+_version_from_file() {
+ grep -vE '^\s*(#|$)' "${1}" | head -1 | tr -d '[:space:]'
+}
+
+for f in "${repo}/.nvmrc" "${repo}/.node-version"; do
+ [[ -f "${f}" ]] || continue
+ raw="$(_version_from_file "${f}")"
+ major="$(_major "${raw}")"
+ _check "${major}" "${f#"${repo}"/}: ${raw}"
+done
+
+if [[ -f "${repo}/package.json" ]] && ! command -v jq >/dev/null; then
+ # Never skip a documented source silently: without jq an engines.node pin
+ # below the floor would pass unnoticed, which is worse than a hard failure.
+ echo "::error::package.json is present but jq is not installed; engines.node cannot be checked"
+ exit 2
+fi
+
+if [[ -f "${repo}/package.json" ]]; then
+ eng="$(jq -r '.engines.node // empty' "${repo}/package.json" 2>/dev/null || true)"
+ if [[ -n "${eng}" ]]; then
+ # Lower bound: first numeric token after an optional >= / ^ / ~ prefix.
+ low="$(printf '%s' "${eng}" | grep -oE '[0-9]+(\.[0-9]+)*' | head -1 || true)"
+ major="$(_major "${low}")"
+ _check "${major}" "package.json engines.node: ${eng}"
+ fi
+fi
+
+# Literal opening delimiter of a GitHub Actions expression, built by
+# concatenation so no single-quoted "${{" appears in the source (SC2016).
+expr_open='$'"{{"
+
+shopt -s nullglob
+for wf in "${repo}"/.github/workflows/*.yml "${repo}"/.github/workflows/*.yaml; do
+ rel="${wf#"${repo}"/}"
+ while IFS= read -r line; do
+ val="$(printf '%s' "${line}" | sed -E 's/.*node-version:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^["'"'"']//; s/["'"'"']$//')"
+ if [[ "${val}" == *"${expr_open}"* ]]; then
+ echo "::notice::${rel}: node-version is an expression (${val}); not checked"
+ continue
+ fi
+ major="$(_major "${val}")"
+ # A value that is neither a number nor a known floating alias is not
+ # something this checker can vouch for; say so rather than pass silently.
+ if [[ -z "${major}" && ! "${val}" =~ ^(lts/.*|node|latest|current|\*)$ ]]; then
+ echo "::notice::${rel}: node-version '${val}' is not a recognisable version or alias; not checked"
+ fi
+ _check "${major}" "${rel}: node-version: ${val}"
+ done < <(grep -E '^\s*node-version:' "${wf}" || true)
+ while IFS= read -r line; do
+ vf="$(printf '%s' "${line}" | sed -E 's/.*node-version-file:[[:space:]]*//; s/[[:space:]]+#.*$//; s/^["'"'"']//; s/["'"'"']$//')"
+ if [[ -f "${repo}/${vf}" ]]; then
+ raw="$(_version_from_file "${repo}/${vf}")"
+ major="$(_major "${raw}")"
+ _check "${major}" "${rel}: node-version-file ${vf} -> ${raw}"
+ fi
+ done < <(grep -E '^\s*node-version-file:' "${wf}" || true)
+done
+
+if (( errors > 0 )); then
+ echo "::error::${errors} Node pin(s) below floor ${floor}. Node 20 reached EOL 2026-04-30."
+ exit 1
+fi
+echo "Node floor ${floor}: OK"
diff --git a/standards/markdownlint.json b/standards/markdownlint.json
new file mode 100644
index 0000000..8a1e9bf
--- /dev/null
+++ b/standards/markdownlint.json
@@ -0,0 +1,13 @@
+{
+ "default": true,
+ "MD013": false,
+ "MD024": {
+ "siblings_only": true
+ },
+ "MD029": false,
+ "MD033": false,
+ "MD036": false,
+ "MD040": false,
+ "MD046": false,
+ "MD060": false
+}
diff --git a/standards/run-standards.sh b/standards/run-standards.sh
new file mode 100755
index 0000000..72b5c56
--- /dev/null
+++ b/standards/run-standards.sh
@@ -0,0 +1,149 @@
+#!/usr/bin/env bash
+# run-standards.sh — the deterministic standards check.
+#
+# run-standards.sh [--repo DIR] [--config-dir DIR] [--skip a,b,c] [--node-floor N]
+#
+# Runs shellcheck, yamllint, actionlint, zizmor, markdownlint, and the
+# Node-floor check over the tracked files of DIR (default: cwd). Exit 0 only
+# when every enabled linter is clean. A linter with nothing to lint passes
+# with a notice — absence of files is not a failure.
+#
+# Config precedence, per linter: a config at the repo root wins; otherwise
+# the canonical file under --config-dir (github-workflows/standards/) is
+# used. zizmor's canonical config is ../zizmor.yml relative to --config-dir.
+#
+# Same script runs in CI (standards-check.yml) and locally.
+set -euo pipefail
+
+repo="$(pwd)"
+config_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+skip=""
+node_floor="22"
+while (($# > 0)); do
+ case "$1" in
+ --repo) repo="$2"; shift 2 ;;
+ --config-dir) config_dir="$2"; shift 2 ;;
+ --skip) skip="$2"; shift 2 ;;
+ --node-floor) node_floor="$2"; shift 2 ;;
+ *) echo "::error::unknown argument: $1"; exit 2 ;;
+ esac
+done
+repo="$(cd "${repo}" && pwd)"
+config_dir="$(cd "${config_dir}" && pwd)"
+
+# Fail loudly on a directory git cannot read. Every file-based linter
+# enumerates through `_tracked || true`, so without this guard a non-repo (or
+# a repo git refuses, e.g. dubious ownership) yields an empty file list and
+# each linter reports a clean pass over nothing.
+if ! git -C "${repo}" rev-parse --git-dir >/dev/null 2>&1; then
+ echo "::error::--repo ${repo} is not a git repository"
+ exit 2
+fi
+
+failures=0
+_skipped() { [[ ",${skip}," == *",$1,"* ]]; }
+_header() { echo; echo "== $1"; }
+_fail() { echo "::error::$1 found problems"; failures=$((failures + 1)); }
+_tracked() { git -C "${repo}" ls-files -z --cached --others --exclude-standard; }
+
+# Shell lint pass over *.sh, *.bash, and files whose shebang is a
+# bourne-family shell. (Do not start this comment with the linter's name
+# followed by a colon: that parses as a shellcheck directive, SC1073.)
+if _skipped shellcheck; then echo "== shellcheck: skipped by input"; else
+ _header shellcheck
+ files=()
+ while IFS= read -r -d '' f; do
+ case "${f}" in
+ *.sh|*.bash) files+=("${f}") ;;
+ *)
+ if [[ -f "${repo}/${f}" ]] &&
+ head -c 64 "${repo}/${f}" 2>/dev/null | head -1 | grep -qE '^#!.*\b(ba)?sh\b'; then
+ files+=("${f}")
+ fi
+ ;;
+ esac
+ done < <(_tracked || true)
+ if ((${#files[@]} == 0)); then echo "::notice::no shell files"; else
+ (cd "${repo}" && shellcheck -S info "${files[@]}") || _fail shellcheck
+ fi
+fi
+
+# yamllint: *.yml, *.yaml
+if _skipped yamllint; then echo "== yamllint: skipped by input"; else
+ _header yamllint
+ files=()
+ while IFS= read -r -d '' f; do
+ case "${f}" in
+ *.yml|*.yaml) files+=("${f}") ;;
+ *) ;;
+ esac
+ done < <(_tracked || true)
+ if ((${#files[@]} == 0)); then echo "::notice::no YAML files"; else
+ cfg=""
+ for c in .yamllint .yamllint.yml .yamllint.yaml; do
+ if [[ -f "${repo}/${c}" ]]; then cfg="${repo}/${c}"; break; fi
+ done
+ [[ -n "${cfg}" ]] || cfg="${config_dir}/yamllint.yml"
+ (cd "${repo}" && yamllint -c "${cfg}" -f parsable "${files[@]}") || _fail yamllint
+ fi
+fi
+
+# actionlint: .github/workflows only; it finds them itself.
+if _skipped actionlint; then echo "== actionlint: skipped by input"; else
+ _header actionlint
+ if compgen -G "${repo}/.github/workflows/*.y*ml" >/dev/null; then
+ (cd "${repo}" && actionlint -shellcheck= -pyflakes=) || _fail actionlint
+ else echo "::notice::no workflows"; fi
+fi
+
+# zizmor: .github/workflows/*.yml|*.yaml only (a tracked example workflow
+# living elsewhere, e.g. docs/examples/.github/workflows/, is out of scope).
+# Config: repo-root zizmor.yml, else the canonical policy one level above
+# config-dir.
+if _skipped zizmor; then echo "== zizmor: skipped by input"; else
+ _header zizmor
+ files=()
+ while IFS= read -r -d '' f; do
+ case "${f}" in
+ .github/workflows/*.yml|.github/workflows/*.yaml) files+=("${f}") ;;
+ *) ;;
+ esac
+ done < <(_tracked || true)
+ if ((${#files[@]} == 0)); then echo "::notice::no workflows"; else
+ cfg="${repo}/zizmor.yml"; [[ -f "${cfg}" ]] || cfg="${config_dir}/../zizmor.yml"
+ (cd "${repo}" && zizmor --config "${cfg}" --min-severity low --no-online-audits "${files[@]}") || _fail zizmor
+ fi
+fi
+
+# markdownlint: *.md via markdownlint-cli2; repo config wins, else canonical.
+if _skipped markdownlint; then echo "== markdownlint: skipped by input"; else
+ _header markdownlint
+ files=()
+ while IFS= read -r -d '' f; do
+ case "${f}" in
+ *.md) files+=("${f}") ;;
+ *) ;;
+ esac
+ done < <(_tracked || true)
+ if ((${#files[@]} == 0)); then echo "::notice::no Markdown files"; else
+ cfg=""
+ for c in .markdownlint-cli2.jsonc .markdownlint-cli2.yaml .markdownlint-cli2.cjs .markdownlint.jsonc .markdownlint.json .markdownlint.yaml .markdownlint.yml .markdownlintrc; do
+ if [[ -f "${repo}/${c}" ]]; then cfg="${repo}/${c}"; break; fi
+ done
+ [[ -n "${cfg}" ]] || cfg="${config_dir}/markdownlint.json"
+ (cd "${repo}" && markdownlint-cli2 --config "${cfg}" "${files[@]}") || _fail markdownlint
+ fi
+fi
+
+# node-floor
+if _skipped node-floor; then echo "== node-floor: skipped by input"; else
+ _header node-floor
+ bash "${config_dir}/check-node-floor.sh" "${repo}" "${node_floor}" || _fail node-floor
+fi
+
+echo
+if ((failures > 0)); then
+ echo "::error::standards-check: ${failures} linter(s) failed"
+ exit 1
+fi
+echo "standards-check: all enabled linters clean"
diff --git a/standards/yamllint.yml b/standards/yamllint.yml
new file mode 100644
index 0000000..17e1d86
--- /dev/null
+++ b/standards/yamllint.yml
@@ -0,0 +1,8 @@
+---
+# Canonical yamllint config for standards-check.yml. Mirrors
+# ~/.config/yamllint/config in dotfiles; keep them in sync.
+extends: relaxed
+
+rules:
+ line-length:
+ max: 120
diff --git a/tests/run-tests.sh b/tests/run-tests.sh
new file mode 100755
index 0000000..6762314
--- /dev/null
+++ b/tests/run-tests.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Runs every tests/test-*.sh; exits nonzero if any fails.
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+failed=0
+for t in "${here}"/test-*.sh; do
+ echo "== ${t##*/}"
+ if bash "${t}"; then
+ echo "PASS ${t##*/}"
+ else
+ echo "FAIL ${t##*/}"
+ failed=$((failed + 1))
+ fi
+done
+echo "${failed} test file(s) failed"
+[[ "${failed}" -eq 0 ]]
diff --git a/tests/test-check-node-floor.sh b/tests/test-check-node-floor.sh
new file mode 100755
index 0000000..b07811e
--- /dev/null
+++ b/tests/test-check-node-floor.sh
@@ -0,0 +1,75 @@
+#!/usr/bin/env bash
+# Known-bad validation for standards/check-node-floor.sh. Each fixture is a
+# throwaway directory; the control case proves the checker can fail.
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+checker="${here}/../standards/check-node-floor.sh"
+tmp="$(mktemp -d)"
+trap 'rm -rf "${tmp}"' EXIT
+pass=0; fail=0
+_ok() { echo " ok $1"; pass=$((pass + 1)); }
+_bad() { echo " FAIL $1"; fail=$((fail + 1)); }
+
+# Fixture 1: workflow pins node 20 -> must fail
+mkdir -p "${tmp}/f1/.github/workflows"
+cat >"${tmp}/f1/.github/workflows/ci.yml" <<'EOF'
+jobs:
+ build:
+ steps:
+ - uses: actions/setup-node@abc
+ with:
+ node-version: 20
+EOF
+if bash "${checker}" "${tmp}/f1" 22 >/dev/null 2>&1; then _bad "workflow node-version 20 accepted"; else _ok "workflow node-version 20 rejected"; fi
+
+# Fixture 2: .nvmrc 18.20.4 -> must fail
+mkdir -p "${tmp}/f2"; echo "18.20.4" >"${tmp}/f2/.nvmrc"
+if bash "${checker}" "${tmp}/f2" 22 >/dev/null 2>&1; then _bad ".nvmrc 18 accepted"; else _ok ".nvmrc 18 rejected"; fi
+
+# Fixture 3: package.json engines floor admits 14 -> must fail
+mkdir -p "${tmp}/f3"; echo '{"engines":{"node":">=14.0.0"}}' >"${tmp}/f3/package.json"
+if bash "${checker}" "${tmp}/f3" 22 >/dev/null 2>&1; then _bad "engines >=14 accepted"; else _ok "engines >=14 rejected"; fi
+
+# Fixture 4: everything at or above the floor -> must pass
+mkdir -p "${tmp}/f4/.github/workflows"
+printf 'jobs:\n b:\n steps:\n - with:\n node-version: "24"\n' >"${tmp}/f4/.github/workflows/ci.yml"
+echo "lts/krypton" >"${tmp}/f4/.nvmrc"
+echo '{"engines":{"node":">=22"}}' >"${tmp}/f4/package.json"
+if bash "${checker}" "${tmp}/f4" 22 >/dev/null 2>&1; then _ok "conformant repo passes"; else _bad "conformant repo rejected"; fi
+
+# Fixture 5: no Node anywhere -> must pass
+mkdir -p "${tmp}/f5"; echo "hi" >"${tmp}/f5/README.md"
+if bash "${checker}" "${tmp}/f5" 22 >/dev/null 2>&1; then _ok "repo without Node passes"; else _bad "repo without Node rejected"; fi
+
+# Fixture 6: node-version-file indirection to a bad .nvmrc -> must fail
+mkdir -p "${tmp}/f6/.github/workflows"; echo "20.19.4" >"${tmp}/f6/.nvmrc"
+printf 'jobs:\n b:\n steps:\n - with:\n node-version-file: .nvmrc\n' >"${tmp}/f6/.github/workflows/ci.yml"
+if bash "${checker}" "${tmp}/f6" 22 >/dev/null 2>&1; then _bad "node-version-file -> 20 accepted"; else _ok "node-version-file -> 20 rejected"; fi
+
+# Fixture 7: a real YAML inline comment (space before #) must not hide the pin
+mkdir -p "${tmp}/f7/.github/workflows"
+printf 'jobs:\n b:\n steps:\n - with:\n node-version: 20 # legacy\n' >"${tmp}/f7/.github/workflows/ci.yml"
+if bash "${checker}" "${tmp}/f7" 22 >/dev/null 2>&1; then _bad "node-version 20 with trailing comment accepted"; else _ok "node-version 20 with trailing comment rejected"; fi
+
+# Fixture 8: "20#c" is the YAML string "20#c", not version 20 (no space before
+# the #, so YAML starts no comment). It must not be misread as a Node 20 pin.
+mkdir -p "${tmp}/f8/.github/workflows"
+printf 'jobs:\n b:\n steps:\n - with:\n node-version: 20#c\n' >"${tmp}/f8/.github/workflows/ci.yml"
+if bash "${checker}" "${tmp}/f8" 22 >/dev/null 2>&1; then _ok "unparseable '20#c' not misread as Node 20"; else _bad "'20#c' misread as a Node 20 pin"; fi
+
+# Fixture 9: package.json present but jq unavailable must fail loudly (exit 2),
+# never skip the engines.node source in silence.
+mkdir -p "${tmp}/f9/bin" "${tmp}/f9/repo"
+echo '{"engines":{"node":">=14"}}' >"${tmp}/f9/repo/package.json"
+for c in grep head sed printf bash cat tr; do
+ p="$(command -v "${c}" || true)"
+ [[ -n "${p}" ]] && ln -sf "${p}" "${tmp}/f9/bin/${c}"
+done
+if env PATH="${tmp}/f9/bin" bash "${checker}" "${tmp}/f9/repo" 22 >/dev/null 2>&1; then
+ _bad "missing jq silently skipped engines.node"
+else
+ _ok "missing jq fails loudly instead of skipping engines.node"
+fi
+
+echo "${pass} passed, ${fail} failed"
+[[ "${fail}" -eq 0 ]]
diff --git a/tests/test-run-standards.sh b/tests/test-run-standards.sh
new file mode 100755
index 0000000..a6a8a3e
--- /dev/null
+++ b/tests/test-run-standards.sh
@@ -0,0 +1,95 @@
+#!/usr/bin/env bash
+# Known-bad validation for standards/run-standards.sh: one fixture per
+# linter that MUST fail, one clean fixture that MUST pass, and a --skip case
+# proving the toggle really disables a linter. Requires the five tools on
+# PATH (brew install shellcheck yamllint actionlint zizmor markdownlint-cli2).
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+runner="${here}/../standards/run-standards.sh"
+cfg="${here}/../standards"
+for tool in shellcheck yamllint actionlint zizmor markdownlint-cli2 jq; do
+ command -v "${tool}" >/dev/null || { echo "SKIP: ${tool} not on PATH"; exit 0; }
+done
+tmp="$(mktemp -d)"
+# Hermetic fixtures: the user's global init.templateDir scaffolds
+# .editorconfig/.gitignore/.claude into every `git init`, which leaks real
+# files into fixtures that are supposed to be empty. An explicit empty
+# --template overrides it, so a bare fixture really is bare.
+tmpl="$(mktemp -d)"
+trap 'rm -rf "${tmp}" "${tmpl}"' EXIT
+pass=0; fail=0
+_ok() { echo " ok $1"; pass=$((pass + 1)); }
+_bad() { echo " FAIL $1"; fail=$((fail + 1)); }
+_mk() { mkdir -p "${tmp}/$1"; git -C "${tmp}/$1" init -q --template="${tmpl}"; }
+_expect_fail() { # name dir
+ if bash "${runner}" --repo "${tmp}/$2" --config-dir "${cfg}" >"${tmp}/$2.log" 2>&1; then _bad "$1 (accepted; see ${tmp}/$2.log)"; else _ok "$1"; fi
+}
+_expect_pass() {
+ if bash "${runner}" --repo "${tmp}/$2" --config-dir "${cfg}" >"${tmp}/$2.log" 2>&1; then _ok "$1"; else _bad "$1 (rejected; see ${tmp}/$2.log)"; cat "${tmp}/$2.log"; fi
+}
+
+# Fixture literals that must contain "$1" / "${{ }}" verbatim. They are
+# assembled from a lone "$" plus the rest so the source carries no
+# single-quoted expression for shellcheck to warn about (SC2016).
+d='$'
+
+_mk bad-sh; printf '#!/usr/bin/env bash\necho %s1\n' "${d}" >"${tmp}/bad-sh/x.sh"; git -C "${tmp}/bad-sh" add -A
+_expect_fail "shellcheck: unquoted \$1 (SC2086) rejected" bad-sh
+
+_mk bad-yaml; printf 'a: 1\n b: 2\n' >"${tmp}/bad-yaml/x.yml"; git -C "${tmp}/bad-yaml" add -A
+_expect_fail "yamllint: bad indentation rejected" bad-yaml
+
+_mk bad-action; mkdir -p "${tmp}/bad-action/.github/workflows"
+printf 'on: push\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n uses: actions/checkout@v7\n' >"${tmp}/bad-action/.github/workflows/ci.yml"
+git -C "${tmp}/bad-action" add -A
+_expect_fail "actionlint: run+uses in one step rejected" bad-action
+
+_mk bad-zizmor; mkdir -p "${tmp}/bad-zizmor/.github/workflows"
+printf 'on: pull_request_target\npermissions: write-all\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n with:\n ref: %s{{ github.event.pull_request.head.ref }}\n' "${d}" >"${tmp}/bad-zizmor/.github/workflows/ci.yml"
+git -C "${tmp}/bad-zizmor" add -A
+_expect_fail "zizmor: pwn-request / unpinned third-party rejected" bad-zizmor
+
+_mk bad-md; printf '#Bad heading\n\n\n\nx\n' >"${tmp}/bad-md/README.md"; git -C "${tmp}/bad-md" add -A
+_expect_fail "markdownlint: MD018/MD012 rejected" bad-md
+
+# A tracked example workflow that lives outside .github/workflows (e.g. docs
+# that show a caller stub) must not make zizmor scan it: only the repo's real
+# workflows are in scope. Only .github/workflows/ci.yml is a real workflow
+# here, and it is clean, so this must PASS even though the tracked example
+# file would fail zizmor if scanned.
+_mk zizmor-scoped; mkdir -p "${tmp}/zizmor-scoped/.github/workflows" "${tmp}/zizmor-scoped/docs/examples/.github/workflows"
+printf 'on: pull_request_target\npermissions: write-all\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - uses: actions/checkout@v7\n with:\n ref: %s{{ github.event.pull_request.head.ref }}\n' "${d}" >"${tmp}/zizmor-scoped/docs/examples/.github/workflows/bad.yml"
+printf 'on: push\npermissions:\n contents: read\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n' >"${tmp}/zizmor-scoped/.github/workflows/ci.yml"
+git -C "${tmp}/zizmor-scoped" add -A
+_expect_pass "zizmor: tracked example workflow outside .github/workflows is out of scope" zizmor-scoped
+
+_mk bad-node; echo "20" >"${tmp}/bad-node/.nvmrc"; git -C "${tmp}/bad-node" add -A
+_expect_fail "node-floor: .nvmrc 20 rejected" bad-node
+
+_mk clean; mkdir -p "${tmp}/clean/.github/workflows"
+printf '#!/usr/bin/env bash\nset -euo pipefail\necho "%s{1:-}"\n' "${d}" >"${tmp}/clean/ok.sh"
+printf '# Title\n\nBody.\n' >"${tmp}/clean/README.md"
+printf 'key: value\n' >"${tmp}/clean/x.yml"
+printf 'on: push\npermissions:\n contents: read\njobs:\n a:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n' >"${tmp}/clean/.github/workflows/ci.yml"
+echo "lts/krypton" >"${tmp}/clean/.nvmrc"
+git -C "${tmp}/clean" add -A
+_expect_pass "clean repo passes every linter" clean
+
+if bash "${runner}" --repo "${tmp}/bad-sh" --config-dir "${cfg}" --skip shellcheck >/dev/null 2>&1; then _ok "--skip shellcheck disables the linter"; else _bad "--skip shellcheck did not disable it"; fi
+
+_mk empty; git -C "${tmp}/empty" add -A
+_expect_pass "empty repo passes (nothing to lint is not a failure)" empty
+
+# A --repo that is not a git repository must fail loudly with exit 2, not
+# report a clean pass. Every file-based linter enumerates via `git ls-files
+# || true`, so without the guard an unreadable directory lints as empty and
+# the check goes green over nothing.
+mkdir -p "${tmp}/not-a-repo"
+set +e
+bash "${runner}" --repo "${tmp}/not-a-repo" --config-dir "${cfg}" >"${tmp}/not-a-repo.log" 2>&1
+rc=$?
+set -e
+if [[ "${rc}" -eq 2 ]]; then _ok "non-repo --repo exits 2"; else _bad "non-repo --repo exited ${rc}, expected 2 (see ${tmp}/not-a-repo.log)"; fi
+
+echo "${pass} passed, ${fail} failed"
+[[ "${fail}" -eq 0 ]]
diff --git a/zizmor.yml b/zizmor.yml
index 9ae4f64..0bcb326 100644
--- a/zizmor.yml
+++ b/zizmor.yml
@@ -93,3 +93,38 @@ rules:
- claude-blocking-review.yml # caller for claude-blocking-review.yml
- claude.yml # caller for claude-assistant.yml
- dependabot-auto-merge.yml # caller for dependabot-auto-merge.yml
+ - standards-check.yml # caller for standards-check.yml
+
+ # A cooldown deliberately DELAYS applying action updates. This repo was
+ # burned by exactly that delay: claude-code-action sat at v1.0.70 for 123
+ # releases and stayed vulnerable to GHSA-8q5r-mmjf-575q with no PR opened
+ # (see #123 and the note at the top of .github/dependabot.yml). Adding a
+ # cooldown here would re-introduce the lag that incident was about, so the
+ # absence of one is a decision, not an oversight.
+ #
+ # This file also serves as the fleet-wide CI fallback policy:
+ # standards-check.yml uses it for any consuming repo that has no zizmor.yml
+ # of its own, so this ignore applies fleet-wide by design, not only here.
+ dependabot-cooldown:
+ ignore:
+ - dependabot.yml
+
+ # zizmor advises `$/...` for a same-repo reusable workflow call. GitHub and
+ # actionlint both reject that form — actionlint reports "not following the
+ # format owner/repo/path@ref nor ./path/to/workflow.yml" — so `./...` is the
+ # only syntax that actually runs. Verified against zizmor 1.30 / actionlint
+ # on 2026-09-08.
+ self-repository:
+ ignore:
+ - self-review.yml
+ - self-standards-check.yml
+
+ # markdownlint-cli2 and yamllint have no lockfile to install from — they are
+ # single pinned CLI tools, not project dependencies. standards-check.yml
+ # pins an exact version of each (MARKDOWNLINT_CLI2_VERSION,
+ # YAMLLINT_VERSION), and the three binary downloads next to them are
+ # additionally SHA256-verified. A lockfile would add a second place for the
+ # version to drift without removing the install.
+ adhoc-packages:
+ ignore:
+ - standards-check.yml