diff --git a/.claude/settings.json b/.claude/settings.json index 1b17432e8..78ba9b966 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,6 +1,8 @@ { "$schema": "https://json.schemastore.org/claude-code-settings.json", - "enabledMcpjsonServers": ["serena"], + "enabledMcpjsonServers": [ + "serena" + ], "hooks": { "SessionStart": [ { @@ -63,6 +65,17 @@ } ] } + ], + "PostToolUse": [ + { + "matcher": ".*save_issue|.*save_comment", + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/mise-tasks/board-write-record" + } + ] + } ] }, "permissions": { diff --git a/mise-tasks/board-write-record b/mise-tasks/board-write-record new file mode 100755 index 000000000..9d5a8b4ec --- /dev/null +++ b/mise-tasks/board-write-record @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +#MISE description="PostToolUse hook body: record what this branch put on the board, with the tracker's own verdict on whether a new row was refined" +# +# CLOUD-514, phase 1. Every gate here prices FAILING to record something — +# `finding-sink-check` fails a turn that cites evidence and writes nothing, +# `deferral-check` fails a PR that defers without naming an issue. Nothing prices +# the opposite. Filing satisfies all of them in seconds while finishing costs a +# diff, a suite and a landing, so for an agent under pressure the punt is not a +# temptation, it is arithmetic. +# +# THIS FILE GATES NOTHING. It is the sensor half, and shipping a sensor alone is +# normally the "log without a gate" non-negotiable 2 refuses. The exception is +# argued and measured: the gate's firing rate cannot be estimated retrospectively +# because WHICH BRANCH PUT WHICH ROW ON THE BOARD has never been recorded +# anywhere. The one available proxy — rows created between a PR opening and its +# merge — was measured over 40 merged PRs and fires on 183 of 184, because a +# window over a fleet captures every session's filings. So this record is what +# makes the gate specifiable at all, and its enabling trigger is a number this +# produces rather than a date. +# +# WHY THE LINT HAPPENS HERE, WHICH IS THE WHOLE DESIGN. The first draft had the +# agent run `ready-lint` and mint a receipt. That is worthless: `ready-lint` reads +# a payload the caller assembles, and it was run three times during this issue's +# own refinement against text in a local file — once under the id `CLOUD-NEW`, +# for a row that did not exist — green every time. A toll payable in text nobody +# filed is not a toll. A `PostToolUse` body does not have that problem: it fires +# on the tool RESULT, which is the tracker's response to the create. +# +# THE RESULT ENVELOPE IS MEASURED, NOT ASSUMED. No hook in this tree had ever +# read a tool result, and the documented example is a `Write` whose response is a +# flat object. An MCP tool's is not: it is the content-block list +# `[{"type":"text","text":""}]`, so `.tool_response.id` does not +# exist and a body written against the documented shape would silently record +# nothing. +# +# RELATIONS COME FROM THE INPUT, AND THAT IS NOT A COMPROMISE. `ready-lint`'s §8 +# rule cross-checks prose claiming `blockedBy CLOUD-N` against the payload's +# relations, and the create response carries no relations at all — so linting the +# response alone reports `blocker-cited-without-relation` on exactly the rows that +# were refined most carefully. The create call's own `blockedBy` argument is the +# entire relation set on a create (there is nothing prior to append to), so it is +# what the tracker acted on. The BODY being judged is still the tracker's; naming +# a blocker is what creates the relation, so this cannot claim a dependency the +# board does not have. +# +# POINTER-ONLY IS LOAD-BEARING HERE (non-negotiable 4), not decorative: the text +# this reads is the entire issue body. Four fields reach the file — kind, id, +# updatedAt, verdict — and nothing is ever printed. +# +# FAILS OPEN AND SILENT on everything it cannot establish. A recorder that +# blocked or noised a board write would cause the failure `finding-sink-check` +# exists to catch, which is the opposite of the point. +# +# The mutation collapses create and update, so every edit to an existing row is +# recorded as a filing — which inflates the very count the gate will be specified +# against, in the direction that makes filing look normal. +#MUTANT update-recorded-too|s/^\t\[ -n "\$existing" \] && exit 0$/\t[ -n "$existing" ] \&\& :/|updating an existing row is never recorded +# The mutation drops the relations synthesis, so a row whose §8 claims a blocker +# lints as blocker-cited-without-relation and records `unready` — the false +# refusal that would fire on the best-refined rows. +#MUTANT relations-dropped|s/relations: {blockedBy: \$blockers}/relations: {}/|claims a blocker still records a green verdict +set -uo pipefail + +[ -n "${BATTEN_BOARD_WRITE_BYPASS:-}" ] && exit 0 + +raw=$(cat) || exit 0 + +tool=$(printf '%s' "$raw" | jq -r '.tool_name // empty' 2>/dev/null) || exit 0 +[ -n "$tool" ] || exit 0 + +# Suffix, never prefix. CLOUD-178 measured the same connector exposed as +# `mcp__Linear__save_issue`, `mcp____save_issue` and +# `mcp__claude_ai_Linear__save_issue` depending on the registration episode, so a +# rule naming one matches none of the others and the miss is silent. The settings +# matcher is anchored the same way; this is belt to its braces. +case "$tool" in +*save_issue) kind=issue ;; +*save_comment) kind=comment ;; +*) exit 0 ;; +esac + +# An `id` in the INPUT means this updates a row that already exists, which is not +# a board write this branch is answerable for. `finding-sink-check` already tells +# the two apart this way. +if [ "$kind" = issue ]; then + existing=$(printf '%s' "$raw" | jq -r '.tool_input.id // empty' 2>/dev/null) || exit 0 + [ -n "$existing" ] && exit 0 +fi + +# The content-block envelope, per the measurement in the header. `fromjson?` +# rather than `fromjson` so a text block that is not JSON is skipped instead of +# aborting the whole read. +row=$(printf '%s' "$raw" | jq -c ' + [ .tool_response[]? | select(.type == "text") | .text | fromjson? ] | first // empty +' 2>/dev/null) || exit 0 +[ -n "$row" ] || exit 0 + +id=$(jq -r '.id // empty' <<<"$row" 2>/dev/null) || exit 0 +[ -n "$id" ] || exit 0 +updated=$(jq -r '.updatedAt // "-"' <<<"$row" 2>/dev/null) || updated=- + +git_dir=$(git rev-parse --git-dir 2>/dev/null) || exit 0 +[ -n "$git_dir" ] || exit 0 +branch=$(git symbolic-ref --quiet --short HEAD 2>/dev/null) || exit 0 +[ -n "$branch" ] || exit 0 + +# A comment is sink 2 — recorded so the create-versus-comment ratio is +# observable, never judged. Only a new row carries a refinement obligation. +verdict=- +if [ "$kind" = issue ]; then + blockers=$(printf '%s' "$raw" | jq -c '[.tool_input.blockedBy[]? | {id: .}]' 2>/dev/null) || blockers='[]' + payload=$(jq -c --argjson blockers "$blockers" '{ + id: .id, + description: (.description // ""), + relations: {blockedBy: $blockers} + }' <<<"$row" 2>/dev/null) || payload="" + # BY PATH, AND THE STATUS IS READ AS THREE ANSWERS RATHER THAN TWO. + # + # `mise run ready-lint` is the obvious call and is wrong here: a hook inherits + # the cwd of the tool call, which is not required to be inside this project, + # and `mise run` outside a project errors out. That failure is silent and it + # lands on the WRONG side — mapping any non-zero to `unready` would record a + # refusal for every row filed from a directory mise could not resolve, which + # is a verdict about the environment wearing the mask of a verdict about the + # row. Sibling tasks are addressed relative to this file for that reason. + # + # `ready-lint`'s contract is 0 pass / 1 the block is wrong / 2 the input was + # unreadable, so only 1 is a judgement. Anything else — 2, or a 127 from a + # missing interpreter — leaves the verdict `-`, which the gate reads as "not + # answered" rather than as a refusal. + if [ -n "$payload" ]; then + printf '%s' "$payload" | "$(dirname -- "${BASH_SOURCE[0]}")/ready-lint" >/dev/null 2>&1 + case $? in + 0) verdict=ready ;; + 1) verdict=unready ;; + *) verdict=- ;; + esac + fi +fi + +mkdir -p "$git_dir/batten-receipts" 2>/dev/null || exit 0 +# Slashes are the one character a filename cannot carry; the substitution matches +# every other branch-keyed receipt here. +record="$git_dir/batten-receipts/board-writes.${branch//\//-}" +printf '%s %s %s %s\n' "$kind" "$id" "$updated" "$verdict" >>"$record" 2>/dev/null || exit 0 + +exit 0 diff --git a/mise.toml b/mise.toml index 6cb55aaba..4c79e8ae8 100644 --- a/mise.toml +++ b/mise.toml @@ -203,7 +203,7 @@ CI_ANSWERED_CONCLUSIONS = "success,neutral,failure,timed_out,action_required" # # Seeded with the gates this bundle touches, so every "mutation-checked per # CLOUD-418" obligation in it is cashed rather than cited. -MUTANT_GATES = "land,land-lock,checks-green,ci-lease-precondition,finding-sink-check,issue-search-check,issue-search-guard,run-shape-guard,graph-check,board-move-guard" +MUTANT_GATES = "land,land-lock,checks-green,ci-lease-precondition,finding-sink-check,issue-search-check,issue-search-guard,run-shape-guard,graph-check,board-move-guard,board-write-record" # --- GitHub reachability behind an egress proxy (Claude Code web sandbox etc.) --- # mise resolves every tool's release through GitHub's *API* host, api.github.com. diff --git a/tests/board-write-record.bats b/tests/board-write-record.bats new file mode 100644 index 000000000..7e7a0408c --- /dev/null +++ b/tests/board-write-record.bats @@ -0,0 +1,211 @@ +#!/usr/bin/env bats +# CLOUD-514 phase 1. The recorder that answers "what did this branch put on the +# board, and was a new row refined when it was filed". +# +# Every test runs inside a throwaway `git init`, because the subject IS the git +# dir: the record is keyed to the branch and stored under `$GIT_DIR`, so a suite +# running in this repo's checkout would write records for a real session. + +setup() { + REC="$BATS_TEST_DIRNAME/../mise-tasks/board-write-record" + REPO="$BATS_TEST_TMPDIR/repo" + mkdir -p "$REPO" + git -C "$REPO" init --quiet + # Per fixture, never inherited — a CI runner carries no global identity, so a + # bare `git commit` here is `fatal: empty ident name` and fails only there. + # Measured on CLOUD-513, which cost a full CI run; that gate is filed and + # unfixed, so this stays hand-written in every fixture suite. + git -C "$REPO" config user.email t@example.com + git -C "$REPO" config user.name t + git -C "$REPO" commit -q --allow-empty -m init + cd "$REPO" || return 1 +} + +record() { + local branch + branch=$(git symbolic-ref --quiet --short HEAD) + printf '%s\n' "$(git rev-parse --git-dir)/batten-receipts/board-writes.${branch//\//-}" +} + +# A Ready block that satisfies `ready-lint`'s checkable clauses. Kept minimal on +# purpose: this suite is about the recorder, and the lint has its own. +ready_body() { + cat <<'BODY' +**Why** + +A thing is broken. + +**Refinement — Ready** + +* **Source of truth (§1).** A file. +* **Mechanism as a computable predicate (§2).** A grep with an exit code. +* **Effect (§3).** `read`. +* **Output & exit contract (§5).** Pointer-only. +* **Commit / bump (§6).** `fix(thing)` — patch until `0.1.0`. +* **Test obligation (§7).** A bats row, shown able to fail. +* **Blockers (§8).** None. +BODY +} + +# The payload goes through a FILE, and the helper prints its path. Embedding JSON +# into a `bash -c` string lets the shell reinterpret its braces and quotes before +# the body ever sees it. +# +# `tool_response` is the CONTENT-BLOCK envelope an MCP tool actually returns, not +# the flat object the docs illustrate with a Write. That distinction is the whole +# reason this suite exists. +# +# `blockers` is a SPACE-SEPARATED list, not JSON: the helper is called inside a +# `bash -c` string, so a bracketed literal arrives with its quoting mangled and +# `--argjson` rejects it. jq builds the array instead. +event() { + local tool="${1:-mcp__Linear__save_issue}" body="${2:-}" blockers="${3:-}" input_id="${4:-}" + [ -n "$body" ] || body=$(ready_body) + jq -nc \ + --arg t "$tool" --arg b "$body" --arg iid "$input_id" --arg blockers "$blockers" ' + { + tool_name: $t, + tool_input: ({title: "a finding", + blockedBy: ($blockers | split(" ") | map(select(length > 0)))} + + (if $iid == "" then {} else {id: $iid} end)), + tool_response: [{type: "text", text: ({ + id: "CLOUD-999", title: "a finding", + description: $b, updatedAt: "2026-08-13T00:00:00.000Z" + } | tojson)}] + }' >"$BATS_TEST_TMPDIR/event.json" + printf '%s\n' "$BATS_TEST_TMPDIR/event.json" +} + +# --- what gets recorded -------------------------------------------------------- + +@test "a created row is recorded with its id, updatedAt and a green verdict" { + run bash -c "'$REC' < $(event)" + [ "$status" -eq 0 ] + [ -f "$(record)" ] + run cat "$(record)" + [[ "$output" == "issue CLOUD-999 2026-08-13T00:00:00.000Z ready" ]] +} + +# THE ROW THIS DESIGN TURNS ON. `ready-lint`'s §8 rule cross-checks prose claiming +# a blocker against the payload's relations, and the create RESPONSE carries no +# relations at all. Linting the response alone therefore reports +# blocker-cited-without-relation on exactly the rows refined most carefully. The +# create call's own `blockedBy` argument is the whole relation set on a create, so +# it is what the synthesis uses. +@test "a row whose §8 claims a blocker still records a green verdict" { + local body + body=$(ready_body | sed 's/\*\*Blockers (§8).\*\* None./**Blockers (§8).** `blockedBy` CLOUD-1./') + run bash -c "'$REC' < $(event mcp__Linear__save_issue "$body" CLOUD-1)" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == *" ready" ]] +} + +@test "an unrefined row records a verdict of unready rather than being refused" { + run bash -c "'$REC' < $(event mcp__Linear__save_issue 'Just a sentence, no Ready block.')" + [ "$status" -eq 0 ] + [ -z "$output" ] + run cat "$(record)" + [[ "$output" == *" unready" ]] +} + +# An update is not a board write this branch is answerable for. Recording it +# would inflate the very count the gate gets specified against. +@test "updating an existing row is never recorded" { + run bash -c "'$REC' < $(event mcp__Linear__save_issue '' '' CLOUD-1)" + [ "$status" -eq 0 ] + [ ! -f "$(record)" ] +} + +# Sink 2: recorded so the create-versus-comment ratio is observable, never judged. +@test "a comment is recorded as a comment and carries no verdict" { + run bash -c "'$REC' < $(event mcp__Linear__save_comment)" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == "comment CLOUD-999 2026-08-13T00:00:00.000Z -" ]] +} + +# CLOUD-178 measured the same connector under three names depending on the +# registration episode; a rule naming one matches none of the others, silently. +@test "all three live connector spellings are recorded identically" { + local tool + for tool in mcp__Linear__save_issue mcp__claude_ai_Linear__save_issue mcp__4db58e41-0000-0000-0000-000000000000__save_issue; do + rm -f "$(record)" + run bash -c "'$REC' < $(event "$tool")" + [ "$status" -eq 0 ] + run cat "$(record)" + [[ "$output" == "issue CLOUD-999"* ]] + done +} + +@test "a tool that does not write to the board is never recorded" { + local tool + for tool in mcp__Linear__list_issues Bash Write mcp__serena__write_memory; do + run bash -c "'$REC' < $(event "$tool")" + [ "$status" -eq 0 ] + [ ! -f "$(record)" ] + done +} + +# --- pointer-only, and failing open ------------------------------------------- + +# Not decorative: the text this reads is the entire issue body. Four fields reach +# the file and nothing is ever printed (non-negotiable 4). +@test "POINTER, NEVER PAYLOAD: no byte of the description reaches the record" { + run bash -c "'$REC' < $(event mcp__Linear__save_issue 'SECRETMARKER in the body')" + [ -z "$output" ] + run cat "$(record)" + [[ "$output" != *"SECRETMARKER"* ]] + [ "$(wc -l <"$(record)")" -eq 1 ] +} + +@test "FAIL OPEN: an unreadable, nameless or resultless payload records nothing and says nothing" { + local payload + for payload in 'not json' '{}' '{"tool_name":""}' '' '{"tool_name":"mcp__Linear__save_issue"}'; do + run bash -c "printf '%s' $(printf '%q' "$payload") | '$REC'" + [ "$status" -eq 0 ] + [ -z "$output" ] + [ ! -f "$(record)" ] + done +} + +# The flat shape the docs illustrate with a Write. A body written against it would +# silently record nothing, which is why the envelope was measured. +@test "FAIL OPEN: a flat tool_response is not the MCP envelope and records nothing" { + run bash -c "jq -nc '{tool_name:\"mcp__Linear__save_issue\",tool_input:{title:\"x\"},tool_response:{id:\"CLOUD-9\"}}' | '$REC'" + [ "$status" -eq 0 ] + [ ! -f "$(record)" ] +} + +@test "FAIL OPEN: a detached HEAD has no branch to key a record to" { + git checkout -q --detach + run bash -c "'$REC' < $(event)" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "FAIL OPEN: outside a git repository nothing is recorded and nothing is blocked" { + cd "$BATS_TEST_TMPDIR" || return 1 + run bash -c "env GIT_CEILING_DIRECTORIES='$BATS_TEST_TMPDIR' '$REC' < $(event)" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "the bypass is honoured" { + run bash -c "BATTEN_BOARD_WRITE_BYPASS=1 '$REC' < $(event)" + [ "$status" -eq 0 ] + [ ! -f "$(record)" ] +} + +# --- the wiring ---------------------------------------------------------------- + +# A body no matcher reaches is inert, and the miss is silent. Suffix-anchored for +# CLOUD-178's reason, asserted here because this file cannot enforce it. +@test "the settings entry is wired, on a suffix-anchored PostToolUse matcher" { + local settings="$BATS_TEST_DIRNAME/../.claude/settings.json" + run jq -r '[.hooks.PostToolUse[] | select(.hooks[].command | test("board-write-record")) | .matcher] | first' "$settings" + [ "$status" -eq 0 ] + [[ "$output" == *"save_issue"* ]] + [[ "$output" == *"save_comment"* ]] + [[ "$output" != "mcp__Linear__"* ]] +}