@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,303 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ set -e
4
+
5
+ # Session Start hook coordinator. Injects reflection prompt and iterates
6
+ # session-reflection.d/*.sh modules in numbered order. Modules inherit
7
+ # the blocked_banner helper and $BASSCLEF_DIR defined here.
8
+
9
+ # === Hook liveness heartbeat (WU-3 of bet 2026-07-31d; closes #1002) ===
10
+ # Silent-fail — a missing lib never crashes the hook.
11
+ {
12
+ _hb_sd="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
+ for _hb_c in "${_hb_sd}/../../lib/hook-heartbeat.sh" "${HOME:-/}/lib/hook-heartbeat.sh"; do
14
+ [ -f "$_hb_c" ] && source "$_hb_c" && heartbeat_mark "session-reflection-coordinator" && break
15
+ done
16
+ unset _hb_sd _hb_c
17
+ } 2>/dev/null || true
18
+
19
+ INPUT=$(cat)
20
+ # Fail-soft on malformed JSON — same pattern as bassclef#1101 + #1119 + #1123.
21
+ # Prevents crash when harness sends malformed stdin at SessionStart.
22
+ # Audit ticket: bassclef#1125 (jq+set -e pattern across all hooks).
23
+ SOURCE=$(echo "$INPUT" | jq -r '.source // "unknown"' 2>/dev/null || echo "unknown")
24
+ CWD=$(echo "$INPUT" | jq -r '.cwd // "."' 2>/dev/null || echo ".")
25
+
26
+ cd "$CWD"
27
+
28
+ CWD_HASH=$(echo -n "$CWD" | md5 -q 2>/dev/null || echo -n "$CWD" | md5sum 2>/dev/null | cut -c1-8 || echo "default")
29
+ SESSION_TIMING_FILE="/tmp/claude-session-timing-${CWD_HASH}"
30
+ TIMING_NOW=$(date +"%Y-%m-%dT%H:%M:%S%z")
31
+ echo "$TIMING_NOW" > "$SESSION_TIMING_FILE"
32
+
33
+ # Durable dual-write (mobile-ephemeral-session.md §2).
34
+ DURABLE_TIMING_DIR="$CWD/state/markers/session-timing"
35
+ mkdir -p "$DURABLE_TIMING_DIR" 2>/dev/null
36
+ echo "$TIMING_NOW" > "$DURABLE_TIMING_DIR/${CWD_HASH}.timing" 2>/dev/null
37
+
38
+ source "$(dirname "$0")/trace-helper.sh"
39
+ trace_log "session-start" "$SOURCE"
40
+
41
+ # Resolve bassclef dir through symlinks — used by modules to find scripts.
42
+ HOOK_REAL_PATH="$(readlink -f "$0" 2>/dev/null || readlink "$0" 2>/dev/null || echo "$0")"
43
+ BASSCLEF_DIR="$(cd "$(dirname "$HOOK_REAL_PATH")/../.." && pwd)"
44
+
45
+ # High-contrast BLOCKED banner; used by every module that raises a gap.
46
+ blocked_banner() {
47
+ local msg="$1"
48
+ echo ""
49
+ echo "🛑🛑🛑 BLOCKED 🛑🛑🛑"
50
+ echo "────────────────────────────────────────────"
51
+ echo "$msg"
52
+ echo "────────────────────────────────────────────"
53
+ echo "ACTION: resolve OR explicitly defer (per .claude/rules/blocked-items.md)."
54
+ echo "Silence is not deferral. Propose this as item #1 in your session plan."
55
+ echo ""
56
+ }
57
+
58
+ echo "============================================"
59
+ echo "SESSION START — MANDATORY REFLECT → PLAN"
60
+ echo "============================================"
61
+ echo ""
62
+ echo "You MUST run the Session Start protocol before doing ANY other work."
63
+ echo "Do NOT skip this. Do NOT jump to answering the user's question first."
64
+ echo ""
65
+
66
+ # === Last save state ===
67
+ LAST_SAVE_FILE=".claude/LAST_SAVE"
68
+ if [ -f "$LAST_SAVE_FILE" ]; then
69
+ SAVE_TIMESTAMP=$(grep '^timestamp:' "$LAST_SAVE_FILE" | cut -d' ' -f2-)
70
+ SAVE_MACHINE=$(grep '^machine:' "$LAST_SAVE_FILE" | cut -d' ' -f2-)
71
+ SAVE_BRANCH=$(grep '^branch:' "$LAST_SAVE_FILE" | cut -d' ' -f2-)
72
+ SAVE_COMMIT=$(grep '^last_commit:' "$LAST_SAVE_FILE" | cut -d' ' -f2-)
73
+ echo "### LAST SAVE STATE"
74
+ echo ""
75
+ echo "Saved: $SAVE_TIMESTAMP"
76
+ echo "Machine: $SAVE_MACHINE"
77
+ echo "Branch: $SAVE_BRANCH"
78
+ echo "Last commit: $SAVE_COMMIT"
79
+ echo ""
80
+ else
81
+ echo "### NO PREVIOUS SAVE STATE FOUND"
82
+ echo ""
83
+ echo "This may be the first session, or save-state has never run."
84
+ echo "Proceed with caution — check git log for context."
85
+ echo ""
86
+ fi
87
+
88
+ # === Session lock check (BEFORE writing — idempotency rule) ===
89
+ LOCK_FILE=".claude/SESSION_LOCK"
90
+ if [ -f "$LOCK_FILE" ]; then
91
+ # Platform-portable mtime: GNU `stat -c %Y` first, BSD `stat -f %m` fallback.
92
+ # Order matters — GNU `stat -f` exits 0 with WRONG output (mount-point info
93
+ # not mtime), so the `||` chain only triggers on BSD when -c fails. Caught
94
+ # by WU-8 test of /longrun-c on Linux CI (7th TDD-find of the day; first
95
+ # attempt at the fix had the order reversed and still failed).
96
+ LOCK_MTIME=$(stat -c %Y "$LOCK_FILE" 2>/dev/null || stat -f %m "$LOCK_FILE" 2>/dev/null || echo "0")
97
+ LOCK_AGE=$(( $(date +%s) - LOCK_MTIME ))
98
+ if [ "$LOCK_AGE" -lt 7200 ]; then
99
+ echo "### ANOTHER SESSION MAY BE ACTIVE"
100
+ echo ""
101
+ echo "SESSION_LOCK exists and is $(( LOCK_AGE / 60 )) minutes old."
102
+ echo "Another agent or session may be working in this repo."
103
+ echo ""
104
+ echo "→ Ask the user before proceeding. Do NOT overwrite another session's work."
105
+ echo ""
106
+ else
107
+ echo "### STALE SESSION LOCK ($(( LOCK_AGE / 3600 )) hours old)"
108
+ echo ""
109
+ echo "Previous session likely crashed. Proceeding with caution."
110
+ echo ""
111
+ fi
112
+ fi
113
+
114
+ # === Write session lock (AFTER checking — per standards/hook-idempotency.md) ===
115
+ MACHINE=$(hostname -s 2>/dev/null || echo "unknown")
116
+ echo "machine: $MACHINE" > "$LOCK_FILE"
117
+ echo "timestamp: $(date +%Y-%m-%dT%H:%M:%S%z)" >> "$LOCK_FILE"
118
+ echo "pid: $$" >> "$LOCK_FILE"
119
+
120
+ # === Fetch origin before staleness checks (bassclef-upstream#403) ===
121
+ # Several session-reflection.d/ fragments read git log / file listings
122
+ # to compute staleness banners (chronicle, danger-mode summary,
123
+ # architect-review, release-notes, recent-strategic-artifacts,
124
+ # workflow-staleness, metrics-staleness). Without fetching origin
125
+ # first, those reads see this machine's last-pull state — not the
126
+ # shared truth — and may show wrong-now banners when another session
127
+ # has pushed since (e.g., 2026-06-26c saw stale local while origin
128
+ # had today's session-end commit from #401).
129
+ #
130
+ # Best-effort: || true keeps the dispatcher working when offline / no
131
+ # remote / detached HEAD. Each fragment continues to use local git
132
+ # state; we just ensure local has had a chance to sync first.
133
+ CURRENT_BRANCH_FOR_FETCH=$(git symbolic-ref --short HEAD 2>/dev/null)
134
+ if [ -n "$CURRENT_BRANCH_FOR_FETCH" ]; then
135
+ git fetch origin "$CURRENT_BRANCH_FOR_FETCH" --quiet 2>/dev/null || true
136
+ fi
137
+
138
+ # === Iterate modules in numbered order ===
139
+ # Use HOOK_REAL_PATH (line 15, symlinks resolved) so fragments load when
140
+ # the dispatcher is invoked via a symlink from an adopter checkout.
141
+ # Per bassclef-upstream#591 — link-relative path silently skipped all
142
+ # fragments on adopter machines, breaking every session-start gate.
143
+ MODULES_DIR="$(dirname "$HOOK_REAL_PATH")/session-reflection.d"
144
+ if [ -d "$MODULES_DIR" ]; then
145
+ # Modules use the pattern `return 0 2>/dev/null || exit 0` (safe when
146
+ # sourced OR run standalone). A module returning 2 signals a fatal
147
+ # condition the coordinator MUST honor (e.g., invalid substrate config
148
+ # per 15-substrate-config-validate.sh L86). Per Feathers R3.2 semantic
149
+ # preservation — cure b (2026-07-30) restored return semantics while
150
+ # keeping the abort contract via return code 2 + this catch.
151
+ #
152
+ # Disable `set -e` briefly so the coordinator sees each module's
153
+ # return code cleanly. Without this, `return 1` from a sourced module
154
+ # would abort the coordinator via set -e regardless of code.
155
+ set +e
156
+ for module in "$MODULES_DIR"/*.sh; do
157
+ [ -f "$module" ] || continue
158
+ source "$module"
159
+ MODULE_RC=$?
160
+ # Modules may set their own shell options (e.g., 90-release-backlog
161
+ # uses `set -euo pipefail`). Those persist to the coordinator via
162
+ # sourcing. Reset to a permissive default after each module so one
163
+ # module's discipline does not abort the coordinator's remaining
164
+ # body. Cure b (2026-07-30) — Nygard bulkhead + Feathers R3.5.
165
+ set +eu
166
+ if [ "$MODULE_RC" -eq 2 ]; then
167
+ # Module signaled fatal abort. Preserve pre-cure behavior — exit
168
+ # with non-zero code the harness treats as coordinator failure.
169
+ echo "" >&2
170
+ echo "!! session-reflection: aborting after $(basename "$module") returned 2" >&2
171
+ exit 1
172
+ fi
173
+ done
174
+ # Deliberately DO NOT re-enable set -e. Cure b (2026-07-30) discovered
175
+ # the coordinator's ACTIVE CONTEXT block at L162+ uses grep patterns
176
+ # that return 1 when whereami has no in_flight_bet field. Under set -e
177
+ # those aborted the coordinator BEFORE reaching Step 1: Read memory
178
+ # (L200+). Pre-cure the abort was masked because 05-active-bet.sh
179
+ # exited earlier. Post-cure the abort surfaces. Fix: coordinator runs
180
+ # without set -e for the rest of its body — matches modules' tolerance
181
+ # for non-zero returns from optional grep/find calls.
182
+ fi
183
+
184
+ # === Context loading — proactively surface active work ===
185
+ echo "### ACTIVE CONTEXT (read these before planning)"
186
+ echo ""
187
+
188
+ if [ -f "docs/whereami.md" ]; then
189
+ ACTIVE_BET=$(grep 'iteration_bet:' docs/whereami.md 2>/dev/null | head -1 | sed 's/iteration_bet: *//')
190
+ if [ -n "$ACTIVE_BET" ] && [ -f "$ACTIVE_BET" ]; then
191
+ echo "**Active iteration goal**: $ACTIVE_BET"
192
+ head -20 "$ACTIVE_BET" 2>/dev/null
193
+ echo ""
194
+ fi
195
+ ACTIVE_PHASE=$(grep 'iteration_phase:' docs/whereami.md 2>/dev/null | head -1 | sed 's/iteration_phase: *//')
196
+ echo "**Phase**: ${ACTIVE_PHASE:-unknown}"
197
+ echo ""
198
+ fi
199
+
200
+ for CDIR in "chronicle" "docs/chronicle"; do
201
+ if [ -d "$CDIR" ]; then
202
+ LATEST_CHRONICLE=$(ls -1 "$CDIR"/*.md 2>/dev/null | sort | tail -1)
203
+ if [ -n "$LATEST_CHRONICLE" ]; then
204
+ echo "**Last chronicle**: $(basename "$LATEST_CHRONICLE")"
205
+ OPEN_THREADS=$(sed -n '/## Open threads/,/## /p' "$LATEST_CHRONICLE" 2>/dev/null | head -15)
206
+ if [ -n "$OPEN_THREADS" ]; then
207
+ echo "$OPEN_THREADS"
208
+ fi
209
+ echo ""
210
+ fi
211
+ break
212
+ fi
213
+ done
214
+
215
+ if [ -f "substrate.config.md" ]; then
216
+ LEVEL=$(grep '^level:' substrate.config.md 2>/dev/null | head -1 | sed 's/level: *//')
217
+ echo "**Process level**: ${LEVEL:-core}"
218
+ echo ""
219
+ fi
220
+
221
+ echo ""
222
+
223
+ # === Standard reflection steps ===
224
+ echo "### Step 1: Read memory"
225
+ echo "Read project memory from .claude/ directory"
226
+ echo ""
227
+ echo "### Step 2: Recent commits"
228
+ git log --oneline -5 2>/dev/null || echo "(no git history)"
229
+ echo ""
230
+ echo "### Step 3: Open issues"
231
+ REPO_NAME=$(gh repo view --json nameWithOwner -q '.nameWithOwner' 2>/dev/null || echo "")
232
+ if [ -n "$REPO_NAME" ]; then
233
+ gh issue list -R "$REPO_NAME" --limit 10 2>/dev/null || echo "(issues unavailable)"
234
+ else
235
+ echo "(gh repo not detected — check open issues manually)"
236
+ fi
237
+ echo ""
238
+ echo "### Step 4: Summarize + propose 1-3 items, then WAIT for human confirmation."
239
+ echo ""
240
+ echo "### MANDATORY RESPONSE FORMAT (user-visible)"
241
+ echo ""
242
+ echo "Claude Code hook stdout lands in MODEL context only — the operator"
243
+ echo "never sees the 🛑 banners above. The only user-visible surface is"
244
+ echo "what YOU write in your first message. So:"
245
+ echo ""
246
+ echo "Your FIRST user-facing message this session MUST begin with a"
247
+ echo "verbatim echo of every 🛑 BLOCKED block surfaced above — same"
248
+ echo "emoji, same horizontal rule, same message text — inside a fenced"
249
+ echo "code block so the operator sees exactly what the hook emitted,"
250
+ echo "not your paraphrase."
251
+ echo ""
252
+ echo "THEN, immediately below the echoed blocks, add your disposition:"
253
+ echo ""
254
+ echo " BLOCKED ITEMS — my disposition:"
255
+ echo " - [name]: RESOLVE NOW / RESOLVE THIS SESSION / DEFER (reason)"
256
+ echo ""
257
+ echo "If there are zero blocked items, state: 'No blocked items surfaced"
258
+ echo "by session-start hook.'"
259
+ echo ""
260
+ echo "Only AFTER the verbatim echo + disposition may you address the"
261
+ echo "user's question. This overrides the pull of any specific user"
262
+ echo "question — the user's prior instructions live in these rules."
263
+ echo ""
264
+
265
+ # === Manifest-aware session planning ===
266
+ if [ -f "substrate.config.md" ]; then
267
+ LEVEL=$(grep '^level:' substrate.config.md 2>/dev/null | head -1 | sed 's/level: *//')
268
+ CEILING=$(grep 'iteration_ceiling:' substrate.config.md 2>/dev/null | head -1 | sed 's/.*iteration_ceiling: *//')
269
+
270
+ ACTIVE_BET=""
271
+ if [ -f "docs/whereami.md" ]; then
272
+ ACTIVE_BET=$(grep 'iteration_bet:' docs/whereami.md 2>/dev/null | head -1 | sed 's/iteration_bet: *//')
273
+ fi
274
+
275
+ if [ -n "$ACTIVE_BET" ] && [ -f "$ACTIVE_BET" ]; then
276
+ echo "### Active iteration — continue or start new?"
277
+ echo ""
278
+ echo "An iteration goal exists: $ACTIVE_BET"
279
+ echo "Check if it's complete or still has open scope."
280
+ echo "If complete → propose a new iteration from open issues."
281
+ echo "If in progress → resume where it left off."
282
+ else
283
+ echo "### Propose iteration (manifest-aware)"
284
+ echo ""
285
+ echo "No active iteration goal. Based on open issues above,"
286
+ echo "propose a light iteration:"
287
+ echo ""
288
+ echo '```'
289
+ echo "## This session (light iteration)"
290
+ echo "Retiring: [highest-risk open issue]"
291
+ echo "Proving: [most valuable open issue]"
292
+ echo "Scope: [2-4 issues that fit together]"
293
+ echo "Cost ceiling: ${CEILING:-\$0}"
294
+ echo '```'
295
+ echo ""
296
+ echo "Read the open issues and propose this. User confirms or adjusts."
297
+ fi
298
+ echo ""
299
+ fi
300
+
301
+ echo "============================================"
302
+
303
+ exit 0
@@ -0,0 +1,219 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # skill-body-grade-gate.sh — PreToolUse Edit|Write gate on
5
+ # .claude/skills/*/SKILL.md. Measures per-sentence Flesch-Kincaid grade.
6
+ # Honors FKGL-EXEMPT annotations. BLOCKs when the 80%-at-grade-8-9 rule
7
+ # fails per ADR-040 Decision 4.
8
+ #
9
+ # Sister to turn-prose-grade-measure.sh (Stop event; turn prose;
10
+ # measurement only, no gate). This hook fires on file writes and blocks.
11
+ #
12
+ # Input (stdin): JSON with tool_name + tool_input.file_path + content/new_string
13
+ # Exit: 0 pass, 2 block
14
+ #
15
+ # Override per-call: SKIP_SKILL_BODY_GRADE=1
16
+ #
17
+ # Per @luminary kent-beck (Tier 0 strict TDD)
18
+ # + @luminary donald-norman (plain-English target per ADR-040 D4)
19
+ # + @luminary michael-feathers (characterization tests pin real inputs)
20
+
21
+ set +e
22
+
23
+ # === Override path ===
24
+ if [ "${SKIP_SKILL_BODY_GRADE:-0}" = "1" ]; then
25
+ exit 0
26
+ fi
27
+
28
+ # === Parse stdin JSON ===
29
+ STDIN_JSON=$(cat 2>/dev/null || echo '{}')
30
+
31
+ # Bail on unparseable JSON
32
+ if ! echo "$STDIN_JSON" | jq -e . >/dev/null 2>&1; then
33
+ exit 0
34
+ fi
35
+
36
+ TOOL_NAME=$(echo "$STDIN_JSON" | jq -r '.tool_name // ""' 2>/dev/null)
37
+ FILE_PATH=$(echo "$STDIN_JSON" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
38
+
39
+ # Only fire on Write or Edit
40
+ if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
41
+ exit 0
42
+ fi
43
+
44
+ # Only fire on .claude/skills/*/SKILL.md paths
45
+ case "$FILE_PATH" in
46
+ *".claude/skills/"*"/SKILL.md")
47
+ ;;
48
+ *)
49
+ exit 0
50
+ ;;
51
+ esac
52
+
53
+ # === Extract text to grade ===
54
+ if [ "$TOOL_NAME" = "Write" ]; then
55
+ CONTENT=$(echo "$STDIN_JSON" | jq -r '.tool_input.content // ""' 2>/dev/null)
56
+ else
57
+ # Edit: grade the new_string (text being added)
58
+ CONTENT=$(echo "$STDIN_JSON" | jq -r '.tool_input.new_string // ""' 2>/dev/null)
59
+ fi
60
+
61
+ [ -z "$CONTENT" ] && exit 0
62
+
63
+ # === Run the gate via python ===
64
+ # Python does the scrubbing + per-sentence FKGL + threshold check.
65
+ # Same textstat-preferred / fallback pattern as turn-prose-grade-measure.sh.
66
+ GATE_OUTPUT=$(printf '%s' "$CONTENT" | python3 -c '
67
+ import sys, re
68
+
69
+ text = sys.stdin.read()
70
+
71
+ # === Strip YAML frontmatter ===
72
+ # Matches leading "---\n...\n---\n" block
73
+ text = re.sub(r"^---\n.*?\n---\n", "", text, count=1, flags=re.DOTALL)
74
+
75
+ # === Strip FKGL-EXEMPT blocks ===
76
+ # Matches <!-- FKGL-EXEMPT: any-text --> ... <!-- /FKGL-EXEMPT -->
77
+ text = re.sub(r"<!--\s*FKGL-EXEMPT:.*?-->.*?<!--\s*/FKGL-EXEMPT\s*-->",
78
+ "", text, flags=re.DOTALL)
79
+
80
+ # === Strip code blocks ===
81
+ text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
82
+
83
+ # === Strip inline backticks ===
84
+ text = re.sub(r"`[^`]*`", "", text)
85
+
86
+ # === Strip URLs ===
87
+ text = re.sub(r"https?://[^\s)]+", "", text)
88
+
89
+ # === Strip markdown table pipes and headers ===
90
+ text = re.sub(r"^\s*\|.*\|\s*$", "", text, flags=re.MULTILINE)
91
+ text = re.sub(r"^\s*#+\s+.*$", "", text, flags=re.MULTILINE)
92
+
93
+ # === Strip HTML comments ===
94
+ text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
95
+
96
+ # === Split into sentences ===
97
+ # Also split on bullet markers + numbered lists so a bullet block is not
98
+ # treated as one long "sentence" (which inflates FKGL unfairly).
99
+ sentences = [s.strip() for s in re.split(r"[.!?\n]+\s*(?:[-*]\s|\d+\.\s)?", text) if s.strip()]
100
+ # Second pass — plain sentence-terminator split (some markdown lines lack terminators)
101
+ sentences = [ss.strip() for s in sentences for ss in re.split(r"[.!?]+", s) if ss.strip()]
102
+
103
+ # === Total word count check ===
104
+ all_words = re.findall(r"\b[a-zA-Z][a-zA-Z\x27]*\b", text)
105
+ if len(all_words) < 20:
106
+ print("SKIP short")
107
+ sys.exit(0)
108
+
109
+ # === Preferred: textstat per-sentence FKGL ===
110
+ try:
111
+ from textstat import flesch_kincaid_grade
112
+ grade_fn = flesch_kincaid_grade
113
+ except ImportError:
114
+ # Fallback formula — same as turn-prose-grade-measure.sh
115
+ def count_syllables(word):
116
+ word = re.sub(r"[^a-z]", "", word.lower())
117
+ if not word:
118
+ return 0
119
+ vowels = "aeiouy"
120
+ count = 0
121
+ prev_was_vowel = False
122
+ for c in word:
123
+ is_vowel = c in vowels
124
+ if is_vowel and not prev_was_vowel:
125
+ count += 1
126
+ prev_was_vowel = is_vowel
127
+ if word.endswith("e") and count > 1:
128
+ count -= 1
129
+ return max(1, count)
130
+
131
+ def grade_fn(s):
132
+ words = re.findall(r"\b[a-zA-Z][a-zA-Z\x27]*\b", s)
133
+ if not words:
134
+ return 0
135
+ syllables = sum(count_syllables(w) for w in words)
136
+ return 0.39 * len(words) + 11.8 * (syllables / len(words)) - 15.59
137
+
138
+ # === Grade each sentence ===
139
+ above_ceiling = 0
140
+ graded_count = 0
141
+ failing_examples = []
142
+
143
+ for sent in sentences:
144
+ # Skip short sentences (< 5 words — likely list items, fragments)
145
+ words = re.findall(r"\b[a-zA-Z][a-zA-Z\x27]*\b", sent)
146
+ if len(words) < 5:
147
+ continue
148
+ graded_count += 1
149
+ grade = grade_fn(sent)
150
+ if grade > 10:
151
+ above_ceiling += 1
152
+ if len(failing_examples) < 3:
153
+ failing_examples.append((round(grade, 1), sent[:80]))
154
+
155
+ if graded_count == 0:
156
+ print("SKIP no-graded")
157
+ sys.exit(0)
158
+
159
+ fail_pct = (above_ceiling / graded_count) * 100
160
+
161
+ # === 80% at grade 8-9 rule: at most 20% above grade 10 ===
162
+ if fail_pct > 20:
163
+ print(f"BLOCK {above_ceiling} {graded_count} {fail_pct:.0f}")
164
+ for grade, snippet in failing_examples:
165
+ print(f" EXAMPLE {grade} {snippet}")
166
+ sys.exit(0)
167
+
168
+ print(f"PASS {above_ceiling} {graded_count} {fail_pct:.0f}")
169
+ ' 2>/dev/null)
170
+
171
+ # === Parse gate result ===
172
+ FIRST_LINE=$(echo "$GATE_OUTPUT" | head -1)
173
+ VERDICT=$(echo "$FIRST_LINE" | awk '{print $1}')
174
+
175
+ case "$VERDICT" in
176
+ PASS|SKIP)
177
+ exit 0
178
+ ;;
179
+ BLOCK)
180
+ ABOVE=$(echo "$FIRST_LINE" | awk '{print $2}')
181
+ TOTAL=$(echo "$FIRST_LINE" | awk '{print $3}')
182
+ PCT=$(echo "$FIRST_LINE" | awk '{print $4}')
183
+ ;;
184
+ *)
185
+ # Empty or unexpected — pass through safely
186
+ exit 0
187
+ ;;
188
+ esac
189
+
190
+ # === Emit BLOCK message ===
191
+ echo "" >&2
192
+ echo "🛑 SKILL-BODY-GRADE-GATE — BLOCKED" >&2
193
+ echo "" >&2
194
+ echo " File: $FILE_PATH" >&2
195
+ echo " Result: $ABOVE of $TOTAL graded sentences above grade 10 (${PCT}%)" >&2
196
+ echo " Target: 80% at grade 8-9. At most 20% above grade 10." >&2
197
+ echo " Per: ADR-040 Decision 4 (grade 8-9 for 80% of prose)" >&2
198
+ echo "" >&2
199
+ echo " Failing sentence samples (grade | first 80 chars):" >&2
200
+ echo "$GATE_OUTPUT" | grep '^ EXAMPLE' | while read -r _ grade snippet; do
201
+ echo " [$grade] $snippet" >&2
202
+ done
203
+ echo "" >&2
204
+ echo " Cure paths:" >&2
205
+ echo " 1. Rewrite failing sentences in short SVO English" >&2
206
+ echo " 2. Mark sentence exempt: <!-- FKGL-EXEMPT: reason --> ... <!-- /FKGL-EXEMPT -->" >&2
207
+ echo "" >&2
208
+ echo " Override (logged via trace-helper): SKIP_SKILL_BODY_GRADE=1" >&2
209
+ echo "" >&2
210
+
211
+ # === Trace log ===
212
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
213
+ if [ -f "$SCRIPT_DIR/trace-helper.sh" ]; then
214
+ # shellcheck disable=SC1091
215
+ source "$SCRIPT_DIR/trace-helper.sh"
216
+ trace_log "skill-body-grade-gate" "BLOCKED above=$ABOVE total=$TOTAL pct=$PCT file=$FILE_PATH"
217
+ fi
218
+
219
+ exit 2
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # skill-body-intent-drift.sh — PostToolUse Edit|Write hook on
5
+ # .claude/skills/*/SKILL.md. Runs scripts/intent-drift-check.sh in
6
+ # advisory mode. Reports cosine + verdict to stderr. Never blocks.
7
+ #
8
+ # Closes bassclef-upstream#788 (narrow slice of #785). Makes /loop
9
+ # step 5 automatic — no more honor-system per cure.
10
+ #
11
+ # Input (stdin): JSON — tool_name, tool_input.file_path
12
+ # Exit: always 0 (advisory only at V1)
13
+ #
14
+ # Env:
15
+ # VOYAGE_API_KEY — required to run the check; missing = skip silently
16
+ # SKIP_SKILL_BODY_INTENT_DRIFT=1 — per-call override (logged)
17
+ #
18
+ # Per @luminary michael-feathers (characterization measurable not subjective)
19
+ # + @luminary kent-beck (Tier 0 strict TDD)
20
+
21
+ set +e
22
+
23
+ # === Override path ===
24
+ if [ "${SKIP_SKILL_BODY_INTENT_DRIFT:-0}" = "1" ]; then
25
+ exit 0
26
+ fi
27
+
28
+ # === Parse stdin JSON ===
29
+ STDIN_JSON=$(cat 2>/dev/null || echo '{}')
30
+
31
+ if ! echo "$STDIN_JSON" | jq -e . >/dev/null 2>&1; then
32
+ exit 0
33
+ fi
34
+
35
+ TOOL_NAME=$(echo "$STDIN_JSON" | jq -r '.tool_name // ""' 2>/dev/null)
36
+ FILE_PATH=$(echo "$STDIN_JSON" | jq -r '.tool_input.file_path // ""' 2>/dev/null)
37
+
38
+ # Only fire on Write or Edit
39
+ if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
40
+ exit 0
41
+ fi
42
+
43
+ # Only fire on .claude/skills/*/SKILL.md
44
+ case "$FILE_PATH" in
45
+ *".claude/skills/"*"/SKILL.md")
46
+ ;;
47
+ *)
48
+ exit 0
49
+ ;;
50
+ esac
51
+
52
+ # Extract slug from path
53
+ SLUG=$(echo "$FILE_PATH" | sed -E 's|.*\.claude/skills/([^/]+)/SKILL\.md|\1|')
54
+ if [ -z "$SLUG" ] || [ "$SLUG" = "$FILE_PATH" ]; then
55
+ exit 0
56
+ fi
57
+
58
+ # Locate repo root
59
+ if [ -n "${CLAUDE_PROJECT_DIR:-}" ] && [ -d "$CLAUDE_PROJECT_DIR/.claude" ]; then
60
+ REPO_ROOT="$CLAUDE_PROJECT_DIR"
61
+ else
62
+ REPO_ROOT="$(pwd)"
63
+ fi
64
+
65
+ VECTOR_PATH="$REPO_ROOT/.claude/embeddings/skills/${SLUG}.json"
66
+ DRIFT_SCRIPT="$REPO_ROOT/scripts/intent-drift-check.sh"
67
+
68
+ # Skip when baseline vector absent
69
+ if [ ! -f "$VECTOR_PATH" ]; then
70
+ exit 0
71
+ fi
72
+
73
+ # Skip when drift script missing
74
+ if [ ! -x "$DRIFT_SCRIPT" ]; then
75
+ exit 0
76
+ fi
77
+
78
+ # Skip when VOYAGE_API_KEY missing
79
+ if [ -z "${VOYAGE_API_KEY:-}" ]; then
80
+ exit 0
81
+ fi
82
+
83
+ # Run the check + surface result to stderr
84
+ RESULT=$(bash "$DRIFT_SCRIPT" --skill "$SLUG" --json 2>&1)
85
+ CHECK_EXIT=$?
86
+
87
+ # Parse cosine + verdict from JSON result
88
+ COSINE=$(echo "$RESULT" | jq -r '.cosine // "n/a"' 2>/dev/null)
89
+ VERDICT=$(echo "$RESULT" | jq -r '.verdict // "n/a"' 2>/dev/null)
90
+ THRESHOLD=$(echo "$RESULT" | jq -r '.threshold // "n/a"' 2>/dev/null)
91
+
92
+ # Advisory surface — never blocks
93
+ if [ "$VERDICT" = "PASS" ]; then
94
+ echo "" >&2
95
+ echo "[intent-drift-check] /$SLUG cosine $COSINE (threshold $THRESHOLD) — PASS. Intent preserved by measurable cosine." >&2
96
+ elif [ "$VERDICT" = "FAIL" ]; then
97
+ echo "" >&2
98
+ echo "⚠ [intent-drift-check] /$SLUG cosine $COSINE (threshold $THRESHOLD) — FAIL." >&2
99
+ echo " Advisory only at V1 — commit not blocked. Consider re-checking intent preservation." >&2
100
+ echo " Run \`bash scripts/intent-drift-check.sh --skill $SLUG\` for detail." >&2
101
+ else
102
+ # Drift script failed or returned non-JSON — silent skip
103
+ :
104
+ fi
105
+
106
+ # Always exit 0 — advisory only at V1
107
+ exit 0