From 0c22466fac8db8e4fccb92ec6a591fabfc4c3386 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 06:33:07 +0000 Subject: [PATCH 1/5] docs(toolchain): correct the shell discriminator and the stale wiring blocker Three comments in `[tasks."test:bats"]` said the body "runs under /bin/sh with no `set -e`". Measured false: a mise task declaring no `shell =` runs under `sh -e`, and `shell = "bash -c"` is the every-line-runs case. The discriminator is a DECLARED SHELL, not the file. This cost a debugging cycle in #798, where a bare call aborted the task before its own error branch could run. `.claude/rules/toolchain.md` names the contrast at the section readers generalise from, so the next author does not re-derive the three comments. `batten.toml`'s `[hook] exclusive` blocker note named `hooks-wiring-check` on the `$HOME` surface. That stopped being the blocker when CLOUD-1167 landed `input.tree.external`. The real blocker is four live sibling registrations, and the row now carries the table plus the measurement `siblings: 2, merged_siblings: 2`. Admits: 12bc0d5675f0158bf1441a5f15ea8ad6ad04b6e46b25c30153d189a44185b023 Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: c52854e3e5c5761e84fc2ab2712f2decb1d199a4 Admits-epoch: c81e3ad211cddfce765e42d79469223b10d01ff822929696d3914e643004f748 Admits-author: alec@wenzowski.com Admits-prev: - Admits-answer-lost: The next reader scoping CLOUD-1160 is sent at a blocker that no longer exists instead of at the four live sibling registrations that are the real one. That already happened once this session and cost the row a wrong scoping before it was returned to Backlog. Admits-answer-precondition: batten.toml is itself the owning surface for policy config, and the change is prose INSIDE it: the blocker note at :5185 names hooks-wiring-check on the $HOME surface, which stopped being the blocker when CLOUD-1167 landed input.tree.external. No other surface can express a correction to batten.toml's own comments, and the diff is three sentences a reviewer reads in place. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply because batten.toml IS the owning surface for its own comments; there is no narrower surface to redirect to. R-RESTORE-IT would discard the correction and leave the stale blocker naming a retired mechanism. Refs: CLOUD-1085, CLOUD-1268, CLOUD-1160 --- .claude/rules/toolchain.md | 10 ++++++++++ batten.toml | 26 ++++++++++++++++++++++---- mise.toml | 34 ++++++++++++++++++++++++++-------- 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.claude/rules/toolchain.md b/.claude/rules/toolchain.md index fab334298..a22cb33a9 100644 --- a/.claude/rules/toolchain.md +++ b/.claude/rules/toolchain.md @@ -785,6 +785,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 diff --git a/batten.toml b/batten.toml index cf849c97e..b30658093 100644 --- a/batten.toml +++ b/batten.toml @@ -5476,13 +5476,31 @@ 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 and +# four are live, measured 2026-09-01 (`doctor hooks -J`: `siblings: 2`, +# `merged_siblings: 2` for claude-code), each with its own owner: +# +# mise-tasks/run-shape-guard.sh committed CLOUD-856 +# .claude/hooks/session-start.sh committed CLOUD-312 row 10 +# 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. # # 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 diff --git a/mise.toml b/mise.toml index 851be5237..5bee0e46b 100644 --- a/mise.toml +++ b/mise.toml @@ -2113,8 +2113,23 @@ depends = ["doctor --no-targets"] # here. Output stays a pointer (rule 4) — two counts and the worker count, never # a case name. # -# This body runs under /bin/sh with no `set -e`, so every step is guarded -# explicitly rather than trusting the previous line. +# This body runs under `/bin/sh` WITH `-e`, so a bare failing command aborts the +# task where it stands. Every step is guarded explicitly anyway, which is +# belt-and-braces rather than load-bearing — and the guards are what let a step +# CHOOSE its exit code instead of inheriting the abort. +# +# CORRECTED 2026-09-01, measured. These three comments said "with no `set -e`" and +# were wrong. The discriminator is a DECLARED SHELL, not the file: a task that +# declares `shell = "bash -c"` really does run every line regardless — that is the +# case `.claude/rules/toolchain.md` documents and `tests/task-fail-closed.bats` +# gates, over `verify` and `verify:gated`, both of which declare it. This task +# declares none, so it gets the default and `-e` with it. Observed while landing +# CLOUD-1085: a bare call in a default body aborted the task before the branch +# reporting which failure it was could run. +# +# The error direction was benign, which is why nothing broke: believing "no +# `set -e`" makes an author ADD guards. An author who believed the opposite would +# omit them, and that is the direction this correction exists to prevent. # Receipt-gated (CLOUD-424). Deliberately glob-less in step-receipt's table: # these suites read mise.toml, hk.pkl, workflows and fixture trees all over the # repo, so the honest input set is the whole tracked tree — a rebase that @@ -2144,9 +2159,9 @@ if ./mise-tasks/step-receipt.sh check test:bats; then exit 0; fi # besides: it produces the RELEASE binary for the mediated-call hot path, while # `helpers.bash:119-134` and `common/mod.rs:154` both resolve a debug one. # -# Guarded explicitly: this body runs under `/bin/sh` with no `set -e`, so an -# unguarded build would leave the suite running against a stale artifact and -# reporting green — the exact failure this clause exists to remove. +# Guarded explicitly so the failure is NAMED: under the default `-e` an unguarded +# build would abort the task with mise's own line and nothing about stale +# artifacts, and the pointer is the whole value of this clause. if ! cargo build --quiet -p batten; then echo "::error:: test:bats: the debug binary did not build, so the suite would run against a stale artifact or none. That is CLOUD-592/699's class and the reason this step builds at all." >&2 exit 1 @@ -2176,9 +2191,12 @@ export HOOKS_WIRING_DIAGNOSIS # FAIL OPEN TOWARD COST: a selector that cannot answer at all falls back to every # suite. The asymmetry is the whole design — a run that is too wide shows up in # the bill, and one that is too narrow has no symptom at all. -# CLEARED ON A NONZERO SELECTOR (CLOUD-480, found on review of #660). This body -# runs under `/bin/sh` with no `set -e` — the hazard `.claude/rules/toolchain.md` -# records — so a selector that died partway left its PARTIAL output in `suites`, +# CLEARED ON A NONZERO SELECTOR (CLOUD-480, found on review of #660). The guard is +# required whichever way the shell is set, which is why it is written as a status +# read rather than an emptiness test: under the default `-e` a bare assignment from +# a failed substitution aborts the task, and without it the substitution leaves its +# PARTIAL output behind. CLOUD-480 measured the second — a selector that died partway +# left partial output in `suites`, # the emptiness check below then saw a non-empty value, and the fallback never # fired. A narrow run has no symptom, which is the asymmetry the fallback exists # to respect, so the guard has to read the status rather than the output. From 7d1885809d9be12cfed87573118489fef1d3cfa5 Mon Sep 17 00:00:00 2001 From: Alec Wenzowski Date: Tue, 1 Sep 2026 07:08:56 +0000 Subject: [PATCH 2/5] feat(hook): retire session-start.sh into ten declared handler rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLOUD-312 row 10. `.claude/hooks/session-start.sh` was the last by-path hook registration this repository owned. It did three separable things and only one of them was its own: ordering -> the declaration order of ten `[[hook.handler]] on = "session-start"` rows in batten.toml. `dispatch` runs handlers in config order, so install < doctor < preflight is now readable from the authority instead of grepped out of a stubbed call log. the bound -> `timeout_ms`, imposed by the parent. The script had none, on any step, so a hung `mise install` wedged the session. the report -> `mise.toml`'s `session:*` tasks, which keep the `step` helper's log-and-tail contract verbatim. The door does not provide it: stdout on a passing exit is advisory text, so a step that prints on success would announce itself every session (CLOUD-891). Verified by dispatching the real rows, not by reasoning about them: exit 0, the whole provisioning ran, and the reply carried exactly the two lines the retired script also emitted. `crates/batten/tests/session_provisioning.rs` is the tier — ten cases over the compiled binary against a fixture whose handlers are stubs, proving order, the bound (0.749s against a 30s sleep), silence on a pass, the reason reaching the session on a failure, and that stdout is never forwarded. The bootstrap objection that pulled this from #714 is answered rather than argued: `deps-install` (CLOUD-1085, #798) puts the released binary on PATH at provisioning, before Claude launches, so there is something to dispatch with. `session:batten` then replaces it with this checkout's build. Two corrections the live dispatch measured, both of my own writing: `signing-posture` prints on success, so registered bare it added a line the retired script's `step` wrapper had swallowed — repointed at `session:signing`; and the claim that only `session-census` may speak was wrong, because `container-preflight` speaks too and did under the old script as well. NOT READY TO LAND. Three cases in two governed suites grep the retired script and go red: `tests/commit-attribution.bats:41` and `tests/container-preflight.bats:131,138`. Measured over the whole corpus — those three and nothing else. Both suites declare subjects that survive (`hk.pkl`, `mise.toml`, `mise-tasks/container-preflight.sh`), so `bats-tests-not-deleted` refuses deleting them and `shell-retirement` refuses editing them: `only_drops_a_retired_reference` admits a removed line that names a retired path, and a removed `@test` block's other lines name nothing. `V-SHELL-RULE-EDITED` declares no override and no bypass_env. Admits: dddb39ae4779d81289438ea653f09f6610f0945146dd12f8dab939d528de807b Admits-rule: protected-mutation Admits-verdict: V-PROTECTED-MUTATION Admits-subject: batten.toml Admits-head: 094058e4e2b8477e28cfeecbe913e81e23e7b1d1 Admits-epoch: 121f33e1fca81092b00c148867959d9bf6cb54a82832540296b8b7f4fb2b7efb Admits-author: alec@wenzowski.com Admits-prev: 0d15402fe6e42f8c889653b827876c756c2d35c008671b41bf49c239f3b0360e Admits-answer-lost: CLOUD-312 row 10 stays open, .claude/settings.json keeps the last by-path registration on any surface this repository owns, and 295 lines of bash keep ordering provisioning with no bound on any step. The verified work — ten rows, ten tasks, a compiled tier, and a live dispatch that returned exit 0 — would exist only in a container the platform reclaims. Admits-answer-precondition: This commit writes ten hook.handler rows into batten.toml plus the corrections measured while dispatching them. The handler table is compiled-in config with no other surface: no mise task, rego module or CLI verb can add a row to it, so writing batten.toml directly is the only route, and every row lands in a diff a reviewer reads beside the script it replaces. Admits-answer-rejected-route: R-USE-THE-OWNING-SURFACE does not apply: batten.toml is the owning surface for hook.handler and for its own comments. R-RESTORE-IT would discard the rows and leave the deleted script with nothing dispatching its steps. Refs: CLOUD-312, CLOUD-1085, CLOUD-898 --- .claude/hooks/session-start.sh | 295 --------- .claude/rules/toolchain.md | 18 +- .claude/settings.json | 10 +- batten.toml | 219 ++++++- crates/batten/tests/it/main.rs | 1 + .../batten/tests/it/session_provisioning.rs | 571 ++++++++++++++++++ mise-tasks/hooks-wiring-check.sh | 1 - mise.toml | 151 +++++ tests/session-start.bats | 205 ------- 9 files changed, 954 insertions(+), 517 deletions(-) delete mode 100755 .claude/hooks/session-start.sh create mode 100644 crates/batten/tests/it/session_provisioning.rs delete mode 100644 tests/session-start.bats diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh deleted file mode 100755 index 61417bc91..000000000 --- a/.claude/hooks/session-start.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env bash -# SessionStart: perform the per-clone setup AGENTS.md documents, at the start of -# the session. -# -# NOT before the MCP servers start, which this header claimed for its whole life -# and CLOUD-734 measured false. Two samples, one per container incarnation on -# 2026-08-19/20: the generated MCP config is written, the client opens its -# connection logs ~2.4s later, and this hook writes its first byte ~5s after -# that — the connections are initiated BEFORE the hook and complete while it is -# still provisioning. What rescues the install fix below is therefore not -# ordering but the client's 120s connect timeout: serena's connection was -# established 45.5s in, from inside this hook's window. CLOUD-316 recorded that -# timeout as 30s, at which this session would have lost serena outright. -# -# Why this exists (CLOUD-196). `.mcp.json` launches Serena with -# `mise exec -- serena start-mcp-server`, and `mise exec` INSTALLS a missing -# tool on demand. On a cold container that install runs inside the MCP client's -# startup window: measured 24s for `pipx:serena-agent`, the handshake did not -# complete, and MCP servers are not retried mid-session — so Serena was absent -# for a whole session while being perfectly runnable seconds later. Nothing was -# wrong with Serena or with mise; the per-clone `mise install` step simply never -# ran, so `exec` was doing an installer's job at the worst possible moment. -# -# This hook is deliberately SYNCHRONOUS (no `{"async": true}` line). Async would -# reintroduce exactly the race it exists to close: the session would start while -# the install was still running. The cost is paid only when cold — a warm -# `mise install` measured 0.067s. -# -# Failure is loud, never silent: a session that could not provision its -# toolchain must say so, because the original defect's whole signature was an -# absence with no error anywhere. -# -# This hook is NECESSARY BUT NOT SUFFICIENT. Measured on a genuinely cold -# container: the hook ran and completed before the session started, serena was -# installed and startable (21 tools, 1.1s warm) — and it was STILL absent from -# the session. A `.mcp.json` server is project-scoped and requires per-project -# approval; a cold container gets a fresh `~/.claude.json` whose -# `enabledMcpjsonServers` is `[]`, and a remote session has nobody to answer the -# approval prompt. That second gate is closed by committing -# `"enabledMcpjsonServers": ["serena"]` in .claude/settings.json — do not remove -# it thinking this hook covers the case. Both are load-bearing. -# -# It also PREFLIGHTS THE CONTAINER (CLOUD-261), by calling `container-preflight` -# after provisioning. "The toolchain installed" and "this container can do the -# work" are different questions, and the second one used to be answered three -# tasks in, by a failure wearing someone else's name. That task owns the what -# and the why; this hook owns only the WHEN — the very beginning of the session, -# before any work is planned against a container that cannot land it. -set -uo pipefail - -cd "${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel)}" || exit 0 - -# THE SESSION STAMP (CLOUD-431), written before anything else this hook does. -# -# `claim-check`'s `refined-this-session` rule compares an issue's tracker-minted -# `updatedAt` against this file's mtime: refinement must PREDATE the session that -# implements it. That predicate is only honest if the stamp marks the beginning -# of the session, so it is written here — above the provisioning steps, and above -# the `exit 1` a degraded container takes — rather than appended at the end. A -# session whose install failed still began, and a stamp written after the work -# would date the session to whenever the agent got round to claiming, which is -# exactly the ordering the rule exists to refuse. -# -# Silent and never fatal: this hook's job is setup, and a clone whose git dir is -# unwritable has larger problems than the claim gate. `claim-check` refuses on a -# MISSING stamp rather than passing, so a failure here fails closed downstream. -if stamp_dir=$(git rev-parse --git-dir 2>/dev/null) && - mkdir -p "$stamp_dir/batten-receipts" 2>/dev/null; then - : >"$stamp_dir/batten-receipts/session-start" 2>/dev/null || true -fi - -fail=0 -step() { # step