diff --git a/README.md b/README.md index 47860de..4e50842 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,11 @@ that talks when it doesn't need to is just noise: the changed files for you (diff-aware, once per turn — never on every keystroke). Per-vault policy lives in `vault.yaml`: `publish: prompt` (default) · `auto` · -`manual`. Every hook ships with tests that run against **real captured Claude Code -payloads** (`hooks/tests/run.sh`) — a silent hook is worthless if it's silently dead. +`manual`. When one doc needs a different answer than its vault — the server refuses +it, say — an optional `publish_overrides:` block gives that exact path its own policy, +so you don't have to silence the whole vault to quiet one file. Every hook ships with +tests that run against **real captured Claude Code payloads** (`hooks/tests/run.sh`) — +a silent hook is worthless if it's silently dead. ## Already have docs in git? diff --git a/plugins/basalt/hooks/lib.sh b/plugins/basalt/hooks/lib.sh index 2949c5c..0811089 100755 --- a/plugins/basalt/hooks/lib.sh +++ b/plugins/basalt/hooks/lib.sh @@ -97,6 +97,121 @@ vault_policy() { esac } +# ===================================================================================== +# publish_overrides — a per-PATH policy that overrides the vault's (#10) +# ===================================================================================== +# The publish policy is per VAULT; the state that produces the nag is per DOC. So when one +# doc cannot publish — because the server REJECTS it, not because nobody tried — the only +# lever was `publish: manual`, which silences every other doc in the vault too. What is +# left is a nag that fires every session with no action available, which trains the reader +# to ignore the channel. The orphan bucket already decided this case ("clear either way → +# no re-nag"); this is the missing other half. +# +# name: atelier +# publish: prompt +# publish_overrides: +# manual: +# - BITACORA.mdx # why this path is listed goes right here +# - docs/context-architecture.mdx +# +# Absent ⇒ today's behavior, byte for byte. Two properties are the whole point: it is +# EXPLICIT (someone had to type the path, and by convention the reason beside it) and it +# EXPIRES BY ITSELF (delete the line when the block lifts). No implicit suppression, ever. +# +# The shape is deliberately narrow, and every narrowing fails toward KEEPING THE NAG: +# +# - Paths are EXACT and relative to the VAULT ROOT — the directory holding vault.yaml, +# which is the same root the CLI walks up to when it derives a slug. One rule in both +# places or they drift. +# - NO GLOBS. An entry containing any of `* ? [ ] { } !` never matches, so the doc keeps +# its vault policy. The guardrail IS that suppression costs someone an explicit line; +# a glob lets one line silence a subtree nobody enumerated. Same bounded-shapes-or- +# refuse stance `_bp_path_match()` takes below. +# - All three policy words are accepted as keys (`manual`, `prompt`, `auto`), because +# vault.yaml's vocabulary is three words and a key that means three things in one +# place and one thing in another is a divergence waiting to happen. An unrecognized +# key is IGNORED — its docs keep the vault policy, which fails toward nagging. +# - BLOCK STYLE ONLY. `publish_overrides: {manual: [a]}` is not parsed; it reads as NO +# overrides, so every doc keeps its vault policy and the hook keeps nagging. +# - A vault.yaml that cannot be read, a doc outside the vault root, an empty block, a +# capitalised key — all DELEGATE to vault_policy(), i.e. behave exactly as they did +# before this existed. +# +# ⚠️ `auto` is accepted, so a listed path CAN newly reach stop.sh's auto bucket, which +# shells out to `basalt publish` — a real network write. Nothing about that bucket changes +# here; only WHICH files can reach it, and only when someone wrote the path explicitly. + +# doc_policy -> auto | prompt | manual +# An override that names THIS doc wins (first matching entry, top to bottom); everything +# else DELEGATES to vault_policy(). Non-regression is structural that way, not a +# property we have to keep testing for. +doc_policy() { + local v="$1" doc="$2" root rel line t val lead key="" entry hit="" in_block=1 kindent="" + [ -f "$v" ] || { vault_policy "$v"; return 0; } + root="$(dirname "$v")" + case "$root" in + /) rel="${doc#/}" ;; + *) case "$doc" in + "$root"/*) rel="${doc#"$root"/}" ;; + *) vault_policy "$v"; return 0 ;; # not under this root → no override + esac ;; + esac + [ -n "$rel" ] || { vault_policy "$v"; return 0; } + + while IFS= read -r line || [ -n "$line" ]; do + if [ "$in_block" -ne 0 ]; then + # Only a COLUMN-0 `publish_overrides:` with an empty value opens the block. A value + # on the same line is flow style (or a scalar) → not parsed → no overrides at all. + case "$line" in + publish_overrides:*) + val="$(_bp_trim "${line#publish_overrides:}")" + case "$val" in ''|'#'*) in_block=0; key="" ;; esac ;; + esac + continue + fi + + t="$(_bp_trim "$line")" + [ -n "$t" ] || continue # blank lines do not end the block + lead="${line%%[![:space:]]*}" + [ "${#lead}" -gt 0 ] || break # indentation back to column 0 → over + + case "$t" in + -*) + [ -n "$key" ] || continue # entries under an ignored key + entry="$(_bp_trim "${t#-}")" + entry="$(_bp_strip_comment "$entry")" # `- a.mdx # the reason` + entry="$(_bp_unquote "$(_bp_trim "$entry")")" + entry="${entry#./}" + [ -n "$entry" ] || continue + case "$entry" in *'*'*|*'?'*|*'['*|*']'*|*'{'*|*'}'*|*'!'*) continue ;; esac + [ "$entry" = "$rel" ] && { hit="$key"; break; } ;; + *:*) + # `manual:` / `prompt:` / `auto:` open a list. Anything else — a capitalised or + # misspelled word, a key carrying a same-line value, or a key NESTED one level + # deeper than the first key in this block (`weird:` then `manual:` under it) — + # leaves `key` empty, so its entries are skipped and those docs keep the vault + # policy. The nesting check exists because without it an unrecognized key would + # not ignore its subtree, and over-accepting here fails toward SILENCE. + [ -n "$kindent" ] || kindent="${#lead}" + val="$(_bp_trim "${t#*:}")" + entry="$(_bp_trim "${t%%:*}")" + key="" + if [ "${#lead}" -eq "$kindent" ]; then + case "$val" in + ''|'#'*) case "$entry" in manual|prompt|auto) key="$entry" ;; esac ;; + esac + fi ;; + *) key="" ;; + esac + done < "$v" + + case "$hit" in + auto|prompt|manual) printf '%s' "$hit" ;; + *) vault_policy "$v" ;; + esac + return 0 +} + # ===================================================================================== # A2 — the dirty list is an APPEND-ONLY JOURNAL, not a read-modify-write # ===================================================================================== @@ -196,6 +311,30 @@ _bp_unquote() { printf '%s' "$s" } +# _bp_strip_comment -> the scalar with a trailing YAML `# comment` removed. +# A comment starts at a `#` that OPENS the text or follows whitespace, so `a#b.md` stays a +# filename while `a.md # why` loses its tail. Used by doc_policy() for the reason someone +# writes next to an override entry — the convention that makes the block self-documenting. +# (A `#` inside a quoted scalar is cut too: that entry then matches nothing, which is the +# safe direction — it keeps the nag.) +_bp_strip_comment() { + local s="$1" kept="" rest="$1" pre last + while :; do + case "$rest" in *'#'*) ;; *) break ;; esac + pre="${rest%%'#'*}" + if [ -n "$pre" ]; then last="${pre#"${pre%?}"}" # char right before this `#` + elif [ -n "$kept" ]; then last='#' # `##` — not a comment opener + else last=' ' # `#` opens the whole scalar + fi + case "$last" in + [[:space:]]) printf '%s' "$kept$pre"; return 0 ;; + esac + kept="$kept$pre#" + rest="${rest#*'#'}" + done + printf '%s' "$kept$rest" +} + # _bp_path_match # 0 = matches · 1 = does not match · 2 = shape not supported (caller keeps the nag) # diff --git a/plugins/basalt/hooks/stop.sh b/plugins/basalt/hooks/stop.sh index 2c2c3cd..5395b6e 100755 --- a/plugins/basalt/hooks/stop.sh +++ b/plugins/basalt/hooks/stop.sh @@ -42,7 +42,8 @@ of="$(orphan_file "$sid")" # 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. +# (Mechanism B, the #4 case), then bucket what remains by each DOC's policy — the +# vault's `publish:`, unless a `publish_overrides:` entry names that exact path (#10). # # 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 @@ -53,7 +54,7 @@ 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 + case "$(doc_policy "$(find_vault "$f")" "$f")" in auto) auto_files+=("$f") ;; manual) : ;; # explicitly silent *) prompt_n=$((prompt_n + 1)) ;; # prompt (default) diff --git a/plugins/basalt/hooks/tests/run.sh b/plugins/basalt/hooks/tests/run.sh index df0e23d..17fcd42 100755 --- a/plugins/basalt/hooks/tests/run.sh +++ b/plugins/basalt/hooks/tests/run.sh @@ -780,6 +780,174 @@ printf '%s' "$out" | jq -e '.reason|test("1 vault doc")' >/dev/null 2>&1 \ && ok "(d) A7 variant: an entry preserved ACROSS the guard is still cleared by its tombstone" \ || no "(d) A7 variant: expected exactly 1, got: ${out:-}" +echo "── stop.sh — publish_overrides (#10, the per-PATH hole) ─────" +# The publish policy is per VAULT; the state that produces the nag is per DOC. For a doc +# the server REJECTS, the only lever was `publish: manual` — which silences every other +# doc in the vault too, leaving a nag that fires every session with nothing to do about it. +# +# So every arm here is a PAIR: the listed path that goes silent, and a path in the SAME +# session that must still nag. An implementation that silences both has deleted the +# feature rather than repaired it, and an implementation that silences neither has shipped +# a no-op; only the pair can tell those apart from a green run. + +# mk_ovault -> prints the vault dir +# Deliberately NOT a git repo: Mechanism B must not get to answer a question these arms +# are asking. +mk_ovault() { + local d="$1" pol="$2" ov="$3" + mkdir -p "$d/docs" "$d/notes" + { printf 'name: ov\npublish: %s\n' "$pol"; [ -n "$ov" ] && printf '%s' "$ov"; } > "$d/vault.yaml" + printf 'x\n' > "$d/BITACORA.mdx"; printf 'x\n' > "$d/README.mdx" + printf 'x\n' > "$d/docs/context.mdx"; printf 'x\n' > "$d/notes/x.mdx" + printf '%s' "$d" +} +stop_out_pub() { PATH="$PATH_WITH_STUB" BASALT_STUB_MODE=publish-ok run "$(cat "$FIX/stop-inactive.json")" "$HOOKS/stop.sh"; } +expect_nag_n() { # expect_nag_n