From 0d77140771052292d84645c33846e93d8217e012 Mon Sep 17 00:00:00 2001 From: A-PachecoT Date: Mon, 17 Aug 2026 18:05:04 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(hooks):=20publish=5Foverrides=20?= =?UTF-8?q?=E2=80=94=20a=20per-PATH=20policy=20that=20overrides=20the=20va?= =?UTF-8?q?ult's=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish policy is per VAULT (`publish:` in vault.yaml); the state that produces the Stop nudge 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 Absent => today's behavior, byte for byte. Two properties are the point: it is EXPLICIT (someone typed the path, and by convention the reason beside it) and it EXPIRES BY ITSELF (delete the line when the block lifts). No implicit suppression. The shape is narrow, and every narrowing fails toward KEEPING THE NAG: exact paths relative to the VAULT ROOT (the dir holding vault.yaml — the same root the CLI walks up to for a slug, so the two cannot drift); no globs (an entry with * ? [ ] { } ! matches nothing, because one line must not silence a subtree nobody enumerated); all three policy words accepted as keys, an unrecognized key ignored; block style only. `vault_policy()` is untouched. `doc_policy()` sits beside it, resolves an override and otherwise DELEGATES — so non-regression is structural, not a thing we test for. stop.sh changes on one line (the bucketing) plus the comment above it. Tests: 83 -> 100 pass, rc 0. Every arm is a PAIR — the listed path that goes silent and a path in the SAME session that must still nag — because a fix that silences every arm has deleted the feature rather than repaired it. Both mutants killed: forcing delegation turns arm (a) RED (the listed doc nags), forcing `manual` turns arm (b) RED (silence where exactly 1 was required). Closes #10 --- plugins/basalt/hooks/lib.sh | 133 +++++++++++++++ plugins/basalt/hooks/stop.sh | 5 +- plugins/basalt/hooks/tests/run.sh | 155 ++++++++++++++++++ .../skills/basalt/references/cli-path.md | 28 ++++ 4 files changed, 319 insertions(+), 2 deletions(-) diff --git a/plugins/basalt/hooks/lib.sh b/plugins/basalt/hooks/lib.sh index 2949c5c..d03eef6 100755 --- a/plugins/basalt/hooks/lib.sh +++ b/plugins/basalt/hooks/lib.sh @@ -97,6 +97,115 @@ 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 + [ -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 — including a + # capitalised or misspelled word, and any key carrying a same-line value — leaves + # `key` empty, so its entries are skipped and those docs keep the vault policy. + val="$(_bp_trim "${t#*:}")" + entry="$(_bp_trim "${t%%:*}")" + key="" + case "$val" in + ''|'#'*) case "$entry" in manual|prompt|auto) key="$entry" ;; esac ;; + esac ;; + *) 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 +305,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..ff14ef6 100755 --- a/plugins/basalt/hooks/tests/run.sh +++ b/plugins/basalt/hooks/tests/run.sh @@ -780,6 +780,161 @@ 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