@thebassclef/lite 1.0.0 → 1.0.2
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.
- package/dist/cli.cjs +240 -23
- package/dist/cli.js +242 -25
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lite/.claude/hooks/artifact-ingestion-gate.sh +357 -0
- package/dist/lite/.claude/hooks/assert-verify-steering.sh +77 -0
- package/dist/lite/.claude/hooks/bassclef-source-config-validate.sh +215 -0
- package/dist/lite/.claude/hooks/bassclef-sync.sh +716 -0
- package/dist/lite/.claude/hooks/compound-noun-scrub.sh +292 -0
- package/dist/lite/.claude/hooks/kiss-expansion-inject.sh +69 -0
- package/dist/lite/.claude/hooks/longrun-prep-compounding-sequence-check.sh +492 -0
- package/dist/lite/.claude/hooks/plain-english-steering.sh +156 -0
- package/dist/lite/.claude/hooks/post-skill-friction-check.sh +177 -0
- package/dist/lite/.claude/hooks/post-skill-telemetry.sh +62 -0
- package/dist/lite/.claude/hooks/pre-build-gate.sh +511 -0
- package/dist/lite/.claude/hooks/pre-commit-gate.sh +451 -0
- package/dist/lite/.claude/hooks/session-end.sh +433 -0
- package/dist/lite/.claude/hooks/session-reflection.sh +303 -0
- package/dist/lite/.claude/hooks/skill-body-grade-gate.sh +219 -0
- package/dist/lite/.claude/hooks/skill-body-intent-drift.sh +107 -0
- package/dist/lite/.claude/hooks/state-validate.sh +271 -0
- package/dist/lite/.claude/hooks/substrate-clarity-gate.sh +1110 -0
- package/dist/lite/.claude/hooks/temperance-gate.sh +147 -0
- package/dist/lite/.claude/hooks/testing-tier-enforce.sh +233 -0
- package/dist/lite/.claude/hooks/turn-prose-grade-measure.sh +219 -0
- package/dist/lite/.claude/hooks/turn-prose-kiss-check.sh +463 -0
- package/dist/lite/.claude/hooks/vocabulary-migration-check.sh +171 -0
- package/dist/lite/.claude/hooks/whereami-utc-gate.sh +142 -0
- package/dist/lite/CLAUDE.md +2 -2
- package/dist/lite/whereami.md +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1110 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# tier: lite
|
|
3
|
+
# Substrate Clarity Gate — fires on Write/Edit to substrate paths
|
|
4
|
+
# (.claude/skills/, .claude/rules/, .claude/luminaries/). Enforces the
|
|
5
|
+
# clarity + context-engineering discipline from bassclef#371 + #377.
|
|
6
|
+
#
|
|
7
|
+
# v1 (2026-05-07 Phase I — Plain English Sweep) — ALL THREE SURFACES BLOCK:
|
|
8
|
+
# - Skill descriptions ≤280 chars, verb-first opener, no jargon ladder
|
|
9
|
+
# - Rule INSTEAD-block discipline (every don't/never/avoid paired
|
|
10
|
+
# with INSTEAD within 5 lines) — flipped from ADVISORY to BLOCK
|
|
11
|
+
# - Luminary INSTEAD-block discipline — flipped from ADVISORY to BLOCK
|
|
12
|
+
# - Luminary required-sections check stays ADVISORY (documentation
|
|
13
|
+
# discipline, not blocking concern)
|
|
14
|
+
#
|
|
15
|
+
# Allowlist grandfathers ~94 existing violators per bassclef#373/#374/#375
|
|
16
|
+
# audits. Each audit shrinks its section as files get fixed individually.
|
|
17
|
+
#
|
|
18
|
+
# Exit codes:
|
|
19
|
+
# 0 — allow (path doesn't match, or all checks pass, or override,
|
|
20
|
+
# or path on allowlist)
|
|
21
|
+
# 2 — block (matcher hit, violation found, no override)
|
|
22
|
+
#
|
|
23
|
+
# Override: SKIP_SUBSTRATE_CLARITY=1 to bypass (logged via trace-helper)
|
|
24
|
+
#
|
|
25
|
+
# Allowlist: .claude/hooks/substrate-clarity-allowlist.txt
|
|
26
|
+
#
|
|
27
|
+
# Rule: .claude/rules/skill-description-clarity.md
|
|
28
|
+
# Issue: bassclef#382 (v0); Phase I bet (v1 flip)
|
|
29
|
+
|
|
30
|
+
set +e # never crash the tool call; fall through to allow on any unexpected error
|
|
31
|
+
|
|
32
|
+
# === Hook liveness heartbeat (WU-3 of bet 2026-07-31d; closes #1002) ===
|
|
33
|
+
# Silent-fail — a missing lib never crashes the hook.
|
|
34
|
+
{
|
|
35
|
+
_hb_sd="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
36
|
+
for _hb_c in "${_hb_sd}/../../lib/hook-heartbeat.sh" "${HOME:-/}/lib/hook-heartbeat.sh"; do
|
|
37
|
+
[ -f "$_hb_c" ] && source "$_hb_c" && heartbeat_mark "substrate-clarity-gate" && break
|
|
38
|
+
done
|
|
39
|
+
unset _hb_sd _hb_c
|
|
40
|
+
} 2>/dev/null || true
|
|
41
|
+
|
|
42
|
+
INPUT=$(cat)
|
|
43
|
+
TOOL_NAME=$(jq <<< "$INPUT" -r '.tool_name // ""' 2>/dev/null)
|
|
44
|
+
FILE_PATH=$(jq <<< "$INPUT" -r '.tool_input.file_path // ""' 2>/dev/null)
|
|
45
|
+
|
|
46
|
+
# Only fire on Write / Edit
|
|
47
|
+
if [ "$TOOL_NAME" != "Write" ] && [ "$TOOL_NAME" != "Edit" ]; then
|
|
48
|
+
exit 0
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# No file path → allow (defensive)
|
|
52
|
+
if [ -z "$FILE_PATH" ]; then
|
|
53
|
+
exit 0
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
# Determine surface type from path.
|
|
57
|
+
# v0 scope: skill SKILL.md (length + verb-first + jargon — strict)
|
|
58
|
+
# v1 scope: rule + luminary (INSTEAD-block — strict; flipped from advisory in Phase I)
|
|
59
|
+
# v2 scope (Phase II WU-7b): chronicle + iteration_bet + journal_entry + decomposition
|
|
60
|
+
# Advisory only — sentence-length, passive-voice, expanded jargon list.
|
|
61
|
+
# These surfaces are operator-narrative; voice trumps strict rule.
|
|
62
|
+
SURFACE=""
|
|
63
|
+
case "$FILE_PATH" in
|
|
64
|
+
*/.claude/skills/*/SKILL.md) SURFACE="skill" ;;
|
|
65
|
+
*/.claude/rules/*.md) SURFACE="rule" ;;
|
|
66
|
+
*/.claude/luminaries/*.md) SURFACE="luminary" ;;
|
|
67
|
+
*/.claude/agents/*.md) SURFACE="agent" ;;
|
|
68
|
+
*/.claude/hooks/*.sh) SURFACE="hook" ;;
|
|
69
|
+
*/architecture/decisions/ADR-*.md) SURFACE="adr" ;;
|
|
70
|
+
*/chronicle/*.md|*/docs/chronicle/*.md) SURFACE="chronicle" ;;
|
|
71
|
+
*/docs/iteration-bets/*.md) SURFACE="iteration_bet" ;;
|
|
72
|
+
*/docs/operator-private/journals/*.md) SURFACE="journal_entry" ;;
|
|
73
|
+
*/docs/decompositions/*.md) SURFACE="decomposition" ;;
|
|
74
|
+
*/docs/whereami.md) SURFACE="whereami" ;;
|
|
75
|
+
# Surfaces added per goal 25e Path A — 15 SDLC artifact classes newly covered
|
|
76
|
+
*/docs/canvases/*.md) SURFACE="canvas" ;;
|
|
77
|
+
*/docs/use-cases/*.md) SURFACE="use_case" ;;
|
|
78
|
+
*/docs/personas/*.md) SURFACE="persona" ;;
|
|
79
|
+
*/docs/roadmaps/*.md) SURFACE="roadmap" ;;
|
|
80
|
+
*/docs/risk-ledgers/*.md) SURFACE="risk_ledger" ;;
|
|
81
|
+
*/docs/empathy-maps/*.md) SURFACE="empathy_map" ;;
|
|
82
|
+
*/docs/interaction-design/*.md) SURFACE="interaction_design" ;;
|
|
83
|
+
*/docs/handoffs/*.md) SURFACE="handoff" ;;
|
|
84
|
+
*/docs/audits/*.md|*/architecture/audits/*.md) SURFACE="audit" ;;
|
|
85
|
+
*/docs/reflections/*.md) SURFACE="reflection" ;;
|
|
86
|
+
*/docs/regressions/*.md) SURFACE="regression" ;;
|
|
87
|
+
*/docs/release-notes/*.md) SURFACE="release_notes" ;;
|
|
88
|
+
*/docs/analysis/*.md) SURFACE="analysis" ;;
|
|
89
|
+
*/docs/curation/*.md) SURFACE="curation" ;;
|
|
90
|
+
*/standards/*.md) SURFACE="standard" ;;
|
|
91
|
+
*/templates/*.md) SURFACE="template" ;;
|
|
92
|
+
esac
|
|
93
|
+
|
|
94
|
+
if [ -z "$SURFACE" ]; then
|
|
95
|
+
exit 0
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
# Surface tier — strict surfaces BLOCK; advisory surfaces only emit notices.
|
|
99
|
+
# chronicle + iteration_bet + decomposition FLIPPED from advisory to strict
|
|
100
|
+
# after the bassclef-upstream#303 audit emptied the grandfathered allowlist
|
|
101
|
+
# (38 files scrubbed via bet 2026-06-25j). journal_entry remains advisory
|
|
102
|
+
# per memory feedback_writing_voice — personal-narrative voice trumps strict
|
|
103
|
+
# rule.
|
|
104
|
+
case "$SURFACE" in
|
|
105
|
+
skill|rule|luminary|adr|chronicle|iteration_bet|decomposition)
|
|
106
|
+
SURFACE_TIER="strict"
|
|
107
|
+
;;
|
|
108
|
+
journal_entry|whereami)
|
|
109
|
+
# journal_entry — personal-narrative voice trumps strict rule per memory
|
|
110
|
+
# feedback_writing_voice.
|
|
111
|
+
# whereami — V1 advisory per bassclef-upstream bet 2026-07-04a WU-1.
|
|
112
|
+
# Advisory tier lets us ship coverage without retroactively scrubbing
|
|
113
|
+
# today's whereami frontmatter (which carries historical terms).
|
|
114
|
+
SURFACE_TIER="advisory"
|
|
115
|
+
;;
|
|
116
|
+
canvas|use_case|persona|roadmap|risk_ledger|empathy_map|interaction_design|handoff|audit|reflection|regression|release_notes|analysis|curation|standard|template)
|
|
117
|
+
# New surfaces per goal 25e Path A — V1 ADVISORY only.
|
|
118
|
+
# These surfaces get the rewrite-check heuristics layer below.
|
|
119
|
+
# They do NOT run strict INSTEAD-block checks (those apply only to
|
|
120
|
+
# rules + luminaries + skills). V2 may tier strict/medium after
|
|
121
|
+
# calibration cycle.
|
|
122
|
+
SURFACE_TIER="advisory"
|
|
123
|
+
;;
|
|
124
|
+
*)
|
|
125
|
+
SURFACE_TIER="strict"
|
|
126
|
+
;;
|
|
127
|
+
esac
|
|
128
|
+
|
|
129
|
+
# Surface-level allowlist for flip-from-advisory grandfathered content.
|
|
130
|
+
# Bypasses strict checks until the file is scrubbed (per bassclef-upstream#303 audit).
|
|
131
|
+
# After scrub, remove the file's path from the allowlist file.
|
|
132
|
+
ALLOWLIST_FILE="$(dirname "$0")/substrate-clarity-gate-allowlist.txt"
|
|
133
|
+
if [ -f "$ALLOWLIST_FILE" ]; then
|
|
134
|
+
# Match by basename relative to repo root (path may be absolute or relative)
|
|
135
|
+
REL_PATH="${FILE_PATH#*$PWD/}"
|
|
136
|
+
REL_PATH="${REL_PATH#$PWD/}"
|
|
137
|
+
if grep -Fxq "$REL_PATH" "$ALLOWLIST_FILE" 2>/dev/null \
|
|
138
|
+
|| grep -Fxq "$FILE_PATH" "$ALLOWLIST_FILE" 2>/dev/null; then
|
|
139
|
+
exit 0
|
|
140
|
+
fi
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
# Override hatch — log and allow
|
|
144
|
+
if [ "${SKIP_SUBSTRATE_CLARITY:-0}" = "1" ]; then
|
|
145
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
146
|
+
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/trace-helper.sh" ]; then
|
|
147
|
+
source "$SCRIPT_DIR/trace-helper.sh" 2>/dev/null || true
|
|
148
|
+
trace_log "substrate-clarity-gate" "OVERRIDE $TOOL_NAME $FILE_PATH ($SURFACE)" 2>/dev/null || true
|
|
149
|
+
fi
|
|
150
|
+
exit 0
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
# Allowlist check (WU-4 ships the file; pre-WU-4 the file may not exist)
|
|
154
|
+
SCRIPT_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
155
|
+
ALLOWLIST_FILE="${SCRIPT_DIR}/substrate-clarity-allowlist.txt"
|
|
156
|
+
# Match against repo-relative path (strip leading /Users/.../bassclef/ etc.)
|
|
157
|
+
REPO_REL=$(echo "$FILE_PATH" | sed -E 's|^.*/(\.claude/.*)$|\1|')
|
|
158
|
+
if [ -f "$ALLOWLIST_FILE" ]; then
|
|
159
|
+
if grep -Fxq "$REPO_REL" "$ALLOWLIST_FILE" 2>/dev/null; then
|
|
160
|
+
exit 0 # allowlisted (SCRIPT_DIR match); check skipped
|
|
161
|
+
fi
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
# Repo-allowlist fallback (bassclef-upstream#358).
|
|
165
|
+
# When hook is installed at user level (~/.claude/hooks/) and SCRIPT_DIR
|
|
166
|
+
# allowlist doesn't match, also check the consumer repo's allowlist via
|
|
167
|
+
# CLAUDE_PROJECT_DIR. Closes the propagation gap surfaced during bet 25h WU-3.
|
|
168
|
+
if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
|
|
169
|
+
REPO_ALLOWLIST_FILE="${CLAUDE_PROJECT_DIR}/.claude/hooks/substrate-clarity-allowlist.txt"
|
|
170
|
+
if [ -f "$REPO_ALLOWLIST_FILE" ] && [ "$REPO_ALLOWLIST_FILE" != "$ALLOWLIST_FILE" ]; then
|
|
171
|
+
if grep -Fxq "$REPO_REL" "$REPO_ALLOWLIST_FILE" 2>/dev/null; then
|
|
172
|
+
exit 0 # allowlisted (repo-fallback match); check skipped
|
|
173
|
+
fi
|
|
174
|
+
fi
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
# Assemble the content that WILL exist after this operation.
|
|
178
|
+
FUTURE_CONTENT=""
|
|
179
|
+
if [ "$TOOL_NAME" = "Write" ]; then
|
|
180
|
+
FUTURE_CONTENT=$(jq <<< "$INPUT" -r '.tool_input.content // ""' 2>/dev/null)
|
|
181
|
+
elif [ "$TOOL_NAME" = "Edit" ]; then
|
|
182
|
+
NEW_STRING=$(jq <<< "$INPUT" -r '.tool_input.new_string // ""' 2>/dev/null)
|
|
183
|
+
EXISTING=""
|
|
184
|
+
if [ -f "$FILE_PATH" ]; then
|
|
185
|
+
EXISTING=$(cat "$FILE_PATH" 2>/dev/null)
|
|
186
|
+
fi
|
|
187
|
+
# Apply the edit: replace OLD with NEW within EXISTING. For check purposes
|
|
188
|
+
# we use full FUTURE_CONTENT — concat is rough but covers both surface text.
|
|
189
|
+
OLD_STRING=$(jq <<< "$INPUT" -r '.tool_input.old_string // ""' 2>/dev/null)
|
|
190
|
+
if [ -n "$EXISTING" ] && [ -n "$OLD_STRING" ]; then
|
|
191
|
+
# Use python3 for safe string substitution (env vars avoid bash 4+ ${var@Q})
|
|
192
|
+
FUTURE_CONTENT=$(OLD="$OLD_STRING" NEW="$NEW_STRING" python3 -c "
|
|
193
|
+
import os, sys
|
|
194
|
+
existing = sys.stdin.read()
|
|
195
|
+
print(existing.replace(os.environ['OLD'], os.environ['NEW'], 1), end='')
|
|
196
|
+
" <<< "$EXISTING" 2>/dev/null || echo "${EXISTING}
|
|
197
|
+
${NEW_STRING}")
|
|
198
|
+
else
|
|
199
|
+
FUTURE_CONTENT="${EXISTING}
|
|
200
|
+
${NEW_STRING}"
|
|
201
|
+
fi
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
# === Check dispatch ===
|
|
205
|
+
VIOLATION=""
|
|
206
|
+
LINE_REF=""
|
|
207
|
+
SUGGESTED_FIX=""
|
|
208
|
+
|
|
209
|
+
# ---------------------------------------------------------------------------
|
|
210
|
+
# Documentation-surface tier check (bassclef-upstream#1012)
|
|
211
|
+
#
|
|
212
|
+
# The tier: field applies to substrate building blocks (skills, rules,
|
|
213
|
+
# luminaries, agents, hooks, ADRs, standards) whose maturity governs
|
|
214
|
+
# release-script filtering. Documentation surfaces — release-notes,
|
|
215
|
+
# roadmaps, canvases — ship unconditionally via the release script's
|
|
216
|
+
# fallback branch (scripts/release-to-bassclef.sh:367-372). The tier:
|
|
217
|
+
# field there is cosmetic and misleads adopters.
|
|
218
|
+
#
|
|
219
|
+
# Per /luminary consult 2026-07-31: Norman (signifier discipline),
|
|
220
|
+
# Brooks (conceptual integrity), Linus (adopter contract) — all
|
|
221
|
+
# endorsed removing the field from documentation surfaces.
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
case "$SURFACE" in
|
|
224
|
+
release_notes|roadmap|canvas)
|
|
225
|
+
# Extract frontmatter block from FUTURE_CONTENT and grep for tier: line.
|
|
226
|
+
# Frontmatter opens with --- on line 1 and closes with --- on a later line.
|
|
227
|
+
TIER_LINE=$(echo "$FUTURE_CONTENT" | awk '
|
|
228
|
+
BEGIN { in_fm = 0 }
|
|
229
|
+
NR == 1 && /^---[[:space:]]*$/ { in_fm = 1; next }
|
|
230
|
+
in_fm && /^---[[:space:]]*$/ { exit }
|
|
231
|
+
in_fm && /^tier:[[:space:]]/ { print; exit }
|
|
232
|
+
')
|
|
233
|
+
if [ -n "$TIER_LINE" ]; then
|
|
234
|
+
VIOLATION="tier: field on ${SURFACE} surface is cosmetic (release script does not read it here) — remove per bassclef-upstream#1012."
|
|
235
|
+
LINE_REF="frontmatter tier: line"
|
|
236
|
+
SUGGESTED_FIX="Delete the tier: line from frontmatter. The field applies to substrate building-block surfaces (skills, rules, luminaries, agents, hooks, ADRs, standards) — not documentation surfaces (release-notes, roadmaps, canvases). See standards/tier-tag-schema.md § Scope."
|
|
237
|
+
fi
|
|
238
|
+
;;
|
|
239
|
+
esac
|
|
240
|
+
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
# Frontmatter schema validation (bassclef#862 Gap 1, WU-3 of bet 2026-06-18a)
|
|
243
|
+
# Fires for skill + rule surfaces only. Validates:
|
|
244
|
+
# 1. Required fields present and non-empty
|
|
245
|
+
# 2. YAML parses cleanly (malformed → BLOCK)
|
|
246
|
+
# 3. Field names are recognized (unknown → WARN, no block)
|
|
247
|
+
# Schema source: standards/frontmatter-schema.md
|
|
248
|
+
# Allowlist: .claude/hooks/substrate-frontmatter-allowlist.txt (60-day grace)
|
|
249
|
+
# ---------------------------------------------------------------------------
|
|
250
|
+
# Hook (.sh) tier validation — separate path from YAML frontmatter.
|
|
251
|
+
# Hooks carry `# tier: <value>` as a header comment on line 2 (after shebang).
|
|
252
|
+
# Per goal 2026-08-13d Piece 2 (bassclef-upstream#1200):
|
|
253
|
+
# missing `# tier:` header → BLOCK; invalid value → BLOCK.
|
|
254
|
+
if [ "$SURFACE" = "hook" ]; then
|
|
255
|
+
HOOK_TIER=$(echo "$FUTURE_CONTENT" | head -5 | awk '/^# tier:[[:space:]]/ {sub(/^# tier:[[:space:]]*/,""); gsub(/[[:space:]]/,""); print; exit}')
|
|
256
|
+
if [ -z "$HOOK_TIER" ]; then
|
|
257
|
+
echo "" >&2
|
|
258
|
+
echo "============================================" >&2
|
|
259
|
+
echo "🛑 SUBSTRATE-CLARITY GATE — BLOCKED 🛑" >&2
|
|
260
|
+
echo "============================================" >&2
|
|
261
|
+
echo "" >&2
|
|
262
|
+
echo "File: $FILE_PATH" >&2
|
|
263
|
+
echo "Tool: $TOOL_NAME" >&2
|
|
264
|
+
echo "" >&2
|
|
265
|
+
echo "Violation: missing required '# tier:' header for hook (.sh)" >&2
|
|
266
|
+
echo "Line: header comment on line 2 (after shebang)" >&2
|
|
267
|
+
echo "" >&2
|
|
268
|
+
echo "Suggested fix:" >&2
|
|
269
|
+
echo " Add '# tier: <value>' as the second line, e.g.:" >&2
|
|
270
|
+
echo " #!/usr/bin/env bash" >&2
|
|
271
|
+
echo " # tier: standard" >&2
|
|
272
|
+
echo "" >&2
|
|
273
|
+
echo "Valid values: upstream | lite | standard | standard-pro | ultra" >&2
|
|
274
|
+
echo " (private accepted as deprecated alias for upstream)" >&2
|
|
275
|
+
echo "" >&2
|
|
276
|
+
echo "Per rule: standards/tier-tag-schema.md" >&2
|
|
277
|
+
echo "Per goal: bassclef-upstream#1200 Piece 2" >&2
|
|
278
|
+
echo "" >&2
|
|
279
|
+
echo "Override (logged): SKIP_SUBSTRATE_CLARITY=1 <command>" >&2
|
|
280
|
+
echo "============================================" >&2
|
|
281
|
+
exit 2
|
|
282
|
+
fi
|
|
283
|
+
case "$HOOK_TIER" in
|
|
284
|
+
upstream|private|lite|standard|standard-pro|ultra) : ;;
|
|
285
|
+
*)
|
|
286
|
+
echo "" >&2
|
|
287
|
+
echo "============================================" >&2
|
|
288
|
+
echo "🛑 SUBSTRATE-CLARITY GATE — BLOCKED 🛑" >&2
|
|
289
|
+
echo "============================================" >&2
|
|
290
|
+
echo "" >&2
|
|
291
|
+
echo "File: $FILE_PATH" >&2
|
|
292
|
+
echo "Tool: $TOOL_NAME" >&2
|
|
293
|
+
echo "" >&2
|
|
294
|
+
echo "Violation: invalid '# tier:' value '$HOOK_TIER' on hook" >&2
|
|
295
|
+
echo "Valid values: upstream | lite | standard | standard-pro | ultra" >&2
|
|
296
|
+
echo " (private accepted as deprecated alias for upstream)" >&2
|
|
297
|
+
echo "" >&2
|
|
298
|
+
echo "Per rule: standards/tier-tag-schema.md" >&2
|
|
299
|
+
echo "Override (logged): SKIP_SUBSTRATE_CLARITY=1 <command>" >&2
|
|
300
|
+
echo "============================================" >&2
|
|
301
|
+
exit 2
|
|
302
|
+
;;
|
|
303
|
+
esac
|
|
304
|
+
fi
|
|
305
|
+
|
|
306
|
+
if [ "$SURFACE" = "skill" ] || [ "$SURFACE" = "rule" ] || \
|
|
307
|
+
[ "$SURFACE" = "luminary" ] || [ "$SURFACE" = "agent" ] || \
|
|
308
|
+
[ "$SURFACE" = "adr" ] || [ "$SURFACE" = "standard" ]; then
|
|
309
|
+
FRONTMATTER_ALLOWLIST="${SCRIPT_DIR}/substrate-frontmatter-allowlist.txt"
|
|
310
|
+
FM_SKIP=0
|
|
311
|
+
if [ -f "$FRONTMATTER_ALLOWLIST" ]; then
|
|
312
|
+
REPO_REL=$(echo "$FILE_PATH" | sed -E 's|^.*/(\.claude/.*)$|\1|')
|
|
313
|
+
# Allowlist line shape: <repo-rel-path>|<grace-until ISO 8601>. Allow either
|
|
314
|
+
# plain path (legacy) or path|date (matches testing-tier-enforce-allowlist).
|
|
315
|
+
while IFS= read -r LINE; do
|
|
316
|
+
# Strip comments and blank lines
|
|
317
|
+
LINE_TRIM=$(echo "$LINE" | sed -E 's/[[:space:]]*#.*$//' | xargs)
|
|
318
|
+
[ -z "$LINE_TRIM" ] && continue
|
|
319
|
+
LINE_PATH=$(echo "$LINE_TRIM" | cut -d'|' -f1 | xargs)
|
|
320
|
+
LINE_DATE=$(echo "$LINE_TRIM" | cut -d'|' -f2 -s | xargs)
|
|
321
|
+
if [ "$LINE_PATH" = "$REPO_REL" ]; then
|
|
322
|
+
# Grace window check: if a date is supplied, only honor while current
|
|
323
|
+
if [ -n "$LINE_DATE" ]; then
|
|
324
|
+
NOW_ISO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
325
|
+
if [ "$NOW_ISO" \< "$LINE_DATE" ] || [ "$NOW_ISO" = "$LINE_DATE" ]; then
|
|
326
|
+
FM_SKIP=1
|
|
327
|
+
fi
|
|
328
|
+
else
|
|
329
|
+
FM_SKIP=1
|
|
330
|
+
fi
|
|
331
|
+
break
|
|
332
|
+
fi
|
|
333
|
+
done < "$FRONTMATTER_ALLOWLIST"
|
|
334
|
+
fi
|
|
335
|
+
|
|
336
|
+
if [ "$FM_SKIP" = "0" ]; then
|
|
337
|
+
# Run frontmatter validation via python3 yaml
|
|
338
|
+
FM_RESULT=$(echo "$FUTURE_CONTENT" | SURFACE_NAME="$SURFACE" FILE_PATH_ENV="$FILE_PATH" python3 -c "
|
|
339
|
+
import os, sys, re
|
|
340
|
+
try:
|
|
341
|
+
import yaml
|
|
342
|
+
except ImportError:
|
|
343
|
+
# python yaml not available; pass without validation
|
|
344
|
+
print('OK')
|
|
345
|
+
sys.exit(0)
|
|
346
|
+
|
|
347
|
+
content = sys.stdin.read()
|
|
348
|
+
surface = os.environ.get('SURFACE_NAME', '')
|
|
349
|
+
|
|
350
|
+
# Find frontmatter — opens with --- on line 1, closes with --- on a later line.
|
|
351
|
+
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
|
352
|
+
if not m:
|
|
353
|
+
# No frontmatter.
|
|
354
|
+
# Per goal 2026-08-13d Piece 2 (bassclef-upstream#1200):
|
|
355
|
+
# substrate primitive classes require tier tag; missing frontmatter
|
|
356
|
+
# means the tier tag cannot be present, so BLOCK. Backfill via prior
|
|
357
|
+
# commits brought all 488 primitives to 100% tier coverage.
|
|
358
|
+
if surface in ('skill', 'rule', 'luminary', 'agent', 'adr', 'standard'):
|
|
359
|
+
print('MISSING_FRONTMATTER')
|
|
360
|
+
else:
|
|
361
|
+
# Non-primitive surfaces with no frontmatter — pass through.
|
|
362
|
+
print('OK')
|
|
363
|
+
sys.exit(0)
|
|
364
|
+
|
|
365
|
+
fm_text = m.group(1)
|
|
366
|
+
try:
|
|
367
|
+
fm = yaml.safe_load(fm_text)
|
|
368
|
+
except yaml.YAMLError as e:
|
|
369
|
+
print(f'MALFORMED_YAML:{str(e).splitlines()[0]}')
|
|
370
|
+
sys.exit(0)
|
|
371
|
+
|
|
372
|
+
if not isinstance(fm, dict):
|
|
373
|
+
print('MALFORMED_YAML:frontmatter is not a mapping')
|
|
374
|
+
sys.exit(0)
|
|
375
|
+
|
|
376
|
+
# Required fields per surface
|
|
377
|
+
# Per goal 2026-08-13d Piece 2 — tier extended to all 6 .md primitive classes.
|
|
378
|
+
# hook (.sh) is handled in a separate bash pre-check above (different extraction path).
|
|
379
|
+
required = {
|
|
380
|
+
'skill': ['name', 'description', 'tier'],
|
|
381
|
+
'rule': ['tier'],
|
|
382
|
+
'luminary': ['tier'],
|
|
383
|
+
'agent': ['tier'],
|
|
384
|
+
'adr': ['tier'],
|
|
385
|
+
'standard': ['tier'],
|
|
386
|
+
}.get(surface, [])
|
|
387
|
+
|
|
388
|
+
for field in required:
|
|
389
|
+
if field not in fm:
|
|
390
|
+
print(f'MISSING_REQUIRED:{field}')
|
|
391
|
+
sys.exit(0)
|
|
392
|
+
val = fm[field]
|
|
393
|
+
if val is None or (isinstance(val, str) and not val.strip()):
|
|
394
|
+
print(f'MISSING_REQUIRED:{field}')
|
|
395
|
+
sys.exit(0)
|
|
396
|
+
|
|
397
|
+
# tier value validation (WU-1: bassclef-upstream→bassclef export gate)
|
|
398
|
+
# Values per standards/tier-tag-schema.md.
|
|
399
|
+
# 'private' kept accepted as deprecated alias for 'upstream' per ADR-031
|
|
400
|
+
# we-dont-break-adopters compat-shim discipline.
|
|
401
|
+
if 'tier' in fm:
|
|
402
|
+
tier_val = fm['tier']
|
|
403
|
+
if tier_val not in ('upstream', 'private', 'lite', 'standard', 'standard-pro', 'ultra'):
|
|
404
|
+
print(f'INVALID_TIER:{tier_val} (expected upstream|lite|standard|standard-pro|ultra; private accepted as deprecated alias)')
|
|
405
|
+
sys.exit(0)
|
|
406
|
+
|
|
407
|
+
# Recognized optional fields per surface
|
|
408
|
+
recognized = {
|
|
409
|
+
'skill': {
|
|
410
|
+
'name', 'description', 'args', 'composes_baseline', 'composes_with',
|
|
411
|
+
'depends_on', 'deprecated', 'deprecated_decision', 'deprecated_in_favor_of',
|
|
412
|
+
'disable_friction_check', 'disable_model_invocation', 'model_tier',
|
|
413
|
+
'modes', 'no_user_modes', 'requires', 'reserved_name_override',
|
|
414
|
+
'sizes', 'tech_stack_match', 'tier', 'tiers', 'user_invocable',
|
|
415
|
+
},
|
|
416
|
+
'rule': {
|
|
417
|
+
'description', 'composes_with', 'depends_on', 'globs', 'model_tier',
|
|
418
|
+
'tech_stack_match', 'tier',
|
|
419
|
+
},
|
|
420
|
+
}.get(surface, set())
|
|
421
|
+
|
|
422
|
+
unknown = [k for k in fm.keys() if k not in recognized]
|
|
423
|
+
if unknown:
|
|
424
|
+
print(f'UNKNOWN_FIELDS:{\",\".join(unknown)}')
|
|
425
|
+
sys.exit(0)
|
|
426
|
+
|
|
427
|
+
# bassclef#862 Gap 3 (WU-15): composes_with + depends_on targets-exist validation
|
|
428
|
+
# composes_with → STRICT (BLOCK if target skill missing)
|
|
429
|
+
# depends_on → ADVISORY (warn if target missing; pass for V1)
|
|
430
|
+
file_path = os.environ.get('FILE_PATH_ENV', '')
|
|
431
|
+
substrate_root = ''
|
|
432
|
+
if '/.claude/' in file_path:
|
|
433
|
+
substrate_root = file_path.split('/.claude/')[0]
|
|
434
|
+
|
|
435
|
+
def target_resolved(name, substrate_root):
|
|
436
|
+
skill_path = f'{substrate_root}/.claude/skills/{name}/SKILL.md'
|
|
437
|
+
rule_path = f'{substrate_root}/.claude/rules/{name}.md'
|
|
438
|
+
known_config_blocks = {
|
|
439
|
+
'tech_stack', 'adopter_defaults', 'plan_tier',
|
|
440
|
+
'prose_discipline', 'longrun', 'testing',
|
|
441
|
+
}
|
|
442
|
+
return (os.path.isfile(skill_path)
|
|
443
|
+
or os.path.isfile(rule_path)
|
|
444
|
+
or name in known_config_blocks)
|
|
445
|
+
|
|
446
|
+
if substrate_root and 'composes_with' in fm:
|
|
447
|
+
cw = fm['composes_with']
|
|
448
|
+
if not isinstance(cw, list):
|
|
449
|
+
print(f'COMPOSES_WITH_TYPE_ERROR:got {type(cw).__name__}, expected list')
|
|
450
|
+
sys.exit(0)
|
|
451
|
+
missing = []
|
|
452
|
+
for name in cw:
|
|
453
|
+
if not isinstance(name, str):
|
|
454
|
+
print(f'COMPOSES_WITH_TYPE_ERROR:list item is {type(name).__name__}, expected string')
|
|
455
|
+
sys.exit(0)
|
|
456
|
+
skill_path = f'{substrate_root}/.claude/skills/{name}/SKILL.md'
|
|
457
|
+
if not os.path.isfile(skill_path):
|
|
458
|
+
missing.append(name)
|
|
459
|
+
if missing:
|
|
460
|
+
print(f'COMPOSES_WITH_MISSING:{\",\".join(missing)}')
|
|
461
|
+
sys.exit(0)
|
|
462
|
+
|
|
463
|
+
if substrate_root and 'depends_on' in fm:
|
|
464
|
+
do = fm['depends_on']
|
|
465
|
+
if isinstance(do, list):
|
|
466
|
+
missing = [n for n in do
|
|
467
|
+
if isinstance(n, str) and not target_resolved(n, substrate_root)]
|
|
468
|
+
if missing:
|
|
469
|
+
print(f'DEPENDS_ON_MISSING:{\",\".join(missing)}')
|
|
470
|
+
sys.exit(0)
|
|
471
|
+
|
|
472
|
+
print('OK')
|
|
473
|
+
" 2>/dev/null)
|
|
474
|
+
|
|
475
|
+
case "$FM_RESULT" in
|
|
476
|
+
OK|"")
|
|
477
|
+
# Pass — no violation
|
|
478
|
+
;;
|
|
479
|
+
MISSING_FRONTMATTER)
|
|
480
|
+
VIOLATION="Frontmatter required for $SURFACE files but none found."
|
|
481
|
+
LINE_REF="top of file"
|
|
482
|
+
SUGGESTED_FIX="Add a YAML frontmatter block opening with --- on line 1. See standards/frontmatter-schema.md for required + recognized fields."
|
|
483
|
+
;;
|
|
484
|
+
MALFORMED_YAML:*)
|
|
485
|
+
REASON=$(echo "$FM_RESULT" | sed 's/^MALFORMED_YAML://')
|
|
486
|
+
VIOLATION="Frontmatter YAML is malformed: $REASON"
|
|
487
|
+
LINE_REF="frontmatter block"
|
|
488
|
+
SUGGESTED_FIX="Fix YAML syntax. Common causes: unquoted colons in values, mismatched indentation, trailing tabs. See standards/frontmatter-schema.md."
|
|
489
|
+
;;
|
|
490
|
+
MISSING_REQUIRED:*)
|
|
491
|
+
FIELD=$(echo "$FM_RESULT" | sed 's/^MISSING_REQUIRED://')
|
|
492
|
+
VIOLATION="Frontmatter missing required field for $SURFACE: \`$FIELD\`"
|
|
493
|
+
LINE_REF="frontmatter $FIELD: field"
|
|
494
|
+
if [ "$FIELD" = "tier" ]; then
|
|
495
|
+
SUGGESTED_FIX="Add \`tier: standard\` to the frontmatter. Values: upstream | lite | standard | standard-pro | ultra. See standards/tier-tag-schema.md."
|
|
496
|
+
else
|
|
497
|
+
SUGGESTED_FIX="Add \`$FIELD:\` with a non-empty value to the frontmatter. See standards/frontmatter-schema.md for the full schema."
|
|
498
|
+
fi
|
|
499
|
+
;;
|
|
500
|
+
INVALID_TIER:*)
|
|
501
|
+
REASON=$(echo "$FM_RESULT" | sed 's/^INVALID_TIER://')
|
|
502
|
+
VIOLATION="Frontmatter \`tier:\` has invalid value — $REASON"
|
|
503
|
+
LINE_REF="frontmatter tier: field"
|
|
504
|
+
SUGGESTED_FIX="Use one of: private (never ships) | basic (ships first release) | premium (ships after promotion) | public (legacy/already-shipped). See standards/tier-tag-schema.md (WU-1)."
|
|
505
|
+
;;
|
|
506
|
+
UNKNOWN_FIELDS:*)
|
|
507
|
+
# WARN only — not a block. Surface to stderr but exit 0.
|
|
508
|
+
FIELDS=$(echo "$FM_RESULT" | sed 's/^UNKNOWN_FIELDS://')
|
|
509
|
+
echo "substrate-clarity-gate: WARN — unknown frontmatter field(s) in $SURFACE [$REPO_REL]: $FIELDS" >&2
|
|
510
|
+
echo " Recognized fields documented in standards/frontmatter-schema.md." >&2
|
|
511
|
+
echo " If this is a new substrate field, propose it via /promote substrate-evolution." >&2
|
|
512
|
+
;;
|
|
513
|
+
COMPOSES_WITH_TYPE_ERROR:*)
|
|
514
|
+
REASON=$(echo "$FM_RESULT" | sed 's/^COMPOSES_WITH_TYPE_ERROR://')
|
|
515
|
+
VIOLATION="composes_with must be a YAML list of skill names ($REASON)."
|
|
516
|
+
LINE_REF="frontmatter composes_with: field"
|
|
517
|
+
SUGGESTED_FIX="Format: composes_with: [skill-name-1, skill-name-2]. See .claude/rules/skill-composition-declarations.md for semantics."
|
|
518
|
+
;;
|
|
519
|
+
COMPOSES_WITH_MISSING:*)
|
|
520
|
+
NAMES=$(echo "$FM_RESULT" | sed 's/^COMPOSES_WITH_MISSING://')
|
|
521
|
+
VIOLATION="composes_with names skills that don't exist: $NAMES"
|
|
522
|
+
LINE_REF="frontmatter composes_with: field"
|
|
523
|
+
SUGGESTED_FIX="Each name must match a .claude/skills/<name>/SKILL.md file. Likely a typo. List available skills: ls .claude/skills/. See .claude/rules/skill-composition-declarations.md."
|
|
524
|
+
;;
|
|
525
|
+
DEPENDS_ON_MISSING:*)
|
|
526
|
+
# ADVISORY — pass with warning to stderr.
|
|
527
|
+
NAMES=$(echo "$FM_RESULT" | sed 's/^DEPENDS_ON_MISSING://')
|
|
528
|
+
echo "substrate-clarity-gate: ADVISORY — depends_on names targets without a matching skill, rule, or known config-block: $NAMES" >&2
|
|
529
|
+
echo " Recognized config blocks: tech_stack, adopter_defaults, plan_tier, prose_discipline, longrun, testing." >&2
|
|
530
|
+
echo " Per .claude/rules/skill-composition-declarations.md — V1 advisory tier; strict enforcement deferred." >&2
|
|
531
|
+
;;
|
|
532
|
+
esac
|
|
533
|
+
fi
|
|
534
|
+
fi
|
|
535
|
+
|
|
536
|
+
# If frontmatter check fired a violation, skip the per-surface dispatch — fix
|
|
537
|
+
# the frontmatter first.
|
|
538
|
+
if [ -z "$VIOLATION" ]; then
|
|
539
|
+
|
|
540
|
+
case "$SURFACE" in
|
|
541
|
+
skill)
|
|
542
|
+
# Extract description from frontmatter
|
|
543
|
+
DESCRIPTION=$(echo "$FUTURE_CONTENT" | awk '
|
|
544
|
+
/^---$/{c++; next}
|
|
545
|
+
c==1 && /^description:/ {
|
|
546
|
+
sub(/^description:[[:space:]]*/, "")
|
|
547
|
+
# Handle quoted descriptions (single or double quotes)
|
|
548
|
+
if (/^".*"$/) { gsub(/^"|"$/, "") }
|
|
549
|
+
else if (/^'\''.*'\''$/) { gsub(/^'\''|'\''$/, "") }
|
|
550
|
+
print
|
|
551
|
+
exit
|
|
552
|
+
}
|
|
553
|
+
')
|
|
554
|
+
|
|
555
|
+
if [ -z "$DESCRIPTION" ]; then
|
|
556
|
+
# No description field at all — not a violation per this rule (other
|
|
557
|
+
# rules cover required-field). Pass.
|
|
558
|
+
exit 0
|
|
559
|
+
fi
|
|
560
|
+
|
|
561
|
+
DESC_LEN=${#DESCRIPTION}
|
|
562
|
+
|
|
563
|
+
# Check 1: length
|
|
564
|
+
if [ "$DESC_LEN" -gt 280 ]; then
|
|
565
|
+
VIOLATION="Description exceeds 280 chars (got $DESC_LEN)."
|
|
566
|
+
LINE_REF="frontmatter description: field"
|
|
567
|
+
SUGGESTED_FIX="Use verb + outcome + how + why-distinct ≤280 chars total. See worked examples in .claude/rules/skill-description-clarity.md"
|
|
568
|
+
fi
|
|
569
|
+
|
|
570
|
+
# Check 2: verb-first opener (skip if check 1 already fired)
|
|
571
|
+
if [ -z "$VIOLATION" ]; then
|
|
572
|
+
FIRST_WORD=$(echo "$DESCRIPTION" | awk '{print $1}' | tr -d '[:punct:]' | tr '[:upper:]' '[:lower:]')
|
|
573
|
+
# Whitelist of imperative starters (extensible)
|
|
574
|
+
VERB_FIRST=0
|
|
575
|
+
for verb in show run turn create build take write reads generate compose audit launch render fire propose make produce dispatch read scan check verify validate enforce bootstrap install update extend extract ship pause start stop print emit publish capture flag detect orchestrate manage track surface route plan diagnose decompose review compose-with frame mock preview retrieve sync configure deploy invoke kill enumerate filter inspect sweep explain browse scaffold load draft convert declare get set pick choose name handle manage rename pull push kick fetch list count parse format save store post send drop add remove submit accept reject pass fail open close reopen claim release commit log copy move; do
|
|
576
|
+
if [ "$FIRST_WORD" = "$verb" ]; then
|
|
577
|
+
VERB_FIRST=1
|
|
578
|
+
break
|
|
579
|
+
fi
|
|
580
|
+
done
|
|
581
|
+
if [ "$VERB_FIRST" -eq 0 ]; then
|
|
582
|
+
VIOLATION="Description doesn't open with a verb. First word: '$FIRST_WORD'."
|
|
583
|
+
LINE_REF="frontmatter description: field"
|
|
584
|
+
SUGGESTED_FIX="Open with imperative: Show, Run, Turn, Create, Build, Compose, etc. Avoid noun-first openers like 'Composer for...' or 'Tier-preset...'."
|
|
585
|
+
fi
|
|
586
|
+
fi
|
|
587
|
+
|
|
588
|
+
# Check 3: jargon detection (BLOCK — flipped from ADVISORY in bassclef#496
|
|
589
|
+
# LR6.5). Description must use plain language; bassclef jargon names
|
|
590
|
+
# internal substrate concepts that engineers + boss audiences don't have
|
|
591
|
+
# context for. Per `.claude/rules/skill-description-clarity.md`
|
|
592
|
+
# substitution table.
|
|
593
|
+
#
|
|
594
|
+
# Single-word terms checked with word-boundary regex. The "compose"
|
|
595
|
+
# family also catches the verb-form "Composes into" / "composes with"
|
|
596
|
+
# that triggered bassclef#496 (/roadmap-reconcile shipped under 280c
|
|
597
|
+
# but with "Composes into /longrun prep Step 0.5").
|
|
598
|
+
if [ -z "$VIOLATION" ]; then
|
|
599
|
+
JARGON=""
|
|
600
|
+
for term in 'composer' 'primitive' 'tier-preset' 'load-bearing' 'blast radius' 'compose-with' 'scope-bounded' 'operationalize' 'substrate'; do
|
|
601
|
+
if echo "$DESCRIPTION" | grep -qiE "\\b$term\\b"; then
|
|
602
|
+
JARGON="${JARGON}${term} "
|
|
603
|
+
fi
|
|
604
|
+
done
|
|
605
|
+
# Compose-verb forms used as methodology references ("Composes into",
|
|
606
|
+
# "composes with", "Composing the X chain"). The plain "compose" verb
|
|
607
|
+
# by itself (e.g., "compose a chronicle") isn't flagged.
|
|
608
|
+
if echo "$DESCRIPTION" | grep -qiE '\bcomposes? (into|with|across|over)\b'; then
|
|
609
|
+
JARGON="${JARGON}composes-into-or-with "
|
|
610
|
+
fi
|
|
611
|
+
if [ -n "$JARGON" ]; then
|
|
612
|
+
VIOLATION="Description contains bassclef jargon: $JARGON"
|
|
613
|
+
LINE_REF="frontmatter description: field"
|
|
614
|
+
SUGGESTED_FIX="Substitute plain English per .claude/rules/skill-description-clarity.md table. Examples: 'composer' → 'runs' / 'chains' / 'combines'; 'primitive' → 'building block'; 'load-bearing' → 'required'; 'substrate' → 'system' / 'framework'; 'Composes into X' → name what the skill produces and where it slots in (e.g., 'used by /longrun prep' or just drop the methodology reference)."
|
|
615
|
+
fi
|
|
616
|
+
fi
|
|
617
|
+
|
|
618
|
+
# Check 4 (bassclef#1249): Skill-body INSTEAD-block discipline.
|
|
619
|
+
# Same shape as rule + luminary blocks below: every \b(don't|never|avoid)\b
|
|
620
|
+
# (case-insensitive, word-boundary) requires INSTEAD/Instead within 5 lines
|
|
621
|
+
# AFTER the trigger. Code blocks are exempt. Inline pairing passes.
|
|
622
|
+
# Allowlist (.claude/hooks/substrate-clarity-allowlist.txt) grandfathers
|
|
623
|
+
# existing skill bodies with a 60-day grace window.
|
|
624
|
+
if [ -z "$VIOLATION" ]; then
|
|
625
|
+
UNPAIRED=$(echo "$FUTURE_CONTENT" | awk '
|
|
626
|
+
BEGIN { trigger_line = 0; trigger_text = ""; }
|
|
627
|
+
{
|
|
628
|
+
line_num++
|
|
629
|
+
line = $0
|
|
630
|
+
if (line ~ /^```/) { in_code = !in_code; next }
|
|
631
|
+
if (in_code) next
|
|
632
|
+
|
|
633
|
+
if (trigger_line > 0) {
|
|
634
|
+
if (line_num - trigger_line <= 5) {
|
|
635
|
+
if (line ~ /INSTEAD/ || line ~ /^Instead[ ,:]/) {
|
|
636
|
+
trigger_line = 0
|
|
637
|
+
trigger_text = ""
|
|
638
|
+
}
|
|
639
|
+
} else {
|
|
640
|
+
print "L" trigger_line ": " trigger_text
|
|
641
|
+
trigger_line = 0
|
|
642
|
+
trigger_text = ""
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
if (trigger_line == 0) {
|
|
647
|
+
if (line ~ /INSTEAD/ || line ~ /^Instead[ ,:]/) next
|
|
648
|
+
lower = tolower(line)
|
|
649
|
+
if (lower ~ /(^|[^a-z])(don.t|never|avoid)([^a-z]|$)/) {
|
|
650
|
+
trigger_line = line_num
|
|
651
|
+
trigger_text = substr(line, 1, 80)
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
END {
|
|
656
|
+
if (trigger_line > 0) {
|
|
657
|
+
print "L" trigger_line ": " trigger_text
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
')
|
|
661
|
+
|
|
662
|
+
if [ -n "$UNPAIRED" ]; then
|
|
663
|
+
COUNT=$(echo "$UNPAIRED" | wc -l | tr -d ' ')
|
|
664
|
+
VIOLATION="Skill body has $COUNT unpaired don't/never/avoid (no literal INSTEAD within 5 lines)."
|
|
665
|
+
LINE_REF=$(echo "$UNPAIRED" | head -1 | cut -d: -f1)
|
|
666
|
+
SUGGESTED_FIX="Pair each negation with a literal INSTEAD block within 5 lines. Example: 'Don't X.' followed by 'INSTEAD: do Y.' Per rule .claude/rules/context-engineering.md §INSTEAD-block pattern. To grandfather a legacy file: add path to .claude/hooks/substrate-clarity-allowlist.txt with bassclef#1249 reference."
|
|
667
|
+
fi
|
|
668
|
+
fi
|
|
669
|
+
|
|
670
|
+
# Check 5 (bassclef-upstream#799): ADR-044 section-presence — V1 ADVISORY.
|
|
671
|
+
# Scans SKILL body for the 13 required ## section headers named at
|
|
672
|
+
# ADR-044 L55-L82. V1 emits ADVISORY to stderr; exit 0 (does not
|
|
673
|
+
# block). V2 BLOCK flip deferred past 2026-10-31 per
|
|
674
|
+
# we-dont-break-adopters grace window (separate follow-on ticket).
|
|
675
|
+
if [ -z "$VIOLATION" ]; then
|
|
676
|
+
MISSING_SECTIONS=""
|
|
677
|
+
for SECTION in \
|
|
678
|
+
"What you get" \
|
|
679
|
+
"When to use it" \
|
|
680
|
+
"When NOT to use" \
|
|
681
|
+
"FAQ" \
|
|
682
|
+
"Details below the fold" \
|
|
683
|
+
"Examples" \
|
|
684
|
+
"Anti-patterns and INSTEAD" \
|
|
685
|
+
"Composes with" \
|
|
686
|
+
"Distinct from" \
|
|
687
|
+
"Rules" \
|
|
688
|
+
"Test cases" \
|
|
689
|
+
"Sources read" \
|
|
690
|
+
"Why this skill exists"
|
|
691
|
+
do
|
|
692
|
+
if ! echo "$FUTURE_CONTENT" | grep -qE "^## ${SECTION}"; then
|
|
693
|
+
MISSING_SECTIONS="${MISSING_SECTIONS}${SECTION}|"
|
|
694
|
+
fi
|
|
695
|
+
done
|
|
696
|
+
if [ -n "$MISSING_SECTIONS" ]; then
|
|
697
|
+
echo "[substrate-clarity-gate: ADVISORY (ADR-044 section-presence) — $FILE_PATH]" >&2
|
|
698
|
+
echo " Missing required section(s) per ADR-044 L53-L82:" >&2
|
|
699
|
+
echo "$MISSING_SECTIONS" | tr '|' '\n' | sed '/^$/d' | sed 's/^/ - ## /' >&2
|
|
700
|
+
echo " V1 advisory; V2 BLOCK flip deferred past 2026-10-31 per .claude/rules/we-dont-break-adopters.md grace window." >&2
|
|
701
|
+
fi
|
|
702
|
+
fi
|
|
703
|
+
;;
|
|
704
|
+
|
|
705
|
+
rule)
|
|
706
|
+
# Rule INSTEAD-block discipline: every \b(don't|never|avoid)\b
|
|
707
|
+
# (case-insensitive, word-boundary) requires INSTEAD/Instead within
|
|
708
|
+
# 5 lines AFTER the trigger. Multi-line scan via awk.
|
|
709
|
+
UNPAIRED=$(echo "$FUTURE_CONTENT" | awk '
|
|
710
|
+
BEGIN { trigger_line = 0; trigger_text = ""; }
|
|
711
|
+
{
|
|
712
|
+
line_num++
|
|
713
|
+
line = $0
|
|
714
|
+
# Skip code blocks (between ``` markers) — quoted prose is exempt
|
|
715
|
+
if (line ~ /^```/) {
|
|
716
|
+
in_code = !in_code
|
|
717
|
+
next
|
|
718
|
+
}
|
|
719
|
+
if (in_code) next
|
|
720
|
+
|
|
721
|
+
# If we are tracking a trigger and find INSTEAD within window: clear
|
|
722
|
+
# Require INSTEAD (all caps) OR line-start Instead — strict bassclef
|
|
723
|
+
# pattern. Mid-prose "instead" doesnt qualify (the rule discipline
|
|
724
|
+
# is to put a deliberate INSTEAD: substitute, not natural language).
|
|
725
|
+
if (trigger_line > 0) {
|
|
726
|
+
if (line_num - trigger_line <= 5) {
|
|
727
|
+
if (line ~ /INSTEAD/ || line ~ /^Instead[ ,:]/) {
|
|
728
|
+
trigger_line = 0
|
|
729
|
+
trigger_text = ""
|
|
730
|
+
}
|
|
731
|
+
} else {
|
|
732
|
+
# Window expired without INSTEAD — emit unpaired
|
|
733
|
+
print "L" trigger_line ": " trigger_text
|
|
734
|
+
trigger_line = 0
|
|
735
|
+
trigger_text = ""
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
# Detect new trigger (only if not already tracking)
|
|
740
|
+
if (trigger_line == 0) {
|
|
741
|
+
# Skip lines that already contain INSTEAD/line-start Instead on the
|
|
742
|
+
# same line (paired inline)
|
|
743
|
+
if (line ~ /INSTEAD/ || line ~ /^Instead[ ,:]/) next
|
|
744
|
+
|
|
745
|
+
# Check for trigger words. Use lowercased copy for matching.
|
|
746
|
+
lower = tolower(line)
|
|
747
|
+
if (lower ~ /(^|[^a-z])(don.t|never|avoid)([^a-z]|$)/) {
|
|
748
|
+
trigger_line = line_num
|
|
749
|
+
trigger_text = substr(line, 1, 80)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
END {
|
|
754
|
+
# Final pending trigger at EOF
|
|
755
|
+
if (trigger_line > 0) {
|
|
756
|
+
print "L" trigger_line ": " trigger_text
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
')
|
|
760
|
+
|
|
761
|
+
if [ -n "$UNPAIRED" ]; then
|
|
762
|
+
# v1 BLOCK (flipped 2026-05-07 in Phase I — Plain English Sweep).
|
|
763
|
+
# Allowlist grandfathers existing violators per bassclef#374
|
|
764
|
+
# audit; net-new edits to non-allowlisted rules MUST pair every
|
|
765
|
+
# don't/never/avoid with a literal INSTEAD within 5 lines.
|
|
766
|
+
COUNT=$(echo "$UNPAIRED" | wc -l | tr -d ' ')
|
|
767
|
+
VIOLATION="Rule has $COUNT unpaired don't/never/avoid (no literal INSTEAD within 5 lines)."
|
|
768
|
+
LINE_REF=$(echo "$UNPAIRED" | head -1 | cut -d: -f1)
|
|
769
|
+
SUGGESTED_FIX="Pair each negation with a literal INSTEAD block within 5 lines. Example: 'Don't X.' followed by 'INSTEAD: do Y.' Per rule .claude/rules/context-engineering.md §INSTEAD-block pattern. To grandfather a legacy file: add path to .claude/hooks/substrate-clarity-allowlist.txt with bassclef#374 reference."
|
|
770
|
+
fi
|
|
771
|
+
;;
|
|
772
|
+
|
|
773
|
+
luminary)
|
|
774
|
+
# Luminary INSTEAD-block discipline: same as rule check (v0 ADVISORY).
|
|
775
|
+
# Plus required-sections check: every luminary should declare when to
|
|
776
|
+
# channel it + what anti-patterns mark drift.
|
|
777
|
+
UNPAIRED=$(echo "$FUTURE_CONTENT" | awk '
|
|
778
|
+
BEGIN { trigger_line = 0; trigger_text = ""; }
|
|
779
|
+
{
|
|
780
|
+
line_num++
|
|
781
|
+
line = $0
|
|
782
|
+
if (line ~ /^```/) { in_code = !in_code; next }
|
|
783
|
+
if (in_code) next
|
|
784
|
+
|
|
785
|
+
if (trigger_line > 0) {
|
|
786
|
+
if (line_num - trigger_line <= 5) {
|
|
787
|
+
if (line ~ /INSTEAD/ || line ~ /^Instead[ ,:]/) {
|
|
788
|
+
trigger_line = 0
|
|
789
|
+
trigger_text = ""
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
print "L" trigger_line ": " trigger_text
|
|
793
|
+
trigger_line = 0
|
|
794
|
+
trigger_text = ""
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
if (trigger_line == 0) {
|
|
799
|
+
if (line ~ /INSTEAD/ || line ~ /^Instead[ ,:]/) next
|
|
800
|
+
lower = tolower(line)
|
|
801
|
+
if (lower ~ /(^|[^a-z])(don.t|never|avoid)([^a-z]|$)/) {
|
|
802
|
+
trigger_line = line_num
|
|
803
|
+
trigger_text = substr(line, 1, 80)
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
END {
|
|
808
|
+
if (trigger_line > 0) {
|
|
809
|
+
print "L" trigger_line ": " trigger_text
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
')
|
|
813
|
+
|
|
814
|
+
if [ -n "$UNPAIRED" ]; then
|
|
815
|
+
# v1 BLOCK (flipped 2026-05-07 in Phase I — Plain English Sweep).
|
|
816
|
+
# Allowlist grandfathers existing violators per bassclef#373
|
|
817
|
+
# audit; net-new edits to non-allowlisted luminaries MUST pair
|
|
818
|
+
# every don't/never/avoid with a literal INSTEAD within 5 lines.
|
|
819
|
+
COUNT=$(echo "$UNPAIRED" | wc -l | tr -d ' ')
|
|
820
|
+
VIOLATION="Luminary has $COUNT unpaired don't/never/avoid (no literal INSTEAD within 5 lines)."
|
|
821
|
+
LINE_REF=$(echo "$UNPAIRED" | head -1 | cut -d: -f1)
|
|
822
|
+
SUGGESTED_FIX="Pair each negation with a literal INSTEAD block within 5 lines. Example: 'Don't X.' followed by 'INSTEAD: do Y.' Per rule .claude/rules/context-engineering.md §INSTEAD-block pattern. To grandfather a legacy file: add path to .claude/hooks/substrate-clarity-allowlist.txt with bassclef#373 reference. Brand-negation luminaries (whose signature IS negation) get per-luminary treatment in the audit."
|
|
823
|
+
fi
|
|
824
|
+
|
|
825
|
+
# Required sections check (kept as ADVISORY — section presence is
|
|
826
|
+
# documentation discipline, not a blocking concern. bassclef#373
|
|
827
|
+
# audit decides whether to promote this to BLOCK separately).
|
|
828
|
+
if [ -z "$VIOLATION" ]; then
|
|
829
|
+
MISSING_SECTIONS=""
|
|
830
|
+
if ! echo "$FUTURE_CONTENT" | grep -qiE "^#{1,6}[[:space:]]+when[[:space:]]+to[[:space:]]+channel"; then
|
|
831
|
+
MISSING_SECTIONS="${MISSING_SECTIONS}'When to channel this luminary' section, "
|
|
832
|
+
fi
|
|
833
|
+
if ! echo "$FUTURE_CONTENT" | grep -qiE "^#{1,6}[[:space:]]+anti-patterns?\\b"; then
|
|
834
|
+
MISSING_SECTIONS="${MISSING_SECTIONS}'Anti-patterns' section, "
|
|
835
|
+
fi
|
|
836
|
+
if [ -n "$MISSING_SECTIONS" ]; then
|
|
837
|
+
MISSING_SECTIONS="${MISSING_SECTIONS%, }"
|
|
838
|
+
echo "[substrate-clarity-gate: ADVISORY — $FILE_PATH missing $MISSING_SECTIONS]" >&2
|
|
839
|
+
echo "[advisory only; documentation discipline, not blocking]" >&2
|
|
840
|
+
fi
|
|
841
|
+
fi
|
|
842
|
+
;;
|
|
843
|
+
|
|
844
|
+
chronicle|iteration_bet|journal_entry|decomposition|whereami)
|
|
845
|
+
# Phase II WU-7b — broader plain-English discipline. Jargon check
|
|
846
|
+
# honors SURFACE_TIER:
|
|
847
|
+
# - chronicle / iteration_bet / decomposition → strict (BLOCK on jargon)
|
|
848
|
+
# after the #303 audit emptied the grandfathered allowlist.
|
|
849
|
+
# - journal_entry → advisory (voice trumps rule on personal narrative).
|
|
850
|
+
# - whereami → advisory V1 (bassclef-upstream bet 2026-07-04a WU-1).
|
|
851
|
+
# Long-sentence + passive-voice stay advisory on all surfaces; those
|
|
852
|
+
# are soft signals, not BLOCK-worthy.
|
|
853
|
+
NOTICES=""
|
|
854
|
+
|
|
855
|
+
# Check 1 — bassclef-internal jargon
|
|
856
|
+
# 'substrate' is on the allowlist per bassclef#763 — keep it OUT of
|
|
857
|
+
# the BLOCK list here so historical narrative referencing the framework
|
|
858
|
+
# by name does not trip the gate.
|
|
859
|
+
JARGON=""
|
|
860
|
+
for term in 'composer' 'primitive' 'tier-preset' 'load-bearing' 'blast radius' 'compose-with' 'scope-bounded' 'operationalize' 'stuck-signal' 'substrate-defect'; do
|
|
861
|
+
if echo "$FUTURE_CONTENT" | grep -qiE "\\b$term\\b"; then
|
|
862
|
+
JARGON="${JARGON}${term} "
|
|
863
|
+
fi
|
|
864
|
+
done
|
|
865
|
+
if [ -n "$JARGON" ]; then
|
|
866
|
+
if [ "$SURFACE_TIER" = "strict" ]; then
|
|
867
|
+
VIOLATION="bassclef-internal jargon found in ${SURFACE}: ${JARGON}— rewrite in plain English per standards/bassclef-internal-jargon.md"
|
|
868
|
+
else
|
|
869
|
+
NOTICES="${NOTICES}bassclef-internal jargon found: ${JARGON}\\n"
|
|
870
|
+
fi
|
|
871
|
+
fi
|
|
872
|
+
|
|
873
|
+
# Check 2 — long sentences (>50 words) — advisory only on these surfaces
|
|
874
|
+
# Strip frontmatter, strip code blocks, then count words per sentence.
|
|
875
|
+
LONG_SENTENCES=$(echo "$FUTURE_CONTENT" | awk '
|
|
876
|
+
BEGIN { in_frontmatter = 0; in_code = 0; long_count = 0 }
|
|
877
|
+
/^---$/ {
|
|
878
|
+
if (NR == 1) { in_frontmatter = 1; next }
|
|
879
|
+
if (in_frontmatter) { in_frontmatter = 0; next }
|
|
880
|
+
}
|
|
881
|
+
in_frontmatter { next }
|
|
882
|
+
/^```/ { in_code = !in_code; next }
|
|
883
|
+
in_code { next }
|
|
884
|
+
{
|
|
885
|
+
# Skip table rows + headers (markdown noise that fragments sentences)
|
|
886
|
+
if ($0 ~ /^[[:space:]]*\|/) next
|
|
887
|
+
if ($0 ~ /^[[:space:]]*#/) next
|
|
888
|
+
# Split on sentence terminators
|
|
889
|
+
n = split($0, parts, /[.!?]+[[:space:]]+/)
|
|
890
|
+
for (i = 1; i <= n; i++) {
|
|
891
|
+
part = parts[i]
|
|
892
|
+
gsub(/`[^`]*`/, "", part) # strip inline code
|
|
893
|
+
gsub(/^[[:space:]]+|[[:space:]]+$/, "", part)
|
|
894
|
+
if (length(part) == 0) continue
|
|
895
|
+
word_count = split(part, words, /[[:space:]]+/)
|
|
896
|
+
if (word_count > 50) long_count++
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
END { print long_count }
|
|
900
|
+
')
|
|
901
|
+
if [ "$LONG_SENTENCES" -gt 0 ]; then
|
|
902
|
+
NOTICES="${NOTICES}${LONG_SENTENCES} sentence(s) over 50 words — break into shorter sentences\\n"
|
|
903
|
+
fi
|
|
904
|
+
|
|
905
|
+
# Check 3 — passive voice (advisory) — simple regex; conservative
|
|
906
|
+
PASSIVE=$(echo "$FUTURE_CONTENT" | grep -ciE '\b(was|were|been|being)[[:space:]]+[a-z]+ed[[:space:]]+by\b' || true)
|
|
907
|
+
if [ "$PASSIVE" -gt 0 ]; then
|
|
908
|
+
NOTICES="${NOTICES}${PASSIVE} passive-voice construction(s) detected (e.g., 'X was Y-ed by Z') — prefer active voice\\n"
|
|
909
|
+
fi
|
|
910
|
+
|
|
911
|
+
if [ -n "$NOTICES" ]; then
|
|
912
|
+
echo "[substrate-clarity-gate: ADVISORY ($SURFACE) — $FILE_PATH]" >&2
|
|
913
|
+
echo -e "$NOTICES" >&2
|
|
914
|
+
echo "[advisory only; per .claude/rules/plain-english-discipline.md — voice trumps strict rule on narrative surfaces]" >&2
|
|
915
|
+
fi
|
|
916
|
+
# Do not set VIOLATION
|
|
917
|
+
;;
|
|
918
|
+
esac
|
|
919
|
+
|
|
920
|
+
# === Soft-INSTEAD audit (V1 ADVISORY — bassclef-upstream#375) ===
|
|
921
|
+
# Per @luminary alistair-cockburn (MUST/SHALL precision) + @luminary tony-hoare
|
|
922
|
+
# (preconditions absolute). Scans rule/skill/luminary surfaces for soft escape
|
|
923
|
+
# clauses that hand the agent an out from a hard MUST contract.
|
|
924
|
+
# V1: ADVISORY stderr notice. V2 (deferred, after calibration cycle): BLOCK on
|
|
925
|
+
# net-new violations; allowlist for existing.
|
|
926
|
+
# Surfaces scanned: skill, rule, luminary, adr (strict surfaces).
|
|
927
|
+
case "$SURFACE" in
|
|
928
|
+
skill|rule|luminary|adr)
|
|
929
|
+
# Strip code blocks + inline backticks from FUTURE_CONTENT before scan
|
|
930
|
+
# (matches scripts/soft-instead-audit.sh behavior — false-positive guard
|
|
931
|
+
# on quoted examples + pattern mentions).
|
|
932
|
+
SOFT_INSTEAD_STRIPPED=$(echo "$FUTURE_CONTENT" | awk '
|
|
933
|
+
BEGIN { in_code = 0 }
|
|
934
|
+
/^```/ { in_code = !in_code; next }
|
|
935
|
+
!in_code {
|
|
936
|
+
gsub(/`[^`]*`/, "")
|
|
937
|
+
print
|
|
938
|
+
}
|
|
939
|
+
')
|
|
940
|
+
SOFT_HITS=""
|
|
941
|
+
for pattern in 'OR hand-roll' 'OR write inline' 'OR compose by hand' 'if available' 'if at hand' 'when at hand' 'when available' 'if the skill is not' 'when the skill is not'; do
|
|
942
|
+
if echo "$SOFT_INSTEAD_STRIPPED" | grep -qiF "$pattern"; then
|
|
943
|
+
SOFT_HITS="${SOFT_HITS}${pattern} "
|
|
944
|
+
fi
|
|
945
|
+
done
|
|
946
|
+
if [ -n "$SOFT_HITS" ]; then
|
|
947
|
+
echo "[substrate-clarity-gate: ADVISORY (soft-INSTEAD) — $FILE_PATH]" >&2
|
|
948
|
+
echo " Soft-INSTEAD patterns found: $SOFT_HITS" >&2
|
|
949
|
+
echo " Per @luminary alistair-cockburn: INSTEAD names a MUST contract, not a soft alternative." >&2
|
|
950
|
+
echo " Cure: rewrite as MUST + explicit alternative-path (BLOCK when prereq missing)." >&2
|
|
951
|
+
echo " See: scripts/soft-instead-audit.sh + architecture/audits/2026-06-26-soft-instead-clauses.md" >&2
|
|
952
|
+
echo " V1 advisory; V2 (deferred) BLOCKs net-new violations." >&2
|
|
953
|
+
fi
|
|
954
|
+
;;
|
|
955
|
+
esac
|
|
956
|
+
|
|
957
|
+
# === Claim-shape scan (V1 ADVISORY — bet 2026-07-01e WU-4) ===
|
|
958
|
+
# Per bassclef-upstream#582 (R8 class extension to file-write surface) +
|
|
959
|
+
# .claude/rules/assert-only-after-verify.md. Fires on rule / chronicle /
|
|
960
|
+
# iteration_bet / adr surfaces. Detects paragraphs that read as claims
|
|
961
|
+
# (multiple modal words: must, should, always, never, wrong, broken, will)
|
|
962
|
+
# without a nearby citation pattern (file path, ticket #, git SHA,
|
|
963
|
+
# @luminary slug). Skips journal_entry (personal voice) + skill (WU-4 out
|
|
964
|
+
# of scope) + luminary + decomposition.
|
|
965
|
+
#
|
|
966
|
+
# V1 ADVISORY: emits stderr notice; does not block. V2 (deferred, after
|
|
967
|
+
# calibration cycle): flip specific paragraph patterns to BLOCK.
|
|
968
|
+
case "$SURFACE" in
|
|
969
|
+
rule|chronicle|iteration_bet|adr)
|
|
970
|
+
CLAIM_HITS=$(echo "$FUTURE_CONTENT" | python3 -c "
|
|
971
|
+
import sys, re
|
|
972
|
+
|
|
973
|
+
content = sys.stdin.read()
|
|
974
|
+
|
|
975
|
+
# Strip frontmatter
|
|
976
|
+
m = re.match(r'^---\n.*?\n---\n', content, re.DOTALL)
|
|
977
|
+
if m:
|
|
978
|
+
content = content[m.end():]
|
|
979
|
+
|
|
980
|
+
# Strip fenced code blocks
|
|
981
|
+
content = re.sub(r'\`\`\`.*?\`\`\`', '', content, flags=re.DOTALL)
|
|
982
|
+
|
|
983
|
+
# Split into paragraphs (blank-line separated)
|
|
984
|
+
paragraphs = re.split(r'\n\s*\n', content)
|
|
985
|
+
|
|
986
|
+
modal_re = re.compile(r'\b(must|should|always|never|wrong|broken|will)\b', re.IGNORECASE)
|
|
987
|
+
# Citation patterns:
|
|
988
|
+
# file paths: .md .sh .json .yml .yaml .py .ts .tsx
|
|
989
|
+
# ticket refs: #NNN, bassclef#NNN, sunj-labs/, poa#NNN, GH#NNN
|
|
990
|
+
# luminary slugs: @luminary <slug>
|
|
991
|
+
# inline backtick paths or accessors: '\`.*\`'
|
|
992
|
+
# 'per ' or 'see ' followed by any word
|
|
993
|
+
# git SHA (7+ hex)
|
|
994
|
+
citation_re = re.compile(
|
|
995
|
+
r'\b(?:'
|
|
996
|
+
r'[\w./-]+\.(?:md|sh|json|yml|yaml|py|ts|tsx)'
|
|
997
|
+
r'|[a-zA-Z-]*#\d{2,}'
|
|
998
|
+
r'|@luminary\s+[a-z-]+'
|
|
999
|
+
r'|per\s+[\`\w./-]+'
|
|
1000
|
+
r'|see\s+[\`\w./-]+'
|
|
1001
|
+
r'|sunj-labs/'
|
|
1002
|
+
r'|[0-9a-f]{7,40}'
|
|
1003
|
+
r')',
|
|
1004
|
+
re.IGNORECASE
|
|
1005
|
+
)
|
|
1006
|
+
# Inline backtick content also counts as citation
|
|
1007
|
+
inline_backtick_re = re.compile(r'\`[^\`]+\`')
|
|
1008
|
+
|
|
1009
|
+
count = 0
|
|
1010
|
+
for p in paragraphs:
|
|
1011
|
+
p_stripped = p.strip()
|
|
1012
|
+
if not p_stripped:
|
|
1013
|
+
continue
|
|
1014
|
+
# Skip headings and lists and blockquotes and tables
|
|
1015
|
+
first_non_ws = p_stripped[0]
|
|
1016
|
+
if first_non_ws in ('#', '-', '*', '|', '>'):
|
|
1017
|
+
continue
|
|
1018
|
+
# Numbered list
|
|
1019
|
+
if re.match(r'^\d+\.\s', p_stripped):
|
|
1020
|
+
continue
|
|
1021
|
+
modal_count = len(modal_re.findall(p_stripped))
|
|
1022
|
+
if modal_count < 2:
|
|
1023
|
+
continue
|
|
1024
|
+
has_citation = bool(citation_re.search(p_stripped)) or bool(inline_backtick_re.search(p_stripped))
|
|
1025
|
+
if not has_citation:
|
|
1026
|
+
count += 1
|
|
1027
|
+
|
|
1028
|
+
print(count)
|
|
1029
|
+
" 2>/dev/null)
|
|
1030
|
+
if [ -n "$CLAIM_HITS" ] && [ "$CLAIM_HITS" -gt 0 ]; then
|
|
1031
|
+
echo "[substrate-clarity-gate: ADVISORY (claim-shape) — $FILE_PATH]" >&2
|
|
1032
|
+
echo " $CLAIM_HITS paragraph(s) read as claims without cited source (2+ modal words, zero citation patterns nearby)." >&2
|
|
1033
|
+
echo " Per .claude/rules/assert-only-after-verify.md + bassclef-upstream#582 (R8 at file-write surface)." >&2
|
|
1034
|
+
echo " V1 advisory; V2 deferred after calibration cycle." >&2
|
|
1035
|
+
fi
|
|
1036
|
+
;;
|
|
1037
|
+
esac
|
|
1038
|
+
|
|
1039
|
+
fi # close the "skip per-surface dispatch if frontmatter check fired"
|
|
1040
|
+
|
|
1041
|
+
# === Rewrite-check heuristics (V1 ADVISORY — goal 25e Path A) ===
|
|
1042
|
+
# Sources lib/rewrite-check.sh and runs 6 heuristics on the future content:
|
|
1043
|
+
# passive voice, hyphen compounds, sentence length, non-SVO openers,
|
|
1044
|
+
# noun stacks, adverb density. Grounded in UK ONS + NN/g + US Federal
|
|
1045
|
+
# Plain Language + Hemingway.
|
|
1046
|
+
#
|
|
1047
|
+
# V1 ADVISORY: emits stderr notice; does not block. Fires on 17 surfaces
|
|
1048
|
+
# (rule + luminary + adr + skill + iteration_bet + decomposition + chronicle
|
|
1049
|
+
# from existing scope + 15 new SDLC artifact surfaces per Path A). Skips
|
|
1050
|
+
# journal_entry (personal voice) + whereami (frontmatter-heavy).
|
|
1051
|
+
# V2 flip after calibration cycle.
|
|
1052
|
+
case "$SURFACE" in
|
|
1053
|
+
journal_entry|whereami)
|
|
1054
|
+
# Skip — narrative voice / frontmatter-heavy content
|
|
1055
|
+
;;
|
|
1056
|
+
*)
|
|
1057
|
+
REWRITE_LIB="$(dirname "$0")/../../lib/rewrite-check.sh"
|
|
1058
|
+
if [ -f "$REWRITE_LIB" ]; then
|
|
1059
|
+
# Write FUTURE_CONTENT to temp file for heuristic
|
|
1060
|
+
REWRITE_TMP=$(mktemp 2>/dev/null)
|
|
1061
|
+
if [ -n "$REWRITE_TMP" ]; then
|
|
1062
|
+
echo "$FUTURE_CONTENT" > "$REWRITE_TMP"
|
|
1063
|
+
# Source in subshell so functions don't pollute parent scope
|
|
1064
|
+
HITS=$(
|
|
1065
|
+
source "$REWRITE_LIB" 2>/dev/null
|
|
1066
|
+
run_rewrite_checks "$REWRITE_TMP" 2>/dev/null | grep -cE "^-- "
|
|
1067
|
+
)
|
|
1068
|
+
if [ -n "$HITS" ] && [ "$HITS" -gt 0 ]; then
|
|
1069
|
+
echo "[substrate-clarity-gate: ADVISORY (rewrite-check) — $FILE_PATH]" >&2
|
|
1070
|
+
echo " $HITS finding category/ies from lib/rewrite-check.sh — passive voice / hyphen compounds / sentence length / non-SVO openers / noun stacks / adverb density." >&2
|
|
1071
|
+
echo " For details: bash scripts/sweep-kiss-dispatch.sh --dry-run --input <(echo $FILE_PATH)" >&2
|
|
1072
|
+
echo " Per goal 25e Path A. V1 advisory; V2 BLOCK deferred until calibration." >&2
|
|
1073
|
+
fi
|
|
1074
|
+
rm -f "$REWRITE_TMP"
|
|
1075
|
+
fi
|
|
1076
|
+
fi
|
|
1077
|
+
;;
|
|
1078
|
+
esac
|
|
1079
|
+
|
|
1080
|
+
# === Emit BLOCK if violation found ===
|
|
1081
|
+
if [ -n "$VIOLATION" ]; then
|
|
1082
|
+
cat >&2 <<EOF
|
|
1083
|
+
|
|
1084
|
+
============================================
|
|
1085
|
+
🛑 SUBSTRATE-CLARITY GATE — BLOCKED 🛑
|
|
1086
|
+
============================================
|
|
1087
|
+
|
|
1088
|
+
File: $FILE_PATH
|
|
1089
|
+
Tool: $TOOL_NAME
|
|
1090
|
+
|
|
1091
|
+
Violation: $VIOLATION
|
|
1092
|
+
Line: $LINE_REF
|
|
1093
|
+
|
|
1094
|
+
Suggested fix:
|
|
1095
|
+
$SUGGESTED_FIX
|
|
1096
|
+
|
|
1097
|
+
Per rule: .claude/rules/skill-description-clarity.md (bassclef#377)
|
|
1098
|
+
Per meta: .claude/rules/context-engineering.md (bassclef#371)
|
|
1099
|
+
|
|
1100
|
+
Override (logged): SKIP_SUBSTRATE_CLARITY=1 <command>
|
|
1101
|
+
|
|
1102
|
+
Allowlist (existing-violations grace):
|
|
1103
|
+
.claude/hooks/substrate-clarity-allowlist.txt
|
|
1104
|
+
============================================
|
|
1105
|
+
|
|
1106
|
+
EOF
|
|
1107
|
+
exit 2
|
|
1108
|
+
fi
|
|
1109
|
+
|
|
1110
|
+
exit 0
|