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