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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
139 changes: 139 additions & 0 deletions plugins/basalt/hooks/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <vault.yaml-path> <abs-doc-path> -> 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
# =====================================================================================
Expand Down Expand Up @@ -196,6 +311,30 @@ _bp_unquote() {
printf '%s' "$s"
}

# _bp_strip_comment <scalar> -> 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 <github-paths-pattern> <repo-relative-path>
# 0 = matches · 1 = does not match · 2 = shape not supported (caller keeps the nag)
#
Expand Down
5 changes: 3 additions & 2 deletions plugins/basalt/hooks/stop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
168 changes: 168 additions & 0 deletions plugins/basalt/hooks/tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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:-<silence>}"

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 <dir> <publish-policy> <overrides-block> -> 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 <label> <n> — the COUNT is the whole assertion here
local o; o="$(stop_out)"
printf '%s' "$o" | jq -e --arg n "$2" '.decision=="block" and (.reason|test("Basalt: "+$n+" vault doc"))' >/dev/null 2>&1 \
&& ok "$1" || no "$1 — expected a NAG for exactly $2, got: ${o:-<silence>}"
}

OV_MANUAL='publish_overrides:
manual:
- BITACORA.mdx # the server rejects it; delete this line when that lifts
'
OV_A="$(mk_ovault "$WORK/ov-a" prompt "$OV_MANUAL")"

seed_stop "$OV_A/BITACORA.mdx"
expect_silent "#10 (a) prompt vault, publish_overrides.manual lists BITACORA.mdx → SILENT"

seed_stop "$OV_A/BITACORA.mdx" "$OV_A/README.mdx"
expect_nag_n "#10 (b) PAIR OF (a) — the one that matters: +1 UNLISTED doc → nags for EXACTLY 1 (0 = the vault got silenced, 2 = the override did nothing)" 1

OV_C="$(mk_ovault "$WORK/ov-c" prompt "")"
seed_stop "$OV_C/BITACORA.mdx"
expect_nag_n "#10 (c) PAIR OF (a) — the same path with the override line REMOVED, nothing else changed → nags again" 1

# (d) no publish_overrides key at all ⇒ today's behavior, byte for byte, all three policies.
OV_DP="$(mk_ovault "$WORK/ov-d-prompt" prompt "")"
seed_stop "$OV_DP/docs/context.mdx"
expect_nag_n "#10 (d) no publish_overrides + publish: prompt → nags, exactly as today" 1

OV_DM="$(mk_ovault "$WORK/ov-d-manual" manual "")"
seed_stop "$OV_DM/docs/context.mdx"
expect_silent "#10 (d) no publish_overrides + publish: manual → silent, exactly as today"

OV_DA="$(mk_ovault "$WORK/ov-d-auto" auto "")"; : > "$BASALT_STUB_CALLS"
seed_stop "$OV_DA/docs/context.mdx"
out="$(stop_out_pub)"
{ printf '%s' "$out" | grep -qi 'auto-published' && grep -Fxq "$OV_DA/docs/context.mdx" "$BASALT_STUB_CALLS"; } \
&& ok "#10 (d) no publish_overrides + publish: auto → publishes, exactly as today" \
|| no "#10 (d) auto without overrides regressed (out:$out calls:$(cat "$BASALT_STUB_CALLS"))"

# (e) `auto` is an accepted key, so a listed path can newly REACH the bucket that shells
# out to `basalt publish` — a real network write. Its twin proves the reach is confined to
# the path someone actually typed.
OV_AUTOKEY='publish_overrides:
auto:
- docs/context.mdx # safe to ship unattended
'
OV_E="$(mk_ovault "$WORK/ov-e" prompt "$OV_AUTOKEY")"; : > "$BASALT_STUB_CALLS"
seed_stop "$OV_E/docs/context.mdx"
out="$(stop_out_pub)"
{ printf '%s' "$out" | grep -qi 'auto-published' && grep -Fxq "$OV_E/docs/context.mdx" "$BASALT_STUB_CALLS"; } \
&& ok "#10 (e) publish_overrides.auto in a PROMPT vault → that path reaches the auto bucket" \
|| no "#10 (e) expected an auto publish (out:$out calls:$(cat "$BASALT_STUB_CALLS"))"

: > "$BASALT_STUB_CALLS"
seed_stop "$OV_E/README.mdx"
out="$(stop_out_pub)"
{ printf '%s' "$out" | jq -e '.reason|test("Basalt: 1 vault doc")' >/dev/null 2>&1 && [ ! -s "$BASALT_STUB_CALLS" ]; } \
&& ok "#10 (e) PAIR: an UNLISTED path in that same vault stays prompt — never auto-published" \
|| no "#10 (e) PAIR: unlisted path was auto-published or did not nag (out:$out calls:$(cat "$BASALT_STUB_CALLS"))"

# (f) Robustness. Each unsupported shape is paired with a well-formed entry in the SAME
# vault, so "exactly 1" separates "the shape was refused" from "the whole block died".
OV_GLOB='publish_overrides:
manual:
- '"'"'*.mdx'"'"' # a glob: UNSUPPORTED on purpose, matches nothing
- README.mdx
'
OV_F1="$(mk_ovault "$WORK/ov-f-glob" prompt "$OV_GLOB")"
seed_stop "$OV_F1/BITACORA.mdx" "$OV_F1/README.mdx"
expect_nag_n "#10 (f) a glob entry silences NOTHING while the literal beside it works → exactly 1 (0 would mean one line silenced a subtree nobody enumerated)" 1

OV_BADKEY='publish_overrides:
silent:
- BITACORA.mdx
manual:
- README.mdx
'
OV_F2="$(mk_ovault "$WORK/ov-f-badkey" prompt "$OV_BADKEY")"
seed_stop "$OV_F2/BITACORA.mdx" "$OV_F2/README.mdx"
expect_nag_n "#10 (f) an unrecognized key is IGNORED (its doc keeps the vault policy) while a real key beside it works → exactly 1" 1

# …and the same key one level DEEPER, under that unrecognized key. Ignoring a key has to
# ignore its subtree, or "unrecognized" silences after all — the one direction refused here.
OV_NESTED='publish_overrides:
weird:
manual:
- BITACORA.mdx
manual:
- README.mdx
'
OV_F2B="$(mk_ovault "$WORK/ov-f-nested" prompt "$OV_NESTED")"
seed_stop "$OV_F2B/BITACORA.mdx" "$OV_F2B/README.mdx"
expect_nag_n "#10 (f) PAIR: a real key NESTED under an unrecognized one silences nothing, the top-level one still works → exactly 1" 1

OV_FLOW='publish_overrides: {manual: [BITACORA.mdx, README.mdx]}
'
OV_F3="$(mk_ovault "$WORK/ov-f-flow" prompt "$OV_FLOW")"
seed_stop "$OV_F3/BITACORA.mdx" "$OV_F3/README.mdx"
expect_nag_n "#10 (f) PAIR OF (a): flow style is not parsed → BOTH keep the vault policy and nag" 2

# The suffix-matching trap post-publish.sh documents at length, in its two forms.
OV_SUFFIX='publish_overrides:
manual:
- notes/x.mdx
'
OV_F4A="$(mk_ovault "$WORK/ov-f-vault-a" prompt "$OV_SUFFIX")"
OV_F4B="$(mk_ovault "$WORK/ov-f-vault-b" prompt "")"
seed_stop "$OV_F4A/notes/x.mdx"
expect_silent "#10 (f) notes/x.mdx listed in ITS OWN vault → silent"
seed_stop "$OV_F4B/notes/x.mdx"
expect_nag_n "#10 (f) PAIR: the identically-named doc in a DIFFERENT vault → nags (paths resolve against THEIR vault root)" 1

OV_BASENAME='publish_overrides:
manual:
- x.mdx
- notes/x.mdx
'
OV_F5="$(mk_ovault "$WORK/ov-f-basename" prompt "$OV_BASENAME")"
seed_stop "$OV_F5/notes/x.mdx"
expect_silent "#10 (f) the vault-root-relative entry notes/x.mdx matches → silent"
OV_F5B="$(mk_ovault "$WORK/ov-f-basename-only" prompt 'publish_overrides:
manual:
- x.mdx
')"
seed_stop "$OV_F5B/notes/x.mdx"
expect_nag_n "#10 (f) PAIR: the bare basename x.mdx does NOT match notes/x.mdx → nags (exact, never a suffix)" 1

OV_DOTSLASH='publish_overrides:
manual:
- ./docs/context.mdx # a leading ./ normalizes away
'
OV_F6="$(mk_ovault "$WORK/ov-f-dotslash" prompt "$OV_DOTSLASH")"
seed_stop "$OV_F6/docs/context.mdx" "$OV_F6/README.mdx"
expect_nag_n "#10 (f) ./docs/context.mdx normalizes and silences it, the unlisted doc still nags → exactly 1" 1

# The block ends where the indentation returns to column 0 — a list under the NEXT
# top-level key must not be read as more overrides.
OV_TERM='publish_overrides:
manual:
- BITACORA.mdx
tags:
- README.mdx
'
OV_F7="$(mk_ovault "$WORK/ov-f-terminate" prompt "$OV_TERM")"
seed_stop "$OV_F7/BITACORA.mdx" "$OV_F7/README.mdx"
expect_nag_n "#10 (f) the block ends at column 0 → a list under the NEXT key silences nothing, exactly 1" 1

echo "─────────────────────────────────────────────────────────────"
printf 'pass=%d fail=%d\n' "$pass" "$fail"
[ "$fail" -eq 0 ] || exit 1
28 changes: 28 additions & 0 deletions plugins/basalt/skills/basalt/references/cli-path.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,34 @@ name: <project>
publish: prompt # auto | prompt | manual — how the Stop hook behaves
```

### `publish_overrides:` — when one doc needs a different answer

`publish:` is per VAULT, but the state that produces the Stop nudge is per DOC. So a
single doc the server refuses would otherwise force `publish: manual`, silencing every
other doc in the vault. The optional block below overrides the policy for exact paths:

```yaml
name: <project>
publish: prompt
publish_overrides:
manual:
- BITACORA.mdx # why this path is listed goes right here
- docs/context-architecture.mdx
```

Omit it and nothing changes. The shape is narrow on purpose — suppression should cost
someone an explicit line, and it should expire by itself when they delete that line:

- Paths are **exact** and relative to the **vault root** (the directory holding
`vault.yaml`), the same root a slug is derived from. A bare `x.mdx` does not match
`notes/x.mdx`.
- **No globs.** An entry containing `* ? [ ] { } !` matches nothing, so the doc keeps the
vault policy — one glob could silence a subtree nobody enumerated.
- Keys are the same three words as `publish:` — `manual`, `prompt`, `auto`. An
unrecognized key is ignored.
- **Block style only.** `publish_overrides: {manual: [a]}` reads as no overrides.
- Every unreadable or ambiguous case falls back to `publish:`, which keeps the nudge.

## Unattended runs

The CLI credential is stored per host, so once `basalt login` has happened on this
Expand Down
Loading