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
15 changes: 14 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"enabledMcpjsonServers": ["serena"],
"enabledMcpjsonServers": [
"serena"
],
"hooks": {
"SessionStart": [
{
Expand Down Expand Up @@ -63,6 +65,17 @@
}
]
}
],
"PostToolUse": [
{
"matcher": ".*save_issue|.*save_comment",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/mise-tasks/board-write-record"
}
]
}
]
},
"permissions": {
Expand Down
147 changes: 147 additions & 0 deletions mise-tasks/board-write-record
Original file line number Diff line number Diff line change
@@ -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":"<the row as JSON>"}]`, 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__<uuid>__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
2 changes: 1 addition & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
211 changes: 211 additions & 0 deletions tests/board-write-record.bats
Original file line number Diff line number Diff line change
@@ -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__"* ]]
}
Loading