@amsterdamdatalabs/enact-repo-controls
v0.3.7
Published
Repo-local git workflow conventions (enact-m5 git hooks, commit rules, coverage ratchet, push checks, CI scripts) installed into a repository via core.hooksPath.
Downloads
1,050
Readme
enact-repo-controls
Repo-local git workflow conventions — the enact-m5 git hooks, commit-message
rules, coverage ratchet, push checks and CI hygiene scripts — packaged as one
global binary and installed into any repository through git's own
core.hooksPath.
Scope: workflow/git conventions only. Agent setup lives in enact-extensions; the agent guard lives in enact-hook. No lefthook, no husky, no npm lifecycle magic.
How it works
target repo (committed) global package (npm i -g @amsterdamdatalabs/enact-repo-controls)
.workflows/hooks/pre-commit ─┐ enact-repo-controls hook <name>
.workflows/hooks/commit-msg ├─ sh stub ──▶ ├─ validates enact-config.toml [controls]
.workflows/hooks/pre-push │ exec ├─ checks bash >= 4
.workflows/hooks/post-commit ─┘ └─ runs scripts/hooks/<name>
enact-config.toml [controls] (config, shared file — see below) (package-owned script, config via env)
.workflows/repo-controls.lock.json (hashes)
local .git/config: core.hooksPath = .workflows/hooks- Stubs are POSIX
sh, contain no paths, and fail loudly (with the install command) whenenact-repo-controlsis not onPATH— never a silent skip. - Bundled scripts in
scripts/hooks/are package-owned implementations. They originated in enact-m5; commit20ec790removed the former byte-copy tree and manifest when mechanism moved into this package. Their current behaviour is exercised bytests/hooks-e2e.test.tsand focused unit tests. - Logs (gitignored):
.enact/logs/enact-repo-controls-activity.loggets a CSV row for every hook invocation;.enact/logs/enact-repo-controls-deny.loggets onlyFAILrows. The owner-qualified names let Enact tools share one repository-local log root.
Requirements
- bun >= 1.4 on
PATH— runtime of the CLI and of the bundled enact-m5 TypeScript scripts (coverage ratchet, push lock,azure-devops-push.tsall useBun.*/bun:sqlite). - bash >= 4 first on
PATH— the enact-m5 scripts use${var,,}(commit-msg) andmapfile(repo-hygiene). macOS ships/bin/bash3.2: install a newer bash (brew install bash) and make sure GUI git clients see it.doctorand everyhookrun check this and fail loudly on 3.x. - git, and npm only to install the package.
- Agent Vault — only when
[controls.release]is declared.doctorneedsagent-vaultand reads only the canonical Agent Vault file: Linux/etc/enact/agent-vault.env(root-owned) or macOS$HOME/.config/enact/agent-vault.env(owned by the invoking user). It must be a regular0600file containing exactly one non-empty plainAGENT_VAULT_ADDR=,AGENT_VAULT_TOKEN=, andAGENT_VAULT_VAULT=assignment; comments and blank lines are the only other permitted content. The file wins over inherited values, is parsed without being sourced, and is never loaded for repositories without[controls.release]. Never place those values in a repository.envor~/.codex/.env. - uv — only if
[controls.compliance].enabledis non-empty; see below. - ast-grep — optional; only 3 of the compliance registry's 93 checks use it,
and the Python runner degrades those specific rows gracefully (not a hard failure)
when it is absent.
doctorwarns, never fails, on a missingast-grep.
Why bun (and not node)
The package is distributed with npm i -g @amsterdamdatalabs/enact-repo-controls, but the scripts it must run verbatim
are enact-m5's bun scripts (bun .../coverage/command.ts, #!/usr/bin/env bun
push wrapper, Bun.spawnSync, bun:sqlite). Bun is therefore a hard runtime
dependency no matter what the CLI is written in; writing the CLI in bun keeps
one runtime, lets the bin entry run TypeScript directly (no build step, no
dist/ drift), and gives the 100% lines/functions coverage gate natively via
bunfig.toml. npm is only the installer: npm i -g links
bin/enact-repo-controls.ts (shebang #!/usr/bin/env bun) and installs the one
runtime dependency (istanbul-lib-coverage, imported by the ratchet).
A second runtime — only if compliance is enabled
package/enact-compliance is a separate, bundled Python project (managed by
uv, which provisions its own Python 3.14+ — no system Python required). This
is not a contradiction of the "one runtime" point above: bash ≥ 4 is already a
second hard external dependency today, for the bundled enact-m5 hook scripts —
uv/Python simply joins bash in that same "externally invoked bundled tool"
category. Nothing about the CLI's own runtime changes; enact-repo-controls
itself still runs in-process under bun exactly as before.
uv is required only when [controls.compliance].enabled is non-empty (run
compliance will fail loudly, naming uv, if it's absent and needed) — installing
this package and running every other command (install, uninstall, doctor
with compliance disabled, hook, and the other run tools) never touches it.
The UI gate design and the source-to-sink proof required for genuine appearance
centralization are documented in
docs/ui-centralization-compliance.md.
CLI
enact-repo-controls install --repo <path> [--dry-run]
enact-repo-controls uninstall --repo <path> [--dry-run]
enact-repo-controls doctor --repo <path> [--json]
enact-repo-controls hook <pre-commit|commit-msg|pre-push|post-commit> [git hook args]
enact-repo-controls promote --repo <path>
enact-repo-controls run <azdo-push|coverage|repo-hygiene|validate-pr-metadata> --repo <path> [-- args]
enact-repo-controls run compliance --repo <path> [-- <gate-id or domain.name> | --summary | --files LIST | --log-dir DIR]
enact-repo-controls --version | helpinstall requires an existing root enact-config.toml: that is the central
management marker. It deletes and rebuilds the complete Enact-owned
.workflows/ root, then appends its [controls] table only when that table is
missing. It appends the single shared root ignore /.enact/logs/ and
/.coverage-cache/ to .gitignore, writes the lock, and sets
core.hooksPath=.workflows/hooks with git config --local.
- Re-running is state-idempotent: the rebuilt root is byte-identical.
--dry-runprints every action and changes nothing (files or git config).- Extra, altered, missing, and retired managed files below
.workflows/are deleted or repaired on the next install. Pre-hard-cut log evidence is kept in place as quarantine; it is never read, written, copied, moved, or deleted. - Written files contain no absolute paths and no
$HOME.
uninstall removes the authoritative .workflows/ projection and
deactivates its hook path. Canonical logs, their root ignore, quarantined log
evidence, and runtime caches remain. enact-config.toml itself is never deleted — only the
[controls] span this package appended is ever spliced back out.
doctor is read-only and exits 1 on any failure: bash >= 4, bun, binary on
PATH, the bundled compliance package present at its packaged path, repository
root, [controls] config validity, (warning) coverage/coverage-summary.json
exists whenever pre_push.coverage_ratchet is enabled, [controls.compliance] config
validity, uv on PATH (only checked once [controls.compliance].enabled is non-empty;
otherwise reported ok unconditionally, same off-by-default posture as the
coverage-summary check), (warning only, same conditioning) ast-grep on PATH,
lock, each stub (present, unedited, executable), core.hooksPath, .gitignore
lines, and (warning) lock version skew. When [controls.release] is declared,
it also derives the configured origin remote, loads the validated canonical
Agent Vault file before reading Azure DevOps, and records separate fail-closed receipts for the HTTPS handle,
required owner reviewer, minimum-review reset, and build policy.
run executes a bundled enact-m5 tool against the repository with the config
exported: azdo-push (Azure DevOps push with push lock + behind-origin refusal),
coverage <check-push|prewarm-push|status|unlock|...>, repo-hygiene (CI
entrypoint), validate-pr-metadata (Azure PR title/description policy).
run compliance runs the bundled enact-compliance Python engine (via uv)
against the repository. --root <repo> is always injected first from --repo;
once [controls.compliance].enabled is non-empty, --only <the enabled list, comma-joined>
is injected right after it. Both are injected before any args given after --,
so a user-supplied --root or --only there overrides the injected one — last
flag wins, matching Python argparse semantics; this is intentional, not a bug to
guard against. Everything else (a bare gate id or domain.name target, --summary,
--files LIST, --log-dir DIR) passes straight through.
Configuration — enact-config.toml [controls]
enact-config.toml (repository root) is a shared file: three separate tools
converge their config into it, one top-level table each — [hooks]/[rules]/
[repo] for enact-hook, [skills] for enact-extensions, [controls] for
this package. A bare root-level version = 1 is a tool-agnostic marker every
tool accepts and ignores. This package reads and writes only its own
[controls] table; every other byte of the file (including other tools'
comments) is opaque and is never inspected, reordered, or dropped.
enact-config.toml's mere presence marks a repository as enact-hook-managed —
running install may effectively create that marker even if enact-hook itself
is not installed there. enact-config.toml itself is never deleted by uninstall.
Install wires the hooks. It arms nothing.
install seeds exactly three attributes — version, branches and
pre_push.remote — and every gate is off. A first install changes what no
git push and no git commit does until somebody opts in. branches and
pre_push.remote are seeded rather than omitted because they are not gates but the
vocabulary gates are phrased in, and because pre-push reads their env vars with
${VAR:?}, which aborts on an empty value: "no branch policy" cannot be spelled as
an empty list.
Seeding is per attribute, and only when absent. An attribute already in the file
is never rewritten, reordered or reformatted, whatever its value — so re-running
install (or a scheduled job that does) can add a missing key but can never arm,
disarm or restyle one you set. Granularity is the top-level key inside [controls]:
an inline table is one attribute, because editing inside one means rewriting bytes
you own. Nothing is parsed and re-serialized; text is spliced into the table's span.
[controls] is strictly validated: unknown keys and wrong types are errors. An
absent optional key is its documented default, never an error.
| Key | Seeded by install | Default when absent | enact-m5 | Effect |
|---|---|---|---|---|
| branches.protected | yes | required | ["main", "integration"] | pre-push refuses while on these branches |
| branches.prefixes | yes | required | ["feat", "fix", "docs", "chore"] | pre-push / azdo-push branch pattern <prefix>/<kebab> |
| pre_push.remote | yes | required | "origin" | remote azdo-push pushes to and reads status from |
| pre_commit.biome | no | false | true | Biome format+lint on the staged index snapshot |
| pre_commit.staged_coverage | no | false | true | enact-m5-only staged coverage measurement |
| pre_commit.doc_freshness | no | false | opt in | a staged docs/ or plans/ .md carries a last_update stamped within 5h |
| pre_push.coverage_ratchet | no | false | true | per outgoing commit, coverage/coverage-summary.json must exist and not regress |
| pre_push.findings_ratchet | no | false | opt in | per outgoing commit, every declared static-analysis counter's findings must not exceed the committed floor |
| pre_push.static_checks | no | [] | tsc, knip, svelte-check | { label, run } commands run on an export of HEAD, pass cached per tree OID |
| release.source | no | "" | "integration" | source branch for the managed promotion PR; no branch pair is guessed |
| release.target | no | "" | "main" | target branch for the managed promotion PR |
| release.owner_reviewer_id | no | "" | owner identity | required owner identity in the blocking Azure DevOps reviewer policy |
| release.merge_strategy | no | "squash" | "no-fast-forward" | one of squash, rebase, rebase-merge, or no-fast-forward |
install copies two package-owned standalone CI assets byte-for-byte:
.workflows/promote.ts.workflows/scripts/enable-integration-auto-complete.mjs
Both installed assets are real executable files, never symlinks. Their SHA-256
hashes live in .workflows/repo-controls.lock.json, and doctor fails if either
file, mode, or lock entry drifts from the canonical source under assets/. A
repository opts into promotion by declaring [controls.release] and wiring its
pipeline to run the installed promoter with the matching branch pair and merge
strategy. release.owner_reviewer_id is required for doctor to prove
the branch policy. The helper creates or reuses the active PR, requires the
owner reviewer, and arms auto-complete without deleting the source branch or
transitioning work items. Before it reads or mutates PRs, it proves the exact
target branch has enabled blocking repository-wide policies for that owner,
at least one approval with resetOnSourcePush = true, and a build definition;
a missing or weaker policy fails promotion. Pre-commit calls the same read-only
doctor report and never repairs or stages the managed surface: run
enact-repo-controls install --repo . explicitly to repair it.
[controls.compliance] follows the same rule: install seeds version and
enabled = [] only. registry and [controls.compliance.trees] have working
defaults and are not written into every repository.
Fixed conventions (enact-m5, not configurable): commit subject
^(feat|fix|docs|refactor|perf|chore|release)(\([a-z0-9][a-z0-9-]*\))?: .+, at most 80
characters, lowercase description without trailing period; Merge , Revert "
and X.Y.Z subjects exempt; no staged .scratchpad paths or new references;
git diff --cached --check; git environment scrubbed before push checks.
Managed dot paths and protected controls
The pre-commit gate has two deliberately different outcomes:
- A changed or untracked top-level dot path is commit-required.
When it is omitted from the index, the hook names it and tells the author to
stage it. Once staged, it can be committed normally. This covers centrally
managed paths such as
.agents/**,.claude/**,.codex/**,.cursor/**,.mcp.json, and future top-level dot paths. Nested paths such astools/.claude/**are ordinary repository content. The designated ephemeral scratch storage is never committable. - A protected control requires human approval even when staged: every path
under
compliance/orworkflows/,enact-config.toml, and the repository's explicit[controls.integrity].watchedpatterns. The same controls cannot be left dirty while committing other work.
Git cannot make a pre-commit hook run for git commit --no-verify. That is the
reviewed human-only escape hatch for protected controls: enact-hook denies
agents' uses of that flag, while Tarun may review a protected diff and commit it
manually with the flag. This package intentionally does not claim to enforce
that actor boundary itself.
Note: doc_freshness is this package's own check, not a bundled enact-m5 script —
the verbatim workflows/hooks/pre-commit is untouched and the check runs in
runHook before it. It carries the rule that used to be compliance row DOC-003
(same 5h window, same YYYY-MM-DDTHH:MM:SSZ format, same exclusions), graded on
the staged index instead of the whole docs/ tree: as a registry row it reddened
every page five hours after anyone touched it, which no amount of editing kept
green. Blobs are read with git show :<path>, so the work-tree copy is irrelevant.
Note: with coverage_ratchet = true every pushed commit and its parent must
contain coverage/coverage-summary.json (Istanbul json-summary); commit a
baseline first or set it to false.
Note: with findings_ratchet = true, [controls.findings] says where the
floor lives (floor, default compliance/ratchets/static-analysis-findings.yaml)
and [controls.findings.counters] names the repository's own static-analysis
commands — a name -> command map the repository authors itself (e.g.
lint = "just check-biome-lint"), so no tool name is ever written into this
package; only the repository's own config names one. Each counter's command
must print one repository-relative path per line to stdout for a run that
completed (exit 0), one line per finding location — any other exit is that
counter failing to run, never zero findings and never a pass. The gate only
reads: it runs every declared counter fresh and fails naming the counter and
what regressed if its findings moved past the committed floor. It never
writes the floor — lowering it, to record an improvement, is a change a
human makes in the same commit as the fix, the same discipline
coverage-floor.json already keeps.
Configuration — enact-config.toml [controls.compliance]
install also appends a [controls.compliance] table to the same shared
enact-config.toml, independently of [controls] (each table is its own
append-once/user-owned-afterwards span; installing, editing, or uninstalling one
never disturbs the other).
It is nested under controls rather than being a root-level [compliance],
and that is load-bearing rather than cosmetic: enact-hook decodes this same
shared file and rejects the whole document on an unrecognized root key, so a
root-level [compliance] turned every enact-hook invocation in a repository
carrying it into a decode error. Keeping this package's config inside the one
root key it already owns needs no coordination with the other two tools — the
same "no tool parses another tool's table" rule the shared file is built on.
tests/compliance-config.test.ts guards this: the rendered config must only
ever use root keys enact-hook accepts.
[controls.compliance] is strictly validated like [controls]
— unknown keys and wrong types are errors — except [controls.compliance.trees], which is
a genuinely open string map: any key is accepted, only its value must be a
non-empty string.
| Key | Default | Effect |
|---|---|---|
| version | 1 | config schema version |
| enabled | [] | gate ids (RST-001) or domain.name addresses (rust.acp-boundary) to grade; empty = installed, nothing runs |
| registry | "default" | forward-compatible; today both values point at the same packaged registry YAML |
| [controls.compliance.trees] | {} (no entries) | per-key path overrides; a key absent here keeps the packaged (source-shaped) default for that key |
Registry curation
The full compliance registry (145 rows) always ships and loads — enabled is
what decides which of those rows is ever graded, not what's present. The
packaged [controls.compliance.trees] defaults are shaped after the original source
repository the checks were authored against, not any arbitrary target repo. A
repository enabling any row needs its own [controls.compliance.trees] overrides for
whatever that row's scope actually needs in that repository's layout — this
package does not ship a pre-vetted "safe for any repo" subset; enabling a
row without pointing its trees at the right paths will simply grade the wrong
(or nonexistent) files.
Development
bun install
bunx tsc --noEmit
bun test --coverage # 100% lines + functions gate (bunfig.toml)Tests run real git in temporary repositories under .scratchpad/test-tmp/
(gitignored), exercising the installed stubs end to end. CI:
azure-pipelines.yml (publishing present but disabled).
