diff --git a/.gitignore b/.gitignore index 2798948..a61bbe5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ node_modules/ *.log .agents/ .claude/skills/ +# Agent working substrate: kickoff prompts, task specs, run state. Local-only by +# design — this repo is public, and that substrate carries machine paths and +# internal tooling detail that must never land in a published diff. +.cofoundy/ diff --git a/plugins/basalt/hooks/hooks.json b/plugins/basalt/hooks/hooks.json index 9d180ab..868a323 100644 --- a/plugins/basalt/hooks/hooks.json +++ b/plugins/basalt/hooks/hooks.json @@ -22,6 +22,16 @@ "timeout": 5 } ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_PLUGIN_ROOT}/hooks/post-publish.sh", + "timeout": 5 + } + ] } ], "Stop": [ diff --git a/plugins/basalt/hooks/lib.sh b/plugins/basalt/hooks/lib.sh index 6b6a521..2949c5c 100755 --- a/plugins/basalt/hooks/lib.sh +++ b/plugins/basalt/hooks/lib.sh @@ -5,7 +5,7 @@ # only invoke the user's own `basalt` CLI with the user's own credentials. # # Payload shapes are the REAL ones captured 2026-07-24 (see hooks/tests/fixtures/ and -# cofoundy-toolkit/docs/claude-code-capabilities.md). Load-bearing facts baked in here: +# the plugin capability notes). Load-bearing facts baked in here: # - PostToolUse `tool_input.file_path` is an ABSOLUTE path (Write + Edit). # - Every event in a run shares one `session_id` (subagent tool calls included) — # so the dirty-list keyed by session_id captures subagent edits too. @@ -96,3 +96,355 @@ vault_policy() { *) printf 'prompt' ;; esac } + +# ===================================================================================== +# A2 — the dirty list is an APPEND-ONLY JOURNAL, not a read-modify-write +# ===================================================================================== +# Two writers, both O_APPEND only: +# +# Edit|Write → post-edit.sh APPENDS a bare ABSOLUTE path ("this doc was edited") +# Bash → post-publish.sh APPENDS a "-" tombstone ("this doc published") +# Stop → stop.sh REPLAYS the journal top-to-bottom → the LIVE set +# +# `-` is unambiguous as a tombstone marker because a dirty entry is always an absolute +# path and starts with `/`. +# +# Why a journal and not a rewrite: subagent edits fire post-edit.sh under the PARENT's +# session id, so two appends can race. Under mktemp+mv one of them is LOST, and the +# direction of a lost update is SILENCE — the hook stops nagging for a doc nobody +# published. Appending removes the race by construction (no lock: `flock` is absent on +# macOS). It also makes ordering correct for free: `Write → publish → Edit → publish` +# lands right at every point, because each event is applied WHEN IT HAPPENED. A +# subtract-at-Stop design gets that sequence wrong. +# +# Tombstone matching, applied at replay where the whole ordering is visible: +# "-/abs/path" → EXACT match. post-publish.sh already resolved the operand against a +# base it could see (a `cd` in the command, else the payload's cwd). +# No exact match ⇒ drop NOTHING — there is no suffix fallback here, +# because suffix matching across two vaults sharing a basename is +# confident of the WRONG answer. +# "-rel/path" → the base was UNRESOLVABLE. Match by path SUFFIX, and only when +# exactly one live entry matches; 2+ candidates drop none. Ambiguity +# always resolves toward nagging, never toward silence. +# +# replay_journal -> prints the live set, one absolute path per line. +replay_journal() { + local f="$1" line rest i n hit hits + local -a live=() + [ -f "$f" ] || return 0 + while IFS= read -r line || [ -n "$line" ]; do + [ -n "$line" ] || continue + case "$line" in + -*) + rest="${line#-}" + [ -n "$rest" ] || continue + hit=-1; hits=0; n=${#live[@]} + for ((i = 0; i < n; i++)); do + [ -n "${live[i]}" ] || continue + case "$rest" in + /*) [ "${live[i]}" = "$rest" ] && { hit=$i; hits=$((hits + 1)); } ;; + *) case "${live[i]}" in */"$rest") hit=$i; hits=$((hits + 1)) ;; esac ;; + esac + done + [ "$hits" -eq 1 ] && live[$hit]="" + ;; + *) + hit=0; n=${#live[@]} + for ((i = 0; i < n; i++)); do + [ "${live[i]}" = "$line" ] && { hit=1; break; } + done + [ "$hit" -eq 1 ] || live+=("$line") + ;; + esac + done < "$f" + n=${#live[@]} + for ((i = 0; i < n; i++)); do + [ -n "${live[i]}" ] && printf '%s\n' "${live[i]}" + done + return 0 +} + +# ===================================================================================== +# Mechanism B — is this doc already on its way to publication via the repo's Action? +# ===================================================================================== +# The #4 case: the mandated flow for a repo-backed vault is commit + push → the repo's +# GitHub Action publishes. Nothing local ever clears the dirty list, so the nag is +# UNCONDITIONAL. Everything below is local (string ops + `git` plumbing): zero network, +# zero added `basalt` invocations. +# +# WHAT THIS CLAIMS, EXACTLY: not a prediction of what the Action publishes, only +# *positive local evidence that a push of this file, on this branch, STARTS a Basalt +# publish workflow*. Everything downstream of the trigger (run-step filters, `if:` +# conditions, a workflow that starts and then fails) is declared invisible. +# +# EVERY failure and every ambiguity below resolves toward KEEPING THE NAG. + +# --- small string helpers (no regex: ugrep and GNU grep disagree on `[^\n]`, recon F6) -- +_bp_trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +_bp_unquote() { + local s="$1" + case "$s" in + "'"*"'") s="${s#\'}"; s="${s%\'}" ;; + '"'*'"') s="${s#\"}"; s="${s%\"}" ;; + esac + printf '%s' "$s" +} + +# _bp_path_match +# 0 = matches · 1 = does not match · 2 = shape not supported (caller keeps the nag) +# +# BOUNDED shapes only, as `case` globs: `**.EXT` · `**/*.EXT` · `*.EXT` · `dir/**` · +# `dir/**/*.EXT` · literal. Everything else is unsupported ON PURPOSE. `!` negation is +# the sharpest example: GitHub allows it in `paths:` and it INVERTS the filter, so +# guessing its direction yields silence on a doc nobody published. +_bp_path_match() { + local p="$1" f="$2" ext dir + case "$p" in + ''|'!'*|*'?'*|*'['*|*']'*|*'{'*|*'}'*) return 2 ;; + esac + case "$p" in + '**.'*) + ext="${p#'**'}" + case "$ext" in *'*'*|*'/'*) return 2 ;; esac + case "$f" in *"$ext") return 0 ;; *) return 1 ;; esac ;; + '**/*.'*) + ext="${p#'**/*'}" + case "$ext" in *'*'*|*'/'*) return 2 ;; esac + case "$f" in *"$ext") return 0 ;; *) return 1 ;; esac ;; + '*.'*) + ext="${p#'*'}" + case "$ext" in *'*'*|*'/'*) return 2 ;; esac + case "$f" in */*) return 1 ;; esac # `*` does not cross a `/` + case "$f" in *"$ext") return 0 ;; *) return 1 ;; esac ;; + *'/**/*.'*) + dir="${p%%'/**/*'*}"; ext="${p##*'/**/*'}" + case "$dir" in *'*'*) return 2 ;; esac + case "$ext" in *'*'*|*'/'*) return 2 ;; esac + case "$f" in "$dir"/*) : ;; *) return 1 ;; esac + case "$f" in *"$ext") return 0 ;; *) return 1 ;; esac ;; + *'/**') + dir="${p%'/**'}" + case "$dir" in *'*'*) return 2 ;; esac + case "$f" in "$dir"/*) return 0 ;; *) return 1 ;; esac ;; + *'*'*) return 2 ;; + *) [ "$p" = "$f" ] && return 0 || return 1 ;; + esac +} + +# workflow_triggers_publish +# 0 when a push of this file on this branch STARTS this workflow. Conditions (5)–(8): +# 5. it has a `push:` trigger (a schedule:/release:-only workflow never fires) +# 6. EVERY `branches:` list contains the current branch (absent → pass) +# 7. EVERY `paths:` list matches the repo-relative path (absent → pass) +# 8. NO `paths-ignore:` / `branches-ignore:` anywhere (negation is not modelled) +# +# "EVERY list must match" sidesteps YAML nesting entirely — we never need to know which +# trigger owns which list — and being a conjunction it fails toward nagging. +workflow_triggers_publish() { + local wf="$1" branch="$2" rel="$3" + local line t lead rest pre val inner item key + local in_on=1 push_seen=1 + local kind="" list_n=0 list_ok=1 + local -a parts=() + + [ -f "$wf" ] || return 1 + + # (8) — negation is not modelled, in either direction. + grep -Fq 'paths-ignore' "$wf" 2>/dev/null && return 1 + grep -Fq 'branches-ignore' "$wf" 2>/dev/null && return 1 + + while IFS= read -r line || [ -n "$line" ]; do + t="$(_bp_trim "$line")" + case "$t" in '#'*) continue ;; esac + + # --- continuation of an open BLOCK sequence (`paths:` / `branches:` on its own line) + if [ -n "$kind" ]; then + case "$t" in + '') continue ;; + -*) + item="$(_bp_unquote "$(_bp_trim "${t#-}")")" + case "$item" in '!'*) return 1 ;; esac + if [ "$kind" = branches ]; then + [ "$item" = "$branch" ] && list_ok=0 + else + _bp_path_match "$item" "$rel" + case $? in 0) list_ok=0 ;; 2) return 1 ;; esac + fi + list_n=$((list_n + 1)) + continue ;; + *) + { [ "$list_n" -gt 0 ] && [ "$list_ok" -eq 0 ]; } || return 1 + kind=""; list_n=0; list_ok=1 ;; + esac + fi + [ -n "$t" ] || continue + + # --- top-level key tracking: only the `on:` block is trigger configuration --------- + lead="${line%%[![:space:]]*}" + if [ "${#lead}" -eq 0 ]; then + case "$t" in + on:*|'"on":'*|"'on':"*) in_on=0 ;; + *:*) in_on=1 ;; + esac + fi + [ "$in_on" -eq 0 ] || continue + + # --- (5) a `push:` trigger -------------------------------------------------------- + case "$t" in + push:|push:[[:space:]]*|push:'{'*|*' push:'*|*'{push:'*|*',push:'*) push_seen=0 ;; + esac + case "$t" in + on:*) + val="$(_bp_trim "${t#on:}")" + case "$val" in + ''|'{'*|'#'*) : ;; + *) case "$val" in *push*) push_seen=0 ;; esac ;; + esac ;; + esac + + # --- (6)+(7) every `branches:` / `paths:` list, wherever it sits in the `on:` block - + for key in branches: paths:; do + rest="$t" + while :; do + case "$rest" in *"$key"*) ;; *) break ;; esac + pre="${rest%%"$key"*}" + rest="${rest#*"$key"}" + # the key must START a mapping entry: line start, or after `{`, `,` or a space + case "$pre" in ''|*' '|*'{'|*',') ;; *) continue ;; esac + val="$(_bp_trim "$rest")" + case "$val" in + '['*) + inner="${val#'['}" + case "$inner" in *']'*) ;; *) return 1 ;; esac # multi-line flow: unreadable + inner="${inner%%]*}" + list_ok=1; list_n=0 + IFS=',' read -ra parts <<< "$inner" + for item in ${parts[@]+"${parts[@]}"}; do + item="$(_bp_unquote "$(_bp_trim "$item")")" + [ -n "$item" ] || continue + case "$item" in '!'*) return 1 ;; esac + if [ "${key%:}" = branches ]; then + [ "$item" = "$branch" ] && list_ok=0 + else + _bp_path_match "$item" "$rel" + case $? in 0) list_ok=0 ;; 2) return 1 ;; esac + fi + list_n=$((list_n + 1)) + done + { [ "$list_n" -gt 0 ] && [ "$list_ok" -eq 0 ]; } || return 1 + list_ok=1; list_n=0 ;; + ''|'#'*) + kind="${key%:}"; list_n=0; list_ok=1 ;; # block sequence follows + '}'*|','*) return 1 ;; # empty value: unreadable + *) + item="${val%%,*}"; item="${item%%\}*}" + item="$(_bp_unquote "$(_bp_trim "$item")")" + [ -n "$item" ] || return 1 + case "$item" in '!'*) return 1 ;; esac + if [ "${key%:}" = branches ]; then + [ "$item" = "$branch" ] || return 1 + else + _bp_path_match "$item" "$rel" || return 1 + fi ;; + esac + done + done + done < "$wf" + + [ -z "$kind" ] || { [ "$list_n" -gt 0 ] && [ "$list_ok" -eq 0 ]; } || return 1 + [ "$push_seen" -eq 0 ] || return 1 + return 0 +} + +# git_path_committed_and_pushed +# 0 when is TRACKED, has no uncommitted changes, and the commit that last +# touched it is an ancestor of the branch's upstream — i.e. it was PUSHED. +# +# THE SUBJECT IS LOAD-BEARING. This same predicate is applied to two different files: +# - the DOC → spec conditions (2) + (3) +# - the WORKFLOW → spec condition (0) +# Read as "the same two git calls" without naming the subject, the next reader points +# both of them at the doc and reopens the hole the refute-pass found: `basalt onboard` +# writes an UNTRACKED workflow, the agent pushes only the doc, and every condition +# passes against a workflow that does not exist on the branch whose push was verified. +git_path_committed_and_pushed() { + local root="$1" path="$2" st commit up + git -C "$root" ls-files --error-unmatch -- "$path" >/dev/null 2>&1 || return 1 + st="$(git -C "$root" status --porcelain -- "$path" 2>/dev/null)" || return 1 + [ -z "$st" ] || return 1 + commit="$(git -C "$root" log -1 --format=%H -- "$path" 2>/dev/null)" || return 1 + [ -n "$commit" ] || return 1 + up="$(git -C "$root" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null)" || return 1 + [ -n "$up" ] || return 1 + git -C "$root" merge-base --is-ancestor "$commit" "$up" 2>/dev/null || return 1 + return 0 +} + +# --- per-REPO half of the filter, memoised (resolved once per repo root, not per file) -- +# bash 3.2 (macOS) has no associative arrays → two parallel arrays. +_BP_MEMO_ROOT=(); _BP_MEMO_WF=() + +# _bp_compute_candidates -> newline-joined QUALIFIED candidate workflows +# (1) the repo declares at least one candidate Basalt publish workflow +# (`.github/workflows/*.y*ml` mentioning `basalt`) +# (0) that candidate WORKFLOW FILE is itself tracked, clean and pushed +# (4) the current branch IS the repo's default branch. Full stop, no disjunct: +# `branches: [main]` ships in the canonical template (F7), so a permissive default +# here goes quiet on a doc that will never publish, for every user. +_bp_compute_candidates() { + local root="$1" def cur wf out="" + def="$(git -C "$root" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" || return 0 + [ -n "$def" ] || return 0 + def="${def#origin/}" + cur="$(git -C "$root" symbolic-ref --short -q HEAD 2>/dev/null)" || return 0 + [ -n "$cur" ] || return 0 # detached HEAD → keep the nag + [ "$cur" = "$def" ] || return 0 # B3: not the default branch → keep it + for wf in "$root"/.github/workflows/*.y*ml; do + [ -f "$wf" ] || continue + grep -Fq basalt "$wf" 2>/dev/null || continue # (1) + git_path_committed_and_pushed "$root" "$wf" || continue # (0) — the WORKFLOW + out="$out$wf +" + done + printf '%s' "$out" +} + +repo_publish_candidates() { + local root="$1" i n out + n=${#_BP_MEMO_ROOT[@]} + for ((i = 0; i < n; i++)); do + if [ "${_BP_MEMO_ROOT[i]}" = "$root" ]; then printf '%s' "${_BP_MEMO_WF[i]}"; return 0; fi + done + out="$(_bp_compute_candidates "$root")" + _BP_MEMO_ROOT+=("$root"); _BP_MEMO_WF+=("$out") + printf '%s' "$out" +} + +# published_by_repo_action +# 0 → drop it from the dirty list (a push of it starts a Basalt publish workflow) +# 1 → KEEP THE NAG (the default for every failure, every ambiguity, every gap) +published_by_repo_action() { + local file="$1" root rel cands wf cur + command -v git >/dev/null 2>&1 || return 1 + root="$(cd "$(dirname "$file")" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null)" || return 1 + [ -n "$root" ] || return 1 + cands="$(repo_publish_candidates "$root")" + [ -n "$cands" ] || return 1 + git_path_committed_and_pushed "$root" "$file" || return 1 # (2)+(3) — the DOC + case "$file" in "$root"/*) rel="${file#"$root"/}" ;; *) return 1 ;; esac + cur="$(git -C "$root" symbolic-ref --short -q HEAD 2>/dev/null)" || return 1 + # C2: (5)–(8) are evaluated PER CANDIDATE; ONE qualifying candidate is enough. The + # other reading (all must qualify) lets a single workflow_dispatch-only helper kill + # Mechanism B repo-wide, silently, with every test green. + while IFS= read -r wf; do + [ -n "$wf" ] || continue + workflow_triggers_publish "$wf" "$cur" "$rel" && return 0 + done <<< "$cands" + return 1 +} diff --git a/plugins/basalt/hooks/post-edit.sh b/plugins/basalt/hooks/post-edit.sh index 6110fe0..44185d4 100755 --- a/plugins/basalt/hooks/post-edit.sh +++ b/plugins/basalt/hooks/post-edit.sh @@ -19,14 +19,27 @@ sid="$(json_get "$payload" "session_id")" [ -n "$sid" ] || sid="nosession" mkdir -p "$(dirty_dir)" 2>/dev/null || exit 0 -# append_once +# append_once — dedup against HISTORY. Correct for the orphan list, +# WRONG for the dirty journal. See B1 below. append_once() { { [ -f "$1" ] && grep -Fxq "$2" "$1"; } || printf '%s\n' "$2" >> "$1" } vault="$(find_vault "$file")" if [ -n "$vault" ]; then - append_once "$(dirty_file "$sid")" "$file" # under a vault → publishable + # B1 — the dirty append is UNCONDITIONAL, and this is the one blocking defect if it is + # not. The dirty list is an append-only journal (A2) that also carries `-` + # publish tombstones, so its history keeps a tombstoned path forever: + # + # journal: p ; -p → grep -Fxq "p" MATCHES line 1 → the re-edit is SKIPPED + # replay: p → live={p} ; -p → live={} → Stop goes SILENT + # + # That is acceptance arm (c)'s second half — "then edit it again ⇒ it nags again" — + # failing toward silence. `append_once` stays ONLY on the orphan list below, where the + # dedup is semantically required (the fix is one vault, not one per file). Dedup for + # the dirty list now happens at REPLAY, where the whole ordering is visible. + # This also REMOVES a per-edit `grep`. Zero network / zero output are untouched. + printf '%s\n' "$file" >> "$(dirty_file "$sid")" # under a vault → publishable elif is_doc "$file"; then # A doc with no vault above it. Not an error yet — but a publish from here would land # in the wrong space silently, so record the REPO ROOT (not the file): the fix is one diff --git a/plugins/basalt/hooks/post-publish.sh b/plugins/basalt/hooks/post-publish.sh new file mode 100755 index 0000000..f689a85 --- /dev/null +++ b/plugins/basalt/hooks/post-publish.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# post-publish.sh — PostToolUse(Bash) hook. Edit-tracking's twin: when a Bash command +# really published a vault doc, append a TOMBSTONE for it to this session's dirty +# journal, so Stop stops asking for a publish that already happened. +# ZERO network, ZERO output, always. No `basalt` invocation — this reads the command +# string the model already ran. +# +# THE BUG THIS CLOSES (#5): the nudge asks the agent to run `basalt publish `, the +# agent does exactly that mid-turn, and `stop.sh` — the only writer that ever cleared the +# list — nags anyway. The nudge could not be satisfied except by not editing. +# +# WHY THIS CAN TRUST exit-0 AT ALL (recon F2, measured on 2.1.233 with a marker log as an +# independent instrument): PostToolUse fires ONLY when the Bash command exits 0 — 4 +# executions, 2 fires, the two non-zero exits produced no payload at all. So a FAILED +# `basalt publish` never reaches this hook. There is no exit-code field in the payload +# (F1); this is how success is known without one, and without a network call. +# +# THE RESIDUAL RISK THAT BUYS (A5), stated rather than asserted away: if a future harness +# starts firing on non-zero exits, a failed publish clears the entry and the hook goes +# quiet on a genuinely unpublished doc — the feature's one true positive, deleted, with +# no error. The suite cannot pin this (it feeds fixtures to the hook, never the harness's +# firing policy, and F1 gives no exit code to synthesize a "failed publish" from). +# `tests/probe-harness.sh` re-measures F2 against the installed harness; run it when the +# supported version moves. +set -u +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=lib.sh +. "$DIR/lib.sh" + +payload="$(cat)" + +# --- A8: this fires on EVERY Bash call, so bail cheap and bail early ------------------ +# Read the command, `case` it for `basalt`, and exit before touching the dirty dir. +# NEVER substring-match the raw payload: `cwd` and `transcript_path` contain "basalt" in +# this very repo, which would make the matcher fire on every command in it. +cmd="$(json_get "$payload" "tool_input.command")" +[ -n "$cmd" ] || exit 0 +case "$cmd" in *basalt*) ;; *) exit 0 ;; esac + +# --- A1b: metacharacter precondition ------------------------------------------------- +# A command containing a quote, a backslash or a newline clears NOTHING. +# +# The vulnerability is not newlines — it is that quote-blind splitting MANUFACTURES a +# segment whose first token is `basalt`, which defeats the token test below. A `;` inside +# a quoted string does it with no newline at all: +# +# gh issue comment 5 --body "checked; basalt publish ship-log/x.md and done" +# split on ';' → [basalt] [publish] [ship-log/x.md] [and] [done"] +# the token test PASSES, the operand is clean → it clears +# +# What this costs, all failing toward the nag: quoted paths, quoted flag values, +# `cd "/p with spaces"`. Mainline publishing is unquoted and untouched. +case "$cmd" in + *\'*|*\"*|*\\*) exit 0 ;; +esac +case "$cmd" in + *$'\n'*) exit 0 ;; +esac + +# --- the one free payload-checkable guard (A5) --------------------------------------- +[ "$(json_get "$payload" "tool_response.interrupted")" = "true" ] && exit 0 + +sid="$(json_get "$payload" "session_id")" +[ -n "$sid" ] || sid="nosession" +df="$(dirty_file "$sid")" +[ -f "$df" ] || exit 0 # nothing tracked this turn → nothing to clear + +cwd="$(json_get "$payload" "cwd")" + +# --- split the command into segments + the separators between them ------------------- +# Character scan, not a regex (recon F6: ugrep and GNU grep disagree on `[^\n]` inside a +# bracket, so a regex path parser behaves one way locally and another in CI). +SEG=(); SEP=() +split_command() { + local s="$1" cur="" + while [ -n "$s" ]; do + case "$s" in + '&&'*) SEG+=("$cur"); SEP+=("&&"); cur=""; s="${s:2}" ;; + '||'*) SEG+=("$cur"); SEP+=("||"); cur=""; s="${s:2}" ;; + '&'*) SEG+=("$cur"); SEP+=("&"); cur=""; s="${s:1}" ;; + '|'*) SEG+=("$cur"); SEP+=("|"); cur=""; s="${s:1}" ;; + ';'*) SEG+=("$cur"); SEP+=(";"); cur=""; s="${s:1}" ;; + *) cur="$cur${s:0:1}"; s="${s:1}" ;; + esac + done + SEG+=("$cur"); SEP+=("end") +} +split_command "$cmd" + +# --- A1: exit 0 is a property of the COMMAND, not of a segment ----------------------- +# The harness fires only on exit 0 (F2). That says the COMMAND succeeded. After splitting +# a compound command, only `&&` carries that implication down to a segment: +# +# A segment may clear anything only when it is the LAST segment, or every separator +# between it and the last segment is `&&`. A segment followed — before the end — by +# `;`, `||`, `|`, `&` or a newline is UNTRUSTED and clears nothing. +# +# Without this, `basalt publish a.md || true` exits 0 with a FAILED publish, fires the +# hook, and clears the entry. So does `cd v; basalt publish a.md; echo done`. +seg_trusted() { + local i="$1" j n=${#SEP[@]} + for ((j = i; j < n - 1; j++)); do + [ "${SEP[j]}" = "&&" ] || return 1 + done + return 0 +} + +# A command containing a bare `&` anywhere clears nothing — backgrounding decouples the +# segment's fate from the command's exit code entirely. +for ((k = 0; k < ${#SEP[@]}; k++)); do + [ "${SEP[k]}" = "&" ] && exit 0 +done + +# --- A3: is this segment a publish, and what are its operands? ----------------------- +OPERANDS=() +parse_publish_segment() { + local seg="$1" t i n j skipnext=0 + local -a toks=() + OPERANDS=() + IFS=$' \t' read -r -a toks <<< "$seg" + n=${#toks[@]}; i=0 + while [ "$i" -lt "$n" ]; do # skip leading VAR=value assignments + case "${toks[i]}" in [A-Za-z_]*=*) i=$((i + 1)) ;; *) break ;; esac + done + # the first non-assignment token must be `basalt` (or `*/basalt`) and the second + # `publish`. So `echo basalt publish x` and a commit message clear nothing. + [ "$i" -lt "$n" ] || return 1 + case "${toks[i]}" in basalt|*/basalt) ;; *) return 1 ;; esac + i=$((i + 1)) + [ "$i" -lt "$n" ] || return 1 + [ "${toks[i]}" = "publish" ] || return 1 + i=$((i + 1)) + # `--dry-run` exits 0 and fires the hook while publishing NOTHING — exactly the trap F2 + # creates: exit 0 means "the command succeeded", not "a publish happened". + for ((j = 0; j < n; j++)); do + [ "${toks[j]}" = "--dry-run" ] && return 1 + done + while [ "$i" -lt "$n" ]; do + t="${toks[i]}" + if [ "$skipnext" -eq 1 ]; then skipnext=0; i=$((i + 1)); continue; fi + case "$t" in + --project|--tenant) skipnext=1 ;; # value-taking flags: skip the value too + -*) : ;; # any other flag + *) OPERANDS+=("$t") ;; + esac + i=$((i + 1)) + done + return 0 +} + +# seg_cd_dir -> exit 0 when the segment IS a `cd`, printing its target +seg_cd_dir() { + local seg="$1" i n + local -a toks=() + IFS=$' \t' read -r -a toks <<< "$seg" + n=${#toks[@]}; i=0 + while [ "$i" -lt "$n" ]; do + case "${toks[i]}" in [A-Za-z_]*=*) i=$((i + 1)) ;; *) break ;; esac + done + [ "$i" -lt "$n" ] || return 1 + [ "${toks[i]}" = "cd" ] || return 1 + i=$((i + 1)) + [ "$i" -lt "$n" ] && printf '%s' "${toks[i]}" + return 0 +} + +# --- base resolution: resolve when a base exists, SUFFIX only when none does --------- +# Recon F3: the payload's `cwd` does NOT follow a compound `cd` (measured — the command +# really ran in /tmp, the payload still reported the session dir). But `cwd` fails ONLY +# when a `cd` is present, and in that case the `cd` is right there in the command. +# Disjoint cases, correct instrument for each: +# 1. base = the last `cd ` before the publish segment, else the payload's `cwd` +# 2. resolve the operand against that base → an EXACT match at replay +# 3. resolved base + no exact match ⇒ clear NOTHING. No suffix fallback here. +# +# Why: suffix matching clears the WRONG file across two vaults sharing a basename — +# live: …/vault-a/ship-log/2026-08-17.md (edited, never published) +# cmd: cd …/vault-b && basalt publish ship-log/2026-08-17.md +# The exactly-one guard does not fire, because there genuinely IS exactly one match: the +# system is confident of the wrong answer, strictly worse than the ambiguity it guards. +# Suffix matching is the fallback for an UNRESOLVABLE base only, and there it keeps the +# exactly-one-live-entry rule (applied at replay). +base="$cwd" +n_seg=${#SEG[@]} +for ((k = 0; k < n_seg; k++)); do + if dir="$(seg_cd_dir "${SEG[k]}")"; then + case "$dir" in + '') base="" ;; # bare `cd` → $HOME, not knowable here + -|'~'*) base="" ;; + /*) base="$dir" ;; + *) if [ -n "$base" ]; then base="$base/$dir"; else base=""; fi ;; + esac + fi + parse_publish_segment "${SEG[k]}" || continue + seg_trusted "$k" || continue + rbase="" + [ -n "$base" ] && rbase="$(cd "$base" 2>/dev/null && pwd -P)" + for ((m = 0; m < ${#OPERANDS[@]}; m++)); do + op="${OPERANDS[m]}" + op="${op#./}" # `basalt publish ./ship-log/x.md` + case "$op" in ''|*/) continue ;; esac # a trailing `/` is a directory + case "$op" in + /*) target="$op" ;; + *) if [ -n "$rbase" ]; then target="$rbase/$op"; else target="$op"; fi ;; + esac + case "$target" in /*) [ -d "$target" ] && continue ;; esac + printf -- '-%s\n' "$target" >> "$df" # O_APPEND only (A2) + done +done +exit 0 diff --git a/plugins/basalt/hooks/stop.sh b/plugins/basalt/hooks/stop.sh index 4f8d8b2..2c2c3cd 100755 --- a/plugins/basalt/hooks/stop.sh +++ b/plugins/basalt/hooks/stop.sh @@ -15,6 +15,15 @@ # # The dirty-list is CLEARED after we speak, so a subsequent turn with no NEW edits stays # silent (no per-turn nagging). Batching-at-close, never push-on-edit (IRT NO-GO). +# +# THE PROPOSITION THIS USED TO GET WRONG: it asserted "this doc is unpublished" from a +# measurement of "this doc was edited". Those are different propositions, and two flows +# sat entirely inside the gap — a repo-backed vault whose Action publishes on push +# (nothing local ever cleared the list, so the nag was unconditional), and an agent +# running `basalt publish` mid-turn, which is exactly what the nudge asks for. The list +# now means: VAULT DOCS EDITED SINCE THEIR LAST PUBLISH. +# - publish-tracking → post-publish.sh writes tombstones; replay_journal() applies them +# - the Action case → published_by_repo_action() (all local: `git` plumbing + globs) set -u DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=lib.sh @@ -31,17 +40,25 @@ df="$(dirty_file "$sid")" of="$(orphan_file "$sid")" [ -f "$df" ] || [ -f "$of" ] || exit 0 # nothing tracked → silent -# 2) Bucket the (existing, deduped) dirty files by their nearest vault's policy. +# 2) REPLAY the append-only journal into the live set — vault docs edited since their +# last publish (A2) — then drop the ones the repo's own Action will publish +# (Mechanism B, the #4 case), then bucket what remains by vault policy. +# +# The list's real lifetime is one TURN, not one session: this clears it below on +# every Stop that reads it. The exception is the loop-guard Stop above, which returns +# BEFORE the clear and PRESERVES the list — and that exception is precisely how a +# false positive outlives its own turn. auto_files=(); prompt_n=0 if [ -f "$df" ]; then while IFS= read -r f; do [ -n "$f" ] || continue + published_by_repo_action "$f" && continue # Mechanism B → already publishing case "$(vault_policy "$(find_vault "$f")")" in auto) auto_files+=("$f") ;; manual) : ;; # explicitly silent *) prompt_n=$((prompt_n + 1)) ;; # prompt (default) esac - done < "$df" + done < <(replay_journal "$df") fi # 2b) Orphan bucket: docs edited in a repo with NO vault.yaml. Only speak when the CLI is diff --git a/plugins/basalt/hooks/tests/fixtures/posttooluse-bash-publish.json b/plugins/basalt/hooks/tests/fixtures/posttooluse-bash-publish.json new file mode 100644 index 0000000..959e63c --- /dev/null +++ b/plugins/basalt/hooks/tests/fixtures/posttooluse-bash-publish.json @@ -0,0 +1,25 @@ +{ + "session_id": "test-session-0001", + "transcript_path": "/tmp/basalt-hook-fixtures/transcript.jsonl", + "cwd": "/tmp/basalt-hook-fixtures/proj", + "prompt_id": "test-prompt-0001", + "permission_mode": "default", + "effort": { + "level": "xhigh" + }, + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": { + "command": "basalt publish docs/test.md", + "description": "Publish the doc" + }, + "tool_response": { + "stdout": "", + "stderr": "", + "interrupted": false, + "isImage": false, + "noOutputExpected": false + }, + "tool_use_id": "toolu_testbash0001", + "duration_ms": 33 +} diff --git a/plugins/basalt/hooks/tests/fixtures/posttooluse-edit.json b/plugins/basalt/hooks/tests/fixtures/posttooluse-edit.json index 32187fa..1ae7242 100644 --- a/plugins/basalt/hooks/tests/fixtures/posttooluse-edit.json +++ b/plugins/basalt/hooks/tests/fixtures/posttooluse-edit.json @@ -1 +1,42 @@ -{"session_id":"b2e62d1e-5631-4aac-901d-74d0a68ecd1b","transcript_path":"/home/andre/.claude/projects/-tmp-claude-1000--home-andre-cofoundy-products-basalt-3749c94a-50fe-4a19-9695-7f4104293222-scratchpad-hook-validation-proj/b2e62d1e-5631-4aac-901d-74d0a68ecd1b.jsonl","cwd":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj","prompt_id":"ef699644-d6ab-41f2-a4af-4dcfbfd270c1","permission_mode":"bypassPermissions","effort":{"level":"xhigh"},"hook_event_name":"PostToolUse","tool_name":"Edit","tool_input":{"file_path":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj/docs/test.md","old_string":"hello","new_string":"hello world","replace_all":false},"tool_response":{"filePath":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj/docs/test.md","oldString":"hello","newString":"hello world","originalFile":"hello","structuredPatch":[{"oldStart":1,"oldLines":1,"newStart":1,"newLines":1,"lines":["-hello","\\ No newline at end of file","+hello world","\\ No newline at end of file"]}],"userModified":false,"replaceAll":false},"tool_use_id":"toolu_01Rovbd3XvnSyxnZmhgiXit5","duration_ms":2} +{ + "session_id": "test-session-0001", + "transcript_path": "/tmp/basalt-hook-fixtures/transcript.jsonl", + "cwd": "/tmp/basalt-hook-fixtures/proj", + "prompt_id": "test-prompt-0001", + "permission_mode": "bypassPermissions", + "effort": { + "level": "xhigh" + }, + "hook_event_name": "PostToolUse", + "tool_name": "Edit", + "tool_input": { + "file_path": "/tmp/basalt-hook-fixtures/proj/docs/test.md", + "old_string": "hello", + "new_string": "hello world", + "replace_all": false + }, + "tool_response": { + "filePath": "/tmp/basalt-hook-fixtures/proj/docs/test.md", + "oldString": "hello", + "newString": "hello world", + "originalFile": "hello", + "structuredPatch": [ + { + "oldStart": 1, + "oldLines": 1, + "newStart": 1, + "newLines": 1, + "lines": [ + "-hello", + "\\ No newline at end of file", + "+hello world", + "\\ No newline at end of file" + ] + } + ], + "userModified": false, + "replaceAll": false + }, + "tool_use_id": "toolu_testedit0001", + "duration_ms": 2 +} diff --git a/plugins/basalt/hooks/tests/fixtures/posttooluse-write.json b/plugins/basalt/hooks/tests/fixtures/posttooluse-write.json index 63b1cbc..ad58d1c 100644 --- a/plugins/basalt/hooks/tests/fixtures/posttooluse-write.json +++ b/plugins/basalt/hooks/tests/fixtures/posttooluse-write.json @@ -1 +1,26 @@ -{"session_id":"b2e62d1e-5631-4aac-901d-74d0a68ecd1b","transcript_path":"/home/andre/.claude/projects/-tmp-claude-1000--home-andre-cofoundy-products-basalt-3749c94a-50fe-4a19-9695-7f4104293222-scratchpad-hook-validation-proj/b2e62d1e-5631-4aac-901d-74d0a68ecd1b.jsonl","cwd":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj","prompt_id":"ef699644-d6ab-41f2-a4af-4dcfbfd270c1","permission_mode":"bypassPermissions","effort":{"level":"xhigh"},"hook_event_name":"PostToolUse","tool_name":"Write","tool_input":{"file_path":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj/docs/test.md","content":"hello"},"tool_response":{"type":"create","filePath":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj/docs/test.md","content":"hello","structuredPatch":[],"originalFile":null,"userModified":false},"tool_use_id":"toolu_01AYwTomvN5938g3MWHoZxzF","duration_ms":2} +{ + "session_id": "test-session-0001", + "transcript_path": "/tmp/basalt-hook-fixtures/transcript.jsonl", + "cwd": "/tmp/basalt-hook-fixtures/proj", + "prompt_id": "test-prompt-0001", + "permission_mode": "bypassPermissions", + "effort": { + "level": "xhigh" + }, + "hook_event_name": "PostToolUse", + "tool_name": "Write", + "tool_input": { + "file_path": "/tmp/basalt-hook-fixtures/proj/docs/test.md", + "content": "hello" + }, + "tool_response": { + "type": "create", + "filePath": "/tmp/basalt-hook-fixtures/proj/docs/test.md", + "content": "hello", + "structuredPatch": [], + "originalFile": null, + "userModified": false + }, + "tool_use_id": "toolu_testwrite001", + "duration_ms": 2 +} diff --git a/plugins/basalt/hooks/tests/fixtures/sessionstart-startup.json b/plugins/basalt/hooks/tests/fixtures/sessionstart-startup.json index 0425ea3..66d0b65 100644 --- a/plugins/basalt/hooks/tests/fixtures/sessionstart-startup.json +++ b/plugins/basalt/hooks/tests/fixtures/sessionstart-startup.json @@ -1 +1,7 @@ -{"session_id":"b2e62d1e-5631-4aac-901d-74d0a68ecd1b","transcript_path":"/home/andre/.claude/projects/-tmp-claude-1000--home-andre-cofoundy-products-basalt-3749c94a-50fe-4a19-9695-7f4104293222-scratchpad-hook-validation-proj/b2e62d1e-5631-4aac-901d-74d0a68ecd1b.jsonl","cwd":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj","hook_event_name":"SessionStart","source":"startup"} +{ + "session_id": "test-session-0001", + "transcript_path": "/tmp/basalt-hook-fixtures/transcript.jsonl", + "cwd": "/tmp/basalt-hook-fixtures/proj", + "hook_event_name": "SessionStart", + "source": "startup" +} diff --git a/plugins/basalt/hooks/tests/fixtures/stop-active-guard.json b/plugins/basalt/hooks/tests/fixtures/stop-active-guard.json index 3670a8f..7e418b6 100644 --- a/plugins/basalt/hooks/tests/fixtures/stop-active-guard.json +++ b/plugins/basalt/hooks/tests/fixtures/stop-active-guard.json @@ -1 +1,15 @@ -{"session_id":"b2e62d1e-5631-4aac-901d-74d0a68ecd1b","transcript_path":"/home/andre/.claude/projects/-tmp-claude-1000--home-andre-cofoundy-products-basalt-3749c94a-50fe-4a19-9695-7f4104293222-scratchpad-hook-validation-proj/b2e62d1e-5631-4aac-901d-74d0a68ecd1b.jsonl","cwd":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj","prompt_id":"ef699644-d6ab-41f2-a4af-4dcfbfd270c1","permission_mode":"bypassPermissions","effort":{"level":"xhigh"},"hook_event_name":"Stop","stop_hook_active":true,"last_assistant_message":"Done. `docs/test.md` now contains `hello world`.","background_tasks":[],"session_crons":[]} +{ + "session_id": "test-session-0001", + "transcript_path": "/tmp/basalt-hook-fixtures/transcript.jsonl", + "cwd": "/tmp/basalt-hook-fixtures/proj", + "prompt_id": "test-prompt-0001", + "permission_mode": "bypassPermissions", + "effort": { + "level": "xhigh" + }, + "hook_event_name": "Stop", + "stop_hook_active": true, + "last_assistant_message": "Done. `docs/test.md` now contains `hello world`.", + "background_tasks": [], + "session_crons": [] +} diff --git a/plugins/basalt/hooks/tests/fixtures/stop-inactive.json b/plugins/basalt/hooks/tests/fixtures/stop-inactive.json index 63d40ca..7707720 100644 --- a/plugins/basalt/hooks/tests/fixtures/stop-inactive.json +++ b/plugins/basalt/hooks/tests/fixtures/stop-inactive.json @@ -1 +1,15 @@ -{"session_id":"b2e62d1e-5631-4aac-901d-74d0a68ecd1b","transcript_path":"/home/andre/.claude/projects/-tmp-claude-1000--home-andre-cofoundy-products-basalt-3749c94a-50fe-4a19-9695-7f4104293222-scratchpad-hook-validation-proj/b2e62d1e-5631-4aac-901d-74d0a68ecd1b.jsonl","cwd":"/tmp/claude-1000/-home-andre-cofoundy-products-basalt/3749c94a-50fe-4a19-9695-7f4104293222/scratchpad/hook-validation/proj","prompt_id":"ef699644-d6ab-41f2-a4af-4dcfbfd270c1","permission_mode":"bypassPermissions","effort":{"level":"xhigh"},"hook_event_name":"Stop","stop_hook_active":false,"last_assistant_message":"Done. `docs/test.md` now contains `hello world`.","background_tasks":[],"session_crons":[]} +{ + "session_id": "test-session-0001", + "transcript_path": "/tmp/basalt-hook-fixtures/transcript.jsonl", + "cwd": "/tmp/basalt-hook-fixtures/proj", + "prompt_id": "test-prompt-0001", + "permission_mode": "bypassPermissions", + "effort": { + "level": "xhigh" + }, + "hook_event_name": "Stop", + "stop_hook_active": false, + "last_assistant_message": "Done. `docs/test.md` now contains `hello world`.", + "background_tasks": [], + "session_crons": [] +} diff --git a/plugins/basalt/hooks/tests/mutate.sh b/plugins/basalt/hooks/tests/mutate.sh new file mode 100755 index 0000000..e56baf0 --- /dev/null +++ b/plugins/basalt/hooks/tests/mutate.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# mutate.sh — the mutation pass. OPT-IN, not CI. +# +# A suite that has never gone red proves nothing: a guard whose mutant still passes is +# NOT TESTED. So each guard is broken on purpose and the suite must go red. +# +# AND THE MUTANT IS AN ENVIRONMENT TOO. Before a mutant's verdict is read this proves the +# mutant actually mutated — the pattern was found and replaced, `bash -n` passes, and a +# smoke run of every hook still exits 0. Otherwise "the test does not catch it" is +# indistinguishable from "the mutant never ran", which is exactly the conclusion one is +# fishing for. +# +# The set is a RECIPE, not a count: one named mutant per Mechanism B condition (0)–(8), +# plus one per gate-introduced guard (the C3 dedup, the A1b metacharacter precondition, +# the base-resolution rule), plus the A1 segment-trust and A3 --dry-run guards. +# +# bash plugins/basalt/hooks/tests/mutate.sh # all mutants +# bash plugins/basalt/hooks/tests/mutate.sh M0 M9 # named mutants only +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HOOKS="$(dirname "$HERE")" +command -v python3 >/dev/null 2>&1 || { echo "mutate.sh needs python3 for literal-exact patching"; exit 2; } + +survived=0; killed=0; broken=0 + +# apply — literal-exact replacement; fails if absent. +apply() { + BP_FILE="$1/$2" BP_OLD="$3" BP_NEW="$4" python3 -c ' +import os, sys +p, old, new = os.environ["BP_FILE"], os.environ["BP_OLD"], os.environ["BP_NEW"] +s = open(p).read() +if old not in s: + sys.stderr.write("pattern NOT FOUND in %s\n" % p); sys.exit(3) +open(p, "w").write(s.replace(old, new)) +sys.stdout.write(str(s.count(old))) +' +} + +# mutate — the mutation table. Every OLD string is verbatim source. +mutate() { + local id="$1" d="$2" + case "$id" in + M0) apply "$d" lib.sh \ + ' git_path_committed_and_pushed "$root" "$wf" || continue # (0) — the WORKFLOW' \ + ' true || continue' ;; + M1) apply "$d" lib.sh \ + ' grep -Fq basalt "$wf" 2>/dev/null || continue # (1)' \ + ' true || continue' ;; + M2) apply "$d" lib.sh \ + ' [ -z "$st" ] || return 1' \ + ' [ -z "$st" ] || :' ;; + M3) apply "$d" lib.sh \ + ' git -C "$root" merge-base --is-ancestor "$commit" "$up" 2>/dev/null || return 1' \ + ' git -C "$root" merge-base --is-ancestor "$commit" "$up" 2>/dev/null || :' ;; + M4) apply "$d" lib.sh \ + ' [ "$cur" = "$def" ] || return 0 # B3: not the default branch → keep it' \ + ' [ "$cur" = "$def" ] || :' ;; + M5) apply "$d" lib.sh \ + ' [ "$push_seen" -eq 0 ] || return 1' \ + ' [ "$push_seen" -eq 0 ] || :' ;; + M6) apply "$d" lib.sh \ + '[ "$item" = "$branch" ] && list_ok=0' \ + 'list_ok=0' ;; + M7) apply "$d" lib.sh \ + 'case $? in 0) list_ok=0 ;; 2) return 1 ;; esac' \ + 'list_ok=0' ;; + M8) apply "$d" lib.sh \ + " grep -Fq 'paths-ignore' \"\$wf\" 2>/dev/null && return 1 + grep -Fq 'branches-ignore' \"\$wf\" 2>/dev/null && return 1" \ + ' :' ;; + # The '!' guard is defended in three places — the matcher's shape list and both list + # readers. Removing one layer leaves the other two standing, so the mutant has to + # take all of them or it reports "not tested" about a guard that IS tested. + M8b) { apply "$d" lib.sh \ + " ''|'!'*|*'?'*|*'['*|*']'*|*'{'*|*'}'*) return 2 ;;" \ + " ''|*'?'*|*'['*|*']'*|*'{'*|*'}'*) return 2 ;;" \ + && printf '+' \ + && apply "$d" lib.sh \ + "case \"\$item\" in '!'*) return 1 ;; esac" \ + ':' ; } ;; + M9) apply "$d" post-edit.sh \ + ' printf '"'"'%s\n'"'"' "$file" >> "$(dirty_file "$sid")" # under a vault → publishable' \ + ' append_once "$(dirty_file "$sid")" "$file"' ;; + M10) apply "$d" post-publish.sh \ + "case \"\$cmd\" in + *\\'*|*\\\"*|*\\\\*) exit 0 ;; +esac" \ + ':' ;; + M11) apply "$d" post-publish.sh \ + ' *) if [ -n "$rbase" ]; then target="$rbase/$op"; else target="$op"; fi ;;' \ + ' *) target="$op" ;;' ;; + M12) apply "$d" post-publish.sh \ + ' seg_trusted "$k" || continue' \ + ' true || continue' ;; + M13) apply "$d" post-publish.sh \ + ' [ "${toks[j]}" = "--dry-run" ] && return 1' \ + ' [ "${toks[j]}" = "--never-a-real-flag" ] && return 1' ;; + *) echo "unknown mutant: $id"; return 9 ;; + esac +} + +describe() { + case "$1" in + M0) echo "condition (0) — the candidate WORKFLOW must be tracked, clean and pushed" ;; + M1) echo "condition (1) — the workflow must mention basalt to be a candidate" ;; + M2) echo "condition (2) — no uncommitted changes (subject: DOC, and WORKFLOW via (0))" ;; + M3) echo "condition (3) — last commit is an ancestor of upstream, i.e. PUSHED" ;; + M4) echo "condition (4) — the current branch IS the repo's default branch (B3)" ;; + M5) echo "condition (5) — the workflow has a push: trigger" ;; + M6) echo "condition (6) — every branches: list contains the current branch" ;; + M7) echo "condition (7) — every paths: list matches the repo-relative path" ;; + M8) echo "condition (8) — no paths-ignore:/branches-ignore: anywhere" ;; + M8b) echo "condition (8) — a '!'-negated pattern is unsupported, not guessed" ;; + M9) echo "C3 dedup — restore append_once on the DIRTY list (B1's wrong fix)" ;; + M10) echo "A1b — the metacharacter precondition" ;; + M11) echo "base resolution (ii) — resolved base + no exact match clears NOTHING" ;; + M12) echo "A1 — segment trust (only the last segment, or an all-&& chain)" ;; + M13) echo "A3 — --dry-run publishes nothing" ;; + esac +} + +IDS=("$@") +[ "${#IDS[@]}" -gt 0 ] || IDS=(M0 M1 M2 M3 M4 M5 M6 M7 M8 M8b M9 M10 M11 M12 M13) + +BASE_TMP="$(mktemp -d)"; trap 'rm -rf "$BASE_TMP"' EXIT + +for id in "${IDS[@]}"; do + d="$BASE_TMP/$id"; mkdir -p "$d" + cp -R "$HOOKS/." "$d/" + printf '\n══ %s — %s\n' "$id" "$(describe "$id")" + + # 1) the mutation must actually land + if ! n="$(mutate "$id" "$d" 2>&1)"; then + printf ' BROKEN mutation did not apply: %s\n' "$n"; broken=$((broken+1)); continue + fi + printf ' applied at %s site(s)\n' "$n" + + # 2) the mutant must be a RUNNABLE environment + synok=1 + for s in "$d"/*.sh; do bash -n "$s" 2>/dev/null || { printf ' BROKEN bash -n failed on %s\n' "$(basename "$s")"; synok=0; }; done + [ "$synok" -eq 1 ] || { broken=$((broken+1)); continue; } + smoke=1 + printf '{}' | bash "$d/stop.sh" >/dev/null 2>&1 || smoke=0 + printf '{}' | bash "$d/post-edit.sh" >/dev/null 2>&1 || smoke=0 + printf '{}' | bash "$d/post-publish.sh" >/dev/null 2>&1 || smoke=0 + [ "$smoke" -eq 1 ] || { printf ' BROKEN smoke run of a hook exited non-zero\n'; broken=$((broken+1)); continue; } + printf ' mutant is live (bash -n + smoke run of all three hooks: OK)\n' + + # 3) the suite must go RED + out="$(bash "$d/tests/run.sh" 2>&1)" + tally="$(printf '%s' "$out" | grep -E '^pass=' || echo 'pass=? fail=?')" + if printf '%s' "$out" | grep -q '^ FAIL '; then + killed=$((killed+1)) + printf ' KILLED (%s) — catching assertions:\n' "$tally" + printf '%s' "$out" | grep '^ FAIL ' | sed 's/^ FAIL / · /' + else + survived=$((survived+1)) + printf ' SURVIVED (%s) — NO catching assertion. This is a condition the suite does NOT test.\n' "$tally" + fi +done + +printf '\n─────────────────────────────────────────────────────────────\n' +printf 'mutants=%d killed=%d survived=%d broken=%d\n' "${#IDS[@]}" "$killed" "$survived" "$broken" +{ [ "$survived" -eq 0 ] && [ "$broken" -eq 0 ]; } || exit 1 diff --git a/plugins/basalt/hooks/tests/probe-harness.sh b/plugins/basalt/hooks/tests/probe-harness.sh new file mode 100755 index 0000000..cfb53dd --- /dev/null +++ b/plugins/basalt/hooks/tests/probe-harness.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# probe-harness.sh — OPT-IN, NOT CI. Run this when the supported Claude Code version moves. +# +# WHY IT EXISTS. `tests/run.sh` feeds fixtures to the hooks: it exercises THE HOOK, never +# the harness's firing policy. Mechanism A rests on one measured harness fact — +# +# F2: PostToolUse fires ONLY when the Bash command exits 0 +# +# — and `run.sh` cannot pin it. The payload has no exit-code field (F1), so the two worlds +# (fired-on-success / fired-on-failure) are BYTE-IDENTICAL on the wire: no "failed +# publish" fixture can be synthesized, and the hook cannot defend itself either. If a +# future version starts firing on non-zero exits, every assertion in `run.sh` still +# passes and the change is invisible — the exact green-and-silent class this work exists +# to fight. +# +# THE RESIDUAL RISK IN PLAIN WORDS: if the harness flips, a FAILED `basalt publish` clears +# the dirty entry and the hook goes quiet on a genuinely unpublished doc — the feature's +# one true positive, deleted, with no error. This script is what makes that visible. +# +# METHOD (the same control that produced F2): each command appends a marker to a log, so +# EXECUTIONS and HOOK FIRES are two independent instruments. Four commands, two exiting +# non-zero. All four markers must appear (every command really ran) and only the two +# zero-exit commands may produce a hook fire. +# +# bash plugins/basalt/hooks/tests/probe-harness.sh +set -u +command -v claude >/dev/null 2>&1 || { echo "probe-harness.sh needs the \`claude\` CLI on PATH"; exit 2; } +command -v jq >/dev/null 2>&1 || { echo "probe-harness.sh needs jq"; exit 2; } + +W="$(cd "$(mktemp -d)" && pwd -P)"; trap 'rm -rf "$W"' EXIT +PROJ="$W/probe"; mkdir -p "$PROJ/.claude" +MARKERS="$PROJ/markers.log"; FIRES="$PROJ/fires.jsonl" +: > "$MARKERS"; : > "$FIRES" + +cat > "$PROJ/.claude/settings.json" <> $FIRES" } ] } + ] + } +} +JSON + +printf 'harness: %s\n' "$(claude --version 2>/dev/null || echo unknown)" +printf 'probing in %s\n\n' "$PROJ" + +CMDS=( + "echo A >> markers.log; true" + "echo B >> markers.log; exit 7" + "echo C >> markers.log; true" + "echo D >> markers.log; exit 3" +) +for c in "${CMDS[@]}"; do + ( cd "$PROJ" && claude -p "Run exactly this in Bash, nothing else: $c" \ + --allowedTools Bash >/dev/null 2>&1 ) || true +done + +printf '%-34s %-6s %-8s %s\n' "Bash tool call" "exit" "marker" "hook fired" +rc=0 +for i in 0 1 2 3; do + c="${CMDS[$i]}" + mark="$(printf '%s' "$c" | sed -n 's/^echo \([A-D]\).*/\1/p')" + case "$c" in *"exit 7"*) want_exit=7 ;; *"exit 3"*) want_exit=3 ;; *) want_exit=0 ;; esac + grep -Fxq "$mark" "$MARKERS" && got_mark=yes || got_mark=NO + fired=no + if [ -s "$FIRES" ] && jq -sr '.[].tool_input.command' "$FIRES" 2>/dev/null | grep -Fxq "$c"; then fired=yes; fi + printf '%-34s %-6s %-8s %s\n' "$c" "$want_exit" "$got_mark" "$fired" + [ "$got_mark" = yes ] || rc=1 # the command must really have run + if [ "$want_exit" = 0 ]; then + [ "$fired" = yes ] || rc=1 + else + [ "$fired" = no ] || rc=1 # F2 HAS FLIPPED — see the header + fi +done + +echo +if [ $rc -eq 0 ]; then + echo "F2 HOLDS on this harness: 4 executions, 2 fires, non-zero exits produce no payload." +else + echo "F2 DOES NOT HOLD on this harness. Mechanism A's success semantics are no longer" + echo "sound: a FAILED \`basalt publish\` can now clear a dirty entry, and the hook will" + echo "go silent on a genuinely unpublished doc. Re-open the design before shipping." +fi +exit $rc diff --git a/plugins/basalt/hooks/tests/run.sh b/plugins/basalt/hooks/tests/run.sh index 4bf9c2a..df0e23d 100755 --- a/plugins/basalt/hooks/tests/run.sh +++ b/plugins/basalt/hooks/tests/run.sh @@ -3,10 +3,13 @@ # # THE RULE (cantera L-009): a hook whose success mode is SILENCE is indistinguishable from # a DEAD hook. So every hook here is proven against the REAL payloads Claude Code sends -# (hooks/tests/fixtures/*, captured 2026-07-24 — see cofoundy-toolkit/docs/claude-code- -# capabilities.md), asserting it FIRES when it should, stays SILENT when it should, dies -# MUTE without the CLI, and that Stop NEVER loops. A negative control (wrong-shape payload) -# must produce silence + exit 0. +# (hooks/tests/fixtures/* — captured payload SHAPES, sanitized values), asserting it FIRES when +# it should, stays SILENT when it should, dies MUTE without the CLI, and that Stop NEVER +# loops. A negative control (wrong-shape payload) must produce silence + exit 0. +# +# THE SECOND RULE this file now carries: a fix that silences the hook in every arm has +# DELETED the feature rather than repaired it. So every positive (goes silent) is paired +# with the negative that must still nag, and the pairs are named as pairs. # # bash plugins/basalt/hooks/tests/run.sh # exits non-zero on any failure set -u @@ -18,12 +21,23 @@ ok() { printf ' ok %s\n' "$1"; pass=$((pass+1)); } no() { printf ' FAIL %s\n' "$1"; fail=$((fail+1)); } command -v jq >/dev/null 2>&1 || { echo "these tests need jq to build fixtures"; exit 2; } +command -v git >/dev/null 2>&1 || { echo "these tests need git for the Mechanism B fixtures"; exit 2; } -WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +# `pwd -P` matters: post-publish.sh canonicalises a resolved base the same way, and on +# macOS `mktemp -d` hands back /var/… which is a symlink to /private/var/…. +WORK="$(cd "$(mktemp -d)" && pwd -P)"; trap 'rm -rf "$WORK"' EXIT export TMPDIR="$WORK/tmp"; mkdir -p "$TMPDIR" DDIR="$TMPDIR/basalt-hooks" reset_dirty() { rm -rf "$DDIR"; } +# Hermetic git: never let an ambient repo above $WORK, a user gitconfig, or a system +# gitconfig answer a question these fixtures are asking. Without the ceiling, a doc in a +# non-repo fixture dir resolves to whatever repo happens to contain $TMPDIR. +export GIT_CEILING_DIRECTORIES="$WORK" +export GIT_CONFIG_NOSYSTEM=1 +export HOME="$WORK/home"; mkdir -p "$HOME" +unset GIT_DIR GIT_WORK_TREE 2>/dev/null || true + # --- stub `basalt` (mode-driven) ------------------------------------------------------ BIN="$WORK/bin"; mkdir -p "$BIN" export BASALT_STUB_CALLS="$WORK/publish-calls.log" @@ -43,6 +57,14 @@ PATH_NO_BASALT="/usr/bin:/bin" # excludes ~/.local/bin run() { printf '%s' "$1" | bash "$2"; } # run