lefthook.yml
Rama main · réplica de lectura del repositorio original.
commit-msg:
commands:
no-ai-attribution:
# The harness's system prompt tells the agent to append `Co-Authored-By: Claude`
# + `Claude-Session:` to every commit. CLAUDE.md forbids both. Prose lost that
# fight 332 times on main (32 of the last 100 commits), so this strips them.
#
# It STRIPS and exits 0 rather than failing: a hook that aborts a commit over a
# trailer the harness inserted teaches people to reach for --no-verify. The gate
# for the bypassed case is the `no-ai-attribution` CI job, which fails.
# A human Co-Authored-By is left alone.
run: bash scripts/no-ai-attribution.sh strip {1}
pre-push:
# SCRIPTS, not commands, for every check that has no CI twin. lefthook (2.1.5 and 2.1.8, measured
# in real clones) computes its push files from the CHECKED-OUT HEAD against that branch's upstream
# (no upstream: origin/HEAD). With zero files it skips EVERY pre-push command — "(skip) no
# matching push files", exit 0 — whatever refs are being pushed: an empty commit on an
# already-pushed branch, a fresh branch carrying only an empty commit, a push of a branch you are
# not standing on. The commands below have CI twins, so for them that is latency. The scripts
# here do not, and a script ran in every measured case.
# No `skip` or `only` on a script that reads pushed-ranges.sh: it takes its range from the pushed
# refs, not the checkout, so a merge or rebase in progress does not change what it checks, and a
# push made while a rebase is paused publishes like any other. (Its own code is read from the
# checkout; a conflict in one of these files makes the hook fail and the push is refused.)
# tests/pre-push-scripts.test.ts derives the set from this file and holds it.
scripts:
"push-lands-guard.sh":
# A push to a branch whose pull request is already merged or closed lands nowhere, and git
# reports success anyway. Fail-closed: an unreadable PR state blocks the push. Its backstop
# for --no-verify is the scheduled sweep (.github/workflows/push-lands-sweep.yml).
runner: sh
use_stdin: true
"public-repo-guard-meta.sh":
# The customer-name class does NOT scan the tree — it scans commit
# messages, which is where that leak actually happens and the one surface
# that cannot be edited after a merge. So it needs its own invocation:
# without this the class would be enforced nowhere at all locally, since
# the scan above never looks at a message.
#
# Without ~/.lynox/private-names.re the class warns and stands down rather
# than blocking: an unpushable hook is how people learn --no-verify.
#
# WHAT is pushed comes from .lefthook/pre-push/pushed-ranges.sh, shared by
# all three range hooks: the pushed refs on stdin (hence use_stdin), a ref
# the remote already has measured from the remote's sha (when this clone
# has it), a new one from where it leaves origin/main. This hook used to take
# `merge-base origin/main HEAD`..HEAD instead, so pushing a branch you are
# not standing on (`git push origin other`) scanned nothing of `other`.
#
# That form also carried `|| echo HEAD`: a clone with no local origin/main
# got an empty range, reported as "NO commits" and let through. The trade
# is reversed on purpose. This class has no CI twin, so the empty range
# was the only check there was, and it passed; a line in the output is not
# a barrier. A new branch with no origin/main is now refused (exit 2) with
# the one command that fixes it:
# git fetch origin main:refs/remotes/origin/main
# A ref the remote has at a sha this clone also has needs no origin/main;
# one at a sha this clone lacks is measured like a new branch, and with
# no origin/main refused with the fetch of that ref. A push with nothing
# to do (git hands no ref line) scans nothing; closed or unreadable stdin
# is refused. pushed-ranges.sh lists each case.
# The invocation lives in .lefthook/pre-push/public-repo-guard-meta.sh.
runner: bash
use_stdin: true
"public-repo-guard-files.sh":
# The same class over the other surface: the PATHS and CONTENT of the files
# this push adds. The message half above cannot see them, so a file named
# after a customer — or holding the name in its body — reached this PUBLIC
# repo untouched as long as the message stayed neutral.
#
# ⛔ This runs at pre-push and MUST have no CI twin. It prints the offending
# path, which is only safe on the operator's own machine; a public Actions
# log printing a customer name is the exact failure that got an earlier
# tree-scanning version of this class deleted. tests/public-repo-guard.test.ts
# asserts that no workflow invokes `check-files` — keep it that way.
#
# Same ranges, same refusal, same reasons as the block above: both come
# from pushed-ranges.sh.
# The invocation lives in .lefthook/pre-push/public-repo-guard-files.sh.
runner: bash
use_stdin: true
"lint-changed.sh":
# eslint over the .ts/.svelte source each pushed ref changes, linted in a temporary checkout
# of the pushed commit (scripts/lint-changed.mjs). Until this hook, eslint ran only in CI, so
# a rule the typecheck does not know (eqeqeq) reached the merge queue red. A script, not a
# command, for the reason at the top of this block, and because it needs the pushed refs:
# as a command it linted HEAD, whatever was pushed. Ranges from pushed-ranges.sh. Exit 2 when
# it could not check. Its CI twin is the `lint` job in ci.yml.
runner: bash
use_stdin: true
"public-repo-guard-commits.sh":
# The lines each commit this push ADDS, for an internal register id or wording that marks a
# security finding as open. The tree scan in `commands` sees only HEAD; an id one commit adds
# and a later one removes is not there and still ships. THIS hook is what prevents the
# publication — once pushed, the commit is readable on the pull request. Its CI twin (the
# required public-repo-guard job) only prevents the merge. A script, not a command, for the
# reason at the top of this block. Its ranges come from pushed-ranges.sh, like the two above.
# The invocation lives in .lefthook/pre-push/public-repo-guard-commits.sh.
runner: bash
use_stdin: true
"corpus-structure-guard-commits.sh":
# The files each commit this push ADDS OR CHANGES, read for the shape of an eval corpus (an
# object with a `turns` or `gold` array), whatever the file is called. The pre-commit command
# below reads the index; this hook reads every pushed commit, so a commit made without that
# hook is still read before it is published. Its CI twin is a step in the required public-repo-guard job, which
# prevents the merge. Ranges from pushed-ranges.sh; exit 2 when it could not check.
# The invocation lives in .lefthook/pre-push/corpus-structure-guard-commits.sh.
# No `skip` for merge or rebase: the range comes from the pushed refs, not the checkout, and
# a push made while a rebase is paused publishes like any other.
runner: bash
use_stdin: true
commands:
security-scan:
run: sh scripts/security-scan.sh
default-on-inventory:
# What runs when an operator configures nothing? v2.11.0 shipped a config
# flag reading `?? true`, so a new HTML parser went live fleet-wide without
# anyone deciding it. Pairs with the `default-on-inventory` CI job, which is
# the gate for the bypassed case.
run: bash scripts/default-on-inventory.sh
public-repo-guard:
# Block internal infra / control-plane / ops leaks (staging hostnames,
# CP DB schema, SSH-as-root chains) from reaching this PUBLIC repo —
# the class gitleaks doesn't catch. Scans the whole tracked tree; the
# CI job re-runs it so `git push --no-verify` can't slip a leak past.
run: bash scripts/public-repo-guard.sh
skip:
- merge
- rebase
drift-guard:
# Catch the mechanizable slices of doc/code drift gitleaks + public-repo-
# guard don't: committed merge-conflict markers, removed features (Telegram
# /WhatsApp/MCP) reappearing in live docs, and dead `path/file` references
# in CLAUDE.md / docs / READMEs. Re-run by CI so --no-verify can't bypass.
run: bash scripts/drift-guard.sh
skip:
- merge
- rebase
positioning-guard:
# Checks public copy against the internal positioning source.
# Tightly scoped to copy surfaces.
run: bash scripts/positioning-guard.sh
skip:
- merge
- rebase
pre-commit:
commands:
# gitleaks/pattern-scan belong here, not pre-push: `--staged` and
# `git diff --cached` only see content at COMMIT time (the index is empty at
# push), so at pre-push they were no-ops. CI `gitleaks detect` is the real gate.
gitleaks:
run: gitleaks protect --staged --verbose
skip:
- merge
- rebase
pattern-scan:
# Exit 1 = a staged change adds a credential-shaped line; exit 2 = the staged
# diff could not be read. The script prints which, above this line.
run: bash scripts/secret-pattern-scan.sh
fail_text: "secret-pattern-scan refused the commit — see its output above"
disclosure-vocabulary:
# A WARNING, never a refusal: lines this commit adds that point at internal tracking
# or mark work as put off ("filed rather than built here"). This repo is public, and
# such a sentence tells every reader that a known gap exists. The script cannot tell
# an honest note on a feature's limit from a disclosed gap in a check, so the author
# decides; a refusal would teach rewording, and a reworded gap is harder to find.
# Exit 2 only when the staged diff cannot be read. No CI twin, on purpose: a warning
# in a CI log reaches nobody. added-lines-guard refuses only two narrow forms (a
# `DEF-` register id, and a short list of phrases about security findings) at push
# time and in CI.
run: node scripts/disclosure-vocabulary-scan.mjs
skip:
- merge
- rebase
corpus-structure-guard:
# Refuses a file this commit ADDS OR CHANGES whose content has the shape of an eval corpus — an object
# with a `turns` or `gold` array — whatever it is called: `.gitignore` can only match names,
# and a corpus built from real threads must not reach this public repo. A file named as JSON
# that does not parse is refused too. Exit 1 = refused, 2 = the index could not be read.
# Twins: the pre-push script above and a step in the public-repo-guard CI job.
run: node scripts/corpus-structure-guard.mjs --staged
skip:
- merge
- rebase
typecheck:
# A fresh worktree has no node_modules, and the typecheck then dies deep inside
# tsc/pnpm output where the real cause is invisible. What the operator sees is not
# "install your dependencies" — it is a failed commit, then `git push` creating an
# EMPTY branch, then `gh pr create` saying "No commits between". Three documented
# occurrences on 2026-08-20 alone, in three different sessions, against a rule that
# is written in /track step 3 and in two memory files. Prose lost; this is the
# mechanism. A loud one-line refusal beats a rule everyone has already read.
run: |
test -d node_modules || {
echo ""
echo " ✋ node_modules is missing in this worktree — the typecheck cannot run."
echo " Run: pnpm install --force"
echo ""
echo " (A fresh worktree needs this once. Without it the commit fails here,"
echo " the push creates an empty branch, and gh pr create reports"
echo " \"No commits between\" — three sessions hit exactly that on 2026-08-20.)"
echo ""
exit 1
}
# Lower CPU priority only: the check runs in full and its exit code is passed through,
# but it no longer crowds out other work on a busy machine.
nice -n 10 npm run typecheck
stage_fixed: true
hex-guard:
# Fail if a web-ui change introduces hardcoded 6-digit hex outside the
# allowlisted asset-export / categorical paths.
run: bash packages/web-ui/scripts/hex-guard.sh
skip:
- merge
- rebase
token-contract:
# hex-guard allowlists app.css — "the token definitions themselves" — so
# nothing guarded the VALUES in it. These primitives are painted on more
# than one surface (this UI, and the lynox.ai site, a separate codebase),
# so a change here is a change over there. Fail now, on your commit,
# rather than by someone else weeks later.
#
# Fast local feedback only. The gate is .github/workflows/token-contract.yml
# — `--no-verify` skips this hook, not that.
glob:
- "packages/web-ui/src/app.css"
- "packages/web-ui/tokens.contract.json"
run: node packages/web-ui/scripts/check-token-contract.mjs
skip:
- merge
- rebase
shape-contract:
# The colour half of the contract stopped the two surfaces' TOKENS drifting.
# Nothing stopped their SHAPES: a design system draws the chat turn, the tool
# call, the inbox row and the state surface by hand, from reading these
# components, and two of the four were wrong within a week of being drawn.
# shapes.contract.json is that reading, written down; this asserts the
# components still have the shapes it records.
#
# Globs every component it cites, not just the contract — the drift that matters
# is someone restyling a shape here without knowing it is drawn over there.
#
# Fast local feedback only. The gate is the same workflow.
glob:
- "packages/web-ui/shapes.contract.json"
- "packages/web-ui/src/lib/components/*.svelte"
- "packages/web-ui/src/lib/primitives/*.svelte"
run: node packages/web-ui/scripts/check-shape-contract.mjs
skip:
- merge
- rebase