@thebassclef/lite 1.9.9 → 1.9.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/lite/.claude/hooks/artifact-ingestion-gate.sh +9 -3
  6. package/dist/lite/.claude/hooks/pre-commit-gate.sh +31 -0
  7. package/dist/lite/.claude/hooks/pre-commit-identifier-leak-scrub.sh +58 -2
  8. package/dist/lite/.claude/hooks/save-state.sh +6 -1
  9. package/dist/lite/.claude/hooks/session-reflection.d/83-install-written-paths-rebuild.sh +111 -0
  10. package/dist/lite/.claude/hooks/session-reflection.d/97-last-block-count.sh +53 -0
  11. package/dist/lite/.claude/hooks/session-reflection.d/tests/97-last-block-count.test.sh +131 -0
  12. package/dist/lite/.claude/hooks/session-reflection.sh +22 -0
  13. package/dist/lite/.claude/hooks/state-validate.sh +8 -0
  14. package/dist/lite/.claude/rules/install-written-paths-mediation.md +74 -0
  15. package/dist/lite/.claude/rules/last-block-marker.md +79 -0
  16. package/dist/lite/.claude/skills/autonomous/SKILL.md +53 -7
  17. package/dist/lite/.claude/skills/build/SKILL.md +41 -5
  18. package/dist/lite/.claude/skills/deploy-prod/SKILL.md +10 -0
  19. package/dist/lite/.claude/skills/ia-model/SKILL.md +8 -0
  20. package/dist/lite/.claude/skills/interaction-design/SKILL.md +8 -0
  21. package/dist/lite/.claude/skills/jtbd-tasks/SKILL.md +9 -1
  22. package/dist/lite/.claude/skills/last-block/SKILL.md +99 -0
  23. package/dist/lite/.claude/skills/launch/SKILL.md +44 -12
  24. package/dist/lite/.claude/skills/objectory-decompose/SKILL.md +5 -17
  25. package/dist/lite/.claude/skills/onboard-repo/SKILL.md +18 -18
  26. package/dist/lite/.claude/skills/personas/SKILL.md +1 -1
  27. package/dist/lite/.claude/skills/use-case/SKILL.md +5 -4
  28. package/dist/lite/bassclef-version.json +5 -5
  29. package/dist/lite/lib/capability-probe.sh +39 -1
  30. package/dist/lite/lib/composer-preflight.sh +20 -4
  31. package/dist/lite/lib/install-written-paths.sh +369 -0
  32. package/dist/lite/lib/last-block.sh +153 -0
  33. package/dist/lite/lib/luminary-pick.sh +35 -13
  34. package/dist/lite/lib/state.sh +82 -11
  35. package/dist/lite/scripts/interpret-input.sh +19 -4
  36. package/dist/lite/standards/lite-manifest-schema-changes.md +115 -0
  37. package/dist/lite/standards/lite-manifest.json +80 -26
  38. package/dist/lite/standards/state-spine.md +1 -0
  39. package/package.json +2 -2
@@ -44,8 +44,46 @@ probe_gh_auth() {
44
44
  }
45
45
 
46
46
  probe_mcp_playwright() {
47
- # V1 heuristic — env var contract. V2 may read tool inventory.
47
+ # Fast path — explicit opt-in via env var. Adopters who want to force the
48
+ # probe TRUE set CLAUDE_MCP_PLAYWRIGHT=1 in their shell init. Documented at
49
+ # docs/use-cases/UC-lib-capability-probe-mcp-fallback.md.
48
50
  [ -n "${CLAUDE_MCP_PLAYWRIGHT:-}" ] && return 0
51
+
52
+ # Fallback — look for a Playwright MCP server in known MCP config files.
53
+ # jq is required for parsing. When absent, the probe fails soft to 1.
54
+ if ! command -v jq >/dev/null 2>&1; then
55
+ echo "probe_mcp_playwright: jq not found; env-var fast path only" >&2
56
+ return 1
57
+ fi
58
+
59
+ local project_dir="${CLAUDE_PROJECT_DIR:-.}"
60
+ local home_dir="${HOME:-}"
61
+
62
+ # Check paths in precedence order. First hit wins.
63
+ _probe_mcp_json_has_playwright "${project_dir}/.mcp.json" && return 0
64
+ _probe_mcp_json_has_playwright "${project_dir}/.claude/settings.json" && return 0
65
+ if [ -n "$home_dir" ]; then
66
+ _probe_mcp_json_has_playwright "${home_dir}/.claude/settings.json" && return 0
67
+ _probe_mcp_json_has_playwright "${home_dir}/.claude.json" && return 0
68
+ fi
69
+ return 1
70
+ }
71
+
72
+ # _probe_mcp_json_has_playwright <path>
73
+ # Returns 0 when the JSON file names a Playwright MCP server. Checks two
74
+ # shapes:
75
+ # 1. Top-level "playwright" key (used by `.mcp.json` on some adopters)
76
+ # 2. Nested "mcpServers.playwright" (used by `settings.json` + `~/.claude.json`)
77
+ # Fail-soft on missing file + malformed JSON — returns 1 silently.
78
+ # Per bassclef-upstream Finding #6 (#2036): `local path` is a zsh-magic variable
79
+ # name — ties to $PATH when sourced from zsh. Renamed to `local p`.
80
+ _probe_mcp_json_has_playwright() {
81
+ local p="$1"
82
+ [ -f "$p" ] || return 1
83
+ # Shape 1 — top-level playwright
84
+ jq -e '.playwright // empty' "$p" >/dev/null 2>&1 && return 0
85
+ # Shape 2 — nested mcpServers.playwright
86
+ jq -e '.mcpServers.playwright // empty' "$p" >/dev/null 2>&1 && return 0
49
87
  return 1
50
88
  }
51
89
 
@@ -178,10 +178,14 @@ composer_preflight_check() {
178
178
  echo "check_5_recommendation="
179
179
  pass=$((pass + 1))
180
180
  elif [ -f "$repo_skill" ] || [ -L "$repo_skill" ]; then
181
- echo "check_5_status=warn"
182
- echo "check_5_message=Skill at repo-level only ($repo_skill); user-level symlink missing — harness may not pick up (bassclef#271)"
183
- echo "check_5_recommendation=Run bassclef-sync.sh to create user-level symlink OR restart Claude session"
184
- warn=$((warn + 1))
181
+ # Per cli#321 — Claude Code's harness loads project .claude/skills/
182
+ # directly; user-level symlinks are not required. Repo-level IS a
183
+ # valid discovery path. Previously flagged as WARN, trained adopters
184
+ # to ignore warnings. Now PASS with informational message.
185
+ echo "check_5_status=pass"
186
+ echo "check_5_message=Skill resolves at repo-level: $repo_skill (harness loads project .claude/skills directly)"
187
+ echo "check_5_recommendation="
188
+ pass=$((pass + 1))
185
189
  elif [ -n "${BASSCLEF_DIR:-}" ] && [ -f "$bassclef_skill" ]; then
186
190
  echo "check_5_status=warn"
187
191
  echo "check_5_message=Skill at bassclef only; consumer-side links missing — substrate sync incomplete"
@@ -265,6 +269,18 @@ composer_preflight_check() {
265
269
  echo "check_6_message=hosting_platform=none + --no-launch (variants-only run)"
266
270
  echo "check_6_recommendation="
267
271
  pass=$((pass + 1))
272
+ elif [ "$skill_name" = "build" ]; then
273
+ # cli#309 cure — /build has no --no-launch flag and its Phase 7 deploy
274
+ # is a documented stub (see .claude/skills/build/SKILL.md Phase 7). Warn
275
+ # (not fail) so dispatch proceeds; Phase 7 will emit a TODO rather than
276
+ # run a real deploy. Adopter can set a real hosting_platform when ready
277
+ # to use Phase 7 live.
278
+ # Anchor: @luminary tony-hoare (postcondition per skill) +
279
+ # @luminary michael-nygard (fail-soft when it does not matter).
280
+ echo "check_6_status=warn"
281
+ echo "check_6_message=hosting_platform=none — /build Phase 7 deploy is a stub; dispatch proceeds, deploy emits TODO instead of running"
282
+ echo "check_6_recommendation=Set hosting_platform to a deploy provider (amplify) when ready to live-deploy; see .claude/skills/build/SKILL.md Phase 7"
283
+ warn=$((warn + 1))
268
284
  else
269
285
  echo "check_6_status=fail"
270
286
  echo "check_6_message=hosting_platform=none — composer would generate variants that can't launch"
@@ -0,0 +1,369 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # Install-written-paths manifest + accessor lib.
4
+ #
5
+ # Records paths written by bassclef install writers so discipline hooks
6
+ # can skip them. Per bassclef-upstream#2036 Finding #8 structural cure
7
+ # (Kunal Slot 1). See docs/decompositions/2026-10-03-install-written-paths-manifest.md
8
+ # for design + patterns applied (Repository + Deep Module + Null Object + Guard Clause).
9
+ #
10
+ # @pattern patterns/code/fowler/repository.md
11
+ # @pattern patterns/code/ousterhout/deep-module.md
12
+ #
13
+ # Public API (5 verbs):
14
+ # install_written_paths_register <path> <writer>
15
+ # is_install_written_path <path>
16
+ # install_written_paths_rename <old-path> <new-path>
17
+ # install_written_paths_rebuild
18
+ # install_written_paths_verify
19
+ #
20
+ # Override: SKIP_INSTALL_WRITTEN_PATHS=1 — is_install_written_path returns 1 (false)
21
+ # so hooks fire discipline normally (don't skip).
22
+ #
23
+ # Variable naming: internal locals use `tgt` (target path), `arg_*` prefix,
24
+ # or `_iwp_*` namespace to avoid shadowing shell builtins. Reserved names
25
+ # like `PATH`, `CDPATH`, `FPATH`, `MANPATH` are not used as locals per
26
+ # shell-portability discipline (zsh treats lowercase `path` as array-tied
27
+ # to PATH; renaming avoids the kill).
28
+
29
+ set -u
30
+
31
+ # ---- Constants ----
32
+
33
+ _IWP_SCHEMA_VERSION="1.0.0"
34
+
35
+ _IWP_WRITERS="bassclef-hook-connect bassclef-sync-template bassclef-sync-dispatcher schedule-auto-save bassclef-init-npm"
36
+
37
+ # ---- Internal helpers ----
38
+
39
+ _iwp_manifest_path() {
40
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
41
+ echo "$root/state/install-written-paths.json"
42
+ }
43
+
44
+ _iwp_log() {
45
+ if command -v trace_log >/dev/null 2>&1; then
46
+ trace_log "install-written-paths" "$*"
47
+ fi
48
+ }
49
+
50
+ _iwp_sha256() {
51
+ local file="$1"
52
+ if [ ! -f "$file" ]; then
53
+ echo ""
54
+ return 1
55
+ fi
56
+ if command -v sha256sum >/dev/null 2>&1; then
57
+ sha256sum "$file" 2>/dev/null | awk '{print "sha256:" $1}'
58
+ elif command -v shasum >/dev/null 2>&1; then
59
+ shasum -a 256 "$file" 2>/dev/null | awk '{print "sha256:" $1}'
60
+ else
61
+ echo ""
62
+ return 1
63
+ fi
64
+ }
65
+
66
+ _iwp_valid_writer() {
67
+ local w="$1"
68
+ for valid in $_IWP_WRITERS; do
69
+ [ "$w" = "$valid" ] && return 0
70
+ done
71
+ return 1
72
+ }
73
+
74
+ _iwp_validate_path() {
75
+ local p="$1"
76
+ # Reject path traversal (any component equal to .. OR path exactly ..)
77
+ case "$p" in
78
+ *../*|..) return 1 ;;
79
+ esac
80
+ # Reject absolute paths outside $CLAUDE_PROJECT_DIR
81
+ case "$p" in
82
+ /*)
83
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
84
+ case "$p" in
85
+ "$root"/*) ;;
86
+ *) return 1 ;;
87
+ esac
88
+ ;;
89
+ esac
90
+ return 0
91
+ }
92
+
93
+ _iwp_ensure_manifest() {
94
+ local mpath
95
+ mpath=$(_iwp_manifest_path)
96
+ if [ ! -f "$mpath" ]; then
97
+ mkdir -p "$(dirname "$mpath")"
98
+ cat > "$mpath" <<EOF
99
+ {
100
+ "schema_version": "$_IWP_SCHEMA_VERSION",
101
+ "entries": []
102
+ }
103
+ EOF
104
+ fi
105
+ }
106
+
107
+ _iwp_manifest_valid() {
108
+ local mpath
109
+ mpath=$(_iwp_manifest_path)
110
+ [ -f "$mpath" ] || return 1
111
+ jq -e . "$mpath" >/dev/null 2>&1 || return 1
112
+ return 0
113
+ }
114
+
115
+ # ---- Public API ----
116
+
117
+ # install_written_paths_register <path> <writer>
118
+ # Records a path as install-written. Idempotent on path.
119
+ # Returns 0 on success, 1 on validation failure.
120
+ install_written_paths_register() {
121
+ local tgt="${1:-}"
122
+ local writer="${2:-}"
123
+
124
+ if [ -z "$tgt" ] || [ -z "$writer" ]; then
125
+ echo "install-written-paths: register requires <path> <writer>" >&2
126
+ return 1
127
+ fi
128
+
129
+ if ! _iwp_validate_path "$tgt"; then
130
+ echo "install-written-paths: path '$tgt' rejected (traversal or outside project)" >&2
131
+ _iwp_log "register-rejected path=$tgt reason=traversal"
132
+ return 1
133
+ fi
134
+
135
+ if ! _iwp_valid_writer "$writer"; then
136
+ echo "install-written-paths: writer '$writer' not in allowed enum" >&2
137
+ _iwp_log "register-rejected path=$tgt writer=$writer reason=unknown-writer"
138
+ return 1
139
+ fi
140
+
141
+ if ! command -v jq >/dev/null 2>&1; then
142
+ echo "install-written-paths: jq not available; cannot register" >&2
143
+ return 1
144
+ fi
145
+
146
+ _iwp_ensure_manifest
147
+ local mpath
148
+ mpath=$(_iwp_manifest_path)
149
+
150
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
151
+ local full_tgt
152
+ case "$tgt" in
153
+ /*) full_tgt="$tgt" ;;
154
+ *) full_tgt="$root/$tgt" ;;
155
+ esac
156
+
157
+ local hash
158
+ hash=$(_iwp_sha256 "$full_tgt")
159
+ if [ -z "$hash" ]; then
160
+ echo "install-written-paths: unable to compute hash for '$full_tgt'" >&2
161
+ return 1
162
+ fi
163
+
164
+ local now
165
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
166
+
167
+ # Use flock to serialize writes; fall back to no-lock on BSD without flock
168
+ local tmp
169
+ tmp=$(mktemp "$mpath.tmp.XXXXXX")
170
+
171
+ if command -v flock >/dev/null 2>&1; then
172
+ (
173
+ flock -x 9
174
+ jq --arg path "$tgt" --arg writer "$writer" --arg ts "$now" --arg hash "$hash" \
175
+ '.entries |= (map(select(.path != $path)) + [{"path": $path, "writer": $writer, "written_at": $ts, "content_hash": $hash}])' \
176
+ "$mpath" > "$tmp" && mv "$tmp" "$mpath"
177
+ ) 9>"$mpath.lock"
178
+ else
179
+ jq --arg path "$tgt" --arg writer "$writer" --arg ts "$now" --arg hash "$hash" \
180
+ '.entries |= (map(select(.path != $path)) + [{"path": $path, "writer": $writer, "written_at": $ts, "content_hash": $hash}])' \
181
+ "$mpath" > "$tmp" && mv "$tmp" "$mpath"
182
+ fi
183
+
184
+ rm -f "$tmp" 2>/dev/null || true
185
+ _iwp_log "register path=$tgt writer=$writer"
186
+ return 0
187
+ }
188
+
189
+ # is_install_written_path <path>
190
+ # Returns 0 (true) when path in manifest AND content hash matches.
191
+ # Returns 1 (false) otherwise.
192
+ # Override: SKIP_INSTALL_WRITTEN_PATHS=1 forces return 1 (hooks fire normally).
193
+ is_install_written_path() {
194
+ if [ -n "${SKIP_INSTALL_WRITTEN_PATHS:-}" ]; then
195
+ _iwp_log "check override SKIP_INSTALL_WRITTEN_PATHS=1 path=${1:-}"
196
+ return 1
197
+ fi
198
+
199
+ local tgt="${1:-}"
200
+ if [ -z "$tgt" ]; then
201
+ return 1
202
+ fi
203
+
204
+ local mpath
205
+ mpath=$(_iwp_manifest_path)
206
+
207
+ # Fail-soft: manifest missing or malformed → not install-written
208
+ if ! _iwp_manifest_valid; then
209
+ _iwp_log "check path=$tgt result=false reason=no-manifest-or-malformed"
210
+ [ -f "$mpath" ] && echo "install-written-paths: manifest malformed; treating as not-install-written" >&2
211
+ return 1
212
+ fi
213
+
214
+ if ! command -v jq >/dev/null 2>&1; then
215
+ _iwp_log "check path=$tgt result=false reason=no-jq"
216
+ return 1
217
+ fi
218
+
219
+ # Look up entry
220
+ local recorded_hash
221
+ recorded_hash=$(jq -r --arg path "$tgt" '.entries[] | select(.path == $path) | .content_hash' "$mpath" 2>/dev/null)
222
+ if [ -z "$recorded_hash" ] || [ "$recorded_hash" = "null" ]; then
223
+ _iwp_log "check path=$tgt result=false reason=not-in-manifest"
224
+ return 1
225
+ fi
226
+
227
+ # Compute current hash
228
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
229
+ local full_tgt
230
+ case "$tgt" in
231
+ /*) full_tgt="$tgt" ;;
232
+ *) full_tgt="$root/$tgt" ;;
233
+ esac
234
+
235
+ if [ ! -f "$full_tgt" ]; then
236
+ _iwp_log "check path=$tgt result=false reason=file-missing"
237
+ return 1
238
+ fi
239
+
240
+ local current_hash
241
+ current_hash=$(_iwp_sha256 "$full_tgt")
242
+ if [ -z "$current_hash" ]; then
243
+ _iwp_log "check path=$tgt result=false reason=hash-fail"
244
+ return 1
245
+ fi
246
+
247
+ if [ "$recorded_hash" = "$current_hash" ]; then
248
+ _iwp_log "check path=$tgt result=true reason=match"
249
+ return 0
250
+ else
251
+ _iwp_log "check path=$tgt result=false reason=hash-mismatch"
252
+ return 1
253
+ fi
254
+ }
255
+
256
+ # install_written_paths_rename <old-path> <new-path>
257
+ # Moves entry; preserves writer; bumps written_at.
258
+ install_written_paths_rename() {
259
+ local old="${1:-}"
260
+ local new="${2:-}"
261
+
262
+ if [ -z "$old" ] || [ -z "$new" ]; then
263
+ echo "install-written-paths: rename requires <old-path> <new-path>" >&2
264
+ return 1
265
+ fi
266
+
267
+ if ! _iwp_validate_path "$new"; then
268
+ echo "install-written-paths: new path '$new' rejected" >&2
269
+ return 1
270
+ fi
271
+
272
+ if ! _iwp_manifest_valid; then
273
+ echo "install-written-paths: manifest missing or malformed" >&2
274
+ return 1
275
+ fi
276
+
277
+ if ! command -v jq >/dev/null 2>&1; then
278
+ echo "install-written-paths: jq not available" >&2
279
+ return 1
280
+ fi
281
+
282
+ local mpath
283
+ mpath=$(_iwp_manifest_path)
284
+
285
+ # Check old exists
286
+ local writer
287
+ writer=$(jq -r --arg path "$old" '.entries[] | select(.path == $path) | .writer' "$mpath" 2>/dev/null)
288
+ if [ -z "$writer" ] || [ "$writer" = "null" ]; then
289
+ echo "install-written-paths: '$old' not in manifest" >&2
290
+ return 1
291
+ fi
292
+
293
+ local now
294
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
295
+
296
+ # Compute hash from new location if it exists; else keep old hash
297
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
298
+ local full_new
299
+ case "$new" in
300
+ /*) full_new="$new" ;;
301
+ *) full_new="$root/$new" ;;
302
+ esac
303
+
304
+ local new_hash
305
+ if [ -f "$full_new" ]; then
306
+ new_hash=$(_iwp_sha256 "$full_new")
307
+ else
308
+ new_hash=$(jq -r --arg path "$old" '.entries[] | select(.path == $path) | .content_hash' "$mpath")
309
+ fi
310
+
311
+ local tmp
312
+ tmp=$(mktemp "$mpath.tmp.XXXXXX")
313
+
314
+ jq --arg old "$old" --arg new "$new" --arg writer "$writer" --arg ts "$now" --arg hash "$new_hash" \
315
+ '.entries |= (map(select(.path != $old)) + [{"path": $new, "writer": $writer, "written_at": $ts, "content_hash": $hash}])' \
316
+ "$mpath" > "$tmp" && mv "$tmp" "$mpath"
317
+
318
+ rm -f "$tmp" 2>/dev/null || true
319
+ _iwp_log "rename old=$old new=$new"
320
+ return 0
321
+ }
322
+
323
+ # install_written_paths_rebuild
324
+ # Regenerates the manifest. V1: creates empty manifest when missing.
325
+ # Future: walks install writers' known output locations.
326
+ install_written_paths_rebuild() {
327
+ local mpath
328
+ mpath=$(_iwp_manifest_path)
329
+ if [ -f "$mpath" ] && _iwp_manifest_valid; then
330
+ return 0
331
+ fi
332
+ rm -f "$mpath" 2>/dev/null || true
333
+ _iwp_ensure_manifest
334
+ _iwp_log "rebuild manifest=$mpath"
335
+ return 0
336
+ }
337
+
338
+ # install_written_paths_verify
339
+ # Returns 0 on well-formed manifest, 1 on schema violation.
340
+ install_written_paths_verify() {
341
+ local mpath
342
+ mpath=$(_iwp_manifest_path)
343
+
344
+ if [ ! -f "$mpath" ]; then
345
+ echo "install-written-paths: manifest file missing at $mpath" >&2
346
+ return 1
347
+ fi
348
+
349
+ if ! command -v jq >/dev/null 2>&1; then
350
+ echo "install-written-paths: jq not available" >&2
351
+ return 1
352
+ fi
353
+
354
+ if ! jq -e . "$mpath" >/dev/null 2>&1; then
355
+ echo "install-written-paths: manifest JSON malformed" >&2
356
+ return 1
357
+ fi
358
+
359
+ # Basic shape check — required top-level keys present
360
+ local has_version has_entries
361
+ has_version=$(jq -r 'has("schema_version")' "$mpath" 2>/dev/null)
362
+ has_entries=$(jq -r 'has("entries")' "$mpath" 2>/dev/null)
363
+ if [ "$has_version" != "true" ] || [ "$has_entries" != "true" ]; then
364
+ echo "install-written-paths: manifest missing required keys" >&2
365
+ return 1
366
+ fi
367
+
368
+ return 0
369
+ }
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # lib/last-block.sh — persist and read BLOCK banners.
4
+ #
5
+ # Per @luminary john-ousterhout deep module: three narrow functions hide
6
+ # the plaintext marker format. Callers persist, read, and list without
7
+ # touching file layout.
8
+ #
9
+ # Exposed functions:
10
+ # record_last_block <hook> <severity> <message> — write a marker
11
+ # read_last_block [<hook>] — read one marker (newest if no arg)
12
+ # list_last_blocks — list every marker newest first
13
+ #
14
+ # Marker format (plaintext, grep-friendly):
15
+ # hook: <hook-name>
16
+ # severity: blocking|advisory|info
17
+ # fired_at: <ISO-8601 UTC>
18
+ # session: <session-id or "unknown">
19
+ # ---
20
+ # <verbatim banner text, truncated to 8KB with [truncated] marker>
21
+ #
22
+ # Fail-soft: every write path returns 0 even on failure; stderr WARN
23
+ # describes the problem. Per @luminary michael-nygard — a persistence lib
24
+ # must never crash its caller.
25
+ #
26
+ # Per bassclef-upstream#2036 Finding #10 + UC-skill-last-block.md.
27
+
28
+ # Resolve the marker directory. Caller overrides via LAST_BLOCK_DIR for
29
+ # tests. Default is state/markers/last-block/ under CLAUDE_PROJECT_DIR
30
+ # (fallback to current dir when unset).
31
+ _last_block_dir() {
32
+ if [ -n "${LAST_BLOCK_DIR:-}" ]; then
33
+ echo "$LAST_BLOCK_DIR"
34
+ else
35
+ echo "${CLAUDE_PROJECT_DIR:-.}/state/markers/last-block"
36
+ fi
37
+ }
38
+
39
+ # record_last_block <hook> <severity> <message>
40
+ # Writes a marker. Fail-soft on every error (returns 0; WARN to stderr).
41
+ record_last_block() {
42
+ local hook="${1:-unknown}"
43
+ local severity="${2:-blocking}"
44
+ local message="${3:-}"
45
+ local dir
46
+ dir="$(_last_block_dir)"
47
+
48
+ # mkdir -p fail-soft — read-only disk, permission denied, etc.
49
+ if ! mkdir -p "$dir" 2>/dev/null; then
50
+ echo "record_last_block: cannot create '$dir' — marker not written" >&2
51
+ return 0
52
+ fi
53
+
54
+ # Truncate banner text to 8192 bytes to bound marker size.
55
+ local max_bytes=8192
56
+ local truncated=""
57
+ local body_bytes
58
+ body_bytes=$(printf '%s' "$message" | wc -c | tr -d ' ')
59
+ if [ "$body_bytes" -gt "$max_bytes" ]; then
60
+ message="$(printf '%s' "$message" | head -c "$max_bytes")"
61
+ truncated=$'\n[truncated — original was '"$body_bytes"' bytes]'
62
+ fi
63
+
64
+ local ts
65
+ ts="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo 'unknown')"
66
+ local session_id="${CLAUDE_SESSION_ID:-unknown}"
67
+ local marker="$dir/${hook}.marker"
68
+ local tmp="${marker}.tmp.$$"
69
+
70
+ {
71
+ printf 'hook: %s\n' "$hook"
72
+ printf 'severity: %s\n' "$severity"
73
+ printf 'fired_at: %s\n' "$ts"
74
+ printf 'session: %s\n' "$session_id"
75
+ printf '%s\n' '---'
76
+ printf '%s%s\n' "$message" "$truncated"
77
+ } > "$tmp" 2>/dev/null || {
78
+ echo "record_last_block: write to '$tmp' failed — marker not written" >&2
79
+ rm -f "$tmp" 2>/dev/null
80
+ return 0
81
+ }
82
+
83
+ if ! mv "$tmp" "$marker" 2>/dev/null; then
84
+ echo "record_last_block: rename to '$marker' failed — marker not written" >&2
85
+ rm -f "$tmp" 2>/dev/null
86
+ return 0
87
+ fi
88
+
89
+ return 0
90
+ }
91
+
92
+ # read_last_block [<hook>]
93
+ # Prints a marker's banner. With a hook arg, reads that marker. With no
94
+ # args, reads the newest marker by mtime. Returns 1 when no marker found.
95
+ read_last_block() {
96
+ local dir
97
+ dir="$(_last_block_dir)"
98
+
99
+ if [ ! -d "$dir" ]; then
100
+ echo "no block recorded (no markers directory at $dir)" >&2
101
+ return 1
102
+ fi
103
+
104
+ local marker
105
+ if [ $# -ge 1 ] && [ -n "$1" ]; then
106
+ marker="$dir/${1}.marker"
107
+ if [ ! -f "$marker" ]; then
108
+ echo "no block recorded for '$1'" >&2
109
+ return 1
110
+ fi
111
+ else
112
+ # Newest by mtime. ls -t is intentional (sort-by-mtime is its purpose).
113
+ # shellcheck disable=SC2012
114
+ marker="$(ls -t "$dir"/*.marker 2>/dev/null | head -1)"
115
+ if [ -z "$marker" ]; then
116
+ echo "no block recorded this session or prior" >&2
117
+ return 1
118
+ fi
119
+ fi
120
+
121
+ cat "$marker"
122
+ return 0
123
+ }
124
+
125
+ # list_last_blocks
126
+ # Lists every marker newest first — one line per marker with hook name,
127
+ # severity, timestamp. Returns 1 when the directory is empty or absent.
128
+ list_last_blocks() {
129
+ local dir
130
+ dir="$(_last_block_dir)"
131
+
132
+ if [ ! -d "$dir" ]; then
133
+ echo "no blocks recorded (no markers directory at $dir)" >&2
134
+ return 1
135
+ fi
136
+
137
+ local markers
138
+ # shellcheck disable=SC2012
139
+ markers="$(ls -t "$dir"/*.marker 2>/dev/null)"
140
+ if [ -z "$markers" ]; then
141
+ echo "no blocks recorded (directory empty)" >&2
142
+ return 1
143
+ fi
144
+
145
+ local m hook severity fired_at
146
+ echo "$markers" | while IFS= read -r m; do
147
+ hook="$(awk -F': ' '/^hook:/{print $2; exit}' "$m" 2>/dev/null)"
148
+ severity="$(awk -F': ' '/^severity:/{print $2; exit}' "$m" 2>/dev/null)"
149
+ fired_at="$(awk -F': ' '/^fired_at:/{print $2; exit}' "$m" 2>/dev/null)"
150
+ printf '%s\t%s\t%s\n' "$hook" "$severity" "$fired_at"
151
+ done
152
+ return 0
153
+ }
@@ -25,7 +25,7 @@
25
25
  #
26
26
  # Usage from a caller SKILL body:
27
27
  #
28
- # source "${BASSCLEF_DIR:-$HOME/src/sunj-labs/bassclef}/lib/luminary-pick.sh"
28
+ # source "${BASSCLEF_DIR:-${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}}/lib/luminary-pick.sh"
29
29
  # CATALOG=$(luminary_pick_catalog)
30
30
  # # then Claude reasons over $CATALOG + intent, writes JSON in-turn
31
31
  #
@@ -44,22 +44,44 @@ set -euo pipefail
44
44
  # Sort: alphabetical by slug.
45
45
  # Return: 0 on success (including empty catalog); non-zero if dir missing.
46
46
  luminary_pick_catalog() {
47
- local basedir="${BASSCLEF_DIR:-$HOME/src/sunj-labs/bassclef}"
47
+ # Resolve basedir: BASSCLEF_DIR → CLAUDE_PROJECT_DIR → git root → pwd.
48
+ # cli#313 — the former fallback $HOME/src/sunj-labs/bassclef was the
49
+ # engineering team's own home path and failed for every adopter.
50
+ local basedir="${BASSCLEF_DIR:-${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}}"
48
51
  local catalog_dir="${basedir}/state/luminary-implementations"
52
+ local lite_catalog="${basedir}/.claude/luminaries"
49
53
 
50
- if [ ! -d "$catalog_dir" ]; then
51
- echo "[luminary-pick-catalog] catalog dir missing: $catalog_dir" >&2
52
- return 2
54
+ if [ -d "$catalog_dir" ]; then
55
+ (
56
+ shopt -s nullglob
57
+ set +e
58
+ for f in "$catalog_dir"/*.json; do
59
+ jq -r '"\(.luminary_slug) (\(.primary_domain // "unknown")) — \(.name // .luminary_slug)"' "$f" 2>/dev/null || true
60
+ done | sort -u
61
+ )
62
+ return 0
53
63
  fi
54
64
 
55
- (
56
- shopt -s nullglob
57
- set +e
58
- for f in "$catalog_dir"/*.json; do
59
- jq -r '"\(.luminary_slug) (\(.primary_domain // "unknown")) — \(.name // .luminary_slug)"' "$f" 2>/dev/null || true
60
- done | sort -u
61
- )
62
- return 0
65
+ # Lite-tier fallback (cli#313): read frontmatter from .claude/luminaries/*.md.
66
+ # Every tier ships this directory; state/luminary-implementations/ is upstream-only.
67
+ if [ -d "$lite_catalog" ]; then
68
+ (
69
+ shopt -s nullglob
70
+ set +e
71
+ for f in "$lite_catalog"/*.md; do
72
+ local slug domain name
73
+ slug=$(awk '/^slug:[[:space:]]/{sub(/^slug:[[:space:]]*/,""); print; exit}' "$f")
74
+ domain=$(awk '/^primary_domain:[[:space:]]/{sub(/^primary_domain:[[:space:]]*/,""); print; exit}' "$f")
75
+ name=$(awk '/^name:[[:space:]]/{sub(/^name:[[:space:]]*/,""); print; exit}' "$f")
76
+ [ -z "$slug" ] && continue
77
+ echo "${slug} (${domain:-unknown}) — ${name:-$slug}"
78
+ done | sort -u
79
+ )
80
+ return 0
81
+ fi
82
+
83
+ echo "[luminary-pick-catalog] catalog dir missing: $catalog_dir (and no .claude/luminaries/ fallback at $lite_catalog)" >&2
84
+ return 2
63
85
  }
64
86
 
65
87
  # Grace-window stub preserving the old function signature.