@thebassclef/lite 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/cli.cjs +240 -23
  2. package/dist/cli.js +242 -25
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/lite/.claude/hooks/artifact-ingestion-gate.sh +357 -0
  7. package/dist/lite/.claude/hooks/assert-verify-steering.sh +77 -0
  8. package/dist/lite/.claude/hooks/bassclef-source-config-validate.sh +215 -0
  9. package/dist/lite/.claude/hooks/bassclef-sync.sh +634 -0
  10. package/dist/lite/.claude/hooks/compound-noun-scrub.sh +292 -0
  11. package/dist/lite/.claude/hooks/kiss-expansion-inject.sh +69 -0
  12. package/dist/lite/.claude/hooks/longrun-prep-compounding-sequence-check.sh +492 -0
  13. package/dist/lite/.claude/hooks/plain-english-steering.sh +156 -0
  14. package/dist/lite/.claude/hooks/post-skill-friction-check.sh +177 -0
  15. package/dist/lite/.claude/hooks/post-skill-telemetry.sh +62 -0
  16. package/dist/lite/.claude/hooks/pre-build-gate.sh +511 -0
  17. package/dist/lite/.claude/hooks/pre-commit-gate.sh +451 -0
  18. package/dist/lite/.claude/hooks/session-end.sh +433 -0
  19. package/dist/lite/.claude/hooks/session-reflection.sh +303 -0
  20. package/dist/lite/.claude/hooks/skill-body-grade-gate.sh +219 -0
  21. package/dist/lite/.claude/hooks/skill-body-intent-drift.sh +107 -0
  22. package/dist/lite/.claude/hooks/state-validate.sh +271 -0
  23. package/dist/lite/.claude/hooks/substrate-clarity-gate.sh +1110 -0
  24. package/dist/lite/.claude/hooks/temperance-gate.sh +147 -0
  25. package/dist/lite/.claude/hooks/testing-tier-enforce.sh +233 -0
  26. package/dist/lite/.claude/hooks/turn-prose-grade-measure.sh +219 -0
  27. package/dist/lite/.claude/hooks/turn-prose-kiss-check.sh +463 -0
  28. package/dist/lite/.claude/hooks/vocabulary-migration-check.sh +171 -0
  29. package/dist/lite/.claude/hooks/whereami-utc-gate.sh +142 -0
  30. package/dist/lite/CLAUDE.md +2 -2
  31. package/dist/lite/whereami.md +1 -1
  32. package/package.json +1 -1
@@ -0,0 +1,463 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # Turn-prose /kiss check — Stop hook that scans the most recent assistant
5
+ # message in the transcript against bassclef-internal jargon BLOCK terms.
6
+ #
7
+ # Per bassclef#1051: closes the bassclef#1027 "no hook can reach turn prose"
8
+ # gap. Stop hooks receive transcript_path in stdin and CAN read the most
9
+ # recent assistant message.
10
+ #
11
+ # Sibling pattern: .claude/hooks/pr-body-scrub-check.sh
12
+ # Single source of truth for BLOCK terms: standards/bassclef-internal-jargon.md
13
+ # Allowlist per bassclef#763: substrate, bassclef, cameo, Studio, Band, bandleader
14
+ #
15
+ # Input (stdin): JSON with transcript_path + session_id
16
+ # {"transcript_path": "/path/to/transcript.jsonl", "session_id": "..."}
17
+ #
18
+ # Toggle (env or .claude/bassclef-configs.jsonc):
19
+ # PROSE_DISCIPLINE_TOGGLE=true — advisory (exit 0, findings to stderr) [DEFAULT]
20
+ # PROSE_DISCIPLINE_TOGGLE=strict — strict (exit 2, blocks stop, forces rewrite)
21
+ # PROSE_DISCIPLINE_TOGGLE=false — silent (exit 0, no scan)
22
+ #
23
+ # Override per-call:
24
+ # SKIP_TURN_PROSE_KISS=1 — skip scan, exit 0
25
+ #
26
+ # Exit codes:
27
+ # 0 — pass (no findings, advisory mode, toggle off, or override set)
28
+ # 2 — block stop (strict mode + findings present)
29
+ #
30
+ # Reads:
31
+ # - $TRANSCRIPT_PATH (jsonl, one event per line)
32
+ # - standards/bassclef-internal-jargon.md (BLOCK terms)
33
+ # - .claude/bassclef-configs.jsonc (toggle when env not set)
34
+
35
+ set +e
36
+
37
+ # === Override path ===
38
+ if [ "${SKIP_TURN_PROSE_KISS:-0}" = "1" ]; then
39
+ exit 0
40
+ fi
41
+
42
+ # === Source install-class-aware resolver lib (per standards/hook-install-class.md) ===
43
+ # This hook is `dual` — runs under both project install (<repo>/.claude/hooks/)
44
+ # and operator install ($HOME/.claude/hooks/). Pre-cure: SCRIPT_DIR/../../standards
45
+ # was hardcoded; operator-install fell back to an 8-term defensive list. Cure:
46
+ # resolve_standards_path + resolve_config_path from lib/hook-inject.sh.
47
+ # Lib lookup layered: project-relative → HOME-relative (bassclef-sync convention).
48
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
49
+
50
+ # === Hook liveness heartbeat (WU-3 of bet 2026-07-31d; closes #1002) ===
51
+ # Silent-fail — a missing lib never crashes the hook.
52
+ {
53
+ for _hb_c in "${SCRIPT_DIR}/../../lib/hook-heartbeat.sh" "${HOME:-/}/lib/hook-heartbeat.sh"; do
54
+ [ -f "$_hb_c" ] && source "$_hb_c" && heartbeat_mark "turn-prose-kiss-check" && break
55
+ done
56
+ unset _hb_c
57
+ } 2>/dev/null || true
58
+
59
+ for _candidate in \
60
+ "${SCRIPT_DIR}/../../lib/hook-inject.sh" \
61
+ "${HOME_OVERRIDE:-${HOME:-/}}/lib/hook-inject.sh"; do
62
+ if [ -f "$_candidate" ]; then
63
+ # shellcheck disable=SC1090
64
+ source "$_candidate"
65
+ break
66
+ fi
67
+ done
68
+ unset _candidate
69
+
70
+ # === Source lib/prose-scan-boundary.sh (deep module per #1443) ===
71
+ # Strips BLOCKED banner echoes plus SCAN_SKIP fences before scan. Also
72
+ # provides write_prose_counter for the steering hook to read.
73
+ # Silent-fail: no lib = no boundary strip; hook still scans (backward
74
+ # compat with pre-lib behavior).
75
+ for _candidate in \
76
+ "${SCRIPT_DIR}/../../lib/prose-scan-boundary.sh" \
77
+ "${HOME_OVERRIDE:-${HOME:-/}}/lib/prose-scan-boundary.sh"; do
78
+ if [ -f "$_candidate" ]; then
79
+ # shellcheck disable=SC1090
80
+ source "$_candidate"
81
+ break
82
+ fi
83
+ done
84
+ unset _candidate
85
+
86
+ # === Parse stdin JSON ===
87
+ STDIN_JSON=$(cat 2>/dev/null || echo '{}')
88
+ TRANSCRIPT_PATH=$(echo "$STDIN_JSON" | jq -r '.transcript_path // ""' 2>/dev/null)
89
+
90
+ # Graceful skip if no transcript path provided
91
+ if [ -z "$TRANSCRIPT_PATH" ]; then
92
+ exit 0
93
+ fi
94
+
95
+ # Graceful skip if transcript file missing
96
+ if [ ! -f "$TRANSCRIPT_PATH" ]; then
97
+ exit 0
98
+ fi
99
+
100
+ # === Resolve config file (install-class-aware via lib) ===
101
+ if declare -f resolve_config_path >/dev/null 2>&1; then
102
+ CONFIG_FILE=$(resolve_config_path 2>/dev/null) || CONFIG_FILE=""
103
+ else
104
+ CONFIG_FILE="${SCRIPT_DIR}/../bassclef-configs.jsonc"
105
+ fi
106
+
107
+ # === Resolve toggle ===
108
+ TOGGLE="${PROSE_DISCIPLINE_TOGGLE:-}"
109
+ if [ -z "$TOGGLE" ]; then
110
+ if [ -n "$CONFIG_FILE" ] && [ -f "$CONFIG_FILE" ]; then
111
+ # Strip // comments before jq parses (jsonc is JSON + comments)
112
+ TOGGLE=$(sed 's|//.*||g' "$CONFIG_FILE" 2>/dev/null \
113
+ | jq -r '.prose_discipline.kiss_words_turn_prose // "true"' 2>/dev/null)
114
+ fi
115
+ TOGGLE="${TOGGLE:-true}"
116
+ fi
117
+
118
+ # Boolean true normalizes to "true" (advisory). false → silent. "strict" → strict.
119
+ case "$TOGGLE" in
120
+ true|"true") MODE="advisory" ;;
121
+ strict|"strict") MODE="strict" ;;
122
+ false|"false") exit 0 ;;
123
+ *) MODE="advisory" ;;
124
+ esac
125
+
126
+ # bassclef#1171 — read per-place strict escalation list. When global mode is
127
+ # advisory but the message matches a named place pattern AND has BLOCK findings,
128
+ # the hook escalates that message to strict (exit 2).
129
+ # Env var precedence over config: explicit empty string (env set to "") means
130
+ # "no places, do not consult config". Unset means "fall back to config".
131
+ if [ -n "${PROSE_DISCIPLINE_STRICT_PLACES+set}" ]; then
132
+ STRICT_PLACES="$PROSE_DISCIPLINE_STRICT_PLACES"
133
+ else
134
+ STRICT_PLACES=""
135
+ if [ -f "$CONFIG_FILE" ]; then
136
+ STRICT_PLACES=$(sed 's|//.*||g' "$CONFIG_FILE" 2>/dev/null \
137
+ | jq -r '.prose_discipline.kiss_words_strict_places // [] | join(",")' 2>/dev/null)
138
+ fi
139
+ fi
140
+
141
+ # === Extract most recent assistant message ===
142
+ # Transcript is JSONL: one event per line. Last line with type=assistant.
143
+ # Content is an array of content blocks; collect text from:
144
+ # - "text" blocks (assistant prose to operator)
145
+ # - "tool_use" blocks where name == "AskUserQuestion" (option text the
146
+ # operator reads — same operator-facing surface as prose). Pulls the
147
+ # question, each option label, and each option description.
148
+ # Other tool_use blocks (Bash, Read, Edit, etc.) are NOT scanned — their
149
+ # inputs are not operator-facing prose.
150
+ LAST_ASSISTANT_LINE=$(grep '"type":"assistant"' "$TRANSCRIPT_PATH" 2>/dev/null | tail -1)
151
+ if [ -z "$LAST_ASSISTANT_LINE" ]; then
152
+ # No assistant message in transcript yet — graceful skip
153
+ exit 0
154
+ fi
155
+
156
+ MESSAGE_TEXT=$(echo "$LAST_ASSISTANT_LINE" \
157
+ | jq -r '.message.content[]? |
158
+ if .type == "text" then
159
+ .text
160
+ elif .type == "tool_use" and .name == "AskUserQuestion" then
161
+ [
162
+ (.input.questions[]?
163
+ | (.question // ""),
164
+ (.options[]? | (.label // ""), (.description // "")))
165
+ ] | join("\n")
166
+ else
167
+ empty
168
+ end' 2>/dev/null)
169
+ if [ -z "$MESSAGE_TEXT" ]; then
170
+ exit 0
171
+ fi
172
+
173
+ # === Boundary strip (lib/prose-scan-boundary.sh; #1443 canonical case) ===
174
+ # Strip BLOCKED banner echoes (session-start hook output) plus SCAN_SKIP
175
+ # fences before scan. Prior shape scanned banner text as if agent authored
176
+ # it — 5 rewrite cycles in 20 turns of prep (2026-09-09b session).
177
+ #
178
+ # Note: MESSAGE_TEXT_STRIPPED feeds SCRUBBED (jargon/appositive scan).
179
+ # MESSAGE_TEXT stays raw for citation check (needs backticks) and appositive
180
+ # scan on shorthand (needs original context).
181
+ MESSAGE_TEXT_STRIPPED="$MESSAGE_TEXT"
182
+ if declare -f strip_scan_boundaries >/dev/null 2>&1; then
183
+ MESSAGE_TEXT_STRIPPED=$(strip_scan_boundaries "$MESSAGE_TEXT" 2>/dev/null || echo "$MESSAGE_TEXT")
184
+ fi
185
+
186
+ # === Strip code blocks + inline backtick spans + URLs ===
187
+ # Same logic as pr-body-scrub-check.sh — technical refs are not prose
188
+ SCRUBBED=$(echo "$MESSAGE_TEXT_STRIPPED" | awk '
189
+ BEGIN { in_code = 0 }
190
+ /^```/ { in_code = !in_code; next }
191
+ in_code { next }
192
+ { print }
193
+ ' | sed -E 's/`[^`]*`//g; s|https?://[^ )]+||g')
194
+
195
+ # === Load BLOCK terms from standards/bassclef-internal-jargon.md ===
196
+ # Pattern matches pr-body-scrub-check.sh — read BLOCK-tier rows from the table.
197
+ # Path resolution via lib's resolve_standards_path (project → env → user → fail).
198
+ # Falls back to legacy SCRIPT_DIR-relative path when none of the lib's 3 layers
199
+ # resolve — preserves project-class behavior under direct invocation without
200
+ # CLAUDE_PROJECT_DIR set (operator-install always sets CLAUDE_PROJECT_DIR).
201
+ STANDARDS_FILE=""
202
+ if declare -f resolve_standards_path >/dev/null 2>&1; then
203
+ STANDARDS_FILE=$(resolve_standards_path "bassclef-internal-jargon.md" 2>/dev/null) || STANDARDS_FILE=""
204
+ fi
205
+ if [ -z "$STANDARDS_FILE" ] || [ ! -f "$STANDARDS_FILE" ]; then
206
+ STANDARDS_FILE="${KISS_STANDARDS_FILE:-${SCRIPT_DIR}/../../standards/bassclef-internal-jargon.md}"
207
+ fi
208
+
209
+ JARGON_TERMS=()
210
+ if [ -f "$STANDARDS_FILE" ]; then
211
+ while IFS= read -r line; do
212
+ JARGON_TERMS+=("$line")
213
+ done < <(awk -F'|' '
214
+ /^\| [a-zA-Z0-9-]+ / && /BLOCK *\|$/ {
215
+ gsub(/^ +| +$/, "", $2)
216
+ print $2
217
+ }
218
+ ' "$STANDARDS_FILE")
219
+ fi
220
+
221
+ # Defensive fallback if standards file missing or empty
222
+ if [ ${#JARGON_TERMS[@]} -eq 0 ]; then
223
+ JARGON_TERMS=(
224
+ "composer"
225
+ "primitive"
226
+ "tier-preset"
227
+ "load-bearing"
228
+ "blast radius"
229
+ "compose-with"
230
+ "scope-bounded"
231
+ "operationalize"
232
+ )
233
+ fi
234
+
235
+ # === Scan for BLOCK terms ===
236
+ FINDINGS=""
237
+ FINDING_COUNT=0
238
+ for term in "${JARGON_TERMS[@]}"; do
239
+ # Case-insensitive whole-word match
240
+ count=$(echo "$SCRUBBED" | grep -ioE "\b${term}\b" 2>/dev/null | wc -l | tr -d ' ')
241
+ if [ "$count" -gt 0 ]; then
242
+ FINDINGS="${FINDINGS}- \`${term}\` (${count}x)"$'\n'
243
+ FINDING_COUNT=$((FINDING_COUNT + count))
244
+ fi
245
+ done
246
+
247
+ # === Scan for hyphenated-proper-noun + structural-metaphor suffix (bassclef#101) ===
248
+ # Pattern: Capitalized-Word + "-" + Capitalized-Word (one or more hyphen pairs)
249
+ # + space + one of (triangle | pyramid | spectrum | hexagon | gate)
250
+ # Catches dense compound phrases like "Saltzer-Schroeder triangle",
251
+ # "Linus-Torvalds gate", "Beck-Cunningham pyramid" — academic/architectural
252
+ # jargon shape that slips past individual-word checks.
253
+ # Negative cases preserved: "API gateway" (not hyphenated proper noun),
254
+ # "circuit breaker" (suffix not in list).
255
+ PROPER_NOUN_SHAPE_MATCHES=$(echo "$SCRUBBED" | grep -oE \
256
+ '[A-Z][a-z]+(-[A-Z][a-z]+)+ (triangle|pyramid|spectrum|hexagon|gate)\b' \
257
+ 2>/dev/null)
258
+ if [ -n "$PROPER_NOUN_SHAPE_MATCHES" ]; then
259
+ shape_count=$(echo "$PROPER_NOUN_SHAPE_MATCHES" | wc -l | tr -d ' ')
260
+ FINDINGS="${FINDINGS}- dense compound shape (${shape_count}x) — proper-noun + structural-metaphor — see bassclef#101 — rewrite in plain English"$'\n'
261
+ FINDING_COUNT=$((FINDING_COUNT + shape_count))
262
+ fi
263
+
264
+ # === Scan for option-label Greek (bassclef#1286) ===
265
+ # Pattern: (option|shape|pick|tier|approach|step|item|path|alternative) + whitespace + Greek letter
266
+ # Greek lowercase α-ω: α-ω ; Greek uppercase Α-Ω: Α-Ω
267
+ # Using ripgrep-compatible PCRE class via grep -P (GNU grep) when available; falling
268
+ # back to a literal alternation of common Greek letters for portability.
269
+ OPTION_LABEL_GREEK_MATCHES=$(echo "$SCRUBBED" | grep -ioE \
270
+ '(option|shape|pick|tier|approach|step|item|path|alternative)[[:space:]]+(α|β|γ|δ|ε|ζ|η|θ|ι|κ|λ|μ|ν|ξ|ο|π|ρ|σ|τ|υ|φ|χ|ψ|ω|Α|Β|Γ|Δ|Ε|Ζ|Η|Θ|Ι|Κ|Λ|Μ|Ν|Ξ|Ο|Π|Ρ|Σ|Τ|Υ|Φ|Χ|Ψ|Ω)' \
271
+ 2>/dev/null)
272
+ if [ -n "$OPTION_LABEL_GREEK_MATCHES" ]; then
273
+ option_label_count=$(echo "$OPTION_LABEL_GREEK_MATCHES" | wc -l | tr -d ' ')
274
+ FINDINGS="${FINDINGS}- option-label Greek (${option_label_count}x) — see bassclef#1286 + .claude/rules/option-label-discipline.md — use a/b/c or 1/2/3 instead"$'\n'
275
+ FINDING_COUNT=$((FINDING_COUNT + option_label_count))
276
+ fi
277
+
278
+ # === Scan for retired WU-N vocab in new prose (bassclef-web#202) ===
279
+ # ADR-040 D1 renamed "WU-N" → "Step N". Grace window through 2026-10-31 preserves
280
+ # READ compatibility via standards/vocabulary-migration.json — historical refs in
281
+ # closed PRs, past chronicles, memory files stay as-is. NEW authored prose uses
282
+ # the new terms. Backticked refs like `WU-3` are stripped by SCRUBBED at L164 so
283
+ # quoted historical goal IDs pass through cleanly. Only unbacked WU-N in prose fires.
284
+ WU_N_VOCAB_MATCHES=$(echo "$SCRUBBED" | grep -oE '\bWU-[0-9]+\b' 2>/dev/null)
285
+ if [ -n "$WU_N_VOCAB_MATCHES" ]; then
286
+ wu_n_count=$(echo "$WU_N_VOCAB_MATCHES" | wc -l | tr -d ' ')
287
+ FINDINGS="${FINDINGS}- retired WU-N vocab (${wu_n_count}x) — see bassclef-web#202 + ADR-040 D1 rename table — say Step N in new prose; wrap historical goal IDs in backticks"$'\n'
288
+ FINDING_COUNT=$((FINDING_COUNT + wu_n_count))
289
+ fi
290
+
291
+ # === Per-place strict escalation (bassclef#1171) ===
292
+ # When global mode is advisory + BLOCK findings exist + the message matches
293
+ # a named strict-place pattern, escalate that message to strict (exit 2).
294
+ #
295
+ # Two detection paths per place:
296
+ # 1. Marker file at state/markers/turn-prose-surface/<surface>.marker —
297
+ # the SKILL.md procedure touches this before posting; precise. (bassclef#1304)
298
+ # 2. Content pattern grep on SCRUBBED — heuristic fallback for the V1
299
+ # 3 places (closeout_blocks, summary_tables, next_pickup).
300
+ EFFECTIVE_MODE="$MODE"
301
+ ESCALATING_PLACE=""
302
+
303
+ # Repo root for marker lookup
304
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
305
+ SURFACE_MARKER_DIR="${REPO_ROOT}/state/markers/turn-prose-surface"
306
+
307
+ if [ "$MODE" = "advisory" ] && [ "$FINDING_COUNT" -gt 0 ] && [ -n "$STRICT_PLACES" ]; then
308
+ IFS=',' read -ra PLACE_ARRAY <<< "$STRICT_PLACES"
309
+ for place in "${PLACE_ARRAY[@]}"; do
310
+ place_trimmed=$(echo "$place" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
311
+
312
+ # Path 1: marker-file detection (precise; bassclef#1304)
313
+ if [ -f "${SURFACE_MARKER_DIR}/${place_trimmed}.marker" ]; then
314
+ EFFECTIVE_MODE="strict"
315
+ ESCALATING_PLACE="${place_trimmed} (marker)"
316
+ break
317
+ fi
318
+
319
+ # Path 2: content-pattern heuristics (V1 fallback)
320
+ case "$place_trimmed" in
321
+ closeout_blocks)
322
+ if echo "$SCRUBBED" | grep -qE '^## Closeout audit'; then
323
+ EFFECTIVE_MODE="strict"
324
+ ESCALATING_PLACE="closeout_blocks"
325
+ break
326
+ fi
327
+ ;;
328
+ summary_tables)
329
+ if echo "$SCRUBBED" | grep -qE '^## Status[[:space:]]*$'; then
330
+ EFFECTIVE_MODE="strict"
331
+ ESCALATING_PLACE="summary_tables"
332
+ break
333
+ fi
334
+ ;;
335
+ next_pickup)
336
+ if echo "$SCRUBBED" | grep -qE '^## Next pickup'; then
337
+ EFFECTIVE_MODE="strict"
338
+ ESCALATING_PLACE="next_pickup"
339
+ break
340
+ fi
341
+ ;;
342
+ esac
343
+ done
344
+ fi
345
+
346
+ # === Skill-dispatch citation check (V1 ADVISORY — bassclef-upstream#374) ===
347
+ # When a /longrun-surface marker exists at state/markers/turn-prose-surface/
348
+ # <surface>.marker, scan the message for a skill-dispatch citation per
349
+ # operator-facing-prose.md Rule 3. Emit ADVISORY if citation missing.
350
+ # Named surfaces: longrun_prep_proposal, longrun_checkpoint, longrun_closeout, status_update.
351
+ SKILL_CITATION_MISSING=""
352
+ for surface in longrun_prep_proposal longrun_checkpoint longrun_closeout status_update; do
353
+ if [ -f "${SURFACE_MARKER_DIR}/${surface}.marker" ]; then
354
+ # Citation patterns: backtick-wrapped /skill mention, or named discipline blocks.
355
+ if ! echo "$MESSAGE_TEXT" | grep -qE '(via `/[a-z][a-z -]*`|per `/[a-z][a-z -]*`|\(via /[a-z-]+\)|drafted by `/[a-z][a-z -]*`|## /temperance \+ /luminary \+ /loop discipline|\*\*/temperance\*\*|\*\*/luminary\*\*|\*\*Flash \(via `|via /[a-z-]+ )'; then
356
+ SKILL_CITATION_MISSING="${SKILL_CITATION_MISSING}${surface} "
357
+ fi
358
+ fi
359
+ done
360
+
361
+ if [ -n "$SKILL_CITATION_MISSING" ]; then
362
+ cat >&2 <<EOF
363
+ [turn-prose-kiss-check: ADVISORY — skill-dispatch citation missing on surface(s): ${SKILL_CITATION_MISSING}]
364
+ Per .claude/rules/operator-facing-prose.md Rule 3 (bassclef-upstream#374):
365
+ every /longrun-dispatched session-conversation surface declares the
366
+ dispatched skill via inline citation (e.g., "via \`/value-prop flash\`",
367
+ "drafted by \`/state-a-problem brief\`") or a short discipline-marker line.
368
+ V1 advisory; V2 (deferred) BLOCKs net-new surfaces without citation.
369
+ EOF
370
+ fi
371
+
372
+ # === Appositive presence check (V1 ADVISORY — bassclef-upstream#1313) ===
373
+ # Per .claude/rules/operator-facing-prose.md Rule 1 broader appositive
374
+ # amendment. Scans for shorthand patterns and checks each match for an
375
+ # adjacent appositive (parenthetical, comma phrase, em-dash) within 40
376
+ # characters. Emits ADVISORY line per shorthand without adjacent
377
+ # appositive. Structural check only — operator judgment catches
378
+ # shorthand-inside-appositive.
379
+ #
380
+ # Override per-call: SKIP_APPOSITIVE_CHECK=1
381
+ if [ "${SKIP_APPOSITIVE_CHECK:-0}" != "1" ]; then
382
+ APPOSITIVE_FINDINGS=$(printf '%s' "$MESSAGE_TEXT" | python3 -c '
383
+ import re, sys
384
+
385
+ text = sys.stdin.read()
386
+
387
+ # Shorthand patterns — session-invented or bassclef substrate refs
388
+ patterns = [
389
+ (r"(?<![a-zA-Z0-9_/])#(\d+)\b", "ticket"),
390
+ (r"\bBucket\s+[A-Z]\d?[a-z]?\b", "Bucket label"),
391
+ (r"\bOption\s+[a-z]\b", "Option letter"),
392
+ (r"\bShip\s+\d+\b", "Ship label"),
393
+ (r"\bWave\s+\d+\b", "Wave label"),
394
+ ]
395
+
396
+ # Appositive shapes within 40 chars after the shorthand:
397
+ # \s*( — optional whitespace then
398
+ # \( — parenthetical
399
+ # |,\s+\w — comma + space + word
400
+ # |—\s*\w — em-dash + word
401
+ # |:\s+\w — colon + word
402
+ # |\s—\s+\w — spaced em-dash + word
403
+ # )
404
+ appositive_re = re.compile(r"^\s*(\(|,\s+\w|—\s*\w|:\s+\w|-\s+\w)")
405
+
406
+ flagged = []
407
+ for pattern, label in patterns:
408
+ for m in re.finditer(pattern, text):
409
+ end = m.end()
410
+ following = text[end:end+40]
411
+ if not appositive_re.match(following):
412
+ # Extract surrounding context for the finding
413
+ context_start = max(0, m.start() - 10)
414
+ context_end = min(len(text), m.end() + 30)
415
+ snippet = text[context_start:context_end].replace("\n", " ")
416
+ flagged.append(f"{label} \"{m.group(0)}\" — no adjacent appositive (context: \"...{snippet}...\")")
417
+
418
+ for f in flagged[:5]: # cap at 5 findings per turn to avoid noise
419
+ print(f)
420
+ ' 2>/dev/null)
421
+
422
+ if [ -n "$APPOSITIVE_FINDINGS" ]; then
423
+ cat >&2 <<EOF
424
+ [turn-prose-kiss-check: ADVISORY — appositive discipline (bassclef-upstream#1313)]
425
+ Shorthand without adjacent appositive found:
426
+
427
+ ${APPOSITIVE_FINDINGS}
428
+
429
+ Per .claude/rules/operator-facing-prose.md Rule 1 broader appositive amendment:
430
+ every shorthand at any mention benefits from a comma phrase, parenthetical,
431
+ or em-dash appositive that renames or explains it. Appositive text must be
432
+ plain-language for a cold reader (not more shorthand).
433
+ Override one-time: SKIP_APPOSITIVE_CHECK=1.
434
+ V1 advisory; V2 (deferred) BLOCKs after calibration.
435
+ EOF
436
+ fi
437
+ fi
438
+
439
+ # === Emit findings + exit per mode ===
440
+ if [ "$FINDING_COUNT" -gt 0 ]; then
441
+ PLACE_NOTE=""
442
+ if [ -n "$ESCALATING_PLACE" ]; then
443
+ PLACE_NOTE=$'\n''Escalated to strict by named place: '"${ESCALATING_PLACE}"' (bassclef#1171).'
444
+ fi
445
+ cat >&2 <<EOF
446
+ 🛑 /kiss turn-prose check — ${FINDING_COUNT} BLOCK-term finding(s) in your most recent message:
447
+
448
+ ${FINDINGS}
449
+ Rewrite using plain-English alternatives from standards/bassclef-internal-jargon.md.
450
+ Reserved allowlist holds: substrate, bassclef, cameo, Studio, Band, bandleader.
451
+
452
+ Mode: ${EFFECTIVE_MODE}. Toggle: .claude/bassclef-configs.jsonc → prose_discipline.kiss_words_turn_prose.${PLACE_NOTE}
453
+ Override one-time: SKIP_TURN_PROSE_KISS=1.
454
+
455
+ Closes bassclef#1027 wrong-premise (hook surfaces CAN reach turn prose via Stop event).
456
+ EOF
457
+ if [ "$EFFECTIVE_MODE" = "strict" ]; then
458
+ exit 2
459
+ fi
460
+ exit 0
461
+ fi
462
+
463
+ exit 0
@@ -0,0 +1,171 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # vocabulary-migration-check.sh — PreToolUse Edit|Write gate on
5
+ # .claude/skills/*/SKILL.md. Reads old vocabulary terms from
6
+ # standards/vocabulary-migration.json and BLOCKs when a SKILL edit
7
+ # contains an old term outside code blocks and HTML comments.
8
+ #
9
+ # Fills the gap #764 named — /skill's new-composition path runs
10
+ # vocabulary checks, but direct Edit calls on existing SKILL bodies
11
+ # bypass them. This hook adds a dedicated gate at the edit surface.
12
+ #
13
+ # Composes with:
14
+ # - skill-body-grade-gate.sh (grade cap)
15
+ # - compound-noun-scrub.sh (hyphenated jargon)
16
+ # - substrate-clarity-gate.sh (INSTEAD-block + description shape)
17
+ #
18
+ # Input (stdin): JSON — tool_name, tool_input.file_path, tool_input.new_string / .content
19
+ # Exit: 0 pass, 2 block
20
+ #
21
+ # Override per-call: SKIP_VOCABULARY_MIGRATION=1
22
+ #
23
+ # Per @luminary tony-hoare (precondition contracts)
24
+ # + @luminary kent-beck (Tier 0 strict TDD)
25
+
26
+ set +e
27
+
28
+ # === Override path ===
29
+ if [ "${SKIP_VOCABULARY_MIGRATION:-0}" = "1" ]; then
30
+ exit 0
31
+ fi
32
+
33
+ # === Parse stdin JSON ===
34
+ STDIN_JSON=$(cat 2>/dev/null || echo '{}')
35
+
36
+ # Bail on unparseable JSON
37
+ if ! echo "$STDIN_JSON" | jq -e . >/dev/null 2>&1; then
38
+ exit 0
39
+ fi
40
+
41
+ TOOL_NAME=$(echo "$STDIN_JSON" | jq -r '.tool_name // ""' 2>/dev/null)
42
+ FILE_PATH=$(echo "$STDIN_JSON" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
43
+
44
+ # Only fire on Write or Edit
45
+ if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
46
+ exit 0
47
+ fi
48
+
49
+ # Only fire on .claude/skills/*/SKILL.md
50
+ case "$FILE_PATH" in
51
+ *".claude/skills/"*"/SKILL.md")
52
+ ;;
53
+ *)
54
+ exit 0
55
+ ;;
56
+ esac
57
+
58
+ # === Locate vocabulary-migration.json ===
59
+ # Try project-relative path first, then via hook-inject helpers if available.
60
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
61
+ MIGRATION_FILE=""
62
+ for candidate in \
63
+ "${CLAUDE_PROJECT_DIR:-}/standards/vocabulary-migration.json" \
64
+ "${SCRIPT_DIR}/../../standards/vocabulary-migration.json" \
65
+ "$(pwd)/standards/vocabulary-migration.json"; do
66
+ if [ -n "$candidate" ] && [ -f "$candidate" ]; then
67
+ MIGRATION_FILE="$candidate"
68
+ break
69
+ fi
70
+ done
71
+
72
+ # If we cannot find the migration file, pass through (soft dep)
73
+ if [ -z "$MIGRATION_FILE" ]; then
74
+ exit 0
75
+ fi
76
+
77
+ # === Extract old terms + new mappings ===
78
+ # Read old→new pairs into an associative array-ish shell layout.
79
+ # Format: OLD_TERMS is a newline-separated list of "old|new" pairs.
80
+ OLD_TERMS=$(jq -r '.renames[] | "\(.old)|\(.new)"' "$MIGRATION_FILE" 2>/dev/null)
81
+ if [ -z "$OLD_TERMS" ]; then
82
+ exit 0
83
+ fi
84
+
85
+ # === Extract candidate content ===
86
+ # For Edit: new_string is what lands in the file.
87
+ # For Write: content is the whole file.
88
+ CONTENT=$(echo "$STDIN_JSON" | jq -r '.tool_input.new_string // .tool_input.content // ""' 2>/dev/null)
89
+ if [ -z "$CONTENT" ]; then
90
+ exit 0
91
+ fi
92
+
93
+ # === Strip YAML frontmatter, code blocks, inline code, HTML comments, file paths ===
94
+ # YAML frontmatter (first --- ... --- block) is structured metadata, not prose
95
+ STRIPPED=$(echo "$CONTENT" | awk '
96
+ BEGIN { in_fm = 0; fm_done = 0 }
97
+ NR == 1 && /^---[[:space:]]*$/ { in_fm = 1; next }
98
+ in_fm && /^---[[:space:]]*$/ { in_fm = 0; fm_done = 1; next }
99
+ in_fm { next }
100
+ { print }
101
+ ')
102
+ STRIPPED=$(echo "$STRIPPED" | awk '
103
+ BEGIN { in_code = 0 }
104
+ /^```/ { in_code = !in_code; next }
105
+ in_code { next }
106
+ { print }
107
+ ')
108
+ # Strip inline code (backtick-wrapped)
109
+ STRIPPED=$(echo "$STRIPPED" | sed -E 's/`[^`]*`//g')
110
+ # Strip HTML comments (single-line variant covers most cases)
111
+ STRIPPED=$(echo "$STRIPPED" | sed -E 's/<!--[^>]*-->//g')
112
+ # Strip file paths (contain slashes + extensions)
113
+ STRIPPED=$(echo "$STRIPPED" | sed -E 's|[a-zA-Z0-9_./-]*\.(md\|json\|sh\|yml\|py\|js\|jsonc)||g')
114
+
115
+ # === Scan for old terms ===
116
+ FOUND_HITS=""
117
+ while IFS='|' read -r OLD NEW; do
118
+ [ -z "$OLD" ] && continue
119
+ # Skip 1-2 letter renames (false positive risk)
120
+ if [ "${#OLD}" -lt 3 ]; then
121
+ continue
122
+ fi
123
+ # Context-aware check for "shape" — only flag when it labels an operator choice
124
+ # Per standards/vocabulary-migration.json L77: rename applies ONLY when 'shape'
125
+ # labels an operator pick like "shape a", "shape b", "pick shape α"
126
+ if [ "$OLD" = "shape" ]; then
127
+ if echo "$STRIPPED" | grep -qiE "\\bshape [a-fA-F1-9αβγδε]\\b|pick shape\\b|choose shape\\b|shape [ABCDEF]\\b"; then
128
+ FOUND_HITS="${FOUND_HITS}${OLD} → ${NEW} (labels an operator choice)\n"
129
+ fi
130
+ continue
131
+ fi
132
+ # Standard word-boundary match for other terms
133
+ if echo "$STRIPPED" | grep -qiE "\\b${OLD}\\b"; then
134
+ FOUND_HITS="${FOUND_HITS}${OLD} → ${NEW}\n"
135
+ fi
136
+ done <<< "$OLD_TERMS"
137
+
138
+ if [ -z "$FOUND_HITS" ]; then
139
+ exit 0
140
+ fi
141
+
142
+ # === BLOCK ===
143
+ cat >&2 <<EOF
144
+
145
+ ============================================
146
+ 🛑 VOCABULARY-MIGRATION GATE — BLOCKED 🛑
147
+ ============================================
148
+
149
+ File: $FILE_PATH
150
+ Tool: $TOOL_NAME
151
+
152
+ Old vocabulary term(s) found in edit. Rename per
153
+ standards/vocabulary-migration.json (ADR-040 D1):
154
+
155
+ $(printf "$FOUND_HITS" | sed 's/^/ - /')
156
+
157
+ Cure paths:
158
+ 1. Rewrite the sentence using the new term (preferred)
159
+ 2. If the old term is a verbatim quote from a historical artifact,
160
+ wrap in an HTML comment or code fence
161
+ 3. Override with SKIP_VOCABULARY_MIGRATION=1 (logged) — rare
162
+
163
+ Per .claude/rules/we-dont-break-adopters.md — grace window on
164
+ old vocabulary through 2026-10-31; but new writes should land in
165
+ the new vocabulary.
166
+
167
+ Per ADR-040 Decision 1 (planning-skill vocabulary rewrite).
168
+ ============================================
169
+ EOF
170
+
171
+ exit 2