@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,357 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # Artifact Ingestion Gate — fires on Write/Edit to generative artifact paths.
4
+ # Enforces .claude/rules/artifact-ingestion.md: every substantive generative
5
+ # output must include a "## Sources read" block (or a stated-from-scratch
6
+ # rationale) so agents can't substitute training priors for staged artifacts.
7
+ #
8
+ # Exit codes:
9
+ # 0 — allow (path doesn't match, or Sources-read block present, or override)
10
+ # 2 — block (matcher hit, no Sources-read, no override)
11
+ #
12
+ # Override: set SKIP_ARTIFACT_INGESTION=1 to bypass (logged to trace).
13
+ #
14
+ # Rule: .claude/rules/artifact-ingestion.md
15
+ # Issue: bassclef#116
16
+
17
+ set +e # never crash the tool call; fall through to allow on any unexpected error
18
+
19
+ # === Hook liveness heartbeat (WU-3 of bet 2026-07-31d; closes #1002) ===
20
+ # Silent-fail — a missing lib never crashes the hook.
21
+ {
22
+ _hb_sd="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ for _hb_c in "${_hb_sd}/../../lib/hook-heartbeat.sh" "${HOME:-/}/lib/hook-heartbeat.sh"; do
24
+ [ -f "$_hb_c" ] && source "$_hb_c" && heartbeat_mark "artifact-ingestion-gate" && break
25
+ done
26
+ unset _hb_sd _hb_c
27
+ } 2>/dev/null || true
28
+
29
+ INPUT=$(cat)
30
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""' 2>/dev/null)
31
+ FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
32
+
33
+ # Only fire on Write / Edit
34
+ if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
35
+ exit 0
36
+ fi
37
+
38
+ # No file path → allow (defensive)
39
+ if [ -z "$FILE_PATH" ]; then
40
+ exit 0
41
+ fi
42
+
43
+ # Matcher paths — generative artifacts requiring Sources-read block.
44
+ # Narrow by design; expand carefully. Skip paths (.claude/, memory, configs,
45
+ # release-notes, journals) are NOT matched here and pass through.
46
+ MATCH=0
47
+ case "$FILE_PATH" in
48
+ */strategy/canvases/*.md) MATCH=1 ;;
49
+ */docs/specs/*.md) MATCH=1 ;;
50
+ */docs/iteration-bets/*.md) MATCH=1 ;;
51
+ */architecture/decisions/ADR-*.md) MATCH=1 ;;
52
+ */docs/prototypes/*/index.html) MATCH=1 ;;
53
+ */docs/prototypes/*/*.html) MATCH=1 ;;
54
+ */docs/decompositions/*.md) MATCH=1 ;;
55
+ esac
56
+
57
+ if [ "$MATCH" -eq 0 ]; then
58
+ exit 0
59
+ fi
60
+
61
+ # Override hatch — log and allow
62
+ if [ "${SKIP_ARTIFACT_INGESTION:-0}" = "1" ]; then
63
+ SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
64
+ if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/trace-helper.sh" ]; then
65
+ source "$SCRIPT_DIR/trace-helper.sh" 2>/dev/null || true
66
+ trace_log "artifact-ingestion-gate" "OVERRIDE $TOOL_NAME $FILE_PATH" 2>/dev/null || true
67
+ fi
68
+ exit 0
69
+ fi
70
+
71
+ # Assemble the content that WILL exist after this operation.
72
+ # Write: tool_input.content is authoritative.
73
+ # Edit: check existing file on disk (if any) + new_string together.
74
+ FUTURE_CONTENT=""
75
+ if [ "$TOOL_NAME" = "Write" ]; then
76
+ FUTURE_CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // ""' 2>/dev/null)
77
+ elif [ "$TOOL_NAME" = "Edit" ]; then
78
+ NEW_STRING=$(echo "$INPUT" | jq -r '.tool_input.new_string // ""' 2>/dev/null)
79
+ EXISTING=""
80
+ if [ -f "$FILE_PATH" ]; then
81
+ EXISTING=$(cat "$FILE_PATH" 2>/dev/null)
82
+ fi
83
+ FUTURE_CONTENT="${EXISTING}
84
+ ${NEW_STRING}"
85
+ fi
86
+
87
+ # Check for Sources-read block (case-insensitive, tolerant of heading depth).
88
+ # Accepted forms:
89
+ # ## Sources read
90
+ # ### Sources read
91
+ # ## Sources Read
92
+ # ## SOURCES READ
93
+ # Also accepts an explicit from-scratch rationale:
94
+ # "No prior artifacts" (per rule)
95
+ HAS_SOURCES_READ=0
96
+ if echo "$FUTURE_CONTENT" | grep -qiE '^#{1,6}[[:space:]]+sources[[:space:]]+read'; then
97
+ HAS_SOURCES_READ=1
98
+ fi
99
+ if echo "$FUTURE_CONTENT" | grep -qi 'no prior artifacts'; then
100
+ HAS_SOURCES_READ=1
101
+ fi
102
+
103
+ # Block if no Sources-read block at all.
104
+ if [ "$HAS_SOURCES_READ" -eq 0 ]; then
105
+ EMIT_BLOCK=1
106
+ fi
107
+
108
+ # === Parent citation check (bassclef#334 — Layer A determinism gate) ===
109
+ #
110
+ # When the artifact's frontmatter declares parent_roadmap, parent_bet, or
111
+ # parent_canvas, the "Sources read" block MUST cite those paths/ids. This
112
+ # catches the case where an agent declares lineage in frontmatter but
113
+ # doesn't actually read the parent (a determinism failure mode caught by
114
+ # the operator 2026-04-27d when an agent missed parent_roadmap framing).
115
+ #
116
+ # Only fires when HAS_SOURCES_READ=1; if the Sources-read block is missing
117
+ # entirely, the prior check covers it.
118
+ PARENT_CITATION_BLOCK=""
119
+ if [ "$HAS_SOURCES_READ" -eq 1 ]; then
120
+ # Extract YAML frontmatter (between leading --- ... ---)
121
+ FRONTMATTER=$(echo "$FUTURE_CONTENT" | awk '/^---$/{c++; if(c==2) exit; next} c==1')
122
+
123
+ # Extract Sources-read section content (everything from "## Sources read"
124
+ # heading to the next ## heading or end-of-file)
125
+ SOURCES_SECTION=$(echo "$FUTURE_CONTENT" | awk '
126
+ /^#{1,6}[[:space:]]+[Ss][Oo][Uu][Rr][Cc][Ee][Ss][[:space:]]+[Rr][Ee][Aa][Dd]/ { in_section=1; next }
127
+ in_section && /^#{1,6}[[:space:]]/ { exit }
128
+ in_section { print }
129
+ ')
130
+
131
+ # For each parent_* field set in frontmatter, verify it (or its slug/id)
132
+ # is referenced in the Sources-read section. Bash regex via grep -F for
133
+ # literal-string match (paths contain dots).
134
+ MISSING_CITATIONS=""
135
+ for parent_key in parent_roadmap parent_bet parent_canvas parent_decomposition; do
136
+ PARENT_VALUE=$(echo "$FRONTMATTER" | awk -v key="$parent_key" '
137
+ $0 ~ "^"key":" {
138
+ sub("^"key":[[:space:]]*", "")
139
+ # Strip leading/trailing whitespace and surrounding quotes
140
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "")
141
+ gsub(/^"|"$|^'\''|'\''$/, "")
142
+ print
143
+ exit
144
+ }
145
+ ')
146
+ if [ -z "$PARENT_VALUE" ]; then
147
+ continue
148
+ fi
149
+
150
+ # Check if the parent path (or its basename without extension) appears
151
+ # in the Sources-read section. Accepts either:
152
+ # - Full path (docs/roadmaps/foo.md)
153
+ # - Slug/id (foo, no path/extension)
154
+ PARENT_BASENAME=$(basename "$PARENT_VALUE" .md)
155
+
156
+ if echo "$SOURCES_SECTION" | grep -qF "$PARENT_VALUE"; then
157
+ continue
158
+ fi
159
+ if echo "$SOURCES_SECTION" | grep -qF "$PARENT_BASENAME"; then
160
+ continue
161
+ fi
162
+
163
+ MISSING_CITATIONS="${MISSING_CITATIONS} - ${parent_key}: ${PARENT_VALUE}\n"
164
+ done
165
+
166
+ if [ -n "$MISSING_CITATIONS" ]; then
167
+ PARENT_CITATION_BLOCK="$MISSING_CITATIONS"
168
+ EMIT_BLOCK=1
169
+ fi
170
+
171
+ # === Layer A composition (bassclef#337 WU-5) ===
172
+ #
173
+ # When parent_roadmap is cited, verify the citation includes the
174
+ # current last_event.ts OR last_event.event. This proves the agent
175
+ # read CURRENT roadmap state, not a stale snapshot. Closes the
176
+ # determinism loop: parent declared, parent path cited, current
177
+ # event referenced.
178
+ #
179
+ # Skips silently if no parent_roadmap was set (covered by prior loop).
180
+ STALE_EVENT_BLOCK=""
181
+ PARENT_ROADMAP=$(echo "$FRONTMATTER" | awk '
182
+ $0 ~ /^parent_roadmap:/ {
183
+ sub(/^parent_roadmap:[[:space:]]*/, "")
184
+ gsub(/^[[:space:]]+|[[:space:]]+$/, "")
185
+ gsub(/^"|"$|^'\''|'\''$/, "")
186
+ print
187
+ exit
188
+ }
189
+ ')
190
+ if [ -n "$PARENT_ROADMAP" ] && [ -f "$PARENT_ROADMAP" ]; then
191
+ # Extract current last_event from roadmap frontmatter via Python
192
+ LAST_EVENT_TS=$(python3 -c "
193
+ import sys, yaml
194
+ try:
195
+ with open('$PARENT_ROADMAP', 'r') as f:
196
+ content = f.read()
197
+ parts = content.split('---', 2)
198
+ if len(parts) < 3:
199
+ sys.exit(0)
200
+ fm = yaml.safe_load(parts[1]) or {}
201
+ le = fm.get('last_event', {}) or {}
202
+ ts = le.get('ts', '')
203
+ print(str(ts))
204
+ except Exception:
205
+ pass
206
+ " 2>/dev/null)
207
+ LAST_EVENT_NAME=$(python3 -c "
208
+ import sys, yaml
209
+ try:
210
+ with open('$PARENT_ROADMAP', 'r') as f:
211
+ content = f.read()
212
+ parts = content.split('---', 2)
213
+ if len(parts) < 3:
214
+ sys.exit(0)
215
+ fm = yaml.safe_load(parts[1]) or {}
216
+ le = fm.get('last_event', {}) or {}
217
+ ev = le.get('event', '')
218
+ print(str(ev))
219
+ except Exception:
220
+ pass
221
+ " 2>/dev/null)
222
+
223
+ # If roadmap has a last_event, verify Sources cites ts or event name
224
+ if [ -n "$LAST_EVENT_TS" ] || [ -n "$LAST_EVENT_NAME" ]; then
225
+ EVENT_CITED=0
226
+ if [ -n "$LAST_EVENT_TS" ] && echo "$SOURCES_SECTION" | grep -qF "$LAST_EVENT_TS"; then
227
+ EVENT_CITED=1
228
+ fi
229
+ if [ -n "$LAST_EVENT_NAME" ] && echo "$SOURCES_SECTION" | grep -qF "$LAST_EVENT_NAME"; then
230
+ EVENT_CITED=1
231
+ fi
232
+ if [ "$EVENT_CITED" -eq 0 ]; then
233
+ STALE_EVENT_BLOCK="parent_roadmap=$PARENT_ROADMAP\n current last_event.ts=$LAST_EVENT_TS\n current last_event.event=$LAST_EVENT_NAME\n"
234
+ EMIT_BLOCK=1
235
+ fi
236
+ fi
237
+ fi
238
+ fi
239
+
240
+ # All checks passed → allow.
241
+ if [ "${EMIT_BLOCK:-0}" -eq 0 ]; then
242
+ exit 0
243
+ fi
244
+
245
+ # Block — emit banner to stderr (Claude Code surfaces stderr on exit 2)
246
+ if [ "$HAS_SOURCES_READ" -eq 0 ]; then
247
+ cat >&2 <<EOF
248
+
249
+ ============================================
250
+ 🛑 ARTIFACT-INGESTION GATE — BLOCKED 🛑
251
+ ============================================
252
+
253
+ File: $FILE_PATH
254
+ Tool: $TOOL_NAME
255
+
256
+ Generative artifacts require a "## Sources read" block citing the
257
+ artifacts you read before producing this output. This is per
258
+ .claude/rules/artifact-ingestion.md — no Sources-read = no write.
259
+
260
+ Required block (add near top of file):
261
+
262
+ ## Sources read
263
+
264
+ - [path]:[line range] — [what you took]
265
+ - ...
266
+
267
+ ## What I'm NOT reading (with reason)
268
+
269
+ - [path] — [deliberate exclusion reason]
270
+
271
+ If producing genuinely from scratch, state:
272
+
273
+ "No prior artifacts; producing from scratch because [reason]."
274
+
275
+ Override (logged): SKIP_ARTIFACT_INGESTION=1 <command>
276
+
277
+ Rule: .claude/rules/artifact-ingestion.md
278
+ Issue: bassclef#116
279
+ ============================================
280
+
281
+ EOF
282
+ elif [ -n "$STALE_EVENT_BLOCK" ]; then
283
+ cat >&2 <<EOF
284
+
285
+ ============================================
286
+ 🛑 STALE-EVENT GATE — BLOCKED 🛑
287
+ ============================================
288
+
289
+ File: $FILE_PATH
290
+ Tool: $TOOL_NAME
291
+
292
+ This artifact cites parent_roadmap but the "Sources read" block
293
+ doesn't reference the roadmap's CURRENT last_event. Citing the
294
+ parent path proves you read the file; citing the current event
295
+ proves you read the current state, not a stale snapshot.
296
+
297
+ Roadmap state:
298
+
299
+ $(printf "$STALE_EVENT_BLOCK")
300
+
301
+ Add the current ts OR event name to your Sources-read entry. Example:
302
+
303
+ ## Sources read
304
+
305
+ - $PARENT_ROADMAP — current last_event ($LAST_EVENT_NAME at $LAST_EVENT_TS)
306
+ - ...
307
+
308
+ Override (logged): SKIP_ARTIFACT_INGESTION=1 <command>
309
+
310
+ Rule: .claude/rules/artifact-ingestion.md
311
+ Issue: bassclef#337 WU-5 (Layer A composition)
312
+ ============================================
313
+
314
+ EOF
315
+ elif [ -n "$PARENT_CITATION_BLOCK" ]; then
316
+ cat >&2 <<EOF
317
+
318
+ ============================================
319
+ 🛑 PARENT-CITATION GATE — BLOCKED 🛑
320
+ ============================================
321
+
322
+ File: $FILE_PATH
323
+ Tool: $TOOL_NAME
324
+
325
+ This artifact declares parent lineage in frontmatter but the
326
+ "Sources read" block doesn't cite the parent path(s). Declaring a
327
+ parent without reading it is a determinism failure — agent infers
328
+ context instead of reading current state.
329
+
330
+ Missing citations (parent declared in frontmatter, not cited in Sources read):
331
+
332
+ $(printf "$PARENT_CITATION_BLOCK")
333
+
334
+ Add each parent path to the "## Sources read" block. Example:
335
+
336
+ ## Sources read
337
+
338
+ - docs/roadmaps/<YYYY-MM-DD>-<slug>.md — parent_roadmap; current task list + last_event
339
+ - ...
340
+
341
+ Override (logged): SKIP_ARTIFACT_INGESTION=1 <command>
342
+
343
+ Rule: .claude/rules/artifact-ingestion.md
344
+ Issue: bassclef#334 (Layer A determinism gate)
345
+ ============================================
346
+
347
+ EOF
348
+ fi
349
+
350
+ # Trace the block (best-effort, don't let trace failure change the block signal)
351
+ SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
352
+ if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/trace-helper.sh" ]; then
353
+ source "$SCRIPT_DIR/trace-helper.sh" 2>/dev/null || true
354
+ trace_log "artifact-ingestion-gate" "BLOCK $TOOL_NAME $FILE_PATH" 2>/dev/null || true
355
+ fi
356
+
357
+ exit 2
@@ -0,0 +1,77 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # assert-verify-steering.sh — UserPromptSubmit hook that injects
5
+ # additionalContext steering the agent toward Toulmin's argument model
6
+ # (claim + data + warrant) before any operator-facing assertion.
7
+ #
8
+ # Per bassclef-upstream#582 (parent ticket) + bassclef-upstream#559 (R8
9
+ # mechanization umbrella) + bet 2026-07-01b WU-1.
10
+ #
11
+ # Adds argument-shape steering to the reasoning-boundary defense layer,
12
+ # alongside .claude/hooks/plain-english-steering.sh (bassclef#443).
13
+ #
14
+ # The rule this hook pairs with: .claude/rules/assert-only-after-verify.md
15
+ # The three luminaries anchoring the shape:
16
+ # - Stephen Toulmin (argument model — claim/data/warrant/qualifier/rebuttal)
17
+ # - Karl Popper (falsifiability — rebuttal cell)
18
+ # - Charles Sanders Peirce (abductive reasoning — alternative-consideration)
19
+ #
20
+ # Sibling pattern: .claude/hooks/plain-english-steering.sh
21
+ # Envelope helper: lib/hook-inject.sh emit_additional_context
22
+ #
23
+ # Override per-call: SKIP_ASSERT_VERIFY_STEERING=1
24
+ #
25
+ # Tier 0 strict TDD: .claude/hooks/tests/assert-verify-steering.test.sh
26
+
27
+ set +e
28
+
29
+ # === Source install-class-aware lib (per standards/hook-install-class.md) ===
30
+ # Hook is `dual` — runs under both project install (<repo>/.claude/hooks/)
31
+ # and operator install ($HOME/.claude/hooks/). Uses layered resolution
32
+ # per lib/hook-inject.sh convention.
33
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ for _candidate in \
35
+ "${SCRIPT_DIR}/../../lib/hook-inject.sh" \
36
+ "${HOME_OVERRIDE:-${HOME:-/}}/lib/hook-inject.sh"; do
37
+ if [ -f "$_candidate" ]; then
38
+ # shellcheck disable=SC1090
39
+ source "$_candidate"
40
+ break
41
+ fi
42
+ done
43
+
44
+ # Defensive: if lib not loaded, exit silent (fail-soft per Nygard)
45
+ if ! declare -f emit_additional_context >/dev/null 2>&1; then
46
+ exit 0
47
+ fi
48
+
49
+ # Override per-call
50
+ if [ "${SKIP_ASSERT_VERIFY_STEERING:-0}" = "1" ]; then
51
+ exit 0
52
+ fi
53
+
54
+ # === Steering content ===
55
+ STEERING_CONTENT="**Plain check:** Before I make a claim in this turn, I read the source first and cite it. If I cannot cite the source, I do not ship the claim.
56
+
57
+ **Check my claims before I write them:**
58
+
59
+ For any claim to the operator — a finding, a rating, a fix I recommend, why I think X caused Y, how many turns something will take, or a strong-word claim (wrong, broken, should, must, always) — I fill three parts:
60
+
61
+ 1. **My claim** — the assertion in one sentence.
62
+ 2. **Evidence** — the source I read, cited inline (file path, ticket number, git SHA, chronicle filename, standard section).
63
+ 3. **Why the evidence supports the claim** — one sentence tying the source to the claim.
64
+
65
+ Add more when it helps:
66
+
67
+ 4. **Confidence plus how many observations back it** — from Toulmin, *The Uses of Argument* (1958).
68
+ 5. **What would falsify the claim** — from Popper, *The Logic of Scientific Discovery* (1959). A claim that cannot be falsified is a guess.
69
+ 6. **Two or three alternative explanations that fit the same data** — from Peirce, *Deduction, Induction, and Hypothesis* (1878). Name each option before you pick one.
70
+
71
+ If I cannot fill all three, I do not ship the claim. I read the source first.
72
+
73
+ Per bassclef#559 and .claude/rules/assert-only-after-verify.md."
74
+
75
+ # === Emit envelope ===
76
+ emit_additional_context "UserPromptSubmit" "$STEERING_CONTENT"
77
+ exit 0
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # bassclef-source-config-validate.sh — PreToolUse hook validating
4
+ # .bassclef-source.json writes against the bassclef schema.
5
+ #
6
+ # Wires per .claude/settings.json under hooks.PreToolUse with matcher
7
+ # "Edit|Write|MultiEdit". Reads the tool's event JSON from stdin, detects
8
+ # whether the target file is a `.bassclef-source.json`, validates against
9
+ # the schema, and BLOCKs (exit non-zero) on failure.
10
+ #
11
+ # Per ADR-015: JSON Schema draft-2020-12 with format support via ajv-formats.
12
+ # Pairs with: standards/bassclef-source-config.md
13
+ #
14
+ # Override: SKIP_BASSCLEF_SOURCE_VALIDATE=1 (logged to stderr).
15
+ #
16
+ # F77 fix (bassclef#743): probe ajv-cli before validation; distinguish
17
+ # offline / install failure from schema validation failure so the operator
18
+ # sees the real cause rather than a fake "validation failed" exit.
19
+ #
20
+ # F78 fix (bassclef#743): Edit/MultiEdit are PreToolUse — the file on disk
21
+ # is still pre-edit content. Synthesize post-edit content by replaying the
22
+ # event's edit operations against the on-disk content, then validate that.
23
+ # Write is unchanged — tool_input.content is the post-content.
24
+
25
+ set -u
26
+
27
+ # === Override path ===
28
+
29
+ if [ "${SKIP_BASSCLEF_SOURCE_VALIDATE:-}" = "1" ]; then
30
+ echo "[bassclef-source-config-validate.sh] SKIP_BASSCLEF_SOURCE_VALIDATE=1 — bypassing validation" >&2
31
+ exit 0
32
+ fi
33
+
34
+ # === Read PreToolUse event ===
35
+
36
+ EVENT_JSON="$(cat)"
37
+ TOOL_NAME="$(printf '%s' "$EVENT_JSON" | jq -r '.tool_name // empty')"
38
+ TARGET_PATH="$(printf '%s' "$EVENT_JSON" | jq -r '.tool_input.file_path // empty')"
39
+
40
+ # Tool filter: only Edit/Write/MultiEdit trigger validation
41
+ case "$TOOL_NAME" in
42
+ Edit|Write|MultiEdit) ;;
43
+ *) exit 0 ;;
44
+ esac
45
+
46
+ # Empty path → pass-through
47
+ if [ -z "$TARGET_PATH" ]; then
48
+ exit 0
49
+ fi
50
+
51
+ # Path filter: only .bassclef-source.json files trigger validation
52
+ case "$TARGET_PATH" in
53
+ */.bassclef-source.json|.bassclef-source.json) ;;
54
+ *) exit 0 ;;
55
+ esac
56
+
57
+ # === Resolve project dir + schema path ===
58
+
59
+ PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$(pwd)}"
60
+ SCHEMA="${PROJECT_DIR}/standards/bassclef-source/schemas/bassclef-source.schema.json"
61
+
62
+ # If schema not present locally (consumer repo without bassclef-side
63
+ # files), pass-through — the hook is a defensive gate for bassclef-side
64
+ # edits, not for consumer-side edits where the schema isn't available.
65
+ if [ ! -f "$SCHEMA" ]; then
66
+ exit 0
67
+ fi
68
+
69
+ # === Temp-file cleanup trap ===
70
+
71
+ TMP_JSON=""
72
+ cleanup() {
73
+ [ -n "$TMP_JSON" ] && rm -f "$TMP_JSON" 2>/dev/null
74
+ }
75
+ trap cleanup EXIT
76
+
77
+ # === F77 — Probe ajv-cli availability before validating ===
78
+ #
79
+ # Distinguish offline / install-failure from schema-validation failure.
80
+ # Without this probe, the error filter at the end strips npm/npx warnings
81
+ # and the operator sees what looks like a schema-validation failure even
82
+ # when the real cause is network or install. Pass-through on probe failure
83
+ # with a loud stderr explanation; do NOT block on infrastructure.
84
+
85
+ # Probe uses `ajv help` because `ajv --version` exits non-zero in
86
+ # ajv-cli@5's CLI parser (unrecognized option). help is the cheapest
87
+ # parse-and-print that exits 0 when the binary is installed.
88
+ AJV_PROBE_OUTPUT="$(npx -y -p ajv-cli@5 ajv help 2>&1)"
89
+ AJV_PROBE_EXIT=$?
90
+ if [ "$AJV_PROBE_EXIT" -ne 0 ]; then
91
+ echo >&2
92
+ echo "[bassclef-source-config-validate.sh] ajv-cli unavailable — validation skipped" >&2
93
+ echo "[bassclef-source-config-validate.sh] Likely cause: offline, npm registry unreachable, or first-run install failed." >&2
94
+ echo "[bassclef-source-config-validate.sh] Target: $TARGET_PATH" >&2
95
+ echo "[bassclef-source-config-validate.sh] Probe output (last 5 lines):" >&2
96
+ echo "$AJV_PROBE_OUTPUT" | tail -5 | sed 's/^/ /' >&2
97
+ echo "[bassclef-source-config-validate.sh] To pre-cache: run \`npx -y -p ajv-cli@5 -p ajv-formats@2 ajv --version\` while online." >&2
98
+ echo "[bassclef-source-config-validate.sh] To bypass deliberately: \`SKIP_BASSCLEF_SOURCE_VALIDATE=1 <command>\`" >&2
99
+ echo >&2
100
+ exit 0 # Pass-through; don't block on infrastructure
101
+ fi
102
+
103
+ # === F78 — Synthesize post-edit content per tool ===
104
+ #
105
+ # Write: tool_input.content IS the post-content (current shape OK).
106
+ # Edit: apply tool_input.{old_string, new_string, replace_all} to on-disk
107
+ # content; validate the result.
108
+ # MultiEdit: apply each entry of tool_input.edits[] in order; validate the
109
+ # final result.
110
+
111
+ case "$TOOL_NAME" in
112
+ Write)
113
+ # ajv-cli detects format from file extension; macOS mktemp -t
114
+ # appends random suffix AFTER the template, so .json must be
115
+ # explicitly added via mv to ensure it's the trailing extension.
116
+ TMP_JSON_RAW="$(mktemp -t bassclef-source-validate-XXXXXX)"
117
+ TMP_JSON="${TMP_JSON_RAW}.json"
118
+ mv "$TMP_JSON_RAW" "$TMP_JSON"
119
+ printf '%s' "$EVENT_JSON" | jq -r '.tool_input.content // empty' > "$TMP_JSON"
120
+ VALIDATE_TARGET="$TMP_JSON"
121
+ ;;
122
+ Edit|MultiEdit)
123
+ # Edit on non-existent file — the Edit tool will fail with its own
124
+ # error; pass-through quietly.
125
+ if [ ! -f "$TARGET_PATH" ]; then
126
+ exit 0
127
+ fi
128
+ # ajv-cli detects format from file extension; macOS mktemp -t
129
+ # appends random suffix AFTER the template, so .json must be
130
+ # explicitly added via mv to ensure it's the trailing extension.
131
+ TMP_JSON_RAW="$(mktemp -t bassclef-source-validate-XXXXXX)"
132
+ TMP_JSON="${TMP_JSON_RAW}.json"
133
+ mv "$TMP_JSON_RAW" "$TMP_JSON"
134
+
135
+ # Replay edits via python3. Pipes EVENT_JSON to python's stdin;
136
+ # passes target path + tool name as argv. python writes post-edit
137
+ # content to stdout, which bash captures into TMP_JSON.
138
+ #
139
+ # Exit code from python:
140
+ # 0 — replay succeeded; TMP_JSON has post-edit content
141
+ # 2 — old_string not in content; the Edit tool will fail with the
142
+ # same error so pass-through quietly
143
+ # other — unexpected; pass-through (don't block on hook bugs)
144
+ REPLAY_EXIT=0
145
+ printf '%s' "$EVENT_JSON" | python3 -c '
146
+ import json, sys
147
+ event = json.load(sys.stdin)
148
+ target = sys.argv[1]
149
+ tool_name = sys.argv[2]
150
+ with open(target) as f:
151
+ content = f.read()
152
+ if tool_name == "Edit":
153
+ edits = [event["tool_input"]]
154
+ else: # MultiEdit
155
+ edits = event["tool_input"].get("edits", [])
156
+ for e in edits:
157
+ old = e.get("old_string", "")
158
+ new = e.get("new_string", "")
159
+ if old not in content:
160
+ sys.exit(2)
161
+ replace_all = e.get("replace_all", False)
162
+ content = content.replace(old, new) if replace_all else content.replace(old, new, 1)
163
+ sys.stdout.write(content)
164
+ ' "$TARGET_PATH" "$TOOL_NAME" > "$TMP_JSON" 2>/dev/null || REPLAY_EXIT=$?
165
+
166
+ if [ "$REPLAY_EXIT" -ne 0 ]; then
167
+ # Replay failed — the underlying tool will fail too. Pass-through.
168
+ exit 0
169
+ fi
170
+ VALIDATE_TARGET="$TMP_JSON"
171
+ ;;
172
+ esac
173
+
174
+ # === Validate via ajv-cli ===
175
+
176
+ VALIDATION_OUTPUT="$(npx -y -p ajv-cli@5 -p ajv-formats@2 ajv validate \
177
+ -s "$SCHEMA" \
178
+ -d "$VALIDATE_TARGET" \
179
+ --spec=draft2020 \
180
+ -c ajv-formats 2>&1)"
181
+ EXIT_CODE=$?
182
+
183
+ if [ "$EXIT_CODE" -eq 0 ]; then
184
+ exit 0
185
+ fi
186
+
187
+ # === BLOCK on validation failure ===
188
+
189
+ # Strip noisy npm warnings; surface only ajv's actual error lines.
190
+ # (Network failures already short-circuited above via the F77 probe.)
191
+ ERR=$(echo "$VALIDATION_OUTPUT" | grep -vE '^npm |^npx |^$' | head -10)
192
+
193
+ cat >&2 <<EOF
194
+
195
+ 🛑 BLOCKED — .bassclef-source.json validation failed
196
+
197
+ Path: $TARGET_PATH
198
+ Schema: standards/bassclef-source/schemas/bassclef-source.schema.json
199
+ Standard: standards/bassclef-source-config.md
200
+
201
+ ajv-cli error:
202
+ $ERR
203
+
204
+ Resolution paths:
205
+ 1. Fix the data to match the schema (preferred)
206
+ — see standards/bassclef-source-config.md § Schema for field reference
207
+ — see scripts/tests/fixtures/bassclef-source/*.json for working examples
208
+ 2. If the schema is genuinely wrong, open a PR amending it (rare)
209
+ 3. Override with SKIP_BASSCLEF_SOURCE_VALIDATE=1 (logged; rare; not for normal work)
210
+
211
+ Per .claude/rules/blocked-items.md: silence is not deferral.
212
+
213
+ EOF
214
+
215
+ exit 3