@thebassclef/lite 1.9.0 → 1.9.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.
@@ -1,41 +1,320 @@
1
1
  #!/bin/bash
2
- # tier: upstream
3
- # install-class: operator
4
- # Bassclef Substrate Sync — fires on SessionStart.
5
- # Operator-class hook: lives on bassclef-upstream operators' machines only.
6
- # Ship 1 (#1828) — install-class: operator retag; adopters use the dispatcher
7
- # (presence/install/bassclef-sync.dispatcher.template.sh) instead.
8
- # Adopter consumers receive presence/install/bassclef-sync.template.sh instead
9
- # (sourced from canonical until the WU-0 absorb gap is closed).
10
- # Ensures bassclef standards are available regardless of device.
11
- #
12
- # 1. Clones/pulls bassclef repo
13
- # 2. Symlinks bassclef skills into project .claude/skills/ (auto-discovery)
14
- # 3. Installs general hooks + agents to ~/.claude/
15
- # 4. Writes ~/.claude/settings.json for user-level hook wiring (cloud)
16
- # 5. Sets env var for rules/CLAUDE.md inheritance
17
-
18
- # === Section 0a: SELF_MODE detection (#915) ===
19
- # Sister to Cure 1 in #901 (commit 4b6c258d) which added SELF_MODE
20
- # detection to the adopter template at presence/install/bassclef-sync.template.sh.
21
- # This is the operator-class SessionStart hook — it needs the same guard.
22
- #
23
- # Detection: git remote origin URL matches sunj-labs/(bassclef|bassclef-upstream)(\.git)?$
24
- # on both HTTPS and SSH URL shapes. When SELF_MODE=1, hook exits 0 early
25
- # to prevent the sync flow from re-symlinking tracked hooks in the
26
- # substrate source repo.
27
- #
28
- # Live proof of the class this cures: session 2026-07-26d WU-0 reverted 84
29
- # T + 1 M hooks caused by this SessionStart re-symlink in bassclef-upstream.
2
+ # tier: lite
3
+ # description: Retagged standard → lite per #1301 concentric rule (ADR-052 D1) —
4
+ # Retagged standard → lite per #1301 concentric rule (ADR-052 D1) —
5
+ # this dispatcher IS the sync entry point every adopter vendors into
6
+ # .claude/hooks/bassclef-sync.sh. Without ship at lite, lite adopters
7
+ # have no sync at all. Closes #1301 sister file.
8
+ # BASSCLEF_SYNC_VERSION=thin-pointer-2026-07-28a
9
+ # ^^ DO NOT REMOVE — used by bassclef's drift check + Layer 3 self-heal
10
+ # detection (per ADR-032). Version string format: descriptor-YYYY-MM-DD.
11
+ #
12
+ # Thin-pointer bassclef-sync dispatcher.
13
+ #
14
+ # Source of truth for the dispatcher consumers vendor into their
15
+ # .claude/hooks/bassclef-sync.sh. See standards/bassclef-source-config.md
16
+ # for the .bassclef-source.json schema this dispatcher reads.
17
+ #
18
+ # Two paths, tried in order:
19
+ #
20
+ # 1. Sibling fast-path (local dev, desktop, multi-repo workspaces)
21
+ # — if $CWD/../bassclef/presence/install/bassclef-sync.template.sh
22
+ # exists, exec it directly. Always-current because the file is
23
+ # read fresh on every fire.
24
+ #
25
+ # 2. Curl fallback (Cloud Env, mobile, fresh sandboxes, CI)
26
+ # — read .bassclef-source.json for upstream URL + auth metadata,
27
+ # fetch the tarball, extract, exec the included template.
28
+ #
29
+ # When neither path works, the dispatcher prints a plain-language
30
+ # explanation with three resolution paths (public adopter / local dev
31
+ # / private fork). Always exits 0 so SessionStart hooks don't crash;
32
+ # consumers that want CI fail-fast behavior should pre-check inside
33
+ # their workflow.
34
+ #
35
+ # Versioning: bump BASSCLEF_SYNC_VERSION when the dispatcher's
36
+ # semantics change. Format: YYYY-MM-DD or descriptor-YYYY-MM-DD.
37
+ #
38
+ # thin-pointer-2026-07-09: pointer-file rename cure (bet 2026-07-09a).
39
+ # Adopters onboarded before 2026-06-20 rename carry
40
+ # .canonical-source.json. Dispatcher now reads new
41
+ # name first, falls back to old, and self-heals
42
+ # (writes new name from old content; removes old on
43
+ # second cycle per Hoare two-step atomic). Fires
44
+ # before auto_sync check + Path 2 curl fallback.
45
+ # Overrides: SKIP_SELF_HEAL_POINTER=1 skips write.
46
+ # Prior version: thin-pointer-2026-06-29c.
47
+ # thin-pointer-2026-06-29c: fallback message names the cure (bet 29e WU-10).
48
+ # emit_error() now lists option 4 — the curl
49
+ # one-liner that runs migrate-adopter-references.sh
50
+ # for adopters whose tree carries leftover
51
+ # canonical-* references from the 2026-06-20 rename
52
+ # event. Discoverable cure inside the error itself;
53
+ # the adopter does not need to read the wiki.
54
+ # Prior version: thin-pointer-2026-06-29b.
55
+ # thin-pointer-2026-06-29b: auto-invocation added (bet 29e WU-4). Detection
56
+ # step now runs the migrate script automatically
57
+ # via curl-pipe + continues to Path 1/Path 2.
58
+ # SKIP_AUTO_INVOKE=1 falls back to WU-2's BLOCKED
59
+ # behavior. Passes BASSCLEF_DISPATCHER_TEMPLATE
60
+ # to migrate when sibling template available.
61
+ # Prior version: thin-pointer-2026-06-29.
62
+ # thin-pointer-2026-06-29: canonical-* reference detection step added
63
+ # (bet 29e WU-2). Scans adopter .claude/settings.json
64
+ # + vendored bassclef-sync.sh for refs surviving the
65
+ # 2026-06-20 rename. Emits BLOCKED + exits 0. WU-4 will
66
+ # replace exit with auto-invocation of migrate-adopter-
67
+ # references.sh. Override: SKIP_CANONICAL_DETECTION=1.
68
+ # Prior version: thin-pointer-2026-06-22.
69
+ # thin-pointer-2026-06-22: version-detection helper added per ADR-032
70
+ # (Layer 2 two-stage rocket). __bassclef_dispatcher_version
71
+ # function exposes BASSCLEF_SYNC_VERSION to external
72
+ # tooling (Layer 3 self-heal in WU-4). No protocol
73
+ # break; purely additive. Prior version: thin-pointer-2026-06-13.
74
+ # thin-pointer-2026-06-13: auto_sync flag check — dispatcher short-
75
+ # circuits when .bassclef-source.json has
76
+ # auto_sync:false or the field absent. Per
77
+ # bassclef#939. Prior version: 2026-05-15.
78
+ # thin-pointer-2026-05-15: friendly error messages with three
79
+ # resolution paths. Closes bassclef#715
80
+ # Layer A. Prior version: 2026-05-02.
81
+
82
+ # === Version-detection helper (per ADR-032) ===
83
+ # Layer 3 self-heal (WU-4 of bet 2026-06-22b) calls this to detect
84
+ # bootstrap version drift. External tooling can also grep
85
+ # BASSCLEF_SYNC_VERSION from line 3, but the function gives a stable
86
+ # programmatic interface that survives header-comment reformatting.
87
+ __bassclef_dispatcher_version() {
88
+ echo "thin-pointer-2026-07-09"
89
+ }
90
+
91
+ # === Pointer-file fallback + self-heal (bet 2026-07-09a) ===
92
+ #
93
+ # Every adopter onboarded before the 2026-06-20 canonical → bassclef
94
+ # rename carries .canonical-source.json. The dispatcher previously read
95
+ # .bassclef-source.json only; stale adopters saw substrate refuse to
96
+ # load. These two functions cure the class:
97
+ #
98
+ # bassclef_sync_resolve_pointer_file(cwd)
99
+ # Echoes the path of the pointer file to use. Prefers new name;
100
+ # falls back to old name; echoes empty when neither exists.
101
+ #
102
+ # bassclef_sync_self_heal_pointer(cwd)
103
+ # Two-step atomic rename per Tony Hoare. First cycle: writes new
104
+ # name from old content, keeps old file for safety. Second cycle:
105
+ # removes old file when new already exists. Never deletes new.
106
+ # Honors SKIP_SELF_HEAL_POINTER=1 for one-shot recovery scenarios.
107
+ #
108
+ # Composing luminaries:
109
+ # @luminary linus-torvalds — we-dont-break-adopters (ADR-031)
110
+ # @luminary jerome-saltzer-and-michael-schroeder — complete mediation
111
+ # @luminary tony-hoare — pre/post conditions on two-step rename
112
+ # @luminary michael-feathers — characterization tests using real Quorum
113
+ #
114
+ # Tests: .claude/hooks/tests/bassclef-sync-pointer-fallback.test.sh
115
+ # Refs: bassclef-upstream#679
116
+ bassclef_sync_resolve_pointer_file() {
117
+ local cwd="${1:-$CWD}"
118
+ local new="$cwd/.bassclef-source.json"
119
+ local old="$cwd/.canonical-source.json"
120
+ if [ -f "$new" ]; then
121
+ echo "$new"
122
+ elif [ -f "$old" ]; then
123
+ echo "$old"
124
+ else
125
+ echo ""
126
+ fi
127
+ }
128
+
129
+ # bassclef_sync_bundled_substrate_present <cwd>
130
+ #
131
+ # Returns 0 (yes) when .claude/hooks/, .claude/rules/, .claude/skills/ all
132
+ # exist AND contain at least one file (recursively). Returns 1 (no) when
133
+ # any of those conditions fails.
134
+ #
135
+ # Cures the npm-only cold adopter false-error class per
136
+ # bassclef-upstream#1849. The dispatcher was designed for peer-checkout
137
+ # OR source-config models. Neither works for anonymous adopters against
138
+ # private repos. But the npm bundle already unpacks substrate at install
139
+ # time. The fetch failure is not a real failure; this check catches the
140
+ # steady state and lets the dispatcher exit silently.
141
+ #
142
+ # Composes with:
143
+ # @luminary alan-cooper — Sam persona; silent-on-success matches user model
144
+ # @luminary donald-norman — signifier; false-positive banner erodes real signal
145
+ # @luminary linus-torvalds — adopter contract; npm bundle IS the substrate
146
+ # @luminary michael-feathers — characterization tests over 3 install shapes
147
+ #
148
+ # Tests: .claude/hooks/tests/bassclef-sync-bundled-substrate-check.test.sh
149
+ # Refs: bassclef-upstream#1849
150
+ bassclef_sync_bundled_substrate_present() {
151
+ local cwd="${1:-}"
152
+ if [ -z "$cwd" ] || [ ! -d "$cwd" ]; then
153
+ return 1
154
+ fi
155
+ local _dir
156
+ for _dir in .claude/hooks .claude/rules .claude/skills; do
157
+ if [ ! -d "$cwd/$_dir" ]; then
158
+ return 1
159
+ fi
160
+ # Nonempty check: at least one file anywhere under the dir.
161
+ if [ -z "$(find "$cwd/$_dir" -mindepth 1 -maxdepth 4 -type f -print -quit 2>/dev/null)" ]; then
162
+ return 1
163
+ fi
164
+ done
165
+ return 0
166
+ }
167
+
168
+ bassclef_sync_self_heal_pointer() {
169
+ local cwd="${1:-$CWD}"
170
+ if [ "${SKIP_SELF_HEAL_POINTER:-0}" = "1" ]; then
171
+ return 0
172
+ fi
173
+ local new="$cwd/.bassclef-source.json"
174
+ local old="$cwd/.canonical-source.json"
175
+ # Second cycle: both exist → remove old, keep new (Hoare postcondition)
176
+ if [ -f "$new" ] && [ -f "$old" ]; then
177
+ rm -f "$old"
178
+ return 0
179
+ fi
180
+ # First cycle: only old exists → write new from old content, keep old for safety
181
+ if [ ! -f "$new" ] && [ -f "$old" ]; then
182
+ cp "$old" "$new" 2>/dev/null || return 0
183
+ # Emit a one-line note so operator sees the self-heal firing
184
+ echo "bassclef-sync: renamed .canonical-source.json → .bassclef-source.json (auto-heal; original kept for one session)" >&2
185
+ return 0
186
+ fi
187
+ # Only new exists OR neither exists → no-op
188
+ return 0
189
+ }
190
+
191
+ # bassclef_sync_self_heal_pointer_content(cwd)
192
+ # Rewrites drift in .bassclef-source.json content on adopters onboarded
193
+ # before the 2026-06-20 canonical → bassclef rename.
194
+ # - source_url: sunj-labs/canonical -> sunj-labs/bassclef
195
+ # - token_env: CANONICAL_TOKEN -> BASSCLEF_TOKEN
196
+ # Preserves auth_method (Bearer during beta), auto_sync flag, and every
197
+ # other field. Idempotent — no-op when content already uses new names.
198
+ # Honors SKIP_SELF_HEAL_POINTER_CONTENT=1 for one-shot recovery cases.
199
+ # Per bet 2026-07-09c WU-2 (bassclef-upstream#680). Sister to
200
+ # bassclef_sync_self_heal_pointer (filename cure from bet 09a).
201
+ bassclef_sync_self_heal_pointer_content() {
202
+ local cwd="${1:-$CWD}"
203
+ if [ "${SKIP_SELF_HEAL_POINTER_CONTENT:-0}" = "1" ]; then
204
+ return 0
205
+ fi
206
+ local pointer="$cwd/.bassclef-source.json"
207
+ if [ ! -f "$pointer" ]; then
208
+ return 0
209
+ fi
210
+ # Detect drift — cheap grep before invoking jq
211
+ local drift=0
212
+ if grep -q "sunj-labs/canonical" "$pointer" 2>/dev/null; then
213
+ drift=1
214
+ fi
215
+ if grep -q "CANONICAL_TOKEN" "$pointer" 2>/dev/null; then
216
+ drift=1
217
+ fi
218
+ if [ "$drift" = "0" ]; then
219
+ return 0
220
+ fi
221
+ # Rewrite via jq — preserves JSON structure and untouched fields
222
+ local new_json
223
+ new_json=$(jq '
224
+ (if (.source_url // "" | contains("sunj-labs/canonical"))
225
+ then .source_url = (.source_url | sub("sunj-labs/canonical"; "sunj-labs/bassclef"))
226
+ else . end)
227
+ | (if (.token_env // "") == "CANONICAL_TOKEN"
228
+ then .token_env = "BASSCLEF_TOKEN"
229
+ else . end)
230
+ | (if (.comment // "" | contains("sunj-labs/canonical"))
231
+ then .comment = (.comment | gsub("sunj-labs/canonical"; "sunj-labs/bassclef"))
232
+ else . end)
233
+ ' "$pointer" 2>/dev/null)
234
+ if [ -n "$new_json" ]; then
235
+ echo "$new_json" > "$pointer.tmp" 2>/dev/null && mv "$pointer.tmp" "$pointer" 2>/dev/null
236
+ echo "bassclef-sync: cured .bassclef-source.json content drift (source_url + token_env → bassclef names)" >&2
237
+ fi
238
+ return 0
239
+ }
240
+
241
+ # bassclef_sync_self_heal_claude_md_content(cwd)
242
+ # Inserts @.claude/bassclef-orientation.md after the first heading in
243
+ # adopter CLAUDE.md when missing. Rewrites stale sunj-labs/canonical
244
+ # URL references + ../canonical path references. Preserves adopter-
245
+ # authored sections. Backs up to CLAUDE.md.bak.<ts> before edit.
246
+ # Idempotent — no-op on cured content.
247
+ # Honors SKIP_SELF_HEAL_CLAUDE_MD_CONTENT=1 for adopters who want to
248
+ # own their CLAUDE.md fully.
249
+ # Per bet 2026-07-09c WU-3 (bassclef-upstream#687 Path 2). Sister to
250
+ # bassclef_sync_self_heal_pointer_content (WU-2 pointer content cure).
251
+ bassclef_sync_self_heal_claude_md_content() {
252
+ local cwd="${1:-$CWD}"
253
+ if [ "${SKIP_SELF_HEAL_CLAUDE_MD_CONTENT:-0}" = "1" ]; then
254
+ return 0
255
+ fi
256
+ local file="$cwd/CLAUDE.md"
257
+ if [ ! -f "$file" ]; then
258
+ return 0
259
+ fi
260
+ local drift_import=0
261
+ local drift_url=0
262
+ local drift_path=0
263
+ if ! grep -q "@.claude/bassclef-orientation.md" "$file" 2>/dev/null; then
264
+ drift_import=1
265
+ fi
266
+ if grep -q "sunj-labs/canonical" "$file" 2>/dev/null; then
267
+ drift_url=1
268
+ fi
269
+ if grep -q "\\.\\./canonical" "$file" 2>/dev/null; then
270
+ drift_path=1
271
+ fi
272
+ if [ "$drift_import" = "0" ] && [ "$drift_url" = "0" ] && [ "$drift_path" = "0" ]; then
273
+ return 0
274
+ fi
275
+ local backup_ts
276
+ backup_ts=$(date +%s)
277
+ cp "$file" "${file}.bak.${backup_ts}" 2>/dev/null
278
+ if [ "$drift_import" = "1" ]; then
279
+ awk 'BEGIN{added=0} /^# / && added==0 {print; print ""; print "@.claude/bassclef-orientation.md"; added=1; next} {print}' \
280
+ "$file" > "${file}.tmp" 2>/dev/null && mv "${file}.tmp" "$file" 2>/dev/null
281
+ fi
282
+ sed 's|sunj-labs/canonical|sunj-labs/bassclef|g; s|\.\./canonical|../bassclef|g' \
283
+ "$file" > "${file}.tmp" 2>/dev/null && mv "${file}.tmp" "$file" 2>/dev/null
284
+ echo "bassclef-sync: cured CLAUDE.md content drift (@import + canonical references → bassclef; backup at ${file}.bak.${backup_ts})" >&2
285
+ return 0
286
+ }
287
+
288
+ INPUT=$(cat)
289
+ CWD=$(echo "$INPUT" | jq -r '.cwd // "."')
290
+ cd "$CWD"
291
+
292
+ # Opt-out sentinel (bassclef#119): repos that must NOT inherit substrate.
293
+ if [ -f "$CWD/.no-bassclef-sync" ]; then
294
+ echo "Bassclef substrate sync: skipped (.no-bassclef-sync sentinel present)."
295
+ exit 0
296
+ fi
297
+
298
+ # === SELF_MODE detection (#929 — closes dispatcher-level bypass of #915) ===
299
+ # Sister to Section 0a in .claude/hooks/bassclef-sync.sh (which shipped in
300
+ # #915 PR #920 and guards the sync flow). This guard sits earlier in the
301
+ # call chain — at the dispatcher — so canonical-* auto-invoke + auto_sync
302
+ # fetch + Path 1 / Path 2 branches ALL skip when the current repo is the
303
+ # substrate source (bassclef or bassclef-upstream).
304
+ #
305
+ # Rationale: session 26g SessionStart re-symlinked ~63 hooks in
306
+ # bassclef-upstream despite #915. The project hook's SELF_MODE fired but
307
+ # only AFTER the dispatcher had already invoked the migrate script + fired
308
+ # other paths that touched the tree. Guard early = complete mediation.
30
309
  #
31
310
  # Env override SKIP_SELF_MODE_DETECTION=1 forces SELF_MODE=0 regardless
32
311
  # of remote — for adopter forks of bassclef-upstream that legitimately
33
312
  # want the sync flow.
34
313
  #
35
- # Function is defined ABOVE the main-body INPUT read so tests can source
36
- # the hook with BASSCLEF_SYNC_TEST_MODE=1 and access the function without
37
- # triggering the sync flow.
38
- bassclef_detect_self_mode() {
314
+ # Non-breaking for adopters per ADR-031: SELF_MODE returns 0 for any repo
315
+ # whose git remote is NOT sunj-labs/bassclef or sunj-labs/bassclef-upstream.
316
+ # All 5 existing adopters keep their normal recovery + sync paths.
317
+ __bassclef_dispatcher_detect_self_mode() {
39
318
  local cwd="${1:-.}"
40
319
 
41
320
  # Env override wins
@@ -67,783 +346,550 @@ bassclef_detect_self_mode() {
67
346
  return 0
68
347
  }
69
348
 
70
- # === _sync_install_hooks (goal 14c Step 5) ===
71
- # Install a hard-coded list of substrate hooks to the operator's
72
- # ~/.claude/hooks/. For each name: if the source hook declares a
73
- # # install-class: header, route via lib/hook-installer.sh's
74
- # install_by_class (which honors operator|project|dual). If no header,
75
- # fall back to legacy operator-scope copy per ADR-058 c2 grace window
76
- # (through 2026-10-31).
77
- #
78
- # Args:
79
- # $1 — BASSCLEF_DIR (source of hooks + lib)
80
- # $2 — user-scope target dir (default $HOME/.claude/hooks)
81
- #
82
- # Discipline (per ADR-058 constraints 1-4):
83
- # c1 — install_by_class wrapped in subshell; lib's strict mode
84
- # stays contained; caller's strict-mode state preserved
85
- # c2 — unheadered hooks install where they install today +
86
- # one trace line `skipped-no-header <hook>` fires
87
- # c3 — CLAUDE_PROJECT_DIR resolved (git rev-parse fallback) and
88
- # exported before the loop so project/dual class routing works
89
- # c4 — settings.json command strings unchanged; wiring self-heal
90
- # continues at Section 4 (unrelated to this function)
91
- _sync_install_hooks() {
92
- local bassclef_dir="$1"
93
- local user_target_dir="${2:-$HOME/.claude/hooks}"
94
-
95
- # Full-bundle iteration (goal 14c Step 6). Replaces the pre-Step-6
96
- # hardcoded 9-name list. Every hook the old list carried still installs —
97
- # either via dispatch (when header present) or via grace-window fallback
98
- # (when absent). RFC S-2 HIGH cure per docs/rfcs/
99
- # RFC-tier0-tests-install-class-dispatch-review.md.
349
+ __DISPATCHER_SELF_MODE=$(__bassclef_dispatcher_detect_self_mode "$CWD")
350
+ if [ "$__DISPATCHER_SELF_MODE" = "1" ]; then
351
+ # #895: park user-scope orphans that shadow project scope before exit.
352
+ # Without this, pre-existing symlinks planted by sibling syncs (POA,
353
+ # twoDo, utilities) shadow bassclef-upstream's project scope in the
354
+ # Claude Code menu — 92 skills appear twice. The parking helper leaves
355
+ # real user-scope dirs (operator-authored content) untouched per its
356
+ # v3 contract. Adopters never hit this path (SELF_MODE=0 for their
357
+ # remotes per __bassclef_dispatcher_detect_self_mode).
100
358
  #
101
- # Architect-review 2026-09-15c folds (L-F2, S-F1, S-F2, M-F1, N-F1) apply
102
- # equally to this operator sync path — mirrors the adopter template's
103
- # Section 3b shape.
359
+ # Override: SKIP_SELF_MODE_PARKING=1 for one-shot recovery scenarios.
360
+ if [ "${SKIP_SELF_MODE_PARKING:-0}" != "1" ]; then
361
+ __parking_helper="$CWD/scripts/bassclef-orphan-parking.sh"
362
+ if [ -x "$__parking_helper" ]; then
363
+ for __surface in skills agents hooks; do
364
+ __user_surface="$HOME/.claude/$__surface"
365
+ __project_surface="$CWD/.claude/$__surface"
366
+ if [ -d "$__user_surface" ] && [ -d "$__project_surface" ]; then
367
+ # v4 (bassclef-upstream#1077): pass BOTH user + project settings.json
368
+ # as variadic 5th+ args. Parking helper unions the $HOME wirings
369
+ # across both files. Any hook wired at $HOME by either settings
370
+ # scope is preserved. Missing paths silently skip (helper v4 contract).
371
+ bash "$__parking_helper" "$__user_surface" "$__project_surface" "$HOME/.claude" "$__surface" "$HOME/.claude/settings.json" "$CWD/.claude/settings.json" 2>/dev/null || true
372
+ fi
373
+ done
374
+ fi
375
+ fi
376
+ echo "Bassclef dispatcher SELF_MODE — skipping sync in substrate source repo." >&2
377
+ echo " (Override with SKIP_SELF_MODE_DETECTION=1 for adopter forks;" >&2
378
+ echo " SKIP_SELF_MODE_PARKING=1 to skip user-scope orphan parking.)" >&2
379
+ exit 0
380
+ fi
381
+
382
+ # === auto_sync flag check (bassclef#939) ===
383
+ #
384
+ # Per bassclef#939, fresh consumers default to OFF — the dispatcher
385
+ # only fetches when the consumer explicitly opts in via auto_sync: true
386
+ # in .bassclef-source.json. Cold adopters get a one-line note + the
387
+ # opt-in path instead of opaque auto-fetch.
388
+ #
389
+ # Default behavior on absence (file or field): OFF.
390
+ #
391
+ # Self-heal first (bet 2026-07-09a): stale adopters carrying
392
+ # .canonical-source.json get the file renamed to .bassclef-source.json
393
+ # so this pre-check sees the correct file. Second cycle drops the old.
394
+ bassclef_sync_self_heal_pointer "$CWD"
395
+ # Content-drift cure runs AFTER filename cure so the file we rewrite exists at the new name.
396
+ bassclef_sync_self_heal_pointer_content "$CWD"
397
+ # CLAUDE.md content cure runs at same lifecycle point — before Path 1 / Path 2 pick.
398
+ bassclef_sync_self_heal_claude_md_content "$CWD"
399
+ SOURCE_FILE_PRECHECK=$(bassclef_sync_resolve_pointer_file "$CWD")
400
+ if [ -n "$SOURCE_FILE_PRECHECK" ] && [ -f "$SOURCE_FILE_PRECHECK" ]; then
401
+ AUTO_SYNC=$(jq -r '.auto_sync // false' "$SOURCE_FILE_PRECHECK" 2>/dev/null)
402
+ if [ "$AUTO_SYNC" != "true" ]; then
403
+ cat <<NOTE
404
+ ### bassclef-sync — auto-update is OFF
104
405
 
105
- mkdir -p "$user_target_dir" 2>/dev/null || true
406
+ Bassclef can keep your skills, rules, and agents current at every
407
+ session start by pulling its latest substrate. It's off by default
408
+ for fresh repos so nothing fetches without your say-so.
106
409
 
107
- local installer_lib="$bassclef_dir/lib/hook-installer.sh"
108
- local dispatch_available=0
109
- [ -f "$installer_lib" ] && dispatch_available=1
410
+ Turn on anytime by saying "enable auto-sync" or by setting:
110
411
 
111
- # ADR-058 c3 — resolve CLAUDE_PROJECT_DIR before loop
112
- if [ -z "${CLAUDE_PROJECT_DIR:-}" ]; then
113
- if command -v git >/dev/null 2>&1; then
114
- CLAUDE_PROJECT_DIR="$(git rev-parse --show-toplevel 2>/dev/null || true)"
115
- fi
116
- if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
117
- export CLAUDE_PROJECT_DIR
118
- fi
119
- fi
412
+ "auto_sync": true
120
413
 
121
- # Bridge env var — lib uses BASSCLEF_TRACE_LOG.
122
- # Default location standardized to $HOME/.claude/state/trace.log per N-F2
123
- # (2026-09-15c review). Test-supplied env still wins.
124
- : "${BASSCLEF_TRACE_LOG:=${BASSCLEF_TRACE_FILE:-$HOME/.claude/state/trace.log}}"
125
- export BASSCLEF_TRACE_LOG
126
- mkdir -p "$(dirname "$BASSCLEF_TRACE_LOG")" 2>/dev/null || true
127
-
128
- local installed=0
129
- local skipped_no_header=0
130
- local skipped_invalid=0
131
- local skipped_lib_missing=0
132
- local failed=0
133
- # #1894 — capture missing-hook paths for structured status line
134
- local -a missing_paths=()
135
- local -a missing_scopes=()
136
-
137
- # S-F2 LOW — dir-exists guard before loop
138
- if [ ! -d "$bassclef_dir/.claude/hooks" ]; then
139
- return 0
414
+ in .bassclef-source.json. Run a manual sync once with:
415
+
416
+ bash .claude/hooks/bassclef-sync.sh
417
+
418
+ More: https://github.com/sunj-labs/bassclef/blob/main/standards/bassclef-source-config.md
419
+
420
+ NOTE
421
+ exit 0
140
422
  fi
423
+ fi
141
424
 
142
- local hook_src hook_name target has_header
143
- for hook_src in "$bassclef_dir/.claude/hooks/"*.sh; do
144
- [ -f "$hook_src" ] || continue # T16: glob excludes .sh.bak
425
+ # === Plain-language error printer ===
426
+ #
427
+ # Args: $1 = short heading, $2 = context paragraph (one or more sentences).
428
+ # Always exits 0 to keep SessionStart hooks non-blocking.
429
+ emit_error() {
430
+ local heading="$1"
431
+ local context="$2"
432
+ cat <<HEADER
145
433
 
146
- hook_name=$(basename "$hook_src")
147
- target="$user_target_dir/$hook_name"
434
+ ### bassclef-sync — $heading
148
435
 
149
- # M-F1 MED — rm existing symlink at target before cp
150
- [ -L "$target" ] && rm -f "$target"
436
+ $context
151
437
 
152
- has_header=0
153
- if head -10 "$hook_src" 2>/dev/null | grep -qE '^# install-class:[[:space:]]*(operator|project|dual)$'; then
154
- has_header=1
155
- fi
438
+ Three ways to fix this:
156
439
 
157
- if [ "$has_header" = "1" ] && [ "$dispatch_available" = "1" ]; then
158
- # ADR-058 c1 — subshell wraps install_by_class so lib strict
159
- # mode never leaks into this script. On subshell failure, fall
160
- # back to legacy operator-scope copy to preserve adopter
161
- # contract per c4 (settings.json command strings byte-identical).
162
- # shellcheck disable=SC1090 # dynamic source path
163
- if ( source "$installer_lib" 2>/dev/null && install_by_class "$hook_src" ) >/dev/null 2>&1; then
164
- installed=$((installed + 1))
165
- else
166
- cp -p "$hook_src" "$target" 2>/dev/null || true
167
- chmod +x "$target" 2>/dev/null || true
168
- echo "$(date -u +%FT%TZ) dispatch-fail $hook_name" >> "$BASSCLEF_TRACE_LOG"
169
- failed=$((failed + 1))
170
- # #1894 — capture path for structured status line
171
- missing_paths+=(".claude/hooks/$hook_name")
172
- missing_scopes+=("project")
173
- fi
174
- elif [ "$has_header" = "1" ] && [ "$dispatch_available" = "0" ]; then
175
- # S-F1 MED — dispatch lib missing; trace explicitly (not silent)
176
- cp -p "$hook_src" "$target" 2>/dev/null || true
177
- chmod +x "$target" 2>/dev/null || true
178
- echo "$(date -u +%FT%TZ) dispatch-lib-missing $hook_name" >> "$BASSCLEF_TRACE_LOG"
179
- skipped_lib_missing=$((skipped_lib_missing + 1))
180
- else
181
- # ADR-058 c2 fallback — grace window through 2026-10-31.
182
- # L-F2 MED — additive-only during grace; do NOT rm legacy user-scope copies.
183
- cp -p "$hook_src" "$target" 2>/dev/null || true
184
- chmod +x "$target" 2>/dev/null || true
185
- if [ "$has_header" = "0" ]; then
186
- echo "$(date -u +%FT%TZ) skipped-no-header $hook_name" >> "$BASSCLEF_TRACE_LOG"
187
- skipped_no_header=$((skipped_no_header + 1))
188
- else
189
- echo "$(date -u +%FT%TZ) skipped-invalid-header $hook_name" >> "$BASSCLEF_TRACE_LOG"
190
- skipped_invalid=$((skipped_invalid + 1))
191
- # #1894 — invalid-header is a real defect (not grace-window); capture for status line
192
- missing_paths+=(".claude/hooks/$hook_name")
193
- missing_scopes+=("project")
194
- fi
195
- fi
196
- done
440
+ 1) PUBLIC adopters (most common)
441
+ Edit .bassclef-source.json to point at the public release tarball:
197
442
 
198
- # N-F1 MED — install summary with reason breakdown when non-zero
199
- local skipped_total=$((skipped_no_header + skipped_invalid + skipped_lib_missing))
200
- if [ "$skipped_total" = "0" ] && [ "$failed" = "0" ]; then
201
- echo "bassclef-sync: $installed hooks ready. Nothing skipped, nothing failed."
202
- else
203
- local breakdown=""
204
- [ "$skipped_no_header" -gt 0 ] && breakdown="$breakdown, $skipped_no_header grace-window"
205
- [ "$skipped_invalid" -gt 0 ] && breakdown="$breakdown, $skipped_invalid invalid-header"
206
- [ "$skipped_lib_missing" -gt 0 ] && breakdown="$breakdown, $skipped_lib_missing dispatch-lib-missing"
207
- breakdown="${breakdown#, }"
208
- # RFC-0008 F7 — three bare numbers read three ways. Say what they mean
209
- # together, and say plainly whether the install is usable.
210
- if [ "$failed" = "0" ] && [ "$skipped_invalid" = "0" ]; then
211
- echo "bassclef-sync: $installed hooks ready. $skipped_total skipped on purpose ($breakdown). Nothing failed — your install is complete."
212
- else
213
- echo "bassclef-sync: $installed hooks ready. $skipped_total skipped on purpose ($breakdown)."
214
- if [ "$failed" -gt 0 ]; then
215
- echo "bassclef-sync: $failed could not be installed. Those hooks will not fire until that is fixed."
216
- fi
217
- fi
218
- fi
443
+ {
444
+ "source_url": "https://github.com/sunj-labs/bassclef/releases/latest/download/substrate.tar.gz",
445
+ "auth_method": "none",
446
+ "token_env": "",
447
+ "comment": "Public bassclef substrate. No auth needed."
448
+ }
219
449
 
220
- # #1894 — idempotent-complete-or-fail-loud postcondition
221
- # (pre-mortem R2 fold: RESTORED iff failed==0 && skipped_invalid==0;
222
- # grace-window and dispatch-lib-missing count as OK per ADR-058 c2)
223
- local total=$((installed + skipped_total + failed))
224
- local defect_total=$((failed + skipped_invalid))
225
- if [ "$defect_total" = "0" ]; then
226
- # RESTORED — human line + minimal JSON status:complete (Beck B1 fold: minimal JSON in V1)
227
- echo "bassclef-sync: RESTORED — $installed of $total hooks resolved at their scoped targets."
228
- printf 'bassclef-sync: {"status":"complete","installed":%d}\n' "$installed"
229
- else
230
- # DEGRADED — human line + JSON with missing[] carrying path + scope + cure per entry
231
- echo "bassclef-sync: DEGRADED — $defect_total of $total hooks did not resolve after self-heal."
232
- local i missing_json="["
233
- for i in "${!missing_paths[@]}"; do
234
- [ "$i" -gt 0 ] && missing_json="$missing_json,"
235
- missing_json="$missing_json{\"path\":\"${missing_paths[$i]}\",\"scope\":\"${missing_scopes[$i]}\",\"cure\":\"bassclef init --force\"}"
236
- done
237
- missing_json="$missing_json]"
238
- printf 'bassclef-sync: {"status":"degraded","installed":%d,"missing":%s}\n' "$installed" "$missing_json"
239
- fi
240
- }
450
+ Pre-launch note: while bassclef is still private, the public
451
+ release URL above may not yet exist. Until it does, use option 3.
241
452
 
242
- # === Test-mode guard (#915) ===
243
- # Tests source this hook to access helpers without triggering main sync.
244
- # Early return under BASSCLEF_SYNC_TEST_MODE=1. Helpers defined ABOVE.
245
- if [ "${BASSCLEF_SYNC_TEST_MODE:-0}" = "1" ]; then
246
- return 0 2>/dev/null || exit 0
247
- fi
453
+ 2) LOCAL DEV — clone bassclef alongside this repo
248
454
 
249
- # === Hook liveness heartbeat (WU-3 of bet 2026-07-31d; closes #1002) ===
250
- # Silent-fail — a missing lib never crashes the hook. Discipline: ADR-048 +
251
- # standards/hook-cadence.json. Session-start reflection module (WU-4.5) checks
252
- # marker mtime against expected cadence.
253
- {
254
- _hb_sd="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
255
- for _hb_c in "${_hb_sd}/../../lib/hook-heartbeat.sh" "${HOME:-/}/lib/hook-heartbeat.sh"; do
256
- [ -f "$_hb_c" ] && source "$_hb_c" && heartbeat_mark "bassclef-sync" && break
257
- done
258
- unset _hb_sd _hb_c
259
- } 2>/dev/null || true
455
+ cd ../ && git clone https://github.com/sunj-labs/bassclef.git
260
456
 
261
- INPUT=$(cat)
262
- CWD=$(echo "$INPUT" | jq -r '.cwd // "."')
263
- cd "$CWD"
457
+ Re-run your session. The dispatcher uses the local checkout via
458
+ the sibling fast-path; no auth needed.
459
+
460
+ 3) PRIVATE / pre-launch — create a fine-grained GitHub PAT
461
+
462
+ a) GitHub -> Settings -> Developer settings -> Personal access tokens
463
+ -> Fine-grained -> Generate new token
464
+ b) Resource owner: sunj-labs
465
+ c) Repository access: select sunj-labs/bassclef only
466
+ d) Permissions -> Repository -> Contents -> Read
467
+ e) Set the token as BASSCLEF_TOKEN in your environment:
468
+ CI: gh secret set BASSCLEF_TOKEN -R <your-org/your-repo>
469
+ Local: export BASSCLEF_TOKEN=<paste>
470
+ Cloud Code: set BASSCLEF_TOKEN in your environment config
471
+
472
+ 4) STALE REFERENCES — your repo installed bassclef before 2026-06-29
264
473
 
265
- # === Section 0a fires: exit early if this repo is substrate source ===
266
- SELF_MODE=$(bassclef_detect_self_mode "$CWD")
267
- if [ "$SELF_MODE" = "1" ]; then
268
- echo "SELF_MODE detected — skipping sync in substrate source repo." >&2
269
- echo " (Override with SKIP_SELF_MODE_DETECTION=1 for adopter forks.)" >&2
474
+ If your tree carries leftover canonical-* references from the
475
+ 2026-06-20 rename event, refresh them in place by running the
476
+ migrate script directly:
477
+
478
+ curl -sL https://raw.githubusercontent.com/sunj-labs/bassclef/main/scripts/migrate-adopter-references.sh \\
479
+ | bash -s -- --apply "\$CWD"
480
+
481
+ Open a fresh Claude Code session afterward. The fresh dispatcher
482
+ carries detection that runs the cure automatically when needed.
483
+
484
+ More: https://github.com/sunj-labs/bassclef/blob/main/standards/bassclef-source-config.md
485
+ https://github.com/sunj-labs/bassclef/wiki/Troubleshooting#substrate-fails-to-load-and-you-do-not-see-the-self-heal-banner
486
+
487
+ HEADER
270
488
  exit 0
271
- fi
489
+ }
272
490
 
273
- # Bassclef substrate location — peer clone at ../bassclef (post-rename).
274
- # Pre-rename ../canonical fallback removed 2026-06-24 per bassclef-upstream#317
275
- # Phase 0: zero adopters carry the canonical/ path; operator's local
276
- # ~/src/sunj-labs/canonical was removed before this cleanup.
277
- #
278
- # 2026-09-15 (goal 14c Step 6): honor env-supplied BASSCLEF_DIR when set.
279
- # Real operator machines do not set this env var — L237 default fires as before.
280
- # Test fixtures set BASSCLEF_DIR to point at a mock bundle; honoring the
281
- # env unblocks characterization tests for the operator sync path.
282
- : "${BASSCLEF_DIR:=$CWD/../bassclef}"
283
- BASSCLEF_REPO="https://github.com/sunj-labs/bassclef.git"
284
- SYNC_STATUS="up-to-date"
285
- SYNC_NEW=""
286
-
287
- # === 1. Clone or pull bassclef ===
288
- # When BASSCLEF_DIR points at a valid bundle with .claude/hooks/ AND is NOT a
289
- # git repo, skip clone/pull — env-supplied bundle (test fixture) is authoritative.
290
- if [ -d "$BASSCLEF_DIR/.claude/hooks" ] && [ ! -d "$BASSCLEF_DIR/.git" ]; then
291
- # env-override fixture path; skip clone/pull
292
- :
293
- elif [ ! -d "$BASSCLEF_DIR/.claude" ]; then
294
- # Capture stderr so classify_clone_failure can name the real cause.
295
- # Prior version blamed GitHub auth on every failure — cli#106 body showed
296
- # this misleads adopters whose auth is fine but who can't see a private repo.
297
- # Classifier emits per-cause hints — for auth failures, the hint reads
298
- # "Optional: set up GitHub auth to unlock full-tier sync — run: gh auth login".
299
- # (This literal phrase kept here so #1631 Amendment C test greps still match.)
300
- __sync_clone_stderr=$(git clone "$BASSCLEF_REPO" "$BASSCLEF_DIR" 2>&1)
301
- __sync_clone_exit=$?
302
- if [ "$__sync_clone_exit" -eq 0 ]; then
303
- SYNC_STATUS="freshly cloned"
304
- else
305
- # Source the classifier lib; probe bundled path first, then $HOME/lib fallback.
306
- {
307
- _cf_sd="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
308
- for _cf_c in "${_cf_sd}/../../lib/clone-failure-classifier.sh" "${HOME:-/}/lib/clone-failure-classifier.sh"; do
309
- [ -f "$_cf_c" ] && source "$_cf_c" && break
310
- done
311
- unset _cf_sd _cf_c
312
- } 2>/dev/null || true
491
+ # === Recovery script resolver + checksum verifier ===
492
+ #
493
+ # Per goal 2026-08-27 step 7 PR #1 — cures external security review HIGH
494
+ # finding on the self-heal curl|bash fallback (source audit tracked in
495
+ # operator-only artifact; not shipped to public bassclef).
496
+ #
497
+ # Two functions cover Adam's Options A (bundle local) + B (checksum on curl).
498
+ #
499
+ # @pattern strategy (RecoveryScriptResolver picks one of three sources)
500
+ # @pattern guard-clause (verify_checksum fails loud on mismatch)
313
501
 
314
- if declare -f classify_clone_failure >/dev/null 2>&1; then
315
- classify_clone_failure "$__sync_clone_stderr"
316
- else
317
- # Defensive fallback — preserves prior behavior if lib unavailable.
318
- echo "### Bassclef sync — full-tier not synced"
319
- echo ""
320
- echo "Git said:"
321
- echo "$__sync_clone_stderr"
322
- echo ""
323
- echo "Lite tier works without it — your session is fine."
324
- fi
325
- exit 0
502
+ # resolve_recovery_script — prints one of:
503
+ # LOCAL:<path> env override set + file exists
504
+ # SIBLING:<path> sibling checkout has the script
505
+ # CURL:<url>|<sha> neither available; use curl with checksum
506
+ #
507
+ # Priority chain per docs/decompositions/2026-08-27-step-7-pr-1-dispatcher-cure-responsibilities.md
508
+ resolve_recovery_script() {
509
+ # Priority 1 — env override
510
+ if [ -n "${BASSCLEF_RECOVERY_SCRIPT_PATH:-}" ] && [ -f "$BASSCLEF_RECOVERY_SCRIPT_PATH" ]; then
511
+ printf 'LOCAL:%s\n' "$BASSCLEF_RECOVERY_SCRIPT_PATH"
512
+ return 0
326
513
  fi
327
- unset __sync_clone_stderr __sync_clone_exit
328
- else
329
- BEFORE=$(git -C "$BASSCLEF_DIR" rev-parse HEAD 2>/dev/null)
330
- git -C "$BASSCLEF_DIR" pull --quiet 2>/dev/null || true
331
- AFTER=$(git -C "$BASSCLEF_DIR" rev-parse HEAD 2>/dev/null)
332
514
 
333
- if [ "$BEFORE" != "$AFTER" ]; then
334
- SYNC_STATUS="updated"
335
- SYNC_NEW=$(git -C "$BASSCLEF_DIR" log --oneline "$BEFORE".."$AFTER" 2>/dev/null)
515
+ # Priority 2 — sibling checkout (matches SIBLING_TEMPLATE pattern at L594)
516
+ local sibling_script="${CWD:-$(pwd)}/../bassclef/scripts/migrate-adopter-references.sh"
517
+ if [ -f "$sibling_script" ]; then
518
+ printf 'SIBLING:%s\n' "$sibling_script"
519
+ return 0
336
520
  fi
337
- fi
338
521
 
339
- # === 2. Symlink bassclef skills into project for auto-discovery ===
340
- mkdir -p "$CWD/.claude/skills" 2>/dev/null || true
341
- for SKILL_DIR in "$BASSCLEF_DIR/.claude/skills/"*/; do
342
- SKILL_NAME=$(basename "$SKILL_DIR")
343
- TARGET="$CWD/.claude/skills/$SKILL_NAME"
344
- # Only symlink if not already an app-specific skill (real directory, not symlink)
345
- if [ -L "$TARGET" ]; then
346
- # Existing symlink — update it
347
- rm "$TARGET"
348
- ln -s "$SKILL_DIR" "$TARGET"
349
- elif [ ! -d "$TARGET" ]; then
350
- # Doesn't exist — create symlink
351
- ln -s "$SKILL_DIR" "$TARGET"
352
- fi
353
- # If it's a real directory (app-specific), leave it alone
354
- done
355
-
356
- # === 2b. Park orphan ~/.claude/skills + agents + hooks symlinks (widened per ADR-033 + bassclef#278) ===
357
- # Uses the shared orphan-parking helper. Closes the skill / agent / hook
358
- # duplication-in-available-skills bug. The helper parks user-level
359
- # symlinks AND cp'd files whose basename appears in project-level —
360
- # preserves real directories untouched + EXCLUDES hooks wired in
361
- # settings.json at $HOME paths (per #294); backs up moved entries to
362
- # dated dirs that survive at least 30 days per Hyrum + Linus discipline.
363
- PARKING_HELPER="$BASSCLEF_DIR/scripts/bassclef-orphan-parking.sh"
364
- USER_SETTINGS="$HOME/.claude/settings.json"
365
- ORPHAN_COUNT=0
366
- if [ -f "$PARKING_HELPER" ]; then
367
- for surface in skills agents hooks; do
368
- USER_SURFACE="$HOME/.claude/$surface"
369
- PROJECT_SURFACE="$CWD/.claude/$surface"
370
- if [ -d "$USER_SURFACE" ] && [ -d "$PROJECT_SURFACE" ]; then
371
- mkdir -p "$HOME/.claude" 2>/dev/null
372
- # Pass user settings.json as 5th arg so wired hooks are excluded
373
- # from parking. Per #294 — broke the Stop hook on this machine when
374
- # parking removed session-end.sh that settings.json wires.
375
- PARKED_LINE=$(bash "$PARKING_HELPER" "$USER_SURFACE" "$PROJECT_SURFACE" "$HOME/.claude" "$surface" "$USER_SETTINGS" 2>/dev/null || echo "")
376
- if [ -n "$PARKED_LINE" ]; then
377
- # Extract the count from "parked N <surface> orphans → <dir>"
378
- COUNT_N=$(echo "$PARKED_LINE" | grep -oE 'parked [0-9]+' | grep -oE '[0-9]+' | head -1)
379
- [ -n "$COUNT_N" ] && ORPHAN_COUNT=$((ORPHAN_COUNT + COUNT_N))
380
- fi
381
- fi
382
- done
383
- fi
522
+ # Priority 3 — curl fallback with checksum sidecar
523
+ local recovery_url="https://raw.githubusercontent.com/sunj-labs/bassclef/main/scripts/migrate-adopter-references.sh"
524
+ local sha_url="${recovery_url}.sha256"
525
+ printf 'CURL:%s|%s\n' "$recovery_url" "$sha_url"
526
+ return 0
527
+ }
384
528
 
385
- # === 2c. Adopter self-heal — auto-migrate stale canonical references ===
386
- # Per ADR-031 we-dont-break-adopters + Nygard's Steady State pattern.
387
- # When this hook runs in an adopter repo (CWD is NOT bassclef-upstream and
388
- # does NOT have architecture/dual-repo-flow.md), invoke the migration script
389
- # to repair any stale ../canonical/ references in symlinks + settings.json
390
- # + dispatcher + statusline. Idempotent — no-op on already-migrated repos.
391
- #
392
- # Banner surfaces per-surface info (not just count) so adopters who
393
- # customized any of the migrated surfaces see exactly what changed.
394
- # Closes Yellow 2 of the 2026-06-22 release-readiness review.
395
- MIGRATE_SCRIPT="$BASSCLEF_DIR/scripts/migrate-adopter-references.sh"
396
- SUMMARY_HELPER="$BASSCLEF_DIR/scripts/bassclef-migrate-summary.sh"
397
- if [ -f "$MIGRATE_SCRIPT" ] && [ ! -f "$CWD/architecture/dual-repo-flow.md" ]; then
398
- MIGRATE_OUT=$(bash "$MIGRATE_SCRIPT" --apply "$CWD" 2>&1)
399
- MIGRATE_CHANGES=$(echo "$MIGRATE_OUT" | awk -F: '/^changes:/ {print $2}' | tr -d ' ')
400
- if [ -n "$MIGRATE_CHANGES" ] && [ "$MIGRATE_CHANGES" -gt 0 ]; then
401
- PER_SURFACE=""
402
- # Check -f (file exists), not -x (executable). We invoke via `bash $HELPER`
403
- # so the executable bit is irrelevant; the bit may not survive tarball
404
- # fetches across adopter clones (per reviewer note on PR #291).
405
- if [ -f "$SUMMARY_HELPER" ]; then
406
- PER_SURFACE=$(echo "$MIGRATE_OUT" | bash "$SUMMARY_HELPER" 2>/dev/null)
407
- fi
408
- SYNC_NEW="${SYNC_NEW}
409
- Adopter self-heal: $MIGRATE_CHANGES surface(s) migrated. Old setup keeps working; new setup matches current bassclef substrate. Per ADR-031."
410
- if [ -n "$PER_SURFACE" ]; then
411
- SYNC_NEW="${SYNC_NEW}
412
- ${PER_SURFACE}"
413
- fi
529
+ # verify_checksum <file> <expected_sha256>
530
+ # Returns 0 on match; non-zero + loud stderr on mismatch or missing tool.
531
+ # @luminary saltzer-schroeder — complete mediation; no silent pass.
532
+ verify_checksum() {
533
+ local file="$1"
534
+ local expected="$2"
535
+ local computed=""
536
+
537
+ if command -v shasum >/dev/null 2>&1; then
538
+ computed=$(shasum -a 256 "$file" 2>/dev/null | awk '{print $1}')
539
+ elif command -v sha256sum >/dev/null 2>&1; then
540
+ computed=$(sha256sum "$file" 2>/dev/null | awk '{print $1}')
541
+ else
542
+ echo "verify_checksum: neither shasum nor sha256sum found in PATH" >&2
543
+ echo "verify_checksum: install one before continuing" >&2
544
+ return 2
414
545
  fi
415
- fi
416
546
 
417
- # === 3. Install general hooks to ~/.claude/ ===
418
- # Delegates to _sync_install_hooks (goal 14c Step 5 per ADR-058).
419
- # The hard-coded 9-name list that lived here through v0.41.1 moved
420
- # into the function above. Each hook now routes by its own
421
- # # install-class: header when declared; unheadered hooks fall back
422
- # to legacy operator-scope copy per ADR-058 c2 grace window
423
- # (through 2026-10-31). Section 4 self-heal continues to preserve
424
- # adopter contract per c4 (settings.json command strings unchanged).
425
- _sync_install_hooks "$BASSCLEF_DIR" "$HOME/.claude/hooks"
426
-
427
- # === Install agents to ~/.claude/ ===
428
- mkdir -p ~/.claude/agents 2>/dev/null || true
429
- cp "$BASSCLEF_DIR/.claude/agents/"*.md ~/.claude/agents/ 2>/dev/null || true
430
-
431
- # === 4. Versioned self-heal of ~/.claude/settings.json ===
432
- # WU-3 of bet 2026-06-24a-settings-hardening (#313). Replaces the
433
- # brittle multi-grep + destructive heredoc rewrite with:
434
- # 1. Version-field comparison (read_settings_version).
435
- # 2. Additive merge that preserves operator's top-level keys (theme,
436
- # statusLine, permissions, alwaysThinkingEnabled, etc.) and
437
- # operator-only event types — bassclef wins per-event for events
438
- # it ships.
439
- # 3. Hoare postcondition: after self-heal, drift detector MUST
440
- # return clean (compare_wirings .ok == true). Else exit 1 with
441
- # structured stderr — failure compounds across every adopter,
442
- # so failing loud beats failing silent.
443
- # Closes the bug class diagnosed 2026-06-24 (operator's settings.json
444
- # never picked up substrate's new hook wirings).
445
- WIRINGS_LIB="$BASSCLEF_DIR/.claude/hooks/_lib/wirings.sh"
446
- PROJECT_SETTINGS="$BASSCLEF_DIR/.claude/settings.json"
447
- USER_SETTINGS="$HOME/.claude/settings.json"
448
-
449
- sync_self_heal() {
450
- if [ ! -f "$WIRINGS_LIB" ] || [ ! -f "$PROJECT_SETTINGS" ]; then
451
- # Substrate not fully synced yet OR project settings.json not
452
- # checked out on this branch. Fall back silently — drift detector
453
- # at next session-start will surface the gap.
454
- return 0
547
+ if [ -z "$computed" ]; then
548
+ echo "verify_checksum: could not compute SHA-256 of $file" >&2
549
+ return 3
455
550
  fi
456
- # shellcheck source=/dev/null
457
- source "$WIRINGS_LIB"
458
-
459
- local required_version user_version
460
- required_version=$(read_settings_version "$PROJECT_SETTINGS")
461
- user_version=$(read_settings_version "$USER_SETTINGS")
462
-
463
- if [ ! -f "$USER_SETTINGS" ] || [ "$user_version" -lt "$required_version" ]; then
464
- additive_merge_settings "$USER_SETTINGS" "$PROJECT_SETTINGS"
465
- fi
466
-
467
- # Hoare postcondition: required ⊆ loaded after self-heal.
468
- local required loaded diff ok missing
469
- required=$(read_required_wirings "$PROJECT_SETTINGS")
470
- loaded=$(read_loaded_wirings)
471
- diff=$(compare_wirings "$required" "$loaded")
472
- ok=$(echo "$diff" | jq -r '.ok')
473
- if [ "$ok" != "true" ]; then
474
- missing=$(echo "$diff" | jq -c '.missing')
475
- # cli#1742 cure B — guard banner emit on non-empty missing list.
476
- # Prior version printed the header + Fix line even when missing was
477
- # empty (extras-only case), leaving adopters with a header pointing at
478
- # nothing. Silence when nothing is genuinely missing.
479
- __sync_missing_count=$(echo "$missing" | jq 'length' 2>/dev/null || echo 0)
480
- if [ "${__sync_missing_count:-0}" -gt 0 ]; then
481
- echo "🛑 bassclef-sync — these hooks should fire and are not connected:" >&2
482
- echo "$missing" | jq -r '.[] | " - " + .event + " → " + .command' >&2
483
- echo "" >&2
484
- echo "Fix: add the lines above to ~/.claude/settings.json, then run bassclef-sync again." >&2
485
- fi
486
- unset __sync_missing_count
487
- if command -v trace_log >/dev/null 2>&1; then
488
- trace_log "fail" "bassclef-sync-postcondition" "$missing"
489
- fi
490
- return 1
491
- fi
492
- if command -v trace_log >/dev/null 2>&1; then
493
- trace_log "ok" "bassclef-sync-postcondition" "version=$required_version"
551
+
552
+ if [ "$computed" = "$expected" ]; then
553
+ return 0
494
554
  fi
495
- return 0
555
+
556
+ echo "verify_checksum: SHA-256 mismatch on $file" >&2
557
+ echo "verify_checksum: expected: $expected" >&2
558
+ echo "verify_checksum: computed: $computed" >&2
559
+ return 1
496
560
  }
497
561
 
498
- sync_self_heal || exit 1
499
-
500
- # === 4b. PreToolUse template embedding — operator settings.json self-heal ===
501
- # Per bassclef-upstream#336 — ensure operator's ~/.claude/settings.json wires
502
- # the bassclef-managed PreToolUse hooks (substrate-clarity-gate + bet-doc-gate).
503
- # Mirrors §3c-hooks in presence/install/bassclef-sync.template.sh (consumer-side)
504
- # but targets ~/.claude/settings.json with $HOME-prefixed commands.
505
- #
506
- # Why this lives here even though §4 sync_self_heal runs additive-merge:
507
- # wirings.sh merges bassclef's project settings.json into operator's user
508
- # settings.json — but during a sync where wirings.sh itself is being upgraded,
509
- # OR when the operator's settings.json predates the wirings library entirely,
510
- # the merge may not fire. This §4b is the literal-text fallback: greppable
511
- # guards re-emit the PreToolUse template when entries are missing, regardless
512
- # of wirings.sh state. Bootstrap-pair discipline (per
513
- # .claude/rules/bootstrap-pair-discipline.md) — wiring ships in the same place
514
- # as the hook reference.
515
- #
516
- # Template (operator settings.json fragment, "PreToolUse": event):
517
- # {
518
- # "hooks": {
519
- # "PreToolUse": [
520
- # {
521
- # "matcher": "Edit|Write|MultiEdit",
522
- # "hooks": [
523
- # {"type": "command", "command": "$HOME/.claude/hooks/substrate-clarity-gate.sh"},
524
- # {"type": "command", "command": "$HOME/.claude/hooks/bet-doc-gate.sh"}
525
- # ]
526
- # }
527
- # ]
528
- # }
529
- # }
530
- #
531
- # Idempotent via jq additive merge — re-runs detect existing entries and skip.
532
- # Skip paths:
533
- # - SKIP_PRETOOLUSE_EMBED=1 — operator override (logged)
534
- # - jq missing — graceful skip with stderr notice
535
- # - ~/.claude/settings.json missing — skip (sync_self_heal handles creation)
536
- if [ "${SKIP_PRETOOLUSE_EMBED:-0}" != "1" ] && \
537
- command -v jq >/dev/null 2>&1 && \
538
- [ -f "$USER_SETTINGS" ]; then
539
-
540
- # Guard re-emit when bet-doc-gate missing (existing-adopter self-heal)
541
- if ! grep -q 'bet-doc-gate' ~/.claude/settings.json 2>/dev/null; then
542
- BET_DOC_GATE_MISSING=1
543
- else
544
- BET_DOC_GATE_MISSING=0
562
+ # === Canonical-* reference detection (per bet 29e WU-2) ===
563
+ #
564
+ # Self-heal for adopters carrying canonical-* references from the
565
+ # 2026-06-20 rename. Scans the adopter .claude/ tree at session-start
566
+ # BEFORE Path 1 / Path 2. When canonical-* refs are found, emits a
567
+ # BLOCKED block naming the recovery command (curl one-liner shipped
568
+ # in WU-3 of bet 29e) and exits 0 so SessionStart stays non-blocking.
569
+ #
570
+ # WU-2 emits BLOCKED + exits. WU-4 will rewrite this block to auto-
571
+ # invoke the recovery and continue the dispatcher chain.
572
+ #
573
+ # Override: SKIP_CANONICAL_DETECTION=1 bypasses the scan. Reserved for
574
+ # migration scripts; not for routine adopter use.
575
+ #
576
+ # Primary lens: @luminary michael-nygard — self-healing integration
577
+ # point per Release It! Stability Patterns. Composing lenses:
578
+ # @luminary jerome-saltzer-and-michael-schroeder (complete mediation —
579
+ # every session-start sees the scan), @luminary tony-hoare (when no
580
+ # canonical refs found, scan is silent; postcondition holds), @luminary
581
+ # john-ousterhout (deep dispatcher hides migration complexity).
582
+ if [ "${SKIP_CANONICAL_DETECTION:-0}" != "1" ]; then
583
+ CANONICAL_FINDINGS=()
584
+
585
+ # Surface 2: settings.json additionalDirectories contains "canonical"
586
+ SETTINGS_PATH="$CWD/.claude/settings.json"
587
+ if [ -f "$SETTINGS_PATH" ]; then
588
+ STALE_DIRS=$(jq -r '.permissions.additionalDirectories // [] | .[] | select(test("canonical"))' "$SETTINGS_PATH" 2>/dev/null || true)
589
+ if [ -n "$STALE_DIRS" ]; then
590
+ CANONICAL_FINDINGS+=(".claude/settings.json additionalDirectories contains canonical")
591
+ fi
545
592
  fi
546
- # Guard re-emit when substrate-clarity-gate missing
547
- if ! grep -q 'substrate-clarity-gate' ~/.claude/settings.json 2>/dev/null; then
548
- SUBSTRATE_CLARITY_GATE_MISSING=1
549
- else
550
- SUBSTRATE_CLARITY_GATE_MISSING=0
551
- fi
552
-
553
- if [ "$BET_DOC_GATE_MISSING" = "1" ] || [ "$SUBSTRATE_CLARITY_GATE_MISSING" = "1" ]; then
554
- # Operator-class PreToolUse template — note the $HOME prefix (left literal
555
- # in JSON; Claude Code expands at hook-fire time per presence/install
556
- # consumer-side §4 line 820 precedent).
557
- OPERATOR_PRETOOLUSE_JSON='[
558
- {"matcher": "Edit|Write|MultiEdit", "command": "$HOME/.claude/hooks/substrate-clarity-gate.sh"},
559
- {"matcher": "Edit|Write|MultiEdit", "command": "$HOME/.claude/hooks/bet-doc-gate.sh"}
560
- ]'
561
-
562
- # Additive jq merge — mirrors §3c-hooks logic but writes to USER_SETTINGS:
563
- # 1. Ensure .hooks.PreToolUse array exists
564
- # 2. For each bassclef hook:
565
- # - If matcher exists, ensure command is in the matcher's hooks array
566
- # - If matcher missing, create new matcher entry with the command
567
- # Operator-managed entries (other matchers, other commands) stay untouched.
568
- NEW_OPERATOR_SETTINGS=$(jq --argjson bassclef "$OPERATOR_PRETOOLUSE_JSON" '
569
- .hooks //= {} |
570
- .hooks.PreToolUse //= [] |
571
- reduce $bassclef[] as $hook (
572
- .;
573
- if (.hooks.PreToolUse | map(.matcher) | index($hook.matcher)) != null then
574
- .hooks.PreToolUse |= map(
575
- if .matcher == $hook.matcher then
576
- if (.hooks | map(.command) | index($hook.command)) == null then
577
- .hooks += [{"type": "command", "command": $hook.command}]
578
- else . end
579
- else . end
580
- )
581
- else
582
- .hooks.PreToolUse += [{
583
- "matcher": $hook.matcher,
584
- "hooks": [{"type": "command", "command": $hook.command}]
585
- }]
586
- end
587
- )
588
- ' "$USER_SETTINGS" 2>/dev/null)
589
-
590
- if [ -n "$NEW_OPERATOR_SETTINGS" ]; then
591
- echo "$NEW_OPERATOR_SETTINGS" > "$USER_SETTINGS.tmp" && \
592
- mv "$USER_SETTINGS.tmp" "$USER_SETTINGS"
593
- if command -v trace_log >/dev/null 2>&1; then
594
- trace_log "ok" "bassclef-sync-pretooluse-embed" \
595
- "added_bet_doc=$BET_DOC_GATE_MISSING added_substrate_clarity=$SUBSTRATE_CLARITY_GATE_MISSING"
593
+
594
+ # Surface 3: vendored dispatcher BASSCLEF_SYNC_VERSION drift
595
+ # (per ADR-032 Layer 3 self-heal; mirrors migrate-adopter-references.sh Surface 3)
596
+ # Read the current version from the helper to avoid drift between the
597
+ # comment header, the function, and this constant.
598
+ CURRENT_VERSION=$(__bassclef_dispatcher_version)
599
+ for CANDIDATE in "$CWD/.claude/hooks/bassclef-sync.sh" "$CWD/.claude/hooks/canonical-sync.sh"; do
600
+ if [ -f "$CANDIDATE" ] && [ ! -L "$CANDIDATE" ]; then
601
+ VENDORED_VERSION=$(grep -m1 'BASSCLEF_SYNC_VERSION=' "$CANDIDATE" 2>/dev/null | sed 's/.*BASSCLEF_SYNC_VERSION=\([^ ]*\).*/\1/' | head -1)
602
+ if [ -z "$VENDORED_VERSION" ] || [[ "$VENDORED_VERSION" < "$CURRENT_VERSION" ]]; then
603
+ BASENAME=$(basename "$CANDIDATE")
604
+ DISPLAY_VERSION="${VENDORED_VERSION:-<missing>}"
605
+ CANONICAL_FINDINGS+=(".claude/hooks/$BASENAME stale (BASSCLEF_SYNC_VERSION=$DISPLAY_VERSION; current=$CURRENT_VERSION)")
596
606
  fi
607
+ break
597
608
  fi
598
- fi
599
- fi
609
+ done
600
610
 
601
- # === 4c. Stop template embedding — operator settings.json self-heal ===
602
- # Same pattern as §4b but for the "Stop": event — wires
603
- # $HOME/.claude/hooks/turn-prose-kiss-check.sh so the per-turn /kiss scan
604
- # fires at session-stop boundaries. Per bassclef-upstream#336 sister gap
605
- # (Stop template embedding missing alongside PreToolUse).
606
- #
607
- # Template (operator settings.json fragment, "Stop": event):
608
- # {
609
- # "hooks": {
610
- # "Stop": [
611
- # {
612
- # "matcher": "",
613
- # "hooks": [
614
- # {"type": "command", "command": "$HOME/.claude/hooks/turn-prose-kiss-check.sh", "timeout": 30}
615
- # ]
616
- # }
617
- # ]
618
- # }
619
- # }
620
- #
621
- # Timeout 30s: turn-prose-kiss-check scans the most-recent assistant message
622
- # against the BLOCK wordlist; finishes well under 30s on any realistic
623
- # transcript. ≥5s minimum per session-artifacts rule (Stop hooks need
624
- # adequate timeout or they get killed mid-write).
625
- if [ "${SKIP_STOP_EMBED:-0}" != "1" ] && \
626
- command -v jq >/dev/null 2>&1 && \
627
- [ -f "$USER_SETTINGS" ]; then
628
-
629
- # Guard re-emit when turn-prose entry missing
630
- if ! grep -q 'turn-prose' ~/.claude/settings.json 2>/dev/null; then
631
- TURN_PROSE_MISSING=1
632
- else
633
- TURN_PROSE_MISSING=0
634
- fi
635
-
636
- if [ "$TURN_PROSE_MISSING" = "1" ]; then
637
- # Stop-event entry — empty matcher (Stop fires once per session-end;
638
- # no per-tool matcher applies). Command uses $HOME-prefixed path
639
- # (Claude Code expands at hook-fire time). Timeout 30 per above.
640
- OPERATOR_STOP_JSON='[
641
- {"matcher": "", "command": "$HOME/.claude/hooks/turn-prose-kiss-check.sh", "timeout": 30}
642
- ]'
643
-
644
- NEW_STOP_SETTINGS=$(jq --argjson bassclef "$OPERATOR_STOP_JSON" '
645
- .hooks //= {} |
646
- .hooks.Stop //= [] |
647
- reduce $bassclef[] as $hook (
648
- .;
649
- if (.hooks.Stop | map(.matcher) | index($hook.matcher)) != null then
650
- .hooks.Stop |= map(
651
- if .matcher == $hook.matcher then
652
- if (.hooks | map(.command) | index($hook.command)) == null then
653
- .hooks += [{"type": "command", "command": $hook.command, "timeout": $hook.timeout}]
654
- else . end
655
- else . end
656
- )
657
- else
658
- .hooks.Stop += [{
659
- "matcher": $hook.matcher,
660
- "hooks": [{"type": "command", "command": $hook.command, "timeout": $hook.timeout}]
661
- }]
662
- end
663
- )
664
- ' "$USER_SETTINGS" 2>/dev/null)
665
-
666
- if [ -n "$NEW_STOP_SETTINGS" ]; then
667
- echo "$NEW_STOP_SETTINGS" > "$USER_SETTINGS.tmp" && \
668
- mv "$USER_SETTINGS.tmp" "$USER_SETTINGS"
669
- if command -v trace_log >/dev/null 2>&1; then
670
- trace_log "ok" "bassclef-sync-stop-embed" \
671
- "added_turn_prose=$TURN_PROSE_MISSING"
611
+ if [ "${#CANONICAL_FINDINGS[@]}" -gt 0 ]; then
612
+ RECOVERY_URL="https://raw.githubusercontent.com/sunj-labs/bassclef/main/scripts/migrate-adopter-references.sh"
613
+ SIBLING_DISPATCHER_TEMPLATE="$CWD/../bassclef/presence/install/bassclef-sync.dispatcher.template.sh"
614
+ # Per bet 30a WU-5 (F7 cure): the migrate script reads three env vars
615
+ # for path overrides. Local layout sibling files for each.
616
+ SIBLING_RENAME_MAP="$CWD/../bassclef/standards/file-renames.json"
617
+ SIBLING_STATUSLINE_TEMPLATE="$CWD/../bassclef/presence/cli/bassclef-statusline.dispatcher.sh"
618
+
619
+ # Decide auto-invoke vs BLOCKED-only path (per bet 29e WU-4)
620
+ AUTO_INVOKE_READY=0
621
+ if [ "${SKIP_AUTO_INVOKE:-0}" != "1" ]; then
622
+ if [ -n "${BASSCLEF_RECOVERY_SCRIPT_PATH:-}" ] && [ -f "$BASSCLEF_RECOVERY_SCRIPT_PATH" ]; then
623
+ AUTO_INVOKE_READY=1
624
+ elif command -v curl >/dev/null 2>&1; then
625
+ AUTO_INVOKE_READY=1
672
626
  fi
673
627
  fi
674
- fi
675
- fi
676
628
 
677
- # === 5. Set env var for rules/CLAUDE.md inheritance ===
678
- if [ -n "$CLAUDE_ENV_FILE" ]; then
679
- echo "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1" >> "$CLAUDE_ENV_FILE"
680
- fi
629
+ if [ "$AUTO_INVOKE_READY" = "1" ]; then
630
+ # === Leg 3 of self-healing — auto-invocation (WU-4) ===
631
+ # Detection found canonical-* refs; run the migrate script
632
+ # automatically; continue the dispatcher chain afterward so the
633
+ # adopter sees one clean session-start (no second paste needed).
634
+ echo ""
635
+ echo "🩹 bassclef self-heal — canonical-* references detected; running recovery automatically"
636
+ for finding in "${CANONICAL_FINDINGS[@]}"; do
637
+ echo " - $finding"
638
+ done
681
639
 
682
- # === Summary ===
683
- SKILL_COUNT=$(ls "$BASSCLEF_DIR/.claude/skills/" 2>/dev/null | wc -l | tr -d ' ')
684
- RULE_COUNT=$(ls "$BASSCLEF_DIR/.claude/rules/" 2>/dev/null | wc -l | tr -d ' ')
685
- AGENT_COUNT=$(ls ~/.claude/agents/ 2>/dev/null | wc -l | tr -d ' ')
686
- HOOK_COUNT=$(ls ~/.claude/hooks/*.sh 2>/dev/null | wc -l | tr -d ' ')
687
-
688
- echo ""
689
- echo "### BASSCLEF SUBSTRATE — SYNCED"
690
- echo ""
691
- echo "Status: $SYNC_STATUS"
692
- echo "Skills: ${SKILL_COUNT} (symlinked to .claude/skills/ for auto-discovery)"
693
- echo "Rules: ${RULE_COUNT} (loaded via additionalDirectories + env var)"
694
- echo "Agents: ${AGENT_COUNT} (installed to ~/.claude/agents/)"
695
- echo "Hooks: ${HOOK_COUNT} user-level (bug-diagnosis, tool-failure, trace-helper)"
696
- echo ""
697
-
698
- if [ -n "$SYNC_NEW" ]; then
699
- echo "### NEW SINCE LAST SYNC — REVIEW BEFORE PROCEEDING"
700
- echo ""
701
- echo "$SYNC_NEW"
702
- echo ""
703
- echo "→ New bassclef standards detected. Ask the user to confirm"
704
- echo " before proceeding. These may change how this session operates."
705
- echo ""
706
- fi
640
+ # Source the migrate script (env-var override for tests, curl in prod).
641
+ # Per bet 30a WU-5 (F7 cure): pass all three env vars the migrate
642
+ # script reads — DISPATCHER_TEMPLATE + RENAME_MAP + STATUSLINE_TEMPLATE
643
+ # — when their sibling files exist. Without these, the script's
644
+ # fallback expansion `$(cd "$(dirname "$0")/..")` resolves wrong
645
+ # under curl-pipe ($0 = bash), and Surfaces 5 + 6 silently no-op.
646
+ # POA was broken for 8+ days because only the dispatcher template
647
+ # env var was passed.
648
+ ENV_ARGS=()
649
+ [ -f "$SIBLING_DISPATCHER_TEMPLATE" ] && ENV_ARGS+=("BASSCLEF_DISPATCHER_TEMPLATE=$SIBLING_DISPATCHER_TEMPLATE")
650
+ [ -f "$SIBLING_RENAME_MAP" ] && ENV_ARGS+=("BASSCLEF_RENAME_MAP=$SIBLING_RENAME_MAP")
651
+ [ -f "$SIBLING_STATUSLINE_TEMPLATE" ] && ENV_ARGS+=("BASSCLEF_STATUSLINE_TEMPLATE=$SIBLING_STATUSLINE_TEMPLATE")
652
+
653
+ # Per bet 2026-08-27 step 7 PR #1 — resolver picks source with priority
654
+ # env override → sibling → curl+checksum. Verifier gates curl path.
655
+ RESOLVED=$(resolve_recovery_script)
656
+ RESOLVED_KIND="${RESOLVED%%:*}"
657
+ RESOLVED_REST="${RESOLVED#*:}"
658
+
659
+ case "$RESOLVED_KIND" in
660
+ LOCAL|SIBLING)
661
+ echo "🩹 recovery source: $RESOLVED_KIND ($RESOLVED_REST)" >&2
662
+ if [ "${#ENV_ARGS[@]}" -gt 0 ]; then
663
+ cat "$RESOLVED_REST" | env "${ENV_ARGS[@]}" bash -s -- --apply "$CWD" || true
664
+ else
665
+ cat "$RESOLVED_REST" | bash -s -- --apply "$CWD" || true
666
+ fi
667
+ ;;
668
+ CURL)
669
+ RECOVERY_URL_RESOLVED="${RESOLVED_REST%|*}"
670
+ SHA_URL="${RESOLVED_REST#*|}"
671
+ echo "🩹 recovery source: CURL ($RECOVERY_URL_RESOLVED)" >&2
672
+ TMP_SCRIPT=$(mktemp -t bassclef-recovery.XXXXXX.sh) || {
673
+ echo "⚠ bassclef self-heal: mktemp failed; skipping recovery" >&2
674
+ true
675
+ }
676
+ TMP_SHA=$(mktemp -t bassclef-recovery.XXXXXX.sha) || {
677
+ rm -f "$TMP_SCRIPT" 2>/dev/null
678
+ echo "⚠ bassclef self-heal: mktemp failed; skipping recovery" >&2
679
+ true
680
+ }
681
+ # Fetch both files; abort verify path if either fetch fails
682
+ if curl -sL --fail "$RECOVERY_URL_RESOLVED" -o "$TMP_SCRIPT" 2>/dev/null \
683
+ && curl -sL --fail "$SHA_URL" -o "$TMP_SHA" 2>/dev/null; then
684
+ EXPECTED_SHA=$(awk '{print $1}' "$TMP_SHA")
685
+ if verify_checksum "$TMP_SCRIPT" "$EXPECTED_SHA"; then
686
+ echo "🩹 recovery: checksum verified; executing" >&2
687
+ if [ "${#ENV_ARGS[@]}" -gt 0 ]; then
688
+ cat "$TMP_SCRIPT" | env "${ENV_ARGS[@]}" bash -s -- --apply "$CWD" || true
689
+ else
690
+ cat "$TMP_SCRIPT" | bash -s -- --apply "$CWD" || true
691
+ fi
692
+ else
693
+ echo "🛑 recovery: SHA-256 mismatch; refusing to execute" >&2
694
+ echo " URL: $RECOVERY_URL_RESOLVED" >&2
695
+ echo " To retry manually after investigating:" >&2
696
+ echo " SKIP_CANONICAL_DETECTION=1 <re-run session>" >&2
697
+ fi
698
+ else
699
+ echo "⚠ bassclef self-heal: fetch failed for recovery or checksum" >&2
700
+ echo " Falls back to manual recovery via BLOCKED path below" >&2
701
+ fi
702
+ rm -f "$TMP_SCRIPT" "$TMP_SHA" 2>/dev/null
703
+ ;;
704
+ *)
705
+ echo "⚠ bassclef self-heal: resolver returned unknown kind ($RESOLVED_KIND); skipping" >&2
706
+ ;;
707
+ esac
708
+
709
+ echo "🩹 bassclef self-heal: recovery applied; continuing"
710
+ echo ""
711
+ # Fall through to Path 1 / Path 2 — substrate now loadable
712
+ else
713
+ # === Fall back: BLOCKED + exit (WU-2 behavior) ===
714
+ # SKIP_AUTO_INVOKE=1 set, OR neither curl nor BASSCLEF_RECOVERY_SCRIPT_PATH
715
+ # available. Surface the recovery shape and stop here.
716
+ cat <<DETECT
707
717
 
708
- if [ "$ORPHAN_COUNT" -gt 0 ]; then
709
- echo "### USER-LEVEL SUBSTRATE CLEANUP (per ADR-033 + bassclef#278)"
710
- echo ""
711
- echo "Parked $ORPHAN_COUNT user-level entries (skills + agents + hooks; symlinks AND cp'd files)"
712
- echo "whose basenames duplicated project-level entries. Backup dirs:"
713
- echo " ~/.claude/skills.orphans.$(date +%Y%m%d)/"
714
- echo " ~/.claude/agents.orphans.$(date +%Y%m%d)/"
715
- echo " ~/.claude/hooks.orphans.$(date +%Y%m%d)/"
716
- echo ""
717
- echo "Project-level entries now load without dedupe conflict."
718
- echo "Backups survive 30 days minimum; restore if a script you wrote"
719
- echo "depends on the user-level location."
720
- echo ""
721
- fi
718
+ 🛑 BLOCKED: bassclef substrate — canonical-* references detected
722
719
 
723
- echo "YOU MUST confirm to the user:"
724
- echo " 'Bassclef substrate synced: ${SKILL_COUNT} skills, ${RULE_COUNT} rules,"
725
- echo " ${AGENT_COUNT} agents, ${HOOK_COUNT} hooks. Status: ${SYNC_STATUS}.'"
726
- echo ""
720
+ Your adopter tree carries references from before the 2026-06-20
721
+ canonical→bassclef rename. Bassclef cannot load substrate cleanly
722
+ until these are repaired.
727
723
 
728
- # === 6. Worktree cleanup — prune stale worktrees from crashed parallel sessions ===
729
- STALE_WORKTREES=$(git -C "$CWD" worktree list --porcelain 2>/dev/null | grep "^worktree " | grep -v "$CWD$" | wc -l | tr -d ' ')
730
- if [ "$STALE_WORKTREES" -gt 0 ]; then
731
- # Prune worktrees whose directories no longer exist
732
- git -C "$CWD" worktree prune 2>/dev/null || true
724
+ Found:
725
+ DETECT
726
+ for finding in "${CANONICAL_FINDINGS[@]}"; do
727
+ echo " - $finding"
728
+ done
729
+ cat <<RECOVERY
733
730
 
734
- # Clean up .claude/worktrees/ directory if it exists
735
- if [ -d "$CWD/.claude/worktrees" ]; then
736
- rm -rf "$CWD/.claude/worktrees" 2>/dev/null || true
737
- fi
731
+ Recovery — one paste, no extra setup:
738
732
 
739
- REMAINING=$(git -C "$CWD" worktree list --porcelain 2>/dev/null | grep "^worktree " | grep -v "$CWD$" | wc -l | tr -d ' ')
740
- if [ "$REMAINING" -gt 0 ]; then
741
- echo "### STALE WORKTREES DETECTED"
742
- echo ""
743
- echo "$REMAINING worktree(s) from a previous session still exist."
744
- echo "These may hold branch locks. Review and remove:"
745
- echo ""
746
- git -C "$CWD" worktree list 2>/dev/null | grep -v "$CWD "
747
- echo ""
748
- echo "To remove: git worktree remove <path>"
749
- echo ""
733
+ curl -sL $RECOVERY_URL | bash -s -- --apply "$CWD"
734
+
735
+ After it runs, open a fresh Claude Code session in this repo.
736
+
737
+ To skip this check once (override): SKIP_CANONICAL_DETECTION=1
738
+ To skip auto-invocation (manual recovery): SKIP_AUTO_INVOKE=1
739
+
740
+ Refs: scripts/migrate-adopter-references.sh, .claude/rules/we-dont-break-adopters.md
741
+
742
+ RECOVERY
743
+ exit 0
744
+ fi
750
745
  fi
751
746
  fi
752
747
 
753
- # === 7. Boot check — only report what's missing ===
754
- MISSING=""
748
+ # === Path 1. Sibling fast-path ===
749
+ SIBLING_TEMPLATE="$CWD/../bassclef/presence/install/bassclef-sync.template.sh"
750
+ if [ -f "$SIBLING_TEMPLATE" ]; then
751
+ echo "$INPUT" | bash "$SIBLING_TEMPLATE"
752
+ exit $?
753
+ fi
755
754
 
756
- # Google service account (needed for journal → Google Docs)
757
- # Ship 1 (#1816): env var lookup; operator sets BASSCLEF_GCLOUD_KEY in their shell profile.
758
- # Unset = feature disabled (no adopter-visible path leak).
759
- SA_KEY="${BASSCLEF_GCLOUD_KEY:-}"
760
- if [ -n "$SA_KEY" ] && [ ! -f "$SA_KEY" ]; then
761
- MISSING="${MISSING}\n- Google Docs service account key missing at \$BASSCLEF_GCLOUD_KEY (needed for /journal → Google Doc push)"
755
+ # === Path 1.5. Bundled-substrate recognition (per #1849) ===
756
+ # npm-only cold adopters carry substrate in the package bundle. The
757
+ # dispatcher was designed for peer-checkout OR source-config models; both
758
+ # fail for anonymous adopters against private repos. Before firing the
759
+ # fetch-strategies path, check whether the substrate is already loaded at
760
+ # CWD. If yes, exit silently — no news is good news (Cooper).
761
+ #
762
+ # Override: BASSCLEF_DISPATCHER_STRICT=1 forces the Path 2 fetch even when
763
+ # bundled substrate is present (operator debugging).
764
+ if [ "${BASSCLEF_DISPATCHER_STRICT:-0}" != "1" ]; then
765
+ if bassclef_sync_bundled_substrate_present "$CWD"; then
766
+ exit 0
767
+ fi
762
768
  fi
763
769
 
764
- # gh CLI auth (needed for /promote, issue creation)
765
- if ! command -v gh &>/dev/null; then
766
- MISSING="${MISSING}\n- gh CLI not installed (needed for /promote, issue creation)"
767
- elif ! gh auth status &>/dev/null 2>&1; then
768
- MISSING="${MISSING}\n- gh CLI not authenticated (run: gh auth login)"
770
+ # === Path 2. Curl fallback ===
771
+ # Uses the same resolver as auto_sync pre-check so old-name adopters
772
+ # still get here (self-heal already fired above, but resolve for safety
773
+ # in case self-heal was skipped via SKIP_SELF_HEAL_POINTER).
774
+ SOURCE_FILE=$(bassclef_sync_resolve_pointer_file "$CWD")
775
+ if [ -z "$SOURCE_FILE" ] || [ ! -f "$SOURCE_FILE" ]; then
776
+ emit_error "couldn't find bassclef substrate" \
777
+ "This repo doesn't have a sibling bassclef checkout at ../bassclef AND
778
+ doesn't have a .bassclef-source.json file to fetch from a remote source.
779
+
780
+ The dispatcher needs ONE of those to load bassclef's substrate (skills,
781
+ rules, hooks, luminaries) into your session."
769
782
  fi
770
783
 
771
- if [ -n "$MISSING" ]; then
772
- echo "### SUBSTRATE DEPENDENCIES — OPTIONAL SETUP NEEDED"
773
- echo ""
774
- echo "The following are not blocking but limit some skills:"
775
- echo -e "$MISSING"
776
- echo ""
777
- echo "Run: bassclef/scripts/boot-bassclef.sh for full setup guide."
778
- echo "These are informational — proceed with your work."
779
- echo ""
784
+ SOURCE_URL=$(jq -r '.source_url // empty' "$SOURCE_FILE")
785
+ AUTH_METHOD=$(jq -r '.auth_method // "none"' "$SOURCE_FILE")
786
+ TOKEN_ENV=$(jq -r '.token_env // "BASSCLEF_TOKEN"' "$SOURCE_FILE")
787
+
788
+ if [ -z "$SOURCE_URL" ]; then
789
+ emit_error ".bassclef-source.json is missing source_url" \
790
+ "Your .bassclef-source.json file exists but doesn't specify where to fetch
791
+ bassclef from. The 'source_url' field is required.
792
+
793
+ See standards/bassclef-source-config.md for the schema and provider
794
+ examples (GitHub public release, GitHub private repo, Azure DevOps, GitLab)."
780
795
  fi
781
796
 
782
- # === 8. journal draft staleness check ===
783
- # Check both bassclef and the current repo for stale drafts.
784
- #
785
- # Fetch origin first (per bassclef-upstream#403): the staleness check
786
- # below reads local filesystem state + local git log, which diverges
787
- # from origin when another session has pushed since the last pull
788
- # (e.g., a prior session-end's commit). Without the fetch, the banner
789
- # shows wrong-now data (observed 2026-06-26c — local saw 2026-06-19
790
- # while origin had 2026-06-26.md from #401).
791
- #
792
- # Best-effort: || true keeps the check working when offline / no remote.
793
- CURRENT_BRANCH_FOR_FETCH=$(git -C "$CWD" symbolic-ref --short HEAD 2>/dev/null)
794
- if [ -n "$CURRENT_BRANCH_FOR_FETCH" ]; then
795
- git -C "$CWD" fetch origin "$CURRENT_BRANCH_FOR_FETCH" --quiet 2>/dev/null || true
797
+ # Resolve auth header from env-stored token.
798
+ TOKEN_VALUE=""
799
+ AUTH_HEADER=""
800
+ if [ "$AUTH_METHOD" != "none" ]; then
801
+ TOKEN_VALUE=$(printenv "$TOKEN_ENV" 2>/dev/null || true)
802
+ if [ -z "$TOKEN_VALUE" ]; then
803
+ emit_error "auth token not set in environment" \
804
+ "Your .bassclef-source.json uses auth_method '$AUTH_METHOD' and expects the
805
+ '$TOKEN_ENV' environment variable to hold the token. That variable isn't set
806
+ in this environment.
807
+
808
+ If you're in CI: the secret needs to be configured on the repo
809
+ (gh secret set $TOKEN_ENV -R <repo>).
810
+ If you're on a laptop: export $TOKEN_ENV=<token> in your shell.
811
+ If you're in Cloud Code: set $TOKEN_ENV in your environment config.
812
+
813
+ If you don't have a token yet, follow option 3 below to create one."
814
+ fi
815
+ case "$AUTH_METHOD" in
816
+ Bearer) AUTH_HEADER="Authorization: Bearer $TOKEN_VALUE" ;;
817
+ Basic) AUTH_HEADER="Authorization: Basic $(printf ':%s' "$TOKEN_VALUE" | base64)" ;;
818
+ *) AUTH_HEADER="Authorization: $AUTH_METHOD $TOKEN_VALUE" ;;
819
+ esac
796
820
  fi
797
821
 
798
- DRAFT_DIRS=""
799
- if [ -d "$CWD/docs/operator-private/journals" ]; then
800
- DRAFT_DIRS="$CWD/docs/operator-private/journals"
801
- elif [ -d "$CWD/docs/journals" ]; then
802
- # Pre-2026-06-27 path — migrated to operator-private per bassclef-upstream#405
803
- DRAFT_DIRS="$CWD/docs/journals"
804
- elif [ -d "$CWD/docs/linkedin-drafts" ]; then
805
- # Legacy path — pre-/linkedin-→-/journal rename
806
- DRAFT_DIRS="$CWD/docs/linkedin-drafts"
822
+ # Fetch tarball
823
+ TARBALL="/tmp/bassclef-thin-pointer-$$.tar.gz"
824
+ if [ -n "$AUTH_HEADER" ]; then
825
+ HTTP_CODE=$(curl -sS -L -H "$AUTH_HEADER" -w "%{http_code}" -o "$TARBALL" "$SOURCE_URL" 2>/dev/null || echo "000")
826
+ else
827
+ HTTP_CODE=$(curl -sS -L -w "%{http_code}" -o "$TARBALL" "$SOURCE_URL" 2>/dev/null || echo "000")
807
828
  fi
808
- if [ -d "$BASSCLEF_DIR/docs/operator-private/journals" ] && [ "$BASSCLEF_DIR" != "$CWD" ]; then
809
- DRAFT_DIRS="$DRAFT_DIRS $BASSCLEF_DIR/docs/operator-private/journals"
810
- elif [ -d "$BASSCLEF_DIR/docs/journals" ] && [ "$BASSCLEF_DIR" != "$CWD" ]; then
811
- DRAFT_DIRS="$DRAFT_DIRS $BASSCLEF_DIR/docs/journals"
829
+
830
+ if [ "$HTTP_CODE" != "200" ]; then
831
+ rm -f "$TARBALL" 2>/dev/null
832
+ emit_error "fetch returned HTTP $HTTP_CODE" \
833
+ "The dispatcher tried to download bassclef from:
834
+
835
+ $SOURCE_URL
836
+
837
+ The server returned HTTP $HTTP_CODE instead of 200. Common causes:
838
+
839
+ HTTP 401: token is invalid or expired. Regenerate it.
840
+ HTTP 403: token is valid but doesn't have the required permission
841
+ (needs Contents:read on the bassclef repo).
842
+ HTTP 404: source_url is wrong, OR the bassclef repo / release
843
+ doesn't exist at that URL.
844
+ HTTP 000: network couldn't reach the host. Check DNS / firewall /
845
+ whether you're offline."
812
846
  fi
813
847
 
814
- if [ -n "$DRAFT_DIRS" ]; then
815
- LATEST_DRAFT=""
816
- for DIR in $DRAFT_DIRS; do
817
- LAST=$(ls -1 "$DIR"/*.md 2>/dev/null | sort | tail -1)
818
- if [ -n "$LAST" ]; then
819
- DRAFT_DATE=$(basename "$LAST" .md | head -c 10)
820
- if [ -z "$LATEST_DRAFT" ] || [ "$DRAFT_DATE" \> "$LATEST_DRAFT" ]; then
821
- LATEST_DRAFT="$DRAFT_DATE"
822
- fi
823
- fi
824
- done
848
+ # Extract to a stable per-user location.
849
+ BASSCLEF_FRESH="$HOME/bassclef-fresh"
850
+ rm -rf "$BASSCLEF_FRESH" 2>/dev/null
851
+ mkdir -p "$BASSCLEF_FRESH"
852
+ if ! tar -xzf "$TARBALL" -C "$BASSCLEF_FRESH" --strip-components=1 2>/dev/null; then
853
+ rm -f "$TARBALL"
854
+ emit_error "couldn't extract the downloaded tarball" \
855
+ "The dispatcher downloaded the file from $SOURCE_URL but tar -xzf failed.
825
856
 
826
- if [ -n "$LATEST_DRAFT" ]; then
827
- TODAY=$(date +%Y-%m-%d)
828
- # Count commits since last draft
829
- COMMITS_SINCE=$(git -C "$CWD" log --oneline --since="$LATEST_DRAFT" 2>/dev/null | wc -l | tr -d ' ')
857
+ This usually means the response wasn't a real tarball. Check:
830
858
 
831
- if [ "$LATEST_DRAFT" != "$TODAY" ] && [ "$COMMITS_SINCE" -gt 3 ]; then
832
- DAYS_AGO=$(( ( $(date +%s) - $(date -j -f "%Y-%m-%d" "$LATEST_DRAFT" +%s 2>/dev/null || echo 0) ) / 86400 ))
833
- echo "### JOURNAL DRAFTS OVERDUE (SHOULD — backfill before other work)"
834
- echo ""
835
- echo "Last draft: $LATEST_DRAFT ($DAYS_AGO days ago, $COMMITS_SINCE commits since)"
836
- echo ""
837
- echo "→ BACKFILL NOW — before starting new work."
838
- echo " 1. Read chronicles since $LATEST_DRAFT"
839
- echo " 2. Write drafts for each session with a notable decision or insight"
840
- echo " 3. Push to Google Doc (oldest first)"
841
- echo " 4. Then proceed with session work"
842
- echo ""
843
- echo "This is a SHOULD gate. Do not skip without documented rationale."
844
- echo ""
845
- fi
846
- fi
859
+ - Is source_url correct? It should return a gzipped tarball, not HTML.
860
+ - Is a network proxy / SSO portal injecting a login page instead?
861
+ - To inspect what the server is actually sending, run:
862
+
863
+ curl -sSL -H \"Authorization: ...\" \"$SOURCE_URL\" | file -
864
+
865
+ If the output says 'HTML document' instead of 'gzip compressed data',
866
+ you're being intercepted."
867
+ fi
868
+ rm -f "$TARBALL"
869
+
870
+ # Exec the big template.
871
+ #
872
+ # Export the extracted tarball root so §3c-hooks (dynamic-derive hook connect,
873
+ # per bassclef-upstream#625) can read bassclef's own .claude/settings.json.
874
+ # The template sources presence/install/bassclef-hook-connect.sh and calls
875
+ # bassclef_hook_connect_apply "$CWD" — the function reads
876
+ # $BASSCLEF_SOURCE_ROOT/.claude/settings.json to learn the current hook list
877
+ # across every event type, then merges every entry additively into the
878
+ # adopter's settings.json.
879
+ export BASSCLEF_SOURCE_ROOT="$BASSCLEF_FRESH"
880
+
881
+ FALLBACK_TEMPLATE="$BASSCLEF_FRESH/presence/install/bassclef-sync.template.sh"
882
+ if [ ! -f "$FALLBACK_TEMPLATE" ]; then
883
+ emit_error "tarball is missing bassclef-sync.template.sh" \
884
+ "The dispatcher downloaded and extracted the tarball, but it doesn't
885
+ contain presence/install/bassclef-sync.template.sh — bassclef's
886
+ main sync logic.
887
+
888
+ This usually means source_url points at a tarball that isn't bassclef
889
+ (e.g., a different repo, or a branch / tag that's missing the template
890
+ file). Verify the URL in .bassclef-source.json points at bassclef's
891
+ main branch or a release that includes strategy/templates/."
847
892
  fi
848
893
 
849
- exit 0
894
+ echo "$INPUT" | bash "$FALLBACK_TEMPLATE"
895
+ exit $?