diff --git a/AGENTS.md b/AGENTS.md index adbfff59e..920138458 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -110,9 +110,9 @@ run in the web sandbox — read `mem:github-access` before doubting.) branch is rebased on current `origin/main`. "Green but stale" is not green. 3. **`mise run linear-check`.** Don't ready by hand: `land` readies, after its push, and a ready spent before that buys only draft-era skips (CLOUD-247). -4. **`mise run land`, backgrounded.** It drives the whole loop — **no timeout, no - cap, never the PR webhook** — and stops for three things only: a rebase - conflict, a failed `verify`, or red CI, re-drafting the PR. `mem:workflow/landing-loop`. +4. **`mise run land`, backgrounded.** Drives the loop — no wall clock, only + counts, never the PR webhook. Stops on a conflict, a failed `verify`, red CI, + or a spent lap budget. **When a stop says run it again, run it again.** 5. **Never re-run CI on an already-tested SHA.** Fast-forward means `main` takes the PR's exact, already-passed commits. Don't add push-to-`main` triggers. diff --git a/batten.toml b/batten.toml index e3a5fee84..3db3b5ce5 100644 --- a/batten.toml +++ b/batten.toml @@ -13487,3 +13487,57 @@ id = "host-dependencies-present" gloss = "the released batten resolves by the bare name every hook registration invokes it under" check = ["env", "batten", "--version"] repair = ["mise", "run", "deps-install"] + +# THE COMMIT PATH IS A PRECONDITION, AND NOTHING DECLARED IT (CLOUD-1398). +# +# The five rows above ask whether the ENGINE can run. None of them asks whether +# the engine is on the path a COMMIT takes, and in the container this repository +# provisions for itself those came apart: `git commit` ran neither `pre-commit` +# nor `commit-msg`, so every commit bypassed the gate while `batten startup` +# reported every row green. The session-start advisory's "every declared repair +# has already run this session; what is listed is what it did not fix" was TRUE +# and useless — the hooks were never in the declared set at all. +# +# `mise-tasks/doctor.sh` did see it and emitted two `::error::` lines, which is +# the CLOUD-1454 shape one layer up: a reporter is not a gate. Worse, its remedy +# named `.claude/hooks/session-start.sh`, a program `7d188580` deleted, so the +# refusal was right and its instruction could not be followed. Declaring the +# precondition with a repair that RUNS is the half that closes it. +# +# NOT `hk install`, AND THAT IS MEASURED RATHER THAN PREFERRED (the row's §8). +# On this container `hk` resolves only through the pin — `mise exec -- hk +# --version` answers 1.56.1 while `doctor`'s bare-`PATH` probe reports +# `program-not-on-path hk` and the pin record is absent. `hk install` generates a +# hook whose body calls `hk` BARE, so the hook it installs makes every +# `git commit` fail with `hk: not found`: a repair that reads as installed and +# breaks the thing it installed. `mise.toml`'s `session:git-hooks` is the +# symlink-based form that works, and it is already the session-start step — so +# this row adds an ASSERTION rather than a second installer. +# +# WHY A `batten` VERB HERE, WHERE `toolchain-is-provisioned` REFUSED ONE. That +# row's subject is a third party's install state, and an engine-side reader of it +# would put a specific tool's output format in `crates/batten` — non-negotiable +# rule 1. This row's subject is `$GIT_DIR/hooks`, which is git's own vocabulary +# and every consumer's question, so the engine-side reader names nobody. +# +# `doctor gate` AND NOT `doctor commit-gate`: a man page is committed as the +# hyphen-joined command path and the surface suite maps that filename back by +# replacing every hyphen, so a sub-verb carrying an internal one is not +# round-trippable. `crates/batten/src/surface.rs` records the measurement. +# +# AND THE SUB-VERB RATHER THAN BARE `doctor`, which is `host-dependencies-present`'s +# trap approached from the other side. A row decides on an exit status and cannot +# select one line out of a report, so `check = ["batten", "doctor"]` would fail +# here whenever any unrelated declared program was unreachable — the state this +# very container is in, on the `hk` reading above — and would then fire a +# git-hook repair that cannot fix that, reporting `repair-failed` forever over a +# gate that is installed. +# +# The repair writes under `$GIT_DIR/hooks`, which is outside the worktree and so +# outside `protected`; this key in the committed authority is the authorisation +# to run it. +[[startup]] +id = "commit-gate-installed" +gloss = "this clone's commit path runs the gate, so a commit made here cannot bypass it" +check = ["batten", "doctor", "gate"] +repair = ["mise", "run", "session:git-hooks"] diff --git a/completions/batten.bash b/completions/batten.bash index 212a20740..e28538149 100644 --- a/completions/batten.bash +++ b/completions/batten.bash @@ -310,6 +310,9 @@ _batten() { batten__subcmd__doctor,egress) cmd="batten__subcmd__doctor__subcmd__egress" ;; + batten__subcmd__doctor,gate) + cmd="batten__subcmd__doctor__subcmd__gate" + ;; batten__subcmd__doctor,help) cmd="batten__subcmd__doctor__subcmd__help" ;; @@ -325,6 +328,9 @@ _batten() { batten__subcmd__doctor__subcmd__help,egress) cmd="batten__subcmd__doctor__subcmd__help__subcmd__egress" ;; + batten__subcmd__doctor__subcmd__help,gate) + cmd="batten__subcmd__doctor__subcmd__help__subcmd__gate" + ;; batten__subcmd__doctor__subcmd__help,help) cmd="batten__subcmd__doctor__subcmd__help__subcmd__help" ;; @@ -562,6 +568,9 @@ _batten() { batten__subcmd__help__subcmd__doctor,egress) cmd="batten__subcmd__help__subcmd__doctor__subcmd__egress" ;; + batten__subcmd__help__subcmd__doctor,gate) + cmd="batten__subcmd__help__subcmd__doctor__subcmd__gate" + ;; batten__subcmd__help__subcmd__doctor,hooks) cmd="batten__subcmd__help__subcmd__doctor__subcmd__hooks" ;; @@ -2911,7 +2920,7 @@ _batten() { return 0 ;; batten__subcmd__doctor) - opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help mediator egress hooks session help" + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help mediator egress gate hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 2 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2970,8 +2979,38 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__gate) + opts="-J -q -v -y -h --json --strictness --fail-on-warning --config-from --config-in --silent --quiet --verbose --debug --trace --log-level --no-color --no-input --yes --help" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + --strictness) + COMPREPLY=($(compgen -W "permissive standard strict" -- "${cur}")) + return 0 + ;; + --config-from) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --config-in) + COMPREPLY=($(compgen -f "${cur}")) + return 0 + ;; + --log-level) + COMPREPLY=($(compgen -W "silent quiet normal verbose debug trace" -- "${cur}")) + return 0 + ;; + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__help) - opts="mediator egress hooks session help" + opts="mediator egress gate hooks session help" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -2998,6 +3037,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__doctor__subcmd__help__subcmd__gate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__doctor__subcmd__help__subcmd__help) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then @@ -3945,7 +3998,7 @@ _batten() { return 0 ;; batten__subcmd__help__subcmd__doctor) - opts="mediator egress hooks session" + opts="mediator egress gate hooks session" if [[ ${cur} == -* || ${COMP_CWORD} -eq 3 ]] ; then COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 @@ -3972,6 +4025,20 @@ _batten() { COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) return 0 ;; + batten__subcmd__help__subcmd__doctor__subcmd__gate) + opts="" + if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + fi + case "${prev}" in + *) + COMPREPLY=() + ;; + esac + COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) + return 0 + ;; batten__subcmd__help__subcmd__doctor__subcmd__hooks) opts="" if [[ ${cur} == -* || ${COMP_CWORD} -eq 4 ]] ; then diff --git a/completions/batten.fish b/completions/batten.fish index 0c0d5d861..98c8f87b0 100644 --- a/completions/batten.fish +++ b/completions/batten.fish @@ -584,33 +584,34 @@ complete -c batten -n "__fish_batten_using_subcommand spec" -l no-color -d 'Neve complete -c batten -n "__fish_batten_using_subcommand spec" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand spec" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand spec" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' quiet\t'Suppress ordinary progress; keep warnings' normal\t'The default' verbose\t'Explain what is being checked' debug\t'Add resolution detail' trace\t'Add everything'" -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l debug -d 'Add resolution detail' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l trace -d 'Add everything' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' -complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "gate" -d 'Diagnose whether this checkout\'s commit path runs the gate' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' +complete -c batten -n "__fish_batten_using_subcommand doctor; and not __fish_seen_subcommand_from mediator egress gate hooks session help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from mediator" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -655,6 +656,28 @@ complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from egress" -l no-input -d 'Never prompt; treat the run as unattended' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from egress" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from egress" -s h -l help -d 'Print help (see more with \'--help\')' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' +standard\t'The default: a finding is a violation' +strict\t'Everything `Standard` fails on, plus anything advisory'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l config-from -d 'Read the committed config from a git ref (e.g. origin/main) instead of the working tree' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l config-in -d 'Read the committed config from this directory instead of the directory being judged' -r +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l log-level -d 'Set the verbosity rung by name' -r -f -a "silent\t'Say nothing but a verdict or a usage error' +quiet\t'Suppress ordinary progress; keep warnings' +normal\t'The default' +verbose\t'Explain what is being checked' +debug\t'Add resolution detail' +trace\t'Add everything'" +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s J -l json -d 'Emit byte-stable JSON instead of pointer lines' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l fail-on-warning -d 'Promote a warn-severity finding to a violation (an override may only turn this on)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l silent -d 'Say nothing but a verdict or a usage error' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s q -l quiet -d 'Suppress ordinary progress (repeatable: -qq is silent)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s v -l verbose -d 'Explain what is being checked (repeatable: -vv is debug)' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l debug -d 'Add resolution detail' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l trace -d 'Add everything' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l no-color -d 'Never colour stderr, whatever it is attached to' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -l no-input -d 'Never prompt; treat the run as unattended' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s y -l yes -d 'Confirm a destructive operation that would otherwise refuse' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from gate" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from hooks" -l strictness -d 'Raise how strictly gates apply (an override may only tighten policy)' -r -f -a "permissive\t'Advisory: findings are reported without failing the run' standard\t'The default: a finding is a violation' strict\t'Everything `Standard` fails on, plus anything advisory'" @@ -701,6 +724,7 @@ complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_su complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from session" -s h -l help -d 'Print help (see more with \'--help\')' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' +complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "gate" -d 'Diagnose whether this checkout\'s commit path runs the gate' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand doctor; and __fish_seen_subcommand_from help" -f -a "help" -d 'Print this message or the help of the given subcommand(s)' @@ -3619,6 +3643,7 @@ complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subc complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from lint" -f -a "brief" -d 'Check a delegation brief against the handoff schema (any missing section is a violation)' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "mediator" -d 'Diagnose whether the engine the registrations reach was built from this tree' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "egress" -d 'Diagnose whether the agent proxy would carry this container\'s requests' +complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "gate" -d 'Diagnose whether this checkout\'s commit path runs the gate' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "hooks" -d 'Diagnose whether batten is wired on every hook surface of every harness' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from doctor" -f -a "session" -d 'Diagnose whether this session has declared work it has not finished' complete -c batten -n "__fish_batten_using_subcommand help; and __fish_seen_subcommand_from generate" -f -a "completions" -d 'Emit the shell completion script for one shell' diff --git a/completions/batten.zsh b/completions/batten.zsh index 7427ceb21..77f9501e9 100644 --- a/completions/batten.zsh +++ b/completions/batten.zsh @@ -999,6 +999,37 @@ trace\:"Add everything"))' \ '--help[Print help (see more with '\''--help'\'')]' \ && ret=0 ;; +(gate) +_arguments "${_arguments_options[@]}" : \ +'--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" +standard\:"The default\: a finding is a violation" +strict\:"Everything \`Standard\` fails on, plus anything advisory"))' \ +'--config-from=[Read the committed config from a git ref (e.g. origin/main) instead of the working tree]: :_default' \ +'--config-in=[Read the committed config from this directory instead of the directory being judged]: :_default' \ +'--log-level=[Set the verbosity rung by name]: :((silent\:"Say nothing but a verdict or a usage error" +quiet\:"Suppress ordinary progress; keep warnings" +normal\:"The default" +verbose\:"Explain what is being checked" +debug\:"Add resolution detail" +trace\:"Add everything"))' \ +'-J[Emit byte-stable JSON instead of pointer lines]' \ +'--json[Emit byte-stable JSON instead of pointer lines]' \ +'--fail-on-warning[Promote a warn-severity finding to a violation (an override may only turn this on)]' \ +'*--silent[Say nothing but a verdict or a usage error]' \ +'*-q[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*--quiet[Suppress ordinary progress (repeatable\: -qq is silent)]' \ +'*-v[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--verbose[Explain what is being checked (repeatable\: -vv is debug)]' \ +'*--debug[Add resolution detail]' \ +'*--trace[Add everything]' \ +'--no-color[Never colour stderr, whatever it is attached to]' \ +'--no-input[Never prompt; treat the run as unattended]' \ +'-y[Confirm a destructive operation that would otherwise refuse]' \ +'--yes[Confirm a destructive operation that would otherwise refuse]' \ +'-h[Print help (see more with '\''--help'\'')]' \ +'--help[Print help (see more with '\''--help'\'')]' \ +&& ret=0 +;; (hooks) _arguments "${_arguments_options[@]}" : \ '--strictness=[Raise how strictly gates apply (an override may only tighten policy)]: :((permissive\:"Advisory\: findings are reported without failing the run" @@ -1081,6 +1112,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(gate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -6164,6 +6199,10 @@ _arguments "${_arguments_options[@]}" : \ _arguments "${_arguments_options[@]}" : \ && ret=0 ;; +(gate) +_arguments "${_arguments_options[@]}" : \ +&& ret=0 +;; (hooks) _arguments "${_arguments_options[@]}" : \ && ret=0 @@ -7450,6 +7489,7 @@ _batten__subcmd__doctor_commands() { local commands; commands=( 'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'egress:Diagnose whether the agent proxy would carry this container'\''s requests' \ +'gate:Diagnose whether this checkout'\''s commit path runs the gate' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -7461,11 +7501,17 @@ _batten__subcmd__doctor__subcmd__egress_commands() { local commands; commands=() _describe -t commands 'batten doctor egress commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__gate_commands] )) || +_batten__subcmd__doctor__subcmd__gate_commands() { + local commands; commands=() + _describe -t commands 'batten doctor gate commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__help_commands] )) || _batten__subcmd__doctor__subcmd__help_commands() { local commands; commands=( 'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'egress:Diagnose whether the agent proxy would carry this container'\''s requests' \ +'gate:Diagnose whether this checkout'\''s commit path runs the gate' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ 'help:Print this message or the help of the given subcommand(s)' \ @@ -7477,6 +7523,11 @@ _batten__subcmd__doctor__subcmd__help__subcmd__egress_commands() { local commands; commands=() _describe -t commands 'batten doctor help egress commands' commands "$@" } +(( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__gate_commands] )) || +_batten__subcmd__doctor__subcmd__help__subcmd__gate_commands() { + local commands; commands=() + _describe -t commands 'batten doctor help gate commands' commands "$@" +} (( $+functions[_batten__subcmd__doctor__subcmd__help__subcmd__help_commands] )) || _batten__subcmd__doctor__subcmd__help__subcmd__help_commands() { local commands; commands=() @@ -7832,6 +7883,7 @@ _batten__subcmd__help__subcmd__doctor_commands() { local commands; commands=( 'mediator:Diagnose whether the engine the registrations reach was built from this tree' \ 'egress:Diagnose whether the agent proxy would carry this container'\''s requests' \ +'gate:Diagnose whether this checkout'\''s commit path runs the gate' \ 'hooks:Diagnose whether batten is wired on every hook surface of every harness' \ 'session:Diagnose whether this session has declared work it has not finished' \ ) @@ -7842,6 +7894,11 @@ _batten__subcmd__help__subcmd__doctor__subcmd__egress_commands() { local commands; commands=() _describe -t commands 'batten help doctor egress commands' commands "$@" } +(( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__gate_commands] )) || +_batten__subcmd__help__subcmd__doctor__subcmd__gate_commands() { + local commands; commands=() + _describe -t commands 'batten help doctor gate commands' commands "$@" +} (( $+functions[_batten__subcmd__help__subcmd__doctor__subcmd__hooks_commands] )) || _batten__subcmd__help__subcmd__doctor__subcmd__hooks_commands() { local commands; commands=() diff --git a/crates/batten/src/cli.rs b/crates/batten/src/cli.rs index 7f9a0ef5d..2db82089a 100644 --- a/crates/batten/src/cli.rs +++ b/crates/batten/src/cli.rs @@ -1477,6 +1477,26 @@ pub enum DoctorCommand { /// Emit the classification as byte-stable JSON. json: bool, }, + /// Whether this checkout's commit path runs the gate (CLOUD-1398). + /// + /// APPENDED LAST, for the reason [`DoctorCommand::Egress`] above it records: + /// this enum carries no `repr`, so a variant placed beside its neighbours + /// shifts every later discriminant and `mise run semver` reads that as a + /// break the crate has to declare. + /// + /// **A sub-verb AND a row in the bare report, which its two neighbours are + /// not** — and the difference is what it is for. `mediator` and `egress` sit + /// outside the report because they answer properties of the WORLD; this + /// answers a property of the CHECKOUT, so it belongs in the report too. The + /// sub-verb exists so a `[[startup]]` row can ask this question ALONE: a + /// startup row decides on an exit status, and bare `doctor` would fail it for + /// an unrelated unreachable program and then run a repair that cannot fix + /// that — `repair-failed`, forever, over a gate that is installed. + /// [`crate::doctor::diagnose_commit_gate`] is the one predicate both reach. + CommitGate { + /// Emit the diagnosis as byte-stable JSON. + json: bool, + }, } /// Subcommands of `generate`. @@ -1816,6 +1836,9 @@ fn doctor_of(matches: &ArgMatches) -> DoctorCommand { Some(("egress", matches)) => DoctorCommand::Egress { json: flag(matches, "json"), }, + Some(("gate", matches)) => DoctorCommand::CommitGate { + json: flag(matches, "json"), + }, // The bare verb reads `-J` from its OWN matches, which is where clap put // it when no subcommand was given. _ => DoctorCommand::Diagnose { diff --git a/crates/batten/src/doctor.rs b/crates/batten/src/doctor.rs index 56a76184a..74c4700c9 100644 --- a/crates/batten/src/doctor.rs +++ b/crates/batten/src/doctor.rs @@ -185,6 +185,38 @@ const GIT_REPO: &str = "git-repo"; /// This harness's plan/todo surface has been SURVEYED — which is a different /// question from whether it has one (CLOUD-472). const PLAN_SURFACE: &str = "plan-surface"; +/// This checkout's commit path runs the gate (CLOUD-1398). +/// +/// **A SUB-VERB ONLY, and never a row in the bare report** — which is the +/// opposite of where this landed first, corrected by the suite rather than by +/// argument. The reasoning that put it in the report was that a clone's commit +/// path is a property of the CHECKOUT rather than of the world: byte-stable +/// across machines, the same class as [`GIT_REPO`], unlike [`Mediator`] and +/// [`Egress`] which answer about this container. That is right about the +/// PREDICATE and wrong about the REPORT. +/// +/// `container-health` renders [`diagnose`] at session start, so a row here makes +/// every checkout with no git hooks announce itself as unhealthy. Batten +/// requires git hooks of nobody — whether a consumer's commit path should run a +/// gate is that consumer's judgement, and minting it in `crates/batten` is +/// non-negotiable rule 1's violation. Measured: it reddened +/// `contract_drift::a_session_seeded_at_session_start_is_silent_and_stays_silent` +/// over a fixture that has no hooks and wants none. +/// +/// So the split is the same one the whole engine is built on. The PREDICATE is +/// repo-agnostic and lives here, reachable as `doctor commit-gate`. The +/// JUDGEMENT lives in the consumer's own `[[startup]]` row — this repository +/// declares `commit-gate-installed` against it, and every other adopter is +/// untouched. +/// +/// # What it is NOT +/// +/// Not a claim that the gate PASSES — only that a commit in this clone runs it. +/// `mise-tasks/doctor.sh` asks the stronger question (can the hook resolve its +/// runner) by executing the hook under a probe, which a `read` verb may not do +/// (§5, CLOUD-170). This stats, exactly as [`on_path`] does and for the same +/// reason. +const COMMIT_GATE: &str = "commit-gate"; /// Every `command`-kind rule names a program the spawn can reach — on `PATH`, or /// through the project's pin. /// @@ -643,6 +675,137 @@ fn on_path(program: &str) -> bool { crate::rules::on_path_verbatim(program).is_some() } +/// The two hooks a commit in this clone must run for the gate to be on its path. +/// +/// `pre-commit` runs the gate; `commit-msg` carries the Conventional Commits +/// check release-plz's semver bump depends on, so leaving it out would assert the +/// expensive half and not the deciding one. Both are git's own names, which is +/// what keeps this list generic: every consumer's commit path runs these two. +const COMMIT_HOOKS: [&str; 2] = ["pre-commit", "commit-msg"]; + +/// The stable reason id for a commit path that does not run the gate. +const COMMIT_HOOK_MISSING: &str = "commit-hook-missing"; + +/// Where git would look for this checkout's hooks. +/// +/// **The COMMON directory, never the per-worktree one**, which is the trap this +/// helper exists to hold. [`crate::git::git_dir`] is per-worktree and is right +/// for receipts and for `HEAD`; hooks are not per-worktree, and git resolves +/// `hooks/` against the common dir — so a linked worktree checked against +/// `git_dir()` would report the gate missing while every commit in it runs the +/// gate correctly. +/// +/// **And `core.hooksPath` outranks both.** A repository that redirects its hooks +/// has hooks; a probe that ignored the key would call it bare and send its owner +/// to install a second copy somewhere git will never read. Resolved through +/// [`crate::git::config_value`], which reads the value across every scope, +/// exactly as git does. A relative value resolves against the worktree root, +/// which is git's own reading of it. +/// +/// `None` is could-not-look and never an empty answer: a directory that is not a +/// repository has already been reported by [`GIT_REPO`], and manufacturing a +/// second failure from it would double-count one fault. +fn hooks_dir(dir: &Path) -> Option { + if let Ok(Some(configured)) = crate::git::config_value(dir, "core.hooksPath") + && !configured.trim().is_empty() + { + let at = Path::new(configured.trim()); + return Some(if at.is_absolute() { + at.to_path_buf() + } else { + crate::git::repo_root(dir).ok()?.join(at) + }); + } + Some(Path::new(&crate::git::common_dir(dir).ok()?).join("hooks")) +} + +/// Whether `at` is a file this checkout's git would actually run. +/// +/// **Stats and follows, never executes** — [`on_path`]'s rule, one subject over: +/// running the hook to see whether it works is what `mise-tasks/doctor.sh` does +/// behind a probe variable, and a `read` verb may not reach user-supplied code +/// (§5, CLOUD-170). `metadata` follows the symlink deliberately: this +/// repository's own installer makes the hooks symlinks into the tree precisely +/// so the checked-in body stays the one authority, and a check that refused to +/// follow one would fail the shape it is meant to certify. +/// +/// The executable bit is what git itself requires, so it is what is asked. On a +/// platform without one, existence is the whole of the question git asks too. +fn is_runnable_hook(at: &Path) -> bool { + let Ok(meta) = at.metadata() else { + return false; + }; + if !meta.is_file() { + return false; + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + meta.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + true + } +} + +/// Whether this checkout's commit path runs the gate (CLOUD-1398). +/// +/// **The predicate, asked only where a caller asks for it** — `doctor +/// commit-gate`, which this repository's `commit-gate-installed` `[[startup]]` +/// row names as its `check`. Deliberately NOT pushed into [`diagnose`]; see +/// [`COMMIT_GATE`] for why a row there would make the engine assert a consumer +/// judgement it has no standing to make. +/// +/// One implementation, because a second is the defect the row it repairs was +/// filed about one layer along: `mise-tasks/doctor.sh` and the committed +/// authority disagreeing about what an installed gate is. +/// +/// **Pointer-only, and here that costs something worth naming.** The subjects are +/// the HOOK NAMES — `pre-commit`, `commit-msg` — and never the directory they +/// were looked for in, because that path is absolute, differs per machine, and +/// would defeat §6 byte-stability while leaking the layout of someone's disk +/// (rule 4). The names are git's own vocabulary rather than anything read out of +/// a file, which is the same line [`Check::subjects`] already draws for a +/// declared program. +/// +/// Could-not-look PASSES, which is this module's posture and not a softening: a +/// directory whose hooks path cannot be resolved is one [`GIT_REPO`] has already +/// failed on, and reading "I cannot tell" as "the gate is bypassed" would redden +/// every checkout on a machine where the read failed for an unrelated reason. +#[must_use] +pub fn diagnose_commit_gate(dir: &Path) -> Check { + let Some(hooks) = hooks_dir(dir) else { + return Check::passed(COMMIT_GATE); + }; + let missing: Vec = COMMIT_HOOKS + .iter() + .filter(|name| !is_runnable_hook(&hooks.join(name))) + .map(|name| (*name).to_owned()) + .collect(); + if missing.is_empty() { + Check::passed(COMMIT_GATE) + } else { + Check::failed_naming(COMMIT_GATE, COMMIT_HOOK_MISSING, missing) + } +} + +// THE OBLIGATION THIS ROW OWES, BOUND TO THE LINE THAT DECIDES (CLOUD-1398). +// +// The mutation is the row's own defect expressed as a patch: a check that looks +// at the hooks and reports `ok` whatever it finds. That is precisely what this +// repository shipped — `mise-tasks/doctor.sh` SAW the missing hooks and decided +// nothing, and the container went on bypassing the gate — so a survivor here is +// the original defect back, not a hypothetical one. +// +// The suite is the `[[startup]]` one, and with the row out of the bare report it +// is the ONLY tier that can catch this: the arm that discriminates is the repair +// loop, where a check stuck on `ok` reports the row provisioned, `--repair` +// never runs its argv, and the fixture's hooks stay absent. That is also what +// makes the declared precondition load-bearing rather than decorative. +//MUTANT-SUITE crates/batten/tests/it/startup.rs +//MUTANT hooks-check-reports-without-deciding|s@^ Check::failed_naming(COMMIT_GATE, COMMIT_HOOK_MISSING, missing)$@ Check::passed(COMMIT_GATE)@|a_clone_with_no_commit_hooks_fails_the_row_and_repair_installs_them + /// The check the declared transcript earns, or `None` where none is declared /// (CLOUD-1035). /// @@ -2634,6 +2797,28 @@ mod tests { // resolved config too, and whether it can still answer decides whether // `COMMAND_PROGRAMS` above is reading the pin or guessing. Its own row // rather than a mode of that one, because the repairs differ. + // + // `COMMIT_GATE` IS DELIBERATELY ABSENT, and it is the addition this case + // REFUSED (CLOUD-1398). It was pushed here for one revision, on the + // argument that a clone's commit path is a property of the CHECKOUT + // rather than of the world — the same class as `GIT_REPO`, byte-stable + // across machines. That argument is sound about the PREDICATE and wrong + // about the REPORT, and the suite is what said so: `container-health` + // renders this report at session start, so the row made every checkout + // without git hooks announce itself as unhealthy — measured, it broke + // `contract_drift::a_session_seeded_at_session_start_is_silent_and_stays_silent` + // over a fixture that has no hooks and does not want any. + // + // The line it crosses is non-negotiable rule 1. WHETHER a consumer's + // commit path should run a gate is that consumer's judgement; batten + // requires no git hooks of anybody. So the predicate stays in the engine + // as `doctor commit-gate`, where a caller asks for it, and the JUDGEMENT + // lives in the consumer's own `[[startup]]` row — this repository + // declares `commit-gate-installed` and every other adopter is untouched. + // + // The list stays EXPLICIT rather than becoming a count, which is what + // keeps this case load-bearing: a sub-verb leaking in is a diff on this + // line, and has to be argued for here. let names: Vec<&str> = diagnose(&scratch("bare-unchanged")) .checks .iter() diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index 5d14ac7cb..f6acd4969 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -18007,9 +18007,42 @@ fn run_doctor(command: &cli::DoctorCommand, out: &mut dyn Write) -> Result run_doctor_mediator(json, out), cli::DoctorCommand::Session { json } => run_doctor_session(json, out), cli::DoctorCommand::Egress { json } => run_doctor_egress(json, out), + cli::DoctorCommand::CommitGate { json } => run_doctor_commit_gate(json, out), } } +/// Does a commit in this clone run the gate (CLOUD-1398)? +/// +/// **The same [`doctor::Check`] the bare report pushes, asked alone.** It is not +/// a second reading and must never become one: a `[[startup]]` row decides on an +/// exit status, so it needs a command that answers THIS question and no other — +/// bare `doctor` would fail it for an unrelated unreachable program and then run +/// a git-hook repair that cannot fix that. Rationale on +/// [`doctor::diagnose_commit_gate`] and on [`cli::DoctorCommand::CommitGate`]. +/// +/// One pointer line — the row's name, and on a failure the HOOK NAMES it is +/// missing. Never the directory they were looked for in: that path is absolute +/// and per-machine, which would defeat §6 byte-stability and put the layout of +/// somebody's disk in a diagnostic that promises not to carry one (rule 4). +fn run_doctor_commit_gate(json: bool, out: &mut dyn Write) -> Result { + let check = doctor::diagnose_commit_gate(&std::env::current_dir()?); + if json { + // A data channel emits its document unconditionally, including on the + // healthy path: JSON that is sometimes absent is unparseable. + writeln!(out, "{}", serde_json::to_string_pretty(&check)?)?; + } else { + output::line(out, &check)?; + } + // The parent's promise, inherited rather than re-decided: `ExitCode::Violation` + // is unreachable here, because a mediating harness reads `2` as a deny and + // "this clone has no commit hooks" is not "policy says no". + Ok(if check.ok { + ExitCode::Success + } else { + ExitCode::Usage + }) +} + /// Was the engine the registrations reach built from this tree (CLOUD-1349)? /// /// `doctor hooks` answers whether the registrations reach an engine; this answers diff --git a/crates/batten/src/spec.rs b/crates/batten/src/spec.rs index a6e603dc4..1571fca5e 100644 --- a/crates/batten/src/spec.rs +++ b/crates/batten/src/spec.rs @@ -462,6 +462,15 @@ mod tests { // task runner grades a value that runner's env block has already // corrected. "doctor egress".to_owned(), + // Whether this checkout's commit path runs the gate (CLOUD-1398). + // `read`, and structurally so: it resolves a directory through + // git's own config and stats two files. Nothing is EXECUTED, and + // the distinction is the whole reason this is admissible — the + // stronger question, whether each hook RUNS, needs executing one, + // which reaches user-supplied code and is exactly what a row on + // this allowlist may not do (CLOUD-170). Following the symlink is + // a read of the link's target, not a spawn of it. + "doctor gate".to_owned(), // The one row that is both a parent and a `read` verb of its own // (CLOUD-777). House style §2 spells the verb `doctor ` and // §8 promises what bare `doctor` does, so both are on the list — @@ -768,6 +777,7 @@ mod tests { "design audit".to_owned(), "doctor".to_owned(), "doctor egress".to_owned(), + "doctor gate".to_owned(), "doctor hooks".to_owned(), "doctor mediator".to_owned(), "doctor session".to_owned(), diff --git a/crates/batten/src/surface.rs b/crates/batten/src/surface.rs index f68bcbd97..f2c62b275 100644 --- a/crates/batten/src/surface.rs +++ b/crates/batten/src/surface.rs @@ -2787,6 +2787,44 @@ pub const SURFACE: &[CommandDecl] = &[ effect: Effect::Read, flags: &[JSON], }, + // THE ONE SUB-VERB THAT IS ALSO A ROW IN THE BARE REPORT (CLOUD-1398), and + // the asymmetry with the two rows above it is the decision rather than an + // oversight. `doctor mediator` and `doctor egress` are outside the report + // because they answer properties of the WORLD; whether this clone's commit + // path runs the gate is a property of the CHECKOUT, the same class as + // `git-repo`, so it is reported where a reader is already looking. + // + // It exists as a verb ANYWAY because a `[[startup]]` row decides on an exit + // status and has no way to select one row out of a report. `check = ["batten", + // "doctor"]` would fail the commit-gate row whenever an unrelated declared + // program was unreachable — measured in this container, where `hk` resolves + // only under `mise exec` — and then run a repair that installs git hooks, + // which cannot fix that, reporting `repair-failed` over a gate that is + // installed. One predicate answers both callers; this row is what lets the + // narrow caller ask it. + // + // `read`, and structurally so: it resolves a directory and stats two files. + // Nothing is executed — running a hook to see whether it works is what a + // shell task can do behind a probe variable, and reaching user-supplied code + // from the `filter(effect == read)` allowlist is CLOUD-170's actual invariant. + // + // `gate` RATHER THAN `commit-gate`, AND THE HYPHEN IS THE WHOLE REASON. A man + // page is committed as the hyphen-joined command path — `batten-doctor-gate.1` + // — and `surface.rs`'s own suite maps that filename back by replacing EVERY + // hyphen with a space. So a sub-verb whose name contains one is not + // round-trippable: `batten-doctor-commit-gate.1` reads back as the command + // `doctor commit gate`, which renders nothing, and three cases in + // `crates/batten/tests/it/surface.rs` go red at once. Measured here rather + // than reasoned — no verb on this surface has ever carried an internal + // hyphen, and this is why. Do not reintroduce one. + CommandDecl { + path: "doctor gate", + id: "doctor.gate", + about: "Diagnose whether this checkout's commit path runs the gate", + data_channel: true, + effect: Effect::Read, + flags: &[JSON], + }, CommandDecl { path: "doctor hooks", id: "doctor.hooks", diff --git a/crates/batten/tests/it/pointer_only.rs b/crates/batten/tests/it/pointer_only.rs index 4d1f6926f..43ba583b0 100644 --- a/crates/batten/tests/it/pointer_only.rs +++ b/crates/batten/tests/it/pointer_only.rs @@ -1175,6 +1175,24 @@ const CENSUS: &[Verb] = &[ // since the value differs per machine. The remedy is a change to the // container's Environment variables field, and the verdict is what says // whether to make it. + // POINTER-ONLY OVER A SUBJECT THAT IS ENTIRELY PATHS (CLOUD-1398). This verb + // resolves a hooks directory — through `core.hooksPath` when set, the common + // git dir otherwise — and stats two files in it. Every one of those is an + // ABSOLUTE path that differs per machine, so emitting one would defeat §6's + // byte-stability and put the layout of somebody's disk in a diagnostic that + // promises not to carry one. + // + // What it emits instead is git's own vocabulary: the hook NAMES, `pre-commit` + // and `commit-msg`, as the failure's subjects. They are the actionable half — + // a reader knows which hook to install — and they are the same class as a + // declared program name in `command-programs`, which is a token the consumer + // already has rather than a byte read out of a file. + Verb { + path: "doctor gate", + args: &[], + stdin: Stdin::Nothing, + disposition: Disposition::PointerOnly, + }, Verb { path: "doctor egress", args: &[], diff --git a/crates/batten/tests/it/session_provisioning.rs b/crates/batten/tests/it/session_provisioning.rs index b048af935..d98618c36 100644 --- a/crates/batten/tests/it/session_provisioning.rs +++ b/crates/batten/tests/it/session_provisioning.rs @@ -257,6 +257,9 @@ struct Row { id: String, on: String, bounded: bool, + /// The `run` argv exactly as the row spells it, so a case can ask WHAT a + /// handler dispatches and not only that it is declared (CLOUD-1620). + run: String, } /// The committed `[[hook.handler]]` rows, in declaration order, read as TEXT. @@ -290,6 +293,7 @@ fn handler_rows() -> Vec { id: field("id").unwrap_or_default(), on: field("on").unwrap_or_default(), bounded: field("timeout_ms").is_some(), + run: field("run").unwrap_or_default(), }); } rows @@ -482,6 +486,197 @@ fn the_committed_provisioning_declares_every_step_in_order() { ); } +/// The two entry points that compile the engine, named once. +/// +/// `install:local` declares `depends = ["build:release"]`, so either spelling +/// reaching a session-start task is the same four-minute compile. +const COMPILE_ENTRY_POINTS: [&str; 2] = ["install:local", "build:release"]; + +/// The task each `session-start` row dispatches, plus every task those bodies +/// reach through `mise run`. +/// +/// Bounded rather than fully transitive: the delegation in this manifest is one +/// hop (`session:doctor` → `doctor`, `session:identity` → `attribution-identity`) +/// and a fixed depth cannot loop on a manifest that gains a cycle. Depth is +/// asserted to have found something by `the_reachable_set_is_not_empty`. +fn reachable_session_task_bodies() -> Vec<(String, String)> { + let manifest = + std::fs::read_to_string(at_root("mise.toml")).expect("the task manifest is readable"); + + // THE EXECUTABLE SURFACE, NEVER THE WHOLE BLOCK, and the distinction is not + // pedantry — it is what makes this a gate rather than a prose scanner. The + // first draft returned everything under the header, and both cases below went + // red against a manifest that was already correct: `session:batten`'s comment + // NAMES `install:local` while explaining why it no longer runs it, and + // `session:install`'s names `cargo-zigbuild`. A comment is the one place a + // retired mechanism is supposed to still be written down. + // + // So this reads `run` and `depends` — what mise will actually execute — + // discarding the commentary around them. `depends` is here because + // `install:local` carries `depends = ["build:release"]`: a task can compile + // without its own body naming a compiler. + // + // `[tasks."name"]` and `[tasks.name]` are both spelled in this file. + let body_of = |name: &str| -> Option { + let headers = [ + format!("\n[tasks.\"{name}\"]\n"), + format!("\n[tasks.{name}]\n"), + ]; + let block = headers + .iter() + .find_map(|header| manifest.split(header.as_str()).nth(1))?; + let block = block.split("\n[").next().unwrap_or(block); + + let value = |key: &str| -> String { + let Some(rest) = block.split(&format!("\n{key} = ")).nth(1) else { + return String::new(); + }; + rest.strip_prefix("\"\"\"").map_or_else( + || rest.lines().next().unwrap_or_default().to_owned(), + |triple| triple.split("\"\"\"").next().unwrap_or(triple).to_owned(), + ) + }; + Some(format!("{}\n{}", value("run"), value("depends"))) + }; + + let mut queue: Vec = session_rows() + .iter() + .filter_map(|row| { + // `run = ["mise", "run", ""]` — the task is the word after the + // `run` verb. A row dispatching something that is not `mise run` has + // no task body to read and drops out here. + let argv: Vec<&str> = row + .run + .split(['[', ']', ',', '"']) + .map(str::trim) + .filter(|word| !word.is_empty()) + .collect(); + match argv.as_slice() { + ["mise", "run", task, ..] => Some((*task).to_owned()), + _ => None, + } + }) + .collect(); + + let mut seen = Vec::new(); + let mut found = Vec::new(); + for _ in 0..4 { + let mut next = Vec::new(); + for name in std::mem::take(&mut queue) { + if seen.contains(&name) { + continue; + } + seen.push(name.clone()); + let Some(body) = body_of(&name) else { + continue; + }; + for word in body.split_whitespace().collect::>().windows(3) { + if word[0].ends_with("mise") && word[1] == "run" { + next.push(word[2].trim_matches(['"', '\'']).to_owned()); + } + } + found.push((name, body)); + } + queue = next; + } + found +} + +#[test] +fn the_reachable_set_is_not_empty() { + // THE ANTI-VACUITY CASE FOR THE SCAN BELOW, and it is the same class as + // `every_handler_row_is_read` one case down: a header this manifest + // re-spells, or a `run` argv shape the parser stops recognising, returns an + // empty set and the compiler scan then passes over nothing at all. + let bodies = reachable_session_task_bodies(); + assert!( + bodies.len() >= session_rows().len(), + "every session-start row's task body resolves, found {} for {} rows", + bodies.len(), + session_rows().len() + ); + assert!( + bodies.iter().any(|(name, _)| name == "session:batten"), + "the row that used to carry the compile is in the scanned set" + ); + assert!( + bodies + .iter() + .any(|(name, _)| name == "attribution-identity"), + "delegation is followed: `session:identity` reaches `attribution-identity`" + ); +} + +/// No session-start handler compiles the engine (CLOUD-1620). +/// +/// # Why this is a case and not a comment +/// +/// The premise that put a compile here was that consumer #1 must judge the +/// engine it ships, and it is a premise a reader can re-derive at any time — +/// which is how it arrived. What it cost is not re-derivable from reading the +/// manifest: measured 2026-09-08, `session:batten` at 224.87s and +/// `session:identity` at ~215s, ~440s of a ~450s session start, to produce a +/// binary byte-identical to the release already on PATH. +/// +/// So the property is asserted rather than explained. `install.sh` cannot +/// compile — `install-does-one-thing` in `batten.toml` bans `cargo` from it +/// outright — and this is that same ban one layer up, over the tasks a session +/// start actually dispatches. +/// +/// # Skew is not what this weakens +/// +/// A release binary older than the tree's config is a real failure (CLOUD-1326, +/// measured at ~15 hours of silently permitting gates). It is `batten doctor +/// mediator`'s to detect (CLOUD-1630) and this case takes no position on it: a +/// branch that needs the tree's engine runs `mise run install:local`, which stays +/// reachable and is what `verify` and `batten-check` already do. +// THE MUTATION TARGETS VACUITY, NOT THE ASSERTION, and the first draft got this +// backwards. It emptied `COMPILE_ENTRY_POINTS`, which makes the scan below loop +// over nothing and PASS — a survivor, so the mutation would have reported the +// suite as non-discriminating while the real defect it names went unmodelled. +// +// The way this gate dies quietly is the reachable set going empty: a `run` argv +// spelled differently, a task header re-spelled, and every case here passes over +// zero bodies. So the mutation makes the extraction yield nothing, and the case +// it must redden is the anti-vacuity one — which is the only case that can tell +// "nothing compiles" from "nothing was read". +//MUTANT-SUITE crates/batten/tests/it/session_provisioning.rs +//MUTANT session-start-compiles|s@^ \["mise", "run", task, \.\.\] => Some((\*task).to_owned()),$@ ["mise", "run", _task, ..] => None,@|the_reachable_set_is_not_empty +#[test] +fn no_session_start_step_compiles_the_engine() { + for (name, body) in reachable_session_task_bodies() { + for entry in COMPILE_ENTRY_POINTS { + assert!( + !body.contains(entry), + "the session-start task `{name}` reaches `{entry}`, which compiles \ + the engine on the one path that may not assume a toolchain — \ + install the release and let `doctor mediator` decide staleness" + ); + } + } +} + +/// A `cargo` fallback is allowed, and only behind a resolution guard. +/// +/// `attribution-identity` keeps `cargo run` for the clone whose provisioning has +/// not happened yet — it has to set an identity before it writes a commit. That +/// is a fallback, not a path: the guard is what keeps it off the session-start +/// path, and without this case the guard could be dropped and the case above +/// would still pass, since `cargo run -p batten` names neither entry point. +#[test] +fn a_cargo_fallback_in_a_session_task_is_guarded() { + for (name, body) in reachable_session_task_bodies() { + if !body.contains("cargo") { + continue; + } + assert!( + body.contains("command -v batten"), + "the session-start task `{name}` spawns `cargo` with nothing \ + establishing that the installed binary is absent first" + ); + } +} + #[test] fn every_handler_row_is_read() { // THE ANTI-VACUITY CASE FOR THE SCAN ABOVE, and it is the reason the scan is diff --git a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap index c0a7d560b..afc2dce06 100644 --- a/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap +++ b/crates/batten/tests/it/snapshots/it__snapshots__golden_json_schema.snap @@ -888,6 +888,24 @@ expression: stdout_of(&output) ], "subcommands": [] }, + { + "path": "doctor gate", + "id": "doctor.gate", + "about": "Diagnose whether this checkout's commit path runs the gate", + "effect": "read", + "data_channel": true, + "flags": [ + { + "name": "json", + "short": "J", + "long": "json", + "takes_value": false, + "positional": false, + "help": "Emit byte-stable JSON instead of pointer lines" + } + ], + "subcommands": [] + }, { "path": "doctor hooks", "id": "doctor.hooks", @@ -3057,6 +3075,10 @@ expression: stdout_of(&output) "id": "doctor.egress", "path": "doctor egress" }, + { + "id": "doctor.gate", + "path": "doctor gate" + }, { "id": "doctor.hooks", "path": "doctor hooks" diff --git a/crates/batten/tests/it/startup.rs b/crates/batten/tests/it/startup.rs index 4f261b8ae..7b2970d27 100644 --- a/crates/batten/tests/it/startup.rs +++ b/crates/batten/tests/it/startup.rs @@ -120,6 +120,259 @@ fn repair_fixes_and_says_so_while_the_bare_verb_changes_nothing() { assert_eq!(stdout(&again), "makes-it ok\nstartup: 1 row(s), 0 failed\n"); } +/// The commit gate as a declared precondition, end to end (CLOUD-1398). +/// +/// **The one case in this file whose check is a REAL precondition**, which the +/// module header says the others deliberately avoid — and the exception is the +/// point rather than a lapse. Every other fixture uses `true`/`false`/`test -f` +/// because a real check would be testing the container; this row exists because +/// nothing was testing the container, and a fixture that stubbed the predicate +/// out would reproduce exactly the defect it is here to catch. `doctor +/// commit-gate` is batten's own verb over a scratch repository, so what is under +/// test is still this tree and not the host. +/// +/// The repair sets `core.hooksPath`, which is argv-only — no shell between the +/// declaration and what runs (`batten.toml`'s own bound on `check`/`repair`) — +/// and it exercises the branch of [`doctor::hooks_dir`] that a repository +/// redirecting its hooks takes. A `git init` leaves `.git/hooks` carrying only +/// `*.sample` files, so the fixture starts genuinely bypassed rather than being +/// made so. +/// +/// The third run is what the row's §7 asks for and is not decoration: `ok` +/// WITHOUT `repaired` is the only thing separating a repair that worked from one +/// that exits zero having fixed nothing. +#[test] +fn a_clone_with_no_commit_hooks_fails_the_row_and_repair_installs_them() { + let dir = scratch("startup-commit-gate"); + write(&dir, "batten.toml", "version = 1\n\n"); + common::init_repo(&dir); + + // The hooks the repair will point git at. Written before the row, so the + // repair is a redirection and never a creation — it is `session:git-hooks`'s + // shape (an existing body, linked into place) rather than a second author of + // the hook. + let hooks = dir.join("committed-hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + for name in ["pre-commit", "commit-msg"] { + let at = hooks.join(name); + std::fs::write(&at, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&at, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + // THE BUILT BINARY BY ABSOLUTE PATH, never bare `batten` — and this is the + // one thing this case got wrong first. `startup` spawns a row's `check` by + // resolving it the way any spawn does, so a bare name reaches whatever + // `batten` is INSTALLED on PATH. That made this case grade the container's + // install currency rather than this tree: it passed while the installed copy + // happened to carry the verb, and went red the moment the verb was renamed + // here. Which is CLOUD-1650's own subject, arriving inside CLOUD-1398's suite. + let bin = json(env!("CARGO_BIN_EXE_batten")); + let rows = row( + "commit-gate-installed", + &format!("[{bin}, \"doctor\", \"gate\"]"), + Some(&format!( + "[\"git\", \"config\", \"core.hooksPath\", {}]", + json(hooks.to_str().unwrap()) + )), + ); + write(&dir, "batten.toml", &format!("version = 1\n\n{rows}")); + + // A fresh clone: `.git/hooks` holds samples and nothing git will run, so a + // commit here bypasses the gate. This is the state the row was filed over. + let bare = startup(&dir, &[]); + assert_eq!(out_code(&bare), 1); + assert_eq!( + stdout(&bare), + "commit-gate-installed failed not-provisioned\nstartup: 1 row(s), 1 failed\n", + "a clone whose commit path does not run the gate must say so" + ); + + let repaired = startup(&dir, &["--repair"]); + assert_eq!(out_code(&repaired), 0); + assert_eq!( + stdout(&repaired), + "commit-gate-installed ok repaired\nstartup: 1 row(s), 0 failed\n" + ); + + let again = startup(&dir, &["--repair"]); + assert_eq!( + stdout(&again), + "commit-gate-installed ok\nstartup: 1 row(s), 0 failed\n", + "a repair that runs every time is a repair whose check is wrong" + ); +} + +/// The sub-verb answers alone, and answers the same thing the row does. +/// +/// **This is the property the `[[startup]]` row depends on and nothing else +/// asserts.** A row decides on an exit status, so `doctor commit-gate` has to +/// carry the commit-gate verdict and NO other check's — if it ever started +/// folding in a sibling row, the committed row would fail for an unrelated +/// unreachable program and fire a git-hook repair that cannot fix it. +#[test] +fn the_commit_gate_sub_verb_answers_only_its_own_question() { + let dir = scratch("startup-commit-gate-verb"); + write(&dir, "batten.toml", "version = 1\n\n"); + common::init_repo(&dir); + + let bare = batten() + .current_dir(&dir) + .args(["doctor", "gate"]) + .output() + .expect("the binary runs"); + assert_eq!(out_code(&bare), 1, "doctor never renders a policy verdict"); + assert_eq!( + stdout(&bare), + "commit-gate failed commit-hook-missing commit-msg pre-commit\n", + "the hook NAMES are the actionable subjects; the directory is a path and never emitted" + ); + + let hooks = dir.join(".git").join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + for name in ["pre-commit", "commit-msg"] { + let at = hooks.join(name); + std::fs::write(&at, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions(&at, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + } + let healthy = batten() + .current_dir(&dir) + .args(["doctor", "gate"]) + .output() + .expect("the binary runs"); + assert_eq!(out_code(&healthy), 0); + assert_eq!(stdout(&healthy), "commit-gate ok\n"); +} + +/// A non-executable hook is not a hook — which is git's reading on unix, and +/// deliberately not its reading everywhere. +/// +/// The arm that separates this check from a file-existence one: "present but git +/// will not run it" is indistinguishable from healthy to a probe that only stats. +/// +/// **`cfg!` IN THE BODY RATHER THAN `#[cfg(unix)]` ON THE CASE**, which is +/// `platform-gated-test-added`'s own remedy and the reason it exists. Narrowing +/// the case to unix would turn a red leg green while leaving the Windows +/// contract unstated and one arm never compiled on the host that authors it. +/// Both arms compile on every target here, and the Windows expectation is +/// written down rather than skipped. +/// +/// The two readings are both correct, which is the substance: git on Windows has +/// no executable bit to consult and runs any hook file it finds, so a present +/// file IS a live hook there. `is_runnable_hook` tracks what git will actually do +/// on each platform. Measured on the `windows` job at 9891539c, where asserting +/// the unix reading everywhere gave `left: "commit-gate ok"` against +/// `right: "… failed commit-hook-missing pre-commit"`. +/// +/// The sibling cases need no such split: a `git init` leaves no `pre-commit` at +/// all, so they turn on existence, which reads the same on both platforms. +#[test] +fn a_present_but_unrunnable_hook_reads_as_missing() { + let dir = scratch("startup-commit-gate-mode"); + write(&dir, "batten.toml", "version = 1\n\n"); + common::init_repo(&dir); + let hooks = dir.join(".git").join("hooks"); + std::fs::create_dir_all(&hooks).unwrap(); + std::fs::write(hooks.join("commit-msg"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions( + hooks.join("commit-msg"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + // Present, and mode 0644: git skips it silently. + std::fs::write(hooks.join("pre-commit"), "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt as _; + std::fs::set_permissions( + hooks.join("pre-commit"), + std::fs::Permissions::from_mode(0o644), + ) + .unwrap(); + } + + let out = batten() + .current_dir(&dir) + .args(["doctor", "gate"]) + .output() + .expect("the binary runs"); + let expected = if cfg!(unix) { + // The mode bit is real here, so a 0644 hook is one git will skip. + "commit-gate failed commit-hook-missing pre-commit\n" + } else { + // No executable bit exists for git to consult, so a present file is a + // live hook and the row is honestly satisfied. + "commit-gate ok\n" + }; + assert_eq!( + stdout(&out), + expected, + "only the unrunnable one is named — a subject list is what a reader acts on" + ); +} + +/// The row's own remedy names a path that exists. +/// +/// **The assertion that stops CLOUD-1398 recurring, and it is the whole reason +/// the row exists at all.** `doctor.sh` told an agent to run +/// `.claude/hooks/session-start.sh` for its entire life after `7d188580` deleted +/// that program: the refusal was correct and its instruction could not be +/// followed. Prose cannot hold that; a case over the tracked file can. +/// +/// It asserts the SUCCESSOR resolves rather than that the old name is absent, +/// because those are different claims — a remedy could name a second dead path +/// and pass the weaker one. +#[test] +fn every_remedy_the_hook_check_prints_names_something_that_resolves() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("the workspace root is two levels above the crate"); + // THE COMMITTED AUTHORITY IS THE SUBJECT, not `mise-tasks/doctor.sh`, and + // that narrowing is measured rather than chosen. The shell task's two + // `::error::` remedies still name `.claude/hooks/session-start.sh`, which + // `7d188580` deleted — the defect this row was filed on. It is NOT repaired + // here because `policy/shell-retirement.rego` refuses the edit: its one + // admitted arm, `only_drops_a_retired_reference`, requires every removed line + // to name a path THIS SAME DELTA deleted, and this delta deletes nothing. So + // the task has two landable shapes — retire it whole, or leave it alone — and + // this change leaves it alone. + // + // What the row CAN hold is its own remedy, and that is what this asserts: the + // `[[startup]]` row's `repair` argv must name a task the manifest declares and + // a hook body present in the tree. A remedy naming a retired file is what + // CLOUD-1398 is about; this makes the successor's remedy unable to become one. + let config = + std::fs::read_to_string(root.join("batten.toml")).expect("the authority is tracked"); + assert!( + config.contains(r#"repair = ["mise", "run", "session:git-hooks"]"#), + "the commit-gate row must repair through the installer that exists" + ); + let manifest = + std::fs::read_to_string(root.join("mise.toml")).expect("the manifest is tracked"); + assert!( + manifest.contains("[tasks.\"session:git-hooks\"]"), + "the remedy names a task the manifest must declare" + ); + assert!( + root.join(".claude") + .join("hooks") + .join("git-hook.sh") + .is_file(), + "the hook body the row's repair links must be present in the tree" + ); +} + /// A repository declaring nothing says so, rather than saying nothing. /// /// The count line is what makes silence legible: without it, "no rows" and diff --git a/install.sh b/install.sh index 4ca08e7b0..9a7c44ff0 100755 --- a/install.sh +++ b/install.sh @@ -633,8 +633,35 @@ main() { mkdir -p "$dest" || die 1 "cannot create $dest. Set BATTEN_INSTALL_DIR to a writable directory." chmod +x "$tmp/$BIN" - cp "$tmp/$BIN" "$dest/$BIN" || - die 1 "cannot write $dest/$BIN. Set BATTEN_INSTALL_DIR to a writable directory." + + # ATOMIC REPLACE, BECAUSE THE TARGET MAY BE THE RUNNING BINARY (CLOUD-1620). + # + # `cp` opens the destination inode O_WRONLY|O_TRUNC. When that inode is a + # RUNNING executable the kernel refuses with ETXTBSY, and this installer's + # `die` then blamed the directory — "Set BATTEN_INSTALL_DIR to a writable + # directory" — for a fault that has nothing to do with permissions. + # + # Measured 2026-09-08: `session:batten` installs the release at session start, + # and at that moment `batten` IS executing — it is the SessionStart dispatcher + # running this very handler. So the one caller that most needs a current binary + # was the one caller guaranteed to fail. It went unseen because the other + # caller, `deps-install`, runs at provisioning time when nothing is running + # yet, and because `install:local` — the mechanism `session:batten` used before + # — spells `install -m 0755`, and GNU coreutils `install` unlinks the + # destination first, so it silently had the property this lacked. + # + # `rename(2)` gives it honestly: the running process keeps its old inode until + # it exits, and the name flips in one step, so no reader ever sees a partial + # binary. THE TEMPORARY MUST LIVE IN `$dest`, not in `$tmp` — `mv` across + # filesystems degrades to a copy onto the destination and re-hits the very + # ETXTBSY this avoids, and `$TMPDIR` is routinely a different filesystem. + staged="$dest/.$BIN.new.$$" + cp "$tmp/$BIN" "$staged" || + die 1 "cannot write $staged. Set BATTEN_INSTALL_DIR to a writable directory." + mv -f "$staged" "$dest/$BIN" || { + rm -f "$staged" + die 1 "cannot replace $dest/$BIN." + } # KEY=VALUE, the same shape `mise-tasks/dist.sh` emits, so a caller can consume # this without parsing prose. diff --git a/man/batten-doctor-gate.1 b/man/batten-doctor-gate.1 new file mode 100644 index 000000000..f9c95c068 --- /dev/null +++ b/man/batten-doctor-gate.1 @@ -0,0 +1,16 @@ +.ie \n(.g .ds Aq \(aq +.el .ds Aq ' +.TH batten-doctor-gate 1 batten +.SH NAME +batten\-doctor\-gate \- Diagnose whether this checkout\*(Aqs commit path runs the gate +.SH SYNOPSIS +\fBbatten doctor gate\fR [\fB\-J\fR|\fB\-\-json\fR] [\fB\-h\fR|\fB\-\-help\fR] +.SH DESCRIPTION +Diagnose whether this checkout\*(Aqs commit path runs the gate +.SH OPTIONS +.TP +\fB\-J\fR, \fB\-\-json\fR +Emit byte\-stable JSON instead of pointer lines +.TP +\fB\-h\fR, \fB\-\-help\fR +Print help diff --git a/man/batten-doctor.1 b/man/batten-doctor.1 index 78fcb2970..165872795 100644 --- a/man/batten-doctor.1 +++ b/man/batten-doctor.1 @@ -22,6 +22,9 @@ Diagnose whether the engine the registrations reach was built from this tree batten\-doctor\-egress(1) Diagnose whether the agent proxy would carry this container\*(Aqs requests .TP +batten\-doctor\-gate(1) +Diagnose whether this checkout\*(Aqs commit path runs the gate +.TP batten\-doctor\-hooks(1) Diagnose whether batten is wired on every hook surface of every harness .TP diff --git a/mise.toml b/mise.toml index 68d2883a6..06bee6956 100644 --- a/mise.toml +++ b/mise.toml @@ -2435,19 +2435,59 @@ description = "Session start: provision what mise does not own — the rustup cr run = "mise run doctor >/tmp/session-start-doctor.log 2>&1 || { echo '::error:: session-start: doctor failed — see /tmp/session-start-doctor.log' >&2; tail -5 /tmp/session-start-doctor.log >&2; exit 1; }" [tasks."session:batten"] -description = "Session start: build and install this checkout's own engine over the released one `deps-install` provisioned" +description = "Session start: install the latest RELEASE over whatever `deps-install` provisioned — a verified download, never a compile" # THE RELEASED BINARY IS ALREADY ON PATH BY THE TIME THIS RUNS, and that is what # makes this row possible at all. `deps-install` puts it there at provisioning # (CLOUD-1085, #798) — before Claude launches, so before any hook fires — which -# is what dispatches the handler this task is registered as. This step then -# REPLACES it with the working tree's build, because consumer #1 must judge the -# engine it ships rather than the last release. -# -# The ordering consequence, stated because it inverts the old script's: a session -# whose `deps-install` did not run has no binary to dispatch handlers with, so -# nothing here runs and the advisory `contract::unmediated_session` reports it. -# `mise run install:local` stays reachable by hand, which is the manual repair. -run = "mise run install:local >/tmp/session-start-batten.log 2>&1 || { echo '::error:: session-start: install:local failed — see /tmp/session-start-batten.log' >&2; tail -5 /tmp/session-start-batten.log >&2; exit 1; }" +# is what dispatches the handler this task is registered as. +# +# THIS STEP USED TO SPELL `install:local`, AND THAT WAS A COMPILE ON THE ONE PATH +# THAT MAY NOT HAVE ONE (CLOUD-1620). The premise was that consumer #1 must judge +# the engine it ships rather than the last release, which is true and is not a +# reason to build HERE. Measured 2026-09-08 on a container already warm for +# tools: this step took 224.87s, and the binary it produced was 0.0.151 — byte +# for byte the release `deps-install` had already fetched. A session paid a +# four-minute compile to arrive where it started. +# +# THE INVERSION IS THE ROW'S OWN, and this file already argued it two thousand +# lines up. `deps-install`'s comment reads "`install.sh`, NEVER `install:local` … +# A provisioning path cannot assume a Rust toolchain, a 141-second compile, or +# that the checkout builds at all" — and `install-does-one-thing` in batten.toml +# ENFORCES it, banning `cargo` from `install.sh` outright. This task was the one +# caller routing around a rule the repository had already committed to. +# +# SKEW IS NOW DETECTED, NOT PRE-EMPTED. The risk the compile hedged against is +# real — CLOUD-1326 measured an installed batten older than a config key +# disarming every mediated gate in silence — but an unconditional build is the +# wrong instrument for it, because it spends four minutes on every session to +# insure against a skew most sessions do not have. `batten doctor mediator` +# (CLOUD-1630) is the detector, and it answers `mediator-build-behind-source` on +# exactly the branches that need a build. +# +# SO COMPILING IS OPT-IN, AND THE BAR IS STATED: you are changing +# `crates/batten/**` and need the tree's engine to mediate your own calls. +# `mise run install:local` is that route, and `verify` and `batten-check` already +# build on that path anyway — so the case needs no session-start step to serve it. +# +# `./install.sh` AND NOT `mise run deps-install`, WHICH IS THE OBVIOUS SPELLING +# AND IS WRONG HERE. That task is `./install.sh && batten wiring reclaim -y`, and +# the reclaim half is ALREADY a session-start handler: `session-wiring`, row 9, +# placed after this one precisely so it observes repaired wiring rather than the +# launcher's. Routing through it would fire the reclaim twice in one batch, and +# `batten hook` expires the at-load record on this same event — so a second +# reclaim here is the unordered write-and-clear that row warns about, for work +# row 9 has already done. +# +# The proxy seam survives the narrowing, which is the thing worth checking before +# calling the installer by path: `deps-install`'s own header records that a bare +# `./install.sh` is unreachable FROM THE SETUP SCRIPT, because the release API +# needs `NO_PROXY` and `MISE_GITHUB_TOKEN` in mise's own process — and that "a +# task body inherits that environment". This is a task body. +# +# The ordering consequence, unchanged: a session whose `deps-install` did not run +# has no binary to dispatch handlers with, so nothing here runs and the advisory +# `contract::unmediated_session` reports it. +run = "./install.sh >/tmp/session-start-batten.log 2>&1 || { echo '::error:: session-start: release install failed — see /tmp/session-start-batten.log' >&2; tail -5 /tmp/session-start-batten.log >&2; exit 1; }" [tasks."session:git-hooks"] description = "Session start: install the repo-owned git hooks, the per-clone step nothing performed for 24 commits (CLOUD-476)" @@ -3951,7 +3991,55 @@ run = 'cargo run --quiet -p batten -- attribution check --message "{{arg(name="f [tasks.attribution-identity] description = "Write: set this clone's repo-local git identity when it is unset or carries a denied vendor identity" -run = "cargo run --quiet -p batten -- attribution identity" +# THE BINARY ON PATH FIRST, AND `cargo run` ONLY WHERE THERE IS NONE (CLOUD-1620). +# +# This task ran as `session:identity`, the seventh session-start handler, and it +# spelled `cargo run` — the DEBUG profile, which shares no artifacts with the +# release build the fifth handler had just finished. Measured 2026-09-08: ~215s, +# a second full compile of the same crate, to perform a `git config` write that +# the binary already installed at `/root/.local/bin/batten` answers in +# milliseconds. The two compiles together were ~440s of a ~450s session start. +# +# The shape is `target-prune`'s, above: try the installed binary and fall back to +# a build when it cannot do the job. That fallback is not decoration — a clone +# whose provisioning has not run still has to be able to set its identity before +# it writes a commit, which is the whole reason this task exists in the +# provisioning window at all. +# +# THE GUARD IS TRY-AND-SUCCEED, NEVER MERELY `command -v`, and the difference is +# the whole correctness of the change. A presence test asks whether a binary is +# on PATH; the failure that matters here is a binary that IS on PATH and CANNOT +# READ THIS TREE — the release predating a `batten.toml` key, which is CLOUD-1326 +# and which this repository reaches constantly, because config keys land between +# releases. Measured 2026-09-08 on this very lap: the installed 0.0.151 refused +# `[lease]` with "the config declares a key this build predates". A presence-only +# guard sends the task to that binary and fails; `&&` sends it to the build, which +# is what `target-prune` has always done and what the unconditional `cargo run` +# here used to do for free. +# +# It also narrows `task-substitution`, whose subject is whichever declared task +# leads with `cargo`: a reader running `cargo test` was being told to run +# `attribution-identity`, a task with nothing to do with running tests. +# +# ONE LINE, NOT A TRIPLE-QUOTED BLOCK, for the reason `deps-install` states above +# its own `&&`: `inline-task-bodies-not-growing-basic` is a `non_increasing` +# ratchet over block bodies in this file, with no `admits_with`. A block body here +# takes it 2->3 and its only routes are extraction or a waiver — and extraction +# means a new `mise-tasks/*.sh`, which `shell add refused` denies. Neither is +# worth spending on one guarded fallback, so the guard is spelled inline. +# +# AND THIS COMMENT MAY NOT SPELL THE PATTERN IT DESCRIBES. The ratchet is a +# literal count, so quoting its pattern here counted as a third body and reddened +# the gate over prose — measured, on the first draft of this comment. The mirror +# of the defect one file over, where a gate added in this same change scanned task +# COMMENTS for `install:local` and refused a manifest that was already correct. +# A gate that counts literals cannot tell a mechanism from a mention of one, so +# the mention has to give way. +# +# `if`/`else` rather than `&&`/`||`: with `a && b || c`, a FAILING `attribution +# identity` falls through to `c` and spends the four-minute build this row exists +# to remove — the error path would cost more than the thing being avoided. +run = "if command -v batten >/dev/null 2>&1 && batten attribution identity; then :; else cargo run --quiet -p batten -- attribution identity; fi" [tasks.commit-check] description = "Gate: every commit subject in BASE_SHA..HEAD_SHA follows the convention (policy: [commit] in batten.toml)"