@thebassclef/lite 1.9.16 → 1.9.19

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 (45) 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/agents/builder.md +7 -4
  6. package/dist/lite/.claude/bassclef-orientation.md +22 -0
  7. package/dist/lite/.claude/hooks/artifact-ingestion-gate.sh +11 -3
  8. package/dist/lite/.claude/hooks/assert-verify-steering.sh +1 -19
  9. package/dist/lite/.claude/hooks/plain-english-steering.sh +86 -41
  10. package/dist/lite/.claude/hooks/pre-commit-gate.sh +10 -2
  11. package/dist/lite/.claude/hooks/pre-push-shellcheck.sh +196 -0
  12. package/dist/lite/.claude/hooks/reviewer-dispatch-check.sh +153 -0
  13. package/dist/lite/.claude/hooks/session-reflection.d/83-install-written-paths-rebuild.sh +15 -32
  14. package/dist/lite/.claude/hooks/turn-prose-density-measure.sh +109 -0
  15. package/dist/lite/.claude/hooks/turn-prose-kiss-check.sh +28 -0
  16. package/dist/lite/.claude/rules/branching.md +2 -0
  17. package/dist/lite/.claude/rules/identifier-leak-prevention.md +2 -1
  18. package/dist/lite/.claude/rules/install-written-paths-mediation.md +7 -0
  19. package/dist/lite/.claude/rules/lite-manifest-schema-change-discipline.md +23 -0
  20. package/dist/lite/.claude/rules/reviewer-dispatch.md +95 -0
  21. package/dist/lite/.claude/settings.json +10 -0
  22. package/dist/lite/.claude/skills/build/SKILL.md +81 -6
  23. package/dist/lite/.claude/skills/eisenhower/SKILL.md +26 -0
  24. package/dist/lite/.claude/skills/launch/SKILL.md +45 -2
  25. package/dist/lite/.claude/skills/onboard-repo/SKILL.md +208 -40
  26. package/dist/lite/.claude/skills/onboard-repo/claude-oauth-probe.sh +60 -0
  27. package/dist/lite/.claude/skills/onboard-repo/concise-prompt.sh +73 -0
  28. package/dist/lite/.claude/skills/onboard-repo/write-state-marker.sh +81 -0
  29. package/dist/lite/.claude/skills/pre-mortem/SKILL.md +56 -0
  30. package/dist/lite/.claude/skills/sprint/SKILL.md +24 -3
  31. package/dist/lite/.claude/skills/temperance/SKILL.md +15 -4
  32. package/dist/lite/README.md +2 -0
  33. package/dist/lite/bassclef-version.json +5 -5
  34. package/dist/lite/lib/install-written-paths.sh +250 -3
  35. package/dist/lite/lib/prose-marker.sh +203 -0
  36. package/dist/lite/lib/tier-dependency-audit.sh +52 -25
  37. package/dist/lite/presence/install/bassclef-sync.template.sh +86 -8
  38. package/dist/lite/presence/install/schedule-auto-save.cron.sh +30 -2
  39. package/dist/lite/scripts/generate-lite-manifest.sh +15 -0
  40. package/dist/lite/scripts/launch/local-serve.sh +149 -8
  41. package/dist/lite/standards/bassclef-wiring-manifest.json +12 -0
  42. package/dist/lite/standards/lite-manifest-schema-changes.md +84 -0
  43. package/dist/lite/standards/lite-manifest.json +100 -29
  44. package/dist/lite/standards/prose-marker-format.md +105 -0
  45. package/package.json +1 -1
@@ -0,0 +1,203 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ #
4
+ # lib/prose-marker.sh — shared marker write + read helpers for the bassclef
5
+ # prose scorer stack. Pins the marker format from
6
+ # standards/prose-marker-format.md v1.0.
7
+ #
8
+ # Producers (Stop hooks that score prose dimensions):
9
+ # - turn-prose-density-measure.sh (Step 2b of Longrun 2)
10
+ # - turn-prose-kiss-check.sh (Step 2c extension)
11
+ # - turn-prose-grade-measure.sh (future retrofit; writes raw today)
12
+ #
13
+ # Consumer (UserPromptSubmit hook that reads markers + injects numbers):
14
+ # - plain-english-steering.sh (Step 2d extension)
15
+ #
16
+ # Per @luminary john-ousterhout — deep module hides JSON shape + atomic
17
+ # write + expiry logic behind a narrow two-function interface.
18
+ # Per @luminary tony-hoare — pre/postcondition contract per
19
+ # standards/prose-marker-format.md §Hoare.
20
+ # Per @luminary michael-nygard — fail-soft on write permission denied.
21
+
22
+ set +e
23
+
24
+ # === BASSCLEF_STATE_ROOT resolution ===
25
+ # When tests isolate state via BASSCLEF_STATE_ROOT env var, honor it.
26
+ # Otherwise resolve to the repo root (walk up from this file's location).
27
+ _prose_marker_resolve_state_root() {
28
+ if [ -n "${BASSCLEF_STATE_ROOT:-}" ]; then
29
+ echo "$BASSCLEF_STATE_ROOT"
30
+ return
31
+ fi
32
+ local script_dir
33
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ echo "$(cd "$script_dir/.." && pwd)"
35
+ }
36
+
37
+ # === write_prose_marker <scorer-name> <value> [notes] ===
38
+ # Preconditions:
39
+ # - scorer-name is non-empty and matches [a-z0-9-]+
40
+ # - value is non-empty
41
+ # Postconditions:
42
+ # - file at state/markers/<scorer-name>/last-turn.json exists
43
+ # - JSON matches standards/prose-marker-format.md shape
44
+ # - atomic write (temp file + mv)
45
+ # Returns: 0 on success; 1 on invalid input; 2 on write failure (fail-soft).
46
+ write_prose_marker() {
47
+ local scorer="$1"
48
+ local value="$2"
49
+ local notes="${3:-}"
50
+
51
+ # Validate scorer-name
52
+ if [ -z "$scorer" ]; then
53
+ return 1
54
+ fi
55
+ if ! echo "$scorer" | grep -qE '^[a-z0-9-]+$'; then
56
+ return 1
57
+ fi
58
+
59
+ # Validate value
60
+ if [ -z "$value" ]; then
61
+ return 1
62
+ fi
63
+
64
+ local state_root
65
+ state_root="$(_prose_marker_resolve_state_root)"
66
+ local marker_dir="${state_root}/state/markers/${scorer}"
67
+ local marker_path="${marker_dir}/last-turn.json"
68
+
69
+ if ! mkdir -p "$marker_dir" 2>/dev/null; then
70
+ return 2
71
+ fi
72
+
73
+ local timestamp
74
+ timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
75
+
76
+ local tmp
77
+ tmp="$(mktemp "${marker_dir}/last-turn.XXXXXX.tmp" 2>/dev/null)" || return 2
78
+
79
+ # Build JSON. Use jq when available for proper escaping; fall back to raw.
80
+ if command -v jq >/dev/null 2>&1; then
81
+ if [ -n "$notes" ]; then
82
+ jq -n \
83
+ --arg metric "$scorer" \
84
+ --argjson value "$value" \
85
+ --arg measured_at "$timestamp" \
86
+ --arg source "lib/prose-marker.sh" \
87
+ --arg notes "$notes" \
88
+ '{metric: $metric, value: $value, measured_at: $measured_at, source: $source, notes: $notes}' \
89
+ > "$tmp" 2>/dev/null
90
+ else
91
+ jq -n \
92
+ --arg metric "$scorer" \
93
+ --argjson value "$value" \
94
+ --arg measured_at "$timestamp" \
95
+ --arg source "lib/prose-marker.sh" \
96
+ '{metric: $metric, value: $value, measured_at: $measured_at, source: $source}' \
97
+ > "$tmp" 2>/dev/null
98
+ fi
99
+ local rc=$?
100
+ if [ "$rc" -ne 0 ]; then
101
+ rm -f "$tmp" 2>/dev/null
102
+ return 2
103
+ fi
104
+ else
105
+ # Fallback: raw JSON. Scorer + notes get basic escaping (no quotes in input).
106
+ if [ -n "$notes" ]; then
107
+ printf '{"metric":"%s","value":%s,"measured_at":"%s","source":"lib/prose-marker.sh","notes":"%s"}\n' \
108
+ "$scorer" "$value" "$timestamp" "$notes" > "$tmp" 2>/dev/null
109
+ else
110
+ printf '{"metric":"%s","value":%s,"measured_at":"%s","source":"lib/prose-marker.sh"}\n' \
111
+ "$scorer" "$value" "$timestamp" > "$tmp" 2>/dev/null
112
+ fi
113
+ fi
114
+
115
+ # Atomic rename
116
+ if ! mv -f "$tmp" "$marker_path" 2>/dev/null; then
117
+ rm -f "$tmp" 2>/dev/null
118
+ return 2
119
+ fi
120
+ return 0
121
+ }
122
+
123
+ # === read_prose_markers [--max-age-minutes N] <scorer-name> [<scorer-name> ...] ===
124
+ # Preconditions:
125
+ # - zero or more scorer-name arguments
126
+ # - optional --max-age-minutes N (default 10)
127
+ # Postconditions:
128
+ # - stdout emits one JSON object mapping scorer-name → marker object OR null
129
+ # - null when: marker missing, older than max-age, or malformed JSON
130
+ # - fail-soft: never crashes on bad input
131
+ # Returns: 0 always (fail-soft).
132
+ read_prose_markers() {
133
+ local max_age_min=10
134
+ local scorers=()
135
+
136
+ while [ "$#" -gt 0 ]; do
137
+ case "$1" in
138
+ --max-age-minutes)
139
+ shift
140
+ max_age_min="${1:-10}"
141
+ shift
142
+ ;;
143
+ *)
144
+ scorers+=("$1")
145
+ shift
146
+ ;;
147
+ esac
148
+ done
149
+
150
+ local state_root
151
+ state_root="$(_prose_marker_resolve_state_root)"
152
+
153
+ # Build JSON object. If jq missing, emit raw.
154
+ if command -v jq >/dev/null 2>&1; then
155
+ # Start with empty object
156
+ local result='{}'
157
+ local scorer
158
+ for scorer in "${scorers[@]}"; do
159
+ local marker_path="${state_root}/state/markers/${scorer}/last-turn.json"
160
+ local marker_json="null"
161
+ if [ -f "$marker_path" ]; then
162
+ # Age check
163
+ local file_age_sec
164
+ if stat -f %m "$marker_path" >/dev/null 2>&1; then
165
+ # BSD stat (macOS)
166
+ file_age_sec=$(( $(date +%s) - $(stat -f %m "$marker_path") ))
167
+ else
168
+ # GNU stat (Linux)
169
+ file_age_sec=$(( $(date +%s) - $(stat -c %Y "$marker_path") ))
170
+ fi
171
+ local max_age_sec=$(( max_age_min * 60 ))
172
+ if [ "$file_age_sec" -le "$max_age_sec" ]; then
173
+ # Parse marker — null on malformed JSON
174
+ marker_json=$(jq -c '.' "$marker_path" 2>/dev/null)
175
+ if [ -z "$marker_json" ]; then
176
+ marker_json="null"
177
+ fi
178
+ fi
179
+ fi
180
+ result=$(echo "$result" | jq --arg k "$scorer" --argjson v "$marker_json" '. + {($k): $v}' 2>/dev/null)
181
+ done
182
+ echo "$result"
183
+ else
184
+ # Fallback without jq — emit a flat null map
185
+ printf '{'
186
+ local first=1
187
+ local scorer
188
+ for scorer in "${scorers[@]}"; do
189
+ if [ "$first" -eq 0 ]; then
190
+ printf ','
191
+ fi
192
+ printf '"%s":null' "$scorer"
193
+ first=0
194
+ done
195
+ printf '}\n'
196
+ fi
197
+ return 0
198
+ }
199
+
200
+ # Export functions when sourced
201
+ export -f write_prose_marker 2>/dev/null
202
+ export -f read_prose_markers 2>/dev/null
203
+ export -f _prose_marker_resolve_state_root 2>/dev/null
@@ -814,37 +814,64 @@ build_reverse_index() {
814
814
  walk_graph_bfs_inbound() {
815
815
  local seeds_file="$1"
816
816
  local rev_index="$2"
817
- local visited_file="" queue_file=""
818
- visited_file="$(mktemp)"
819
- queue_file="$(mktemp)"
820
- # shellcheck disable=SC2064
821
- trap "rm -f '$visited_file' '$queue_file'" RETURN
822
817
 
823
818
  if [ ! -s "$seeds_file" ]; then
824
- : > "$visited_file"
825
- sort -u "$visited_file"
826
819
  return 0
827
820
  fi
828
- cp "$seeds_file" "$queue_file"
829
821
 
830
- local max_iter=1000 iter=0
831
- while [ -s "$queue_file" ] && [ "$iter" -lt "$max_iter" ]; do
832
- iter=$((iter + 1))
833
- local next_file; next_file="$(mktemp)"
834
- while IFS= read -r p; do
835
- [ -z "$p" ] && continue
836
- if grep -Fxq "$p" "$visited_file" 2>/dev/null; then continue; fi
837
- echo "$p" >> "$visited_file"
838
- # Look up callers of $p in the reverse index (path is column 1; caller is column 2)
839
- awk -F'\t' -v p="$p" '$1 == p { print $2 }' "$rev_index" | while IFS= read -r caller; do
840
- [ -n "$caller" ] && echo "$caller" >> "$next_file"
841
- done
842
- done < "$queue_file"
843
- mv "$next_file" "$queue_file"
844
- sort -u -o "$queue_file" "$queue_file"
845
- done
822
+ if [ ! -s "$rev_index" ]; then
823
+ # No edges — output just the seeds, deduped
824
+ sort -u "$seeds_file"
825
+ return 0
826
+ fi
846
827
 
847
- sort -u "$visited_file"
828
+ # Single-pass awk: build reverse-index hash during Phase 1 (reading
829
+ # rev_index), then run BFS entirely in-memory during Phase 2 (reading
830
+ # seeds_file via END block). Visited set prevents re-visiting; queue
831
+ # grows as new callers are found.
832
+ #
833
+ # Complexity: O(N + M) where N = seeds + M = edges in rev_index.
834
+ # Prior per-item awk was O(N × M) — unusable at 1.1MB index (ticket
835
+ # bassclef-upstream#2109 observed 15+ min hang during /release).
836
+ #
837
+ # Per @luminary john-ousterhout — deep module: one awk hides BFS.
838
+ # Per @luminary michael-feathers — behavior pinned by Tier 0 tests
839
+ # at lib/tests/tier-dependency-audit-inbound.test.sh.
840
+ awk -F'\t' '
841
+ # Phase 1: load reverse index into callers map
842
+ # Each line: <path>\t<caller>; multiple callers per path concatenated
843
+ # with SUBSEP as the separator (safe since SUBSEP is non-printable).
844
+ NR == FNR {
845
+ if (NF >= 2 && $1 != "") {
846
+ callers[$1] = (callers[$1] == "" ? $2 : callers[$1] SUBSEP $2)
847
+ }
848
+ next
849
+ }
850
+ # Phase 2: enqueue seeds (file 2)
851
+ {
852
+ if ($0 != "" && !($0 in visited)) {
853
+ queue[++qlen] = $0
854
+ visited[$0] = 1
855
+ }
856
+ }
857
+ # Phase 3: BFS, draining the queue
858
+ END {
859
+ head = 1
860
+ while (head <= qlen) {
861
+ p = queue[head++]
862
+ if (p in callers) {
863
+ n = split(callers[p], c, SUBSEP)
864
+ for (i = 1; i <= n; i++) {
865
+ if (c[i] != "" && !(c[i] in visited)) {
866
+ visited[c[i]] = 1
867
+ queue[++qlen] = c[i]
868
+ }
869
+ }
870
+ }
871
+ }
872
+ for (p in visited) print p
873
+ }
874
+ ' "$rev_index" "$seeds_file" | sort -u
848
875
  return 0
849
876
  }
850
877
 
@@ -256,6 +256,64 @@ bassclef_sync_parse_whereami_ts() {
256
256
  return 0
257
257
  }
258
258
 
259
+ # === #2130 — scope-aware install-class resolution + prior-sync check ===
260
+ # Reads a hook source file's `# install-class:` header. Returns one of:
261
+ # project | operator | dual | unknown
262
+ # Fails soft — missing file, missing header, or invalid value → "unknown".
263
+ #
264
+ # Anchor luminaries — @luminary linus-torvalds (adopter contract; project-class
265
+ # hooks must not report scope=operator), @luminary jerome-saltzer-and-michael-schroeder
266
+ # (every scope mediated per source declaration), @luminary michael-feathers
267
+ # (characterization anchor — pin the install-class header shape before cure).
268
+ _template_resolve_install_class() {
269
+ local hook_src="$1"
270
+ if [ ! -f "$hook_src" ]; then
271
+ echo "unknown"
272
+ return 0
273
+ fi
274
+ local header_val
275
+ header_val=$(head -10 "$hook_src" 2>/dev/null | grep -E \
276
+ '^# install-class:[[:space:]]*(operator|project|dual)[[:space:]]*$' \
277
+ | head -1 | sed -E 's/^# install-class:[[:space:]]*//;s/[[:space:]]*$//')
278
+ case "$header_val" in
279
+ project|operator|dual) echo "$header_val" ;;
280
+ *) echo "unknown" ;;
281
+ esac
282
+ }
283
+
284
+ # Returns 0 (true) if the hook is installed at the scope its install-class names.
285
+ # For `dual`, either project or operator location satisfies.
286
+ # For `unknown`, defaults to operator check per ADR-058 C2 grace window.
287
+ #
288
+ # $1 — hook basename (e.g., "pre-commit-gate.sh")
289
+ # $2 — scope value (project | operator | dual | unknown)
290
+ # Reads $CWD and $HOME. $CWD falls back to $(pwd) when unset.
291
+ _template_hook_present_at_scope() {
292
+ local hook_name="$1"
293
+ local scope="$2"
294
+ local cwd="${CWD:-$(pwd)}"
295
+ case "$scope" in
296
+ project)
297
+ [ -f "$cwd/.claude/hooks/$hook_name" ] && return 0
298
+ return 1
299
+ ;;
300
+ operator)
301
+ [ -f "$HOME/.claude/hooks/$hook_name" ] && return 0
302
+ return 1
303
+ ;;
304
+ dual)
305
+ [ -f "$cwd/.claude/hooks/$hook_name" ] && return 0
306
+ [ -f "$HOME/.claude/hooks/$hook_name" ] && return 0
307
+ return 1
308
+ ;;
309
+ *)
310
+ # unknown — grace-window; check operator per legacy behavior
311
+ [ -f "$HOME/.claude/hooks/$hook_name" ] && return 0
312
+ return 1
313
+ ;;
314
+ esac
315
+ }
316
+
259
317
  # === #1929 — postcondition emitter (RESTORED/DEGRADED + JSON) ===
260
318
  # Mirrors .claude/hooks/bassclef-sync.sh L215-243 (idempotent-complete-or-fail-loud
261
319
  # pattern shipped under #1894 Cure 1 of #1691). Reads globals INSTALLED_COUNT,
@@ -1022,9 +1080,16 @@ if [ -d "$BASSCLEF_DIR/.claude/hooks" ]; then
1022
1080
  fi
1023
1081
  if [ -n "$_WALKER_MISS" ]; then
1024
1082
  echo "$(date -u +%FT%TZ) install-fail-missing-dep $HOOK_NAME" >> "$BASSCLEF_TRACE_LOG"
1025
- FAILED_COUNT=$((FAILED_COUNT + 1))
1026
- missing_paths+=(".claude/hooks/$HOOK_NAME")
1027
- missing_scopes+=("operator")
1083
+ # #2130 — resolve install-class scope + check if hook is already present
1084
+ # at scoped target from a prior sync. Prior-sync-present counts as installed.
1085
+ _HOOK_SCOPE=$(_template_resolve_install_class "$HOOK_SRC")
1086
+ if _template_hook_present_at_scope "$HOOK_NAME" "$_HOOK_SCOPE"; then
1087
+ INSTALLED_COUNT=$((INSTALLED_COUNT + 1))
1088
+ else
1089
+ FAILED_COUNT=$((FAILED_COUNT + 1))
1090
+ missing_paths+=(".claude/hooks/$HOOK_NAME")
1091
+ missing_scopes+=("$_HOOK_SCOPE")
1092
+ fi
1028
1093
  continue
1029
1094
  fi
1030
1095
 
@@ -1038,9 +1103,15 @@ if [ -d "$BASSCLEF_DIR/.claude/hooks" ]; then
1038
1103
  cp -p "$HOOK_SRC" "$TARGET_USER" 2>/dev/null || true
1039
1104
  chmod +x "$TARGET_USER" 2>/dev/null || true
1040
1105
  echo "$(date -u +%FT%TZ) dispatch-fail $HOOK_NAME" >> "$BASSCLEF_TRACE_LOG"
1041
- FAILED_COUNT=$((FAILED_COUNT + 1))
1042
- missing_paths+=(".claude/hooks/$HOOK_NAME")
1043
- missing_scopes+=("operator")
1106
+ # #2130 — scope-aware missing[] + prior-sync pass-through
1107
+ _HOOK_SCOPE=$(_template_resolve_install_class "$HOOK_SRC")
1108
+ if _template_hook_present_at_scope "$HOOK_NAME" "$_HOOK_SCOPE"; then
1109
+ INSTALLED_COUNT=$((INSTALLED_COUNT + 1))
1110
+ else
1111
+ FAILED_COUNT=$((FAILED_COUNT + 1))
1112
+ missing_paths+=(".claude/hooks/$HOOK_NAME")
1113
+ missing_scopes+=("$_HOOK_SCOPE")
1114
+ fi
1044
1115
  fi
1045
1116
  elif [ "$HAS_HEADER" = "1" ] && [ "$DISPATCH_AVAILABLE" = "0" ]; then
1046
1117
  # S-F1 MED — dispatch lib missing; trace explicitly (not silent)
@@ -1059,8 +1130,15 @@ if [ -d "$BASSCLEF_DIR/.claude/hooks" ]; then
1059
1130
  else
1060
1131
  echo "$(date -u +%FT%TZ) skipped-invalid-header $HOOK_NAME" >> "$BASSCLEF_TRACE_LOG"
1061
1132
  SKIPPED_INVALID=$((SKIPPED_INVALID + 1))
1062
- missing_paths+=(".claude/hooks/$HOOK_NAME")
1063
- missing_scopes+=("operator")
1133
+ # #2130 — invalid header → scope resolves to "unknown"; prior-sync still passes
1134
+ if _template_hook_present_at_scope "$HOOK_NAME" "unknown"; then
1135
+ # Operator-scope fallback cp above landed; treat as installed under grace
1136
+ INSTALLED_COUNT=$((INSTALLED_COUNT + 1))
1137
+ SKIPPED_INVALID=$((SKIPPED_INVALID - 1))
1138
+ else
1139
+ missing_paths+=(".claude/hooks/$HOOK_NAME")
1140
+ missing_scopes+=("unknown")
1141
+ fi
1064
1142
  fi
1065
1143
  fi
1066
1144
  done
@@ -28,8 +28,21 @@
28
28
 
29
29
  set -euo pipefail
30
30
 
31
- BASSCLEF_HOOK_DEFAULT="$HOME/src/sunj-labs/bassclef/.claude/hooks/auto-save-idle.sh"
32
- HOOK_PATH="${1:-${BASSCLEF_HOOK_PATH:-$BASSCLEF_HOOK_DEFAULT}}"
31
+ # Hook path resolution — closes bassclef-upstream#2257.
32
+ # Cold adopters do not have $HOME/src/sunj-labs/bassclef cloned; they get
33
+ # the hook installed at $CLAUDE_PROJECT_DIR/.claude/hooks/ via `bassclef init`.
34
+ # Probe adopter project dir first, then dev sibling. Positional arg and
35
+ # BASSCLEF_HOOK_PATH env override still win over the probe.
36
+ _probe_hook_path() {
37
+ local proj="${CLAUDE_PROJECT_DIR:-}"
38
+ if [ -n "$proj" ] && [ -f "$proj/.claude/hooks/auto-save-idle.sh" ]; then
39
+ echo "$proj/.claude/hooks/auto-save-idle.sh"
40
+ return 0
41
+ fi
42
+ echo "$HOME/src/sunj-labs/bassclef/.claude/hooks/auto-save-idle.sh"
43
+ }
44
+
45
+ HOOK_PATH="${1:-${BASSCLEF_HOOK_PATH:-$(_probe_hook_path)}}"
33
46
  CRON_MARKER="# bassclef auto-save (ADR-046)"
34
47
  CRON_ENTRY="*/15 * * * * bash ${HOOK_PATH} >/dev/null 2>&1"
35
48
 
@@ -54,6 +67,21 @@ fi
54
67
  # Check hook file exists
55
68
  if [ ! -f "$HOOK_PATH" ]; then
56
69
  echo "ERROR: hook file not found at ${HOOK_PATH}"
70
+ echo ""
71
+ echo "Resolution order (per bassclef-upstream#2257):"
72
+ echo " 1. positional arg \$1 — not set"
73
+ if [ -n "${BASSCLEF_HOOK_PATH:-}" ]; then
74
+ echo " 2. BASSCLEF_HOOK_PATH env — set to ${BASSCLEF_HOOK_PATH}"
75
+ else
76
+ echo " 2. BASSCLEF_HOOK_PATH env — not set"
77
+ fi
78
+ if [ -n "${CLAUDE_PROJECT_DIR:-}" ]; then
79
+ echo " 3. CLAUDE_PROJECT_DIR probe — tried ${CLAUDE_PROJECT_DIR}/.claude/hooks/auto-save-idle.sh"
80
+ else
81
+ echo " 3. CLAUDE_PROJECT_DIR probe — unset; skipped"
82
+ fi
83
+ echo " 4. Dev sibling fallback — tried ${HOME}/src/sunj-labs/bassclef/.claude/hooks/auto-save-idle.sh"
84
+ echo ""
57
85
  echo " Pass the path as an argument OR set BASSCLEF_HOOK_PATH env var."
58
86
  echo " Example: bash $0 /path/to/bassclef/.claude/hooks/auto-save-idle.sh"
59
87
  exit 2
@@ -497,6 +497,18 @@ while IFS= read -r f; do
497
497
  done < <(find "$REPO_ROOT/.claude/hooks" -mindepth 2 -type f -name '*.sh' \
498
498
  -not -path '*/tests/*' -not -path '*/fixtures/*' 2>/dev/null | sort)
499
499
 
500
+ # Skill-helper scripts (bash) — per bassclef-upstream#2140. Skills (e.g.,
501
+ # onboard-repo) dispatch helper .sh files from their SKILL.md Procedure
502
+ # steps. Lite adopters that run those skills need the helpers to land
503
+ # in their bundle. Shipped as type=script (reuses existing enum; no
504
+ # schema shape change). Only lite-tagged helpers end up in lite-manifest;
505
+ # standard-tagged helpers inherit into standard-manifest + above.
506
+ SKILL_HELPER_FILES=()
507
+ while IFS= read -r f; do
508
+ SKILL_HELPER_FILES+=("$f")
509
+ done < <(find "$REPO_ROOT/.claude/skills" -mindepth 2 -type f -name '*.sh' \
510
+ -not -path '*/tests/*' -not -path '*/fixtures/*' 2>/dev/null | sort)
511
+
500
512
  # Top-level .claude/*.md (bassclef-orientation.md) — vendored via @import from adopter
501
513
  # CLAUDE.md; ships as root-doc. Per goal 2026-09-16a.
502
514
  CLAUDE_DOC_FILES=()
@@ -635,6 +647,9 @@ ENTRIES_JSON=$(
635
647
  if [[ ${#SCRIPT_FILES[@]} -gt 0 ]]; then
636
648
  emit_entries "script" extract_hook_tier extract_hook_description basename "${SCRIPT_FILES[@]}"
637
649
  fi
650
+ if [[ ${#SKILL_HELPER_FILES[@]} -gt 0 ]]; then
651
+ emit_entries "script" extract_hook_tier extract_hook_description basename "${SKILL_HELPER_FILES[@]}"
652
+ fi
638
653
  if [[ ${#PRESENCE_TEMPLATE_SH_FILES[@]} -gt 0 ]]; then
639
654
  emit_entries "presence-template" extract_hook_tier extract_hook_description basename "${PRESENCE_TEMPLATE_SH_FILES[@]}"
640
655
  fi
@@ -30,16 +30,157 @@
30
30
  # Cleanup: rely on the operator running scripts/launch/local-serve-stop.sh
31
31
  # or just killing the PID. State file lists the PID for reference.
32
32
 
33
- set -e
33
+ # === #2131 — testable helpers for arg parsing + usage ===
34
+ # Anchor luminaries — @luminary linus-torvalds (default bind unchanged;
35
+ # --lan is opt-in; no silent regression), @luminary alan-cooper (honesty
36
+ # default — handoff text matches bind reality), @luminary michael-feathers
37
+ # (characterization anchor via LOCAL_SERVE_TEST_MODE guard).
38
+
39
+ _local_serve_print_usage() {
40
+ cat <<'USAGE'
41
+ usage: local-serve.sh [--lan] <gallery-dir> [port-hint]
42
+
43
+ Serves a /launch local-mode prototype gallery via python http.server.
44
+
45
+ Positional:
46
+ gallery-dir path to the gallery directory (must contain index.html)
47
+ port-hint optional starting port (default 8000)
48
+
49
+ Flags:
50
+ --lan bind to 0.0.0.0 (all interfaces) and PRINT the detected
51
+ LAN IP so phones/iPads on the same Wi-Fi can reach the
52
+ URL directly. Default is 127.0.0.1 (this computer only).
53
+ Prints a warning: any device on this Wi-Fi can open the URL.
54
+ Firewall note: macOS firewall may need to be off for the
55
+ port, or the OS will prompt on first bind.
56
+ --help print this usage
57
+
58
+ Exit codes:
59
+ 0 — server started OR idempotent re-run returns existing URL
60
+ 1 — python3 missing, gallery dir missing, port exhaustion, server crash
61
+ 2 — bad args (missing gallery-dir, unknown flag)
62
+ USAGE
63
+ }
64
+
65
+ # Detects the machine's LAN IP so --lan mode can print a dialable URL
66
+ # instead of literal "0.0.0.0". Tries mac first (ipconfig getifaddr en0),
67
+ # then linux (hostname -I | awk '{print $1}'). Returns empty on both
68
+ # failures — the caller falls back to 127.0.0.1 so the --lan path
69
+ # degrades to "this computer only" rather than crashing.
70
+ # Per bassclef-upstream#2143 — the prior shape printed 0.0.0.0 which
71
+ # phones cannot dial.
72
+ _local_serve_detect_lan_ip() {
73
+ local ip
74
+ # macOS primary: en0 is the typical Wi-Fi interface
75
+ if command -v ipconfig >/dev/null 2>&1; then
76
+ ip=$(ipconfig getifaddr en0 2>/dev/null || true)
77
+ if [ -n "$ip" ]; then
78
+ echo "$ip"
79
+ return 0
80
+ fi
81
+ # macOS fallback: try en1 (secondary Wi-Fi on some models)
82
+ ip=$(ipconfig getifaddr en1 2>/dev/null || true)
83
+ if [ -n "$ip" ]; then
84
+ echo "$ip"
85
+ return 0
86
+ fi
87
+ fi
88
+ # Linux: hostname -I returns space-separated IPs; first is usually primary LAN
89
+ if command -v hostname >/dev/null 2>&1; then
90
+ ip=$(hostname -I 2>/dev/null | awk '{print $1}' || true)
91
+ if [ -n "$ip" ]; then
92
+ echo "$ip"
93
+ return 0
94
+ fi
95
+ fi
96
+ # Fallback: empty — caller uses 127.0.0.1
97
+ return 0
98
+ }
99
+
100
+ # Parses CLI args. Sets globals BIND_ADDR, DISPLAY_HOST, LAN_WARNING,
101
+ # GALLERY_DIR_PARSED, PORT_HINT_PARSED. Returns 0 on success. Returns
102
+ # non-zero on --help, unknown flag, or missing gallery-dir.
103
+ #
104
+ # BIND_ADDR is what python's http.server --bind sees (127.0.0.1 default,
105
+ # 0.0.0.0 under --lan so it listens on all interfaces).
106
+ # DISPLAY_HOST is what the printed URL + state file carries (127.0.0.1
107
+ # default, detected LAN IP under --lan). Separate so the printed URL is
108
+ # always dialable: 127.0.0.1 for the host, LAN IP for a phone on the
109
+ # same Wi-Fi. Per bassclef-upstream#2143.
110
+ _local_serve_parse_args() {
111
+ BIND_ADDR="127.0.0.1"
112
+ DISPLAY_HOST="127.0.0.1"
113
+ LAN_WARNING=""
114
+ GALLERY_DIR_PARSED=""
115
+ PORT_HINT_PARSED="8000"
116
+ local positional=()
117
+ local arg
118
+ local lan_mode=0
119
+ for arg in "$@"; do
120
+ case "$arg" in
121
+ --lan)
122
+ lan_mode=1
123
+ BIND_ADDR="0.0.0.0"
124
+ LAN_WARNING="WARN: --lan set; any device on this Wi-Fi can open this URL. Unset --lan to bind 127.0.0.1 only."
125
+ ;;
126
+ --help|-h)
127
+ _local_serve_print_usage
128
+ return 1
129
+ ;;
130
+ --*)
131
+ echo "ERROR: unknown flag: $arg" >&2
132
+ _local_serve_print_usage >&2
133
+ return 2
134
+ ;;
135
+ *)
136
+ positional+=("$arg")
137
+ ;;
138
+ esac
139
+ done
140
+ if [ "$lan_mode" -eq 1 ]; then
141
+ local detected
142
+ detected=$(_local_serve_detect_lan_ip)
143
+ if [ -n "$detected" ]; then
144
+ DISPLAY_HOST="$detected"
145
+ else
146
+ # Detection failed; fall back to 127.0.0.1 so the printed URL is still
147
+ # dialable from the host at least. Warn so the operator knows the
148
+ # phone-preview path won't work.
149
+ DISPLAY_HOST="127.0.0.1"
150
+ LAN_WARNING="${LAN_WARNING}
151
+ WARN: --lan set but LAN IP detection failed (ipconfig / hostname). Printed URL falls back to 127.0.0.1; phone preview will not work."
152
+ fi
153
+ fi
154
+ if [ "${#positional[@]}" -ge 1 ]; then
155
+ GALLERY_DIR_PARSED="${positional[0]}"
156
+ fi
157
+ if [ "${#positional[@]}" -ge 2 ]; then
158
+ PORT_HINT_PARSED="${positional[1]}"
159
+ fi
160
+ if [ -z "$GALLERY_DIR_PARSED" ]; then
161
+ echo "ERROR: gallery-dir required" >&2
162
+ _local_serve_print_usage >&2
163
+ return 2
164
+ fi
165
+ return 0
166
+ }
34
167
 
35
- GALLERY_DIR="${1:-}"
36
- PORT_HINT="${2:-8000}"
168
+ # === Test-mode guard — tests source + exercise helpers without running main ===
169
+ if [ "${LOCAL_SERVE_TEST_MODE:-}" = "1" ]; then
170
+ return 0 2>/dev/null || exit 0
171
+ fi
172
+
173
+ set -e
37
174
 
38
- if [ -z "$GALLERY_DIR" ]; then
39
- echo "ERROR: gallery-dir required" >&2
40
- echo "usage: $0 <gallery-dir> [port-hint]" >&2
175
+ if ! _local_serve_parse_args "$@"; then
41
176
  exit 2
42
177
  fi
178
+ GALLERY_DIR="$GALLERY_DIR_PARSED"
179
+ PORT_HINT="$PORT_HINT_PARSED"
180
+
181
+ if [ -n "$LAN_WARNING" ]; then
182
+ echo "$LAN_WARNING" >&2
183
+ fi
43
184
 
44
185
  if ! command -v python3 >/dev/null 2>&1; then
45
186
  echo "ERROR: python3 not on PATH. Install python3 first." >&2
@@ -92,7 +233,7 @@ fi
92
233
 
93
234
  # Start http.server in background, bound to 127.0.0.1 only (no external exposure)
94
235
  LOG_FILE="$STATE_DIR/${GALLERY_SLUG}.log"
95
- nohup python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$GALLERY_DIR" \
236
+ nohup python3 -m http.server "$PORT" --bind "$BIND_ADDR" --directory "$GALLERY_DIR" \
96
237
  > "$LOG_FILE" 2>&1 &
97
238
  SERVER_PID=$!
98
239
 
@@ -103,7 +244,7 @@ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
103
244
  exit 1
104
245
  fi
105
246
 
106
- URL="http://127.0.0.1:${PORT}/"
247
+ URL="http://${DISPLAY_HOST}:${PORT}/"
107
248
 
108
249
  # Write state file
109
250
  cat > "$STATE_FILE" <<EOF