Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 0 additions & 295 deletions .claude/hooks/session-start.sh

This file was deleted.

28 changes: 25 additions & 3 deletions .claude/rules/toolchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,21 @@ These load when you touch the workshop; deeper detail is in
**Use mise for everything** — tools via `[tools]`, env via `[env]`, commands as
`[tasks]` run with `mise run`; never a bare `cargo`/`export`/one-off install, so
CI, hk, and your shell run byte-identical commands. Per clone: `mise install`,
`git submodule update --init` (bats, in `tests/bats`), and the git hooks — which
`.claude/hooks/session-start.sh` now performs and `doctor` asserts (CLOUD-476),
so none of the three is left to a human remembering a prose list. Not `hk
`git submodule update --init` (bats, in `tests/bats`), and the git hooks — none
of the three left to a human remembering a prose list, because each is a
`session:*` task that `batten.toml` declares as a `[[hook.handler]] on =
"session-start"` and `doctor` asserts afterwards (CLOUD-476, CLOUD-312 row 10).

**The provisioning order and its bounds are `batten.toml`'s, not a script's.**
The ten `session-*` handler rows ARE the sequence: declaration order is running
order, each row declares its own `timeout_ms`, and `batten hook` dispatches them
inline at session start. `.claude/hooks/session-start.sh` used to hold all of
that and was the last by-path hook registration this repository owned; it is
retired, and `crates/batten/tests/session_provisioning.rs` carries both its
ledger and the tier that proves the door does what the rows say. Add a
provisioning step by adding a task and a row — never by putting a second step
inside an existing task's body, which is the shape that made the script
unreadable from the committed authority. Not `hk
install`: its generated hook calls `hk` bare, which does not resolve where
mise's shims are off PATH, so the installed body is `.claude/hooks/git-hook.sh` —
which also refuses to re-enter a gate that is already running, the recursion
Expand Down Expand Up @@ -785,6 +797,16 @@ prints the echo and exits 0). So in a **gate**, any command whose failure would
change the verdict must be guarded explicitly — otherwise the gate reports on
state it never refreshed, which is a silent false green and worse than no gate.

**The declared shell is the discriminator, and reading this section without it is
how three wrong comments landed** (CLOUD-1085, measured 2026-09-01). A task that
declares NO `shell =` gets the default and `-e` with it, so there a bare failing
command aborts the body where it stands rather than falling through. `test:bats`
is such a task and its own comments claimed the opposite for their whole life;
`verify` and `verify:gated` declare `shell = "bash -c"` and are the case this
section is about. Guard either way — under `-e` the guard is what lets a step name
its own failure instead of inheriting mise's — but do not carry "every line runs"
across to a body that declared nothing.

Two instances of this had already landed. `linear-check`'s `git fetch` fed the
`origin/main` ref every later line reads, so a failed fetch left it comparing a
stale main to itself, passing, and writing a receipt `ready-guard` then honours
Expand Down
10 changes: 1 addition & 9 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,6 @@
"command": "batten hook --harness claude-code"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh"
}
]
}
],
"TaskCompleted": [
Expand Down Expand Up @@ -123,7 +115,7 @@
"autoMode": {
"environment": [
"$defaults",
"This checkout is button-inc/batten, whose product IS `batten` — a repo-agnostic completion gate. The binary is built from crates/batten in this working directory and installed to ~/.local/bin/batten by .claude/session-start.sh via `mise run install:local`. It is not a third-party download and will not appear on any known-tool list. Every workflow here runs through it, directly or as a subprocess of a `mise run` task."
"This checkout is button-inc/batten, whose product IS `batten` — a repo-agnostic completion gate. The binary is built from crates/batten in this working directory and installed to ~/.local/bin/batten twice over: the released build by `mise run deps-install` at container provisioning, then this checkout's own by the `session-batten` handler `batten.toml` declares. It is not a third-party download and will not appear on any known-tool list. Every workflow here runs through it, directly or as a subprocess of a `mise run` task."
],
"allow": [
"$defaults",
Expand Down
265 changes: 260 additions & 5 deletions batten.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2578,11 +2578,26 @@ severity = "deny"
scope = "tree"
no_fix_reason = "declare it as a `[[pattern]]` row and read it by id; a tracker's vocabulary in the core is rule 1's violation, and moving it is the fix rather than rewording it"

# A RETIREMENT LEDGER ARM IS EXEMPT, and this is a structural collision rather
# than a convenience (CLOUD-1294). `bats-tests-not-deleted` matches an arm by the
# deleted case's title QUOTED VERBATIM, and its `declared_in` puts every arm under
# `crates/batten/tests/**` — inside this row's glob. So a governed bats case whose
# TITLE happens to carry this word can never be recorded, and therefore never
# retired, however honest the retirement is.
#
# Measured on CLOUD-312 row 10: `"the fixer is wired: session-start runs it, so a
# clone is compliant before it commits"`.
#
# It is not a weakening of what this row decides. The row bans a consumer's name
# appearing in the core; an arm is DATA ABOUT A LINE THAT NO LONGER EXISTS, and
# the four markers anchor it at the start of a line, so nothing but a ledger row
# can reach the exemption.
[[rule]]
id = "no-consumer-repo-name"
kind = "forbid"
glob = "crates/**"
regex = "(?i)complian"
exclude = "^// (carried|subsumed|changed|withdrawn): "
severity = "deny"
scope = "tree"

Expand Down Expand Up @@ -4113,7 +4128,18 @@ base = "origin/main"
# withdrawal as one over a live subject and refuses it. A false refusal, in the
# direction that blocks correct work.
delta_sources = ["**"]
line_sources = ["mise-tasks/*.sh", "crates/batten/tests/**/*.rs"]
# `tests/**/*.bats` IS HALF THE GOVERNED SET AND WAS NOT A DECLARED SOURCE
# (CLOUD-1294). `governed_at_head` selects every bats suite, but the lines of one
# were never read — so `only_drops_a_retired_reference` bound `base` from
# `base-lines` and came back UNDEFINED for every suite, and arm B refused every
# edited `.bats` unconditionally, whatever the edit was. The one admitted edit
# has been unreachable for half the corpus since it was written, and the refusal
# was indistinguishable from a real one because the finding is byte-identical.
#
# Measured on CLOUD-312 row 10: three cases in two suites, no landable spelling.
# The cost of reading them is `.claude/rules/rust.md`'s ~5.4 µs per declared
# document against ~137 suites — under a millisecond, on a 100 ms budget.
line_sources = ["mise-tasks/*.sh", "crates/batten/tests/**/*.rs", "tests/**/*.bats"]
module = "policy/shell-retirement.rego"
severity = "deny"

Expand Down Expand Up @@ -5476,13 +5502,40 @@ wiring = [".claude/settings.json"]
# remaining shell registration to move behind the door in the same change — and a
# guard cannot go behind the door without changing how it denies, which
# `shell-retirement` refuses for a governed file that is edited rather than
# retired. Two of this repository's guards cannot be retired yet (`run-shape-guard`
# on CLOUD-856, `hooks-wiring-check` on the `$HOME` surface), so declaring the flag
# would force exactly the edits the ratchet exists to refuse.
# retired. `run-shape-guard` cannot be retired yet (CLOUD-856), so declaring the
# flag would force exactly the edit the ratchet exists to refuse.
#
# WHAT ACTUALLY HOLDS THE FLAG SHUT IS A COUNT, NOT A CAPABILITY, and the second
# name in this sentence used to be wrong. It read "`hooks-wiring-check` on the
# `$HOME` surface", which was true until CLOUD-1167 landed `input.tree.external`
# — a declared path beneath a named root variable, which `policy/harness-wiring.rego`
# already reads as `harness-settings`. The `$HOME` surface is answered.
#
# The live obstacle is that `exclusive` refuses EVERY non-batten registration.
# Four were live when this note was written, measured 2026-09-01 (`doctor hooks
# -J`: `siblings: 2`, `merged_siblings: 2` for claude-code). **THIS COMMIT
# CLEARS ONE OF THEM** — the `session-*` handler rows below retire
# `.claude/hooks/session-start.sh`, so the committed surface now carries one
# sibling rather than two. Three remain:
#
# mise-tasks/run-shape-guard.sh committed CLOUD-856
# session-start-git-identity.sh $HOME CLOUD-1079
# stop-hook-git-check.sh $HOME CLOUD-1079
#
# The last two are launcher-provisioned and rewritten at every session start, so
# no commit here can clear them; CLOUD-1079's remedy is the environment
# configuration that generates them. Evidence on CLOUD-1160.
#
# So the flag is now ONE committed retirement away, and that one is CLOUD-856's:
# `run-shape-guard` cannot move until its last family has a mediated-call
# surface. The `$HOME` pair does not block the COMMITTED surface — `doctor`
# reports `siblings` and `merged_siblings` separately — so a per-surface
# `exclusive` would already be declarable, which is the shape CLOUD-1160 should
# consider rather than waiting for a global zero.
#
# The capability is therefore shipped and unused, which is the honest state: a
# consumer with no unretired registrations can declare it today, and this one
# declares it when its last guard retires rather than by editing bash to qualify.
# declares it when that count reaches zero rather than by editing bash to qualify.
#
# THE FIRST HANDLER, AND IT IS A WORKED EXAMPLE (CLOUD-898). `[[hook.action]]`
# shipped with one because a plugin surface nobody uses is a surface nobody has
Expand Down Expand Up @@ -5645,6 +5698,208 @@ timeout_ms = 2000
owner = "CLOUD-312"
expires = "2027-02-28"

# ---------------------------------------------------------------------------
# CLOUD-312 ROW 10: `.claude/hooks/session-start.sh` RETIRES, AND ITS ORDER
# BECOMES THIS LIST.
#
# The last by-path registration in `.claude/settings.json` is gone with it, so
# every event on every harness now names `batten hook` by bare name and nothing
# else. `doctor hooks` reports `siblings` for `claude-code` falling from 2 to 1;
# the remainder is `run-shape-guard`, whose own retirement is CLOUD-856's.
#
# WHAT MOVED, AND WHERE. The script did three separable things, and only one of
# them was its own:
#
# ordering -> the DECLARATION ORDER of the ten rows below. `dispatch` runs
# handlers for an event in the order the config lists them, which
# `Dispatched::refusal`'s doc states is why order is the running
# order: a reader predicts it from the file rather than from a
# rule. The `install < doctor < preflight` ordering the retired
# suite asserted by grepping a call log is now readable directly.
# the bound -> `timeout_ms`, imposed by the PARENT. The script had none at
# all, on any step, and a hung `mise install` wedged the session.
# the report -> `mise.toml`'s `session:*` tasks, which are the `step` helper's
# log-and-tail contract kept verbatim. That half stayed in a task
# because the door does not provide it: stdout on a passing exit
# is ADVISORY TEXT, so a step that prints on success would put a
# line in every session saying nothing went wrong — the
# announcement CLOUD-891 removed from this hook.
#
# THE BOOTSTRAP OBJECTION IS ANSWERED RATHER THAN ARGUED. A handler is dispatched
# BY the binary, so this shape is only sound if something installs `batten`
# before the first hook fires. #714 pulled this move because nothing did.
# CLOUD-1085 closed that: `deps-install` runs from the environment's own
# provisioning script, before Claude launches, and puts the RELEASED binary on
# PATH. Verified warm on 2026-09-01 — `/root/.local/bin/batten`, v0.0.137,
# `verified=sha256`, resolving by bare name. `session:batten` below then replaces
# it with this checkout's build, because consumer #1 judges the engine it ships.
#
# THE RESIDUAL EXPOSURE, STATED BECAUSE IT IS REAL AND NOT MINE TO CLOSE. If
# `deps-install` does not run, no binary dispatches these rows and nothing
# provisions — where the old script, registered by path, would still have run.
# Three things stand against it: the environment's setup script independently
# runs `mise install` and `mise reshim`, so the toolchain survives; every
# `session:*` task stays reachable by hand, so a degraded container has a manual
# repair; and `contract::unmediated_session` (#798) reports a session whose
# SessionStart never reached the engine instead of failing open in silence.
#
# EVERY BOUND BELOW IS COLD-SIZED, WHICH IS THE OPPOSITE STANDARD FROM
# `mcp-attach-check`'s AND DELIBERATELY SO. That row's 2000ms is two orders above
# a measured warm path, because a handler that reads two `jq` documents has no
# cold case. These rows ARE the cold case: this whole surface exists because a
# cold `mise install` took 24s inside the MCP client's startup window, and
# CLOUD-1085 measured a cold `install:local` at 141s. A bound tighter than the
# cold path turns a fail-open handler into the absence the hook was built to
# close, so each is sized against the slowest honest run of that step and not
# against the warm one this container measures.

[[hook.handler]]
id = "session-stamp"
on = "session-start"
run = ["mise", "run", "session:stamp"]
# Two `git rev-parse` calls and a truncation. Nothing here can be slow, and a
# bound this tight is the statement: a stamp that needs ten seconds is broken.
timeout_ms = 10000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-install"
on = "session-start"
run = ["mise", "run", "session:install"]
# Ten minutes. The measured cold figure for ONE tool was 24s (`pipx:serena-agent`,
# CLOUD-196); this installs the whole `[tools]` table on a container with no
# cache, and it is the step every other one depends on.
timeout_ms = 600000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-submodules"
on = "session-start"
run = ["mise", "run", "session:submodules"]
# A shallow network clone of `tests/bats`. Two minutes is generous for one small
# repository and short enough that a hung transport does not hold the session.
timeout_ms = 120000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-doctor"
on = "session-start"
run = ["mise", "run", "session:doctor"]
# Fifteen minutes, the longest bound here and the one with the least measurement
# behind it: doctor's rustup half installs cross targets over the network, and
# the failure this step exists to prevent (CLOUD-218's `detected conflict`) is
# itself a symptom of that install being slow enough to race something else.
timeout_ms = 900000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-batten"
on = "session-start"
run = ["mise", "run", "session:batten"]
# Ten minutes against a MEASURED 141s cold release build (CLOUD-1085's table,
# `/tmp/session-start-batten-build.log`). Four times the measurement, because
# the measurement was taken on a warm dependency cache and a cold one compiles
# the whole graph.
timeout_ms = 600000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-git-hooks"
on = "session-start"
run = ["mise", "run", "session:git-hooks"]
# Two symlinks. Same reasoning as `session-stamp`.
timeout_ms = 10000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-attribution-identity"
on = "session-start"
run = ["mise", "run", "session:identity"]
# AFTER `session-batten`, because this is the ENGINE answering: the policy is
# `[attribution]` in this file and `batten attribution identity` is what reads
# it. The task spells `cargo run`, so a cold container pays a debug build here
# that the release build above does not cover — which is what the ten minutes is
# for, not the identity write itself.
#
# It belongs in the provisioning window at all because the vendor identity is
# injected at the environment level: a fresh clone carries it before any work
# starts, and the first thing that would notice is `commit-attribution` refusing
# a branch whose commits are all already written.
timeout_ms = 600000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-signing-posture"
on = "session-start"
run = ["mise", "run", "session:signing"]
# The signature half of the same defect (CLOUD-669). CLOUD-591 decided the
# interim posture is not to sign and shipped no mechanism, so it was never once
# in force: the launcher writes `commit.gpgsign true` --global every session, and
# local beats global only if something writes local. `Attribution` has no
# signature field, so `identity_deny` structurally cannot see it.
#
# Before any commit is written, for the reason above it: a repair that lands
# after the fact leaves signed commits only a rebase can unwind.
timeout_ms = 600000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-container-preflight"
on = "session-start"
run = ["mise", "run", "container-preflight"]
# LAST OF THE STEPS THAT CAN HALT, and that position is the whole point
# (CLOUD-261). "The toolchain installed" and "this container can do the work" are
# different questions, and the second used to be answered three tasks in, by a
# failure wearing someone else's name.
#
# THE `--degraded` ARM IS GONE AND THAT IS A FIDELITY LOSS, recorded here rather
# than glossed. The script tracked a `fail` variable across its steps and passed
# `--degraded` when an earlier one had failed, telling the preflight not to trust
# toolchain-dependent probes. Handlers do not share state — each is its own
# process with its own outcome — so this now always runs the full probe set. The
# consequence is a preflight that may report a toolchain-dependent probe failing
# on a container whose install already failed: a second symptom of one cause
# rather than a wrong answer, and both refusals are in the same reply. Recovering
# the arm needs a fact the door does not carry today.
timeout_ms = 120000
owner = "CLOUD-312"
expires = "2027-02-28"

[[hook.handler]]
id = "session-census"
on = "session-start"
run = ["mise", "run", "session:census"]
# LAST, AND ONE OF THE TWO ROWS THAT MAY SPEAK ON THE NORMAL PATH. Its report is
# something this hook has to say to a reader with an action to take — what was
# running when the container we replaced went down (CLOUD-451) — so its stdout is
# deliberately left to become advisory text.
#
# THE OTHER IS `session-container-preflight`, and the count was wrong here until
# it was measured: dispatching these rows for real on 2026-09-01 returned
# `signing-posture: …` and `container-preflight: egress fenced, …` in the
# advisory, where this comment claimed one line. Both were the retired script's
# behaviour too — it ran `container-preflight` and the census directly and
# `step`-wrapped everything else — so the preflight's line stays and the signing
# row was repointed at a wrapped task to restore the silence it had. The lesson is
# the one this file keeps re-learning: a claim about what a row PRINTS is a
# measurement, and every other row above is silent by construction only because a
# task makes it so.
#
# It is also the only row that cannot fail the session: the task swallows both
# its exits, because a verdict about a PAST container is not a provisioning
# failure.
timeout_ms = 30000
owner = "CLOUD-312"
expires = "2027-02-28"

# ---------------------------------------------------------------------------
# THE REFUSAL VOCABULARY (CLOUD-1050).
#
Expand Down
Loading
Loading