Skip to content

Commit fdebbd1

Browse files
Make two skill routings mandatory instead of merely instructed
Everything vstack does to route skills is instruction: the SessionStart digest spells out "any prose you write -> unslop", and the skill descriptions carry their own triggers. auto-trigger.sh measures that landing on 12 cases, which is a weaker claim than "always". claude/hooks/skill-mandate.sh runs on Stop and makes two of them certain. It reads the transcript for what actually happened — which files were written, which skills were invoked — and blocks the turn when a rule went unmet: .md/.mdx requires unslop, .ts/.tsx requires typescript-best-practices. Two rules, not twenty. The bar for a third is that the situation is decidable from a tool call rather than from judgement, and that the skill is the right answer every single time. A rule right nine times in ten belongs in the routing block; as a gate it would only teach you to switch the gate off. It blocks at most twice per session, never fires inside an active Stop hook, and VSTACK_NO_MANDATE=1 disables it. Check 27 exercises all seven behaviours in both directions. Measured first, rather than assumed: --debug-file on the real CLI reports 73 skills sent and no dropped descriptions at the current 0.016 listing fraction, so there is headroom for mandated routing. That measurement also confirmed the merged research PR's correction that Claude Code folds slash commands into skills — 32 user skills plus 14 legacy commands. Also in this commit: The redactor added last commit ate ordinary prose. The session digest line "TOKENS: never read whole files" came back as "TOKENS: [REDACTED] read whole files", because matching the name alone is not enough. The name now only nominates a candidate and the value decides: opaque enough to be a credential, or left alone. Check 25 gained four prose controls, which is what the single control line missed. New check 28: every doc under docs/ must be reachable from another doc. The merged research handoff was 783 lines nothing linked to. A link to the containing directory counts, since provenance/README.md indexes plans/ that way. The curl and marketplace lanes measured a published install against local counts, so adding a hook made the matrix red for the correct reason that it was not pushed yet — unclearable, since preflight gates the commit. They now measure against the tree they actually installed and print a note when this checkout is ahead. verify 30/30, falsifiability 32/32, matrix 22/22, preflight green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent c80f41f commit fdebbd1

8 files changed

Lines changed: 292 additions & 20 deletions

File tree

.claude/verify.sh

Lines changed: 101 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,15 +1002,27 @@ DATABASE_URL=postgres://user:s3cr3tpw@host/db|s3cr3tpw
10021002
GITHUB_TOKEN=ghp_FAKEVAL|ghp_FAKEVAL
10031003
REDEOF
10041004

1005-
# The other half of the claim: redaction that eats ordinary diagnostics is its own defect,
1006-
# because the tail exists to be read. A control line has to come back intact.
1007-
ctl="error: cannot find module at /usr/local/lib/thing.js (exit 2)"
1008-
cout=$(printf '%s' "$(jq -cn --arg e "$ctl" '{tool_name:"Bash",tool_response:{stderr:$e}}')" \
1009-
| "$fd" 2>/dev/null)
1010-
case "$cout" in
1011-
*"cannot find module at /usr/local/lib/thing.js"*) ;;
1012-
*) leaks="$leaks\n over-redacted an ordinary error line" ;;
1013-
esac
1005+
# The other half of the claim, and the half that actually broke. Redaction that eats ordinary
1006+
# diagnostics is its own defect, because the tail exists to be read. One control line was not
1007+
# enough: with only "cannot find module" here, a redactor that matched on the NAME alone
1008+
# passed this check while turning the session digest "TOKENS: never read whole files" into
1009+
# "TOKENS: [REDACTED] read whole files". Every line below contains a credential-shaped word
1010+
# followed by a colon and ordinary prose, which is the case that was missed.
1011+
while IFS= read -r ctl; do
1012+
[ -n "$ctl" ] || continue
1013+
cout=$(printf '%s' "$(jq -cn --arg e "$ctl" '{tool_name:"Bash",tool_response:{stderr:$e}}')" \
1014+
| "$fd" 2>/dev/null)
1015+
case "$cout" in
1016+
*"$ctl"*) ;;
1017+
*) leaks="$leaks\n over-redacted an ordinary line: $ctl" ;;
1018+
esac
1019+
done <<'CTLEOF'
1020+
error: cannot find module at /usr/local/lib/thing.js (exit 2)
1021+
TOKENS: never read whole files, use grep
1022+
Keystrokes: 1420 recorded
1023+
passwords: are stored hashed in this table
1024+
npm ERR! code ELIFECYCLE, exit status 1
1025+
CTLEOF
10141026

10151027
[ -z "$leaks" ] \
10161028
&& ok "failure tail redacts credentials (9 shapes masked, plain errors intact)" \
@@ -1048,6 +1060,86 @@ else
10481060
skip "documented platforms match CI" "no .github/workflows/verify.yml"
10491061
fi
10501062

1063+
# --- 27. the skill mandate blocks on an unmet rule and stays quiet otherwise -------------------
1064+
#
1065+
# The digest tells the model to route to a skill and the descriptions carry their own triggers.
1066+
# Both are instructions, and an instruction is a probability -- auto-trigger.sh measures the
1067+
# routing landing, not the routing being certain. This hook is the part that is certain, for the
1068+
# few rules decidable from tool calls rather than judgement, so it has to be exercised in both
1069+
# directions. A gate that never blocks and a gate that always blocks look identical from a
1070+
# passing test that only checks one of them.
1071+
if command -v jq >/dev/null; then
1072+
sm="claude/hooks/skill-mandate.sh"
1073+
if [ ! -x "$sm" ]; then
1074+
bad "skill mandate decides correctly" "$sm is missing or not executable"
1075+
else
1076+
md=$(mktemp -d); errs=""
1077+
say_(){ printf '%s\n' "$@" > "$md/t.jsonl"; }
1078+
hit_(){ printf '{"session_id":"vfy-%s","transcript_path":"%s/t.jsonl","stop_hook_active":%s}' \
1079+
"$1" "$md" "${2:-false}" | "./$sm" 2>/dev/null; }
1080+
W='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"/x/README.md"}}]}}'
1081+
T='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/x/App.tsx"}}]}}'
1082+
U='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Skill","input":{"skill":"unslop"}}]}}'
1083+
P='{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Write","input":{"file_path":"/x/main.py"}}]}}'
1084+
1085+
# blocks when the rule is unmet
1086+
say_ "$W"; hit_ a | grep -q '"decision":"block"' || errs="$errs\nwrote prose without unslop and it did not block"
1087+
say_ "$T"; hit_ b | grep -q '"decision":"block"' || errs="$errs\nwrote TypeScript without the ts skill and it did not block"
1088+
# silent when the rule is met, or does not apply
1089+
say_ "$W" "$U"; [ -z "$(hit_ c)" ] || errs="$errs\nblocked even though unslop had run"
1090+
say_ "$P"; [ -z "$(hit_ d)" ] || errs="$errs\nblocked on a file no mandate covers"
1091+
# cannot trap the session
1092+
say_ "$W"; [ -z "$(hit_ e true)" ] || errs="$errs\nblocked while stop_hook_active was already true"
1093+
say_ "$W"; [ -z "$(VSTACK_NO_MANDATE=1 hit_ f)" ] || errs="$errs\nignored VSTACK_NO_MANDATE=1"
1094+
say_ "$W"; hit_ g >/dev/null; hit_ g >/dev/null
1095+
[ -z "$(hit_ g)" ] || errs="$errs\ndid not latch open after 2 blocks in one session"
1096+
1097+
rm -rf "$md"; rm -f "${TMPDIR:-/tmp}"/vstack-mandate-vfy-* 2>/dev/null
1098+
[ -z "$errs" ] && ok "skill mandate decides correctly (7 cases, both directions)" \
1099+
|| bad "skill mandate decides correctly" "$(printf '%b' "$errs")"
1100+
fi
1101+
else
1102+
skip "skill mandate decides correctly" "jq not installed"
1103+
fi
1104+
1105+
# --- 28. every doc is reachable from another doc ----------------------------------------------
1106+
#
1107+
# A file nobody links to is a file nobody reads and nobody updates, and it rots in public. A
1108+
# 783-line research handoff landed in docs/ and was reachable from nothing: not the README index,
1109+
# not another document. It read as deleted while still being served to anyone browsing the repo.
1110+
#
1111+
# The rule is reachability, not a fixed index: a doc linked from another doc is fine. What is not
1112+
# fine is a doc no path in the repository leads to.
1113+
docs_all=$(find docs -name '*.md' 2>/dev/null | sort)
1114+
if [ -z "$docs_all" ]; then
1115+
skip "every doc is reachable" "no docs/ directory"
1116+
else
1117+
# Written to a file and grepped directly. `printf ... | grep -q` returns 141 under pipefail
1118+
# when grep exits early on a match, so every doc that WAS linked reported as an orphan -- the
1119+
# same pipe-and-exit-status trap that has bitten this repository twice before.
1120+
linkable=$(mktemp)
1121+
cat README.md CHANGELOG.md $(find docs -name '*.md') claude/skills/*/SKILL.md > "$linkable" 2>/dev/null
1122+
orphans=""
1123+
for d in $docs_all; do
1124+
base=${d##*/}
1125+
# Either the repo-relative path or the bare filename, since docs link to each other by
1126+
# sibling name.
1127+
dir=${d%/*}
1128+
# A link to the containing directory counts. docs/provenance/README.md points at `plans/`
1129+
# rather than naming each plan, and a reader following it lands on all three -- that is
1130+
# reachable, and demanding a per-file link would only produce an index nobody maintains.
1131+
if ! grep -qF "$d" "$linkable" \
1132+
&& ! grep -qF "]($base" "$linkable" \
1133+
&& ! grep -qE "\]\((\./)?${dir##*/}/\)" "$linkable"; then
1134+
orphans="$orphans\n $d"
1135+
fi
1136+
done
1137+
rm -f "$linkable"
1138+
[ -z "$orphans" ] \
1139+
&& ok "every doc is reachable ($(printf '%s' "$docs_all" | wc -l | tr -d ' ') under docs/)" \
1140+
|| bad "every doc is reachable" "$(printf 'nothing in this repository links to:%b' "$orphans")"
1141+
fi
1142+
10511143
echo
10521144
# Accounting. Every declared check must have reported either a result or a skip. A check
10531145
# that throws a shell error mid-body, or is wrapped in a conditional with no else, silently

README.md

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ That claim is easy to make and most setups make it. Here it is measured: `tests/
2525
runs 12 prompts against the real model and reports which attempt each one landed on, so the day
2626
routing starts eroding shows up as a number rather than a feeling.
2727

28-
The same idea runs through the rest. Every one of the 28 checks in the verification gate has a
28+
The same idea runs through the rest. Every one of the 30 checks in the verification gate has a
2929
row in a suite that breaks what that check watches and requires the gate to go red naming it — a
3030
check nobody has watched fail is indistinguishable from a check that always passes. The
3131
installer is run into 22 throwaway home directories on Linux, macOS and Alpine on every
@@ -150,7 +150,7 @@ exits 127 on every hook event inside a sandbox.
150150
| Skills | 26 | `~/.claude/skills/` |
151151
| Subagents | 8 | `~/.claude/agents/` |
152152
| Commands | 14 | `~/.claude/commands/` |
153-
| Hooks | 5 | `~/.claude/hooks/` |
153+
| Hooks | 6 | `~/.claude/hooks/` |
154154
| CLI wrappers | 7 | `~/.config/agents/bin/` |
155155
| MCP servers | 2 | merged into `~/.claude.json` |
156156
| Global directives | `CLAUDE.md` | `~/.claude/CLAUDE.md` |
@@ -214,6 +214,30 @@ start.
214214
which skills fired. Strip the routing block and cases fail, which is how you know the test is
215215
worth having. [docs/how-skills-fire.md](docs/how-skills-fire.md) has the measurements.
216216

217+
### The ones that are not optional
218+
219+
Everything above is instruction, and an instruction is a probability. The routing lands on the
220+
cases that measure it, and "lands on the cases that measure it" is a weaker claim than "always".
221+
222+
`claude/hooks/skill-mandate.sh` runs on `Stop` and makes two of them certain. It reads the
223+
session transcript for what actually happened, which files were written and which skills were
224+
invoked, and refuses to let the turn finish when a rule went unmet:
225+
226+
| You wrote | This must have run |
227+
|---|---|
228+
| `.md` or `.mdx` | `unslop` |
229+
| `.ts` or `.tsx` | `typescript-best-practices` |
230+
231+
Two rules, not twenty, and the bar for adding a third is high: the situation has to be decidable
232+
from a tool call rather than from judgement, and the skill has to be the right answer every
233+
single time. A rule that is correct nine times in ten belongs in the routing block as guidance.
234+
As a gate it would just teach you to switch the gate off.
235+
236+
It blocks at most twice per session and then latches open, it never fires while a `Stop` hook is
237+
already running, and `VSTACK_NO_MANDATE=1` turns it off. Check 27 exercises all seven of those
238+
behaviours in both directions, because a gate that always blocks passes any test that only
239+
checks that it blocks.
240+
217241
## What this does that an unconfigured setup does not
218242

219243
Most setups in this space claim to make you better and none of them show it, because the claim
@@ -428,6 +452,9 @@ true.
428452
each of its own checks can fail
429453
- [Why these skills](docs/provenance/pstack-audit.md), the fit-and-benefit audit of all 44 pstack
430454
skills that decided which 18 were worth porting
455+
- [Provenance](docs/provenance/README.md), the design history this project was extracted from
456+
- [Five open investigations](docs/provenance/research-v1.7.0.md), an outside research pass on
457+
skill reachability, false-success labelling, ablation design, evidence bundles and a UI floor
431458
- [Changelog](CHANGELOG.md), with the measurements behind each release
432459

433460
## Credits

claude/hooks/failure-diagnose.sh

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,49 @@ else tool=$(printf '%s' "$in" | sed -n 's/.*"tool_name" *: *"\([^"]*\)".*/\1/p'
2727
#
2828
# Over-redaction is the cheap direction here: this text is diagnostic context for a model, and a
2929
# masked value costs a retry while a leaked one is permanent.
30-
redact(){ sed -E \
31-
-e 's/(sk-ant-|sk-proj-|sk-|github_pat_|ghp_|gho_|ghu_|ghs_|ghr_|glpat-|xoxb-|xoxp-|xoxa-|xapp-|AKIA|ASIA|AIza|ya29\.|hf_|npm_|dop_v1_)[A-Za-z0-9_\/+.-]{6,}/\1[REDACTED]/g' \
32-
-e 's/(eyJ[A-Za-z0-9_-]{4,})\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+/\1.[REDACTED]/g' \
33-
-e 's/([Aa]uthorization|[Pp]roxy-[Aa]uthorization)([[:space:]]*[:=][[:space:]]*)("?)([A-Za-z]+[[:space:]]+)?[^[:space:]"'"'"']{6,}/\1\2\3\4[REDACTED]/g' \
34-
-e 's/(([Aa]pi[_-]?|[Aa]ccess[_-]?|[Ss]ecret[_-]?|[Aa]uth[_-]?|[Pp]rivate[_-]?|[Bb]earer[_-]?|[Rr]efresh[_-]?|[Ss]ession[_-]?)?([Kk][Ee][Yy]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Pp][Aa][Ss][Ss][Ww][Dd]|[Cc][Rr][Ee][Dd][Ee][Nn][Tt][Ii][Aa][Ll][Ss]?)[A-Za-z0-9_-]*)("?[[:space:]]*[:=][[:space:]]*)("?)[^[:space:],;"'"'"']{4,}/\1\4\5[REDACTED]/g' \
35-
-e 's|([A-Za-z][A-Za-z0-9+.-]*://[^/:[:space:]]+):[^@[:space:]]+@|\1:[REDACTED]@|g'
30+
redact(){
31+
# Prefixed tokens, JWTs, auth headers and URL userinfo are unambiguous, so sed handles them.
32+
sed -E \
33+
-e 's/(sk-ant-|sk-proj-|sk-|github_pat_|ghp_|gho_|ghu_|ghs_|ghr_|glpat-|xoxb-|xoxp-|xoxa-|xapp-|AKIA|ASIA|AIza|ya29\.|hf_|npm_|dop_v1_)[A-Za-z0-9_\/+.-]{6,}/\1[REDACTED]/g' \
34+
-e 's/(eyJ[A-Za-z0-9_-]{4,})\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+/\1.[REDACTED]/g' \
35+
-e 's/([Aa]uthorization|[Pp]roxy-[Aa]uthorization)([[:space:]]*[:=][[:space:]]*)("?)([A-Za-z]+[[:space:]]+)?[^[:space:]"'"'"']{6,}/\1\2\3\4[REDACTED]/g' \
36+
-e 's|([A-Za-z][A-Za-z0-9+.-]*://[^/:[:space:]]+):[^@[:space:]]+@|\1:[REDACTED]@|g' \
37+
| awk '
38+
# NAME=VALUE needs both halves judged, which sed cannot do. Matching the name alone ate
39+
# ordinary output: the hook digest line "TOKENS: never read whole files" came back as
40+
# "TOKENS: [REDACTED] read whole files", and "Keystrokes: 1420 recorded" went the same way.
41+
# A diagnostic tail that has been chewed up is worth nothing, and the point of this hook is
42+
# that the tail gets read. So the name only nominates a candidate; the value decides.
43+
#
44+
# A credential is opaque. English is not. Sixteen or more characters, or six-plus with a
45+
# digit in them, or base64 padding, and it is not a word anyone typed as prose. "never" and
46+
# "1420" survive that test and every real key shape below fails it.
47+
function looks_secret(v, n) {
48+
gsub(/^["\047]+|["\047]+$/, "", v)
49+
n = length(v)
50+
if (n < 4) return 0
51+
if (n >= 16) return 1
52+
if (v ~ /[0-9]/ && n >= 6) return 1
53+
if (v ~ /[\/+=]/) return 1
54+
return 0
55+
}
56+
{
57+
line = $0; out = ""
58+
while (1) {
59+
lc = tolower(line)
60+
if (!match(lc, /(^|[^a-z0-9_-])[a-z0-9_-]*(key|token|secret|passwd|password|credential)[a-z0-9_-]*"?[ \t]*[:=][ \t]*"?[^ \t,;"\047]+/)) break
61+
st = RSTART; ln = RLENGTH
62+
seg = substr(line, st, ln)
63+
if (match(seg, /[:=][ \t]*"?/)) {
64+
head = substr(seg, 1, RSTART + RLENGTH - 1)
65+
val = substr(seg, RSTART + RLENGTH)
66+
if (looks_secret(val)) seg = head "[REDACTED]"
67+
}
68+
out = out substr(line, 1, st - 1) seg
69+
line = substr(line, st + ln)
70+
}
71+
print out line
72+
}'
3673
}
3774
if [ -n "$JQ" ]; then
3875
err=$(printf '%s' "$in" | "$JQ" -r '(.tool_response.error // .tool_response.stderr // .tool_response // "") | tostring' 2>/dev/null \

claude/hooks/skill-mandate.sh

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
#!/usr/bin/env bash
2+
# Stop: refuse to finish when the session did work that a skill is mandated for and never ran it.
3+
#
4+
# vstack already tells the model how to route: the SessionStart digest spells out "any prose you
5+
# write -> unslop", and the skill descriptions carry their own triggers. Both are instructions,
6+
# and an instruction is a probability. Measured over 12 prompts the routing lands, but "lands most
7+
# of the time" is not the same claim as "always", and the second one is the one worth having.
8+
#
9+
# This closes the gap for the few rules where the situation is decidable from the transcript
10+
# rather than from judgement. It reads what the session actually did -- which files were written,
11+
# which skills were invoked -- and blocks Stop when a mandate went unmet. Nothing here guesses at
12+
# intent: if the rule cannot be decided by looking at tool calls, it does not belong in this file.
13+
#
14+
# Escape hatch: VSTACK_NO_MANDATE=1 disables it entirely. A gate you cannot turn off gets deleted
15+
# by the first person it inconveniences, which is worse than one that is on by default.
16+
set -uo pipefail
17+
18+
[ "${VSTACK_NO_MANDATE:-0}" = "1" ] && exit 0
19+
20+
JQ=""
21+
if [ -x /usr/bin/jq ]; then JQ=/usr/bin/jq
22+
elif command -v jq >/dev/null 2>&1; then JQ=$(command -v jq); fi
23+
# Without jq there is no way to read the transcript path or emit a block reason safely. Say
24+
# nothing rather than guess: a mandate that misfires is worse than one that abstains.
25+
[ -n "$JQ" ] || exit 0
26+
27+
input=$(cat 2>/dev/null || true)
28+
29+
# Claude Code sets this when Stop already fired once for this turn. Blocking again from inside a
30+
# block is how a hook turns into an infinite loop.
31+
[ "$(printf '%s' "$input" | "$JQ" -r '.stop_hook_active // false')" = "true" ] && exit 0
32+
33+
tr_=$(printf '%s' "$input" | "$JQ" -r '.transcript_path // empty')
34+
[ -n "$tr_" ] && [ -f "$tr_" ] || exit 0
35+
36+
sid=$(printf '%s' "$input" | "$JQ" -r '.session_id // empty'); [ -n "$sid" ] || sid="pid$PPID"
37+
cnt_file="${TMPDIR:-/tmp}/vstack-mandate-$sid"
38+
cnt=$(cat "$cnt_file" 2>/dev/null || echo 0)
39+
# Same latch as the verify gate. A mandate the model cannot satisfy must not trap the session.
40+
[ "$cnt" -ge 2 ] && exit 0
41+
42+
# Every file this session wrote or edited, and every skill it invoked. Both come from the
43+
# transcript, so this measures what happened rather than what was asked for.
44+
paths=$(
45+
"$JQ" -r 'select(.type=="assistant") | .message.content[]?
46+
| select(.type=="tool_use" and (.name=="Write" or .name=="Edit" or .name=="NotebookEdit"))
47+
| .input.file_path // empty' "$tr_" 2>/dev/null | sort -u
48+
)
49+
skills=$(
50+
"$JQ" -r 'select(.type=="assistant") | .message.content[]?
51+
| select(.type=="tool_use" and .name=="Skill") | .input.skill // empty' "$tr_" 2>/dev/null \
52+
| sed 's/.*://' | sort -u
53+
)
54+
55+
fired(){ printf '%s\n' "$skills" | grep -qxF "$1"; }
56+
57+
unmet=""
58+
# --- the mandates -----------------------------------------------------------------------------
59+
# Each one needs a situation decidable from a tool call, and a skill that is the answer to it
60+
# every single time. That second half is the strict part: a rule that is right nine times out of
61+
# ten belongs in the digest as guidance, not here as a gate.
62+
63+
# Prose. Any Markdown that is not a machine-written log or a vendored file.
64+
prose=$(printf '%s\n' "$paths" | grep -iE '\.(md|mdx)$' \
65+
| grep -viE '(CHANGELOG\.md|node_modules|\.audit/|/(dist|build|vendor)/)' | head -5)
66+
if [ -n "$prose" ] && ! fired unslop; then
67+
unmet="$unmet
68+
unslop -- you wrote prose and it never ran: $(printf '%s' "$prose" | tr '\n' ' ')"
69+
fi
70+
71+
# TypeScript. Reading one is judgement; writing one is not.
72+
ts=$(printf '%s\n' "$paths" | grep -E '\.(ts|tsx)$' | grep -v node_modules | head -5)
73+
if [ -n "$ts" ] && ! fired typescript-best-practices; then
74+
unmet="$unmet
75+
typescript-best-practices -- you wrote TypeScript and it never ran: $(printf '%s' "$ts" | tr '\n' ' ')"
76+
fi
77+
78+
[ -n "$unmet" ] || { rm -f "$cnt_file"; exit 0; }
79+
80+
echo $((cnt+1)) > "$cnt_file"
81+
reason="A vstack skill mandate went unmet (attempt $((cnt+1))/2). These fire every time, not when they seem relevant:
82+
$unmet
83+
84+
Run each named skill with the Skill tool against the files listed, apply what it says, then finish.
85+
Set VSTACK_NO_MANDATE=1 to disable this gate."
86+
"$JQ" -cn --arg r "$reason" '{decision:"block",reason:$r}'
87+
exit 0

claude/settings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@
7070
{
7171
"type": "command",
7272
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/verify-gate.sh\""
73+
},
74+
{
75+
"type": "command",
76+
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/skill-mandate.sh\""
7377
}
7478
]
7579
}
File renamed without changes.

0 commit comments

Comments
 (0)