@thebassclef/lite 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -35,7 +35,7 @@ See [`standards/os-support.md`](standards/os-support.md) for the full policy.
35
35
 
36
36
  ## Current release
37
37
 
38
- <!-- version-start -->1.7.0<!-- version-end -->
38
+ <!-- version-start -->1.9.0<!-- version-end -->
39
39
 
40
40
  See [CHANGELOG.md](CHANGELOG.md) for release notes.
41
41
 
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const version = "1.7.0";
3
+ const version = "1.9.0";
4
4
  exports.version = version;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const version: "1.7.0";
1
+ export declare const version: "1.9.0";
2
2
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const version = "1.7.0";
1
+ const version = "1.9.0";
2
2
  export {
3
3
  version
4
4
  };
@@ -1,7 +1,10 @@
1
1
  #!/bin/bash
2
- # tier: lite
2
+ # tier: upstream
3
+ # install-class: operator
3
4
  # Bassclef Substrate Sync — fires on SessionStart.
4
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.
5
8
  # Adopter consumers receive presence/install/bassclef-sync.template.sh instead
6
9
  # (sourced from canonical until the WU-0 absorb gap is closed).
7
10
  # Ensures bassclef standards are available regardless of device.
@@ -127,6 +130,9 @@ _sync_install_hooks() {
127
130
  local skipped_invalid=0
128
131
  local skipped_lib_missing=0
129
132
  local failed=0
133
+ # #1894 — capture missing-hook paths for structured status line
134
+ local -a missing_paths=()
135
+ local -a missing_scopes=()
130
136
 
131
137
  # S-F2 LOW — dir-exists guard before loop
132
138
  if [ ! -d "$bassclef_dir/.claude/hooks" ]; then
@@ -161,6 +167,9 @@ _sync_install_hooks() {
161
167
  chmod +x "$target" 2>/dev/null || true
162
168
  echo "$(date -u +%FT%TZ) dispatch-fail $hook_name" >> "$BASSCLEF_TRACE_LOG"
163
169
  failed=$((failed + 1))
170
+ # #1894 — capture path for structured status line
171
+ missing_paths+=(".claude/hooks/$hook_name")
172
+ missing_scopes+=("project")
164
173
  fi
165
174
  elif [ "$has_header" = "1" ] && [ "$dispatch_available" = "0" ]; then
166
175
  # S-F1 MED — dispatch lib missing; trace explicitly (not silent)
@@ -179,6 +188,9 @@ _sync_install_hooks() {
179
188
  else
180
189
  echo "$(date -u +%FT%TZ) skipped-invalid-header $hook_name" >> "$BASSCLEF_TRACE_LOG"
181
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")
182
194
  fi
183
195
  fi
184
196
  done
@@ -195,13 +207,36 @@ _sync_install_hooks() {
195
207
  breakdown="${breakdown#, }"
196
208
  # RFC-0008 F7 — three bare numbers read three ways. Say what they mean
197
209
  # together, and say plainly whether the install is usable.
198
- if [ "$failed" = "0" ]; then
210
+ if [ "$failed" = "0" ] && [ "$skipped_invalid" = "0" ]; then
199
211
  echo "bassclef-sync: $installed hooks ready. $skipped_total skipped on purpose ($breakdown). Nothing failed — your install is complete."
200
212
  else
201
213
  echo "bassclef-sync: $installed hooks ready. $skipped_total skipped on purpose ($breakdown)."
202
- echo "bassclef-sync: $failed could not be installed. Those hooks will not fire until that is fixed."
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
203
217
  fi
204
218
  fi
219
+
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
205
240
  }
206
241
 
207
242
  # === Test-mode guard (#915) ===
@@ -719,9 +754,11 @@ fi
719
754
  MISSING=""
720
755
 
721
756
  # Google service account (needed for journal → Google Docs)
722
- SA_KEY="$HOME/.config/gcloud/sunjay-google-ops.json"
723
- if [ ! -f "$SA_KEY" ]; then
724
- MISSING="${MISSING}\n- Google Docs service account key missing at $SA_KEY (needed for /journal → Google Doc push)"
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)"
725
762
  fi
726
763
 
727
764
  # gh CLI auth (needed for /promote, issue creation)
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # PreToolUse: Bash
5
+ #
6
+ # git-stash-u guard (bassclef-upstream#1691 Cure 2)
7
+ #
8
+ # Fires ADVISORY banner when a `git stash -u` or `git stash --include-untracked`
9
+ # command runs in a repo where any bassclef substrate directory is gitignored.
10
+ # Warns the operator (or their agent) that `-u` would stash bassclef's own
11
+ # untracked files, causing subsequent hook calls to fail with "No such file
12
+ # or directory" per .claude/bassclef-orientation.md L120-134.
13
+ #
14
+ # ADVISORY tone (exit 0; never blocks). Silent when trap conditions do not
15
+ # match — bare `git status`, plain `git stash` (no -u), stash subcommands
16
+ # (pop, apply, list), or a repo where substrate is committed.
17
+ #
18
+ # Anchor luminaries — @luminary donald-norman (signifier match: ⚠ tone),
19
+ # @luminary alan-cooper (Sam+Louis plain language), @luminary linus-torvalds
20
+ # (adopter contract; additive-only + skippable + gitignore-conditional).
21
+ #
22
+ # Override: SKIP_GIT_STASH_U_GUARD=1 bypasses for one call (logged via trace).
23
+
24
+ set -uo pipefail
25
+
26
+ # --- Read JSON stdin ---
27
+ INPUT=""
28
+ if [ -t 0 ]; then
29
+ # No stdin (interactive test) — nothing to check
30
+ exit 0
31
+ fi
32
+ INPUT=$(cat 2>/dev/null || true)
33
+ [ -z "$INPUT" ] && exit 0
34
+
35
+ # --- Extract tool_input.command ---
36
+ if ! command -v jq >/dev/null 2>&1; then
37
+ # Fail-soft on missing jq (Nygard stability)
38
+ exit 0
39
+ fi
40
+ CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null)
41
+ [ -z "$CMD" ] && exit 0
42
+
43
+ # --- Regex — match ONLY git stash with -u OR --include-untracked (Norman N2 fold) ---
44
+ # Anchor on "git stash" followed by args that include -u or --include-untracked.
45
+ # Excludes: bare `git stash`, `git stash pop`, `git stash apply`, `git stash list`, etc.
46
+ # The -u flag can appear anywhere after the `stash` verb (e.g., `git stash -u`,
47
+ # `git stash push -u`, `git stash -u -m "msg"`).
48
+ if ! echo "$CMD" | grep -qE '(^|[[:space:]])git[[:space:]]+stash([[:space:]]+push)?([[:space:]]+.*)?(-u([[:space:]]|$)|--include-untracked([[:space:]]|$))'; then
49
+ exit 0
50
+ fi
51
+
52
+ # --- Override — SKIP_GIT_STASH_U_GUARD=1 bypasses (logged) ---
53
+ if [ "${SKIP_GIT_STASH_U_GUARD:-0}" = "1" ]; then
54
+ TRACE_LOG="${BASSCLEF_TRACE_LOG:-${BASSCLEF_TRACE_FILE:-$HOME/.claude/state/trace.log}}"
55
+ mkdir -p "$(dirname "$TRACE_LOG")" 2>/dev/null || true
56
+ echo "$(date -u +%FT%TZ) SKIP_GIT_STASH_U_GUARD $CMD" >> "$TRACE_LOG" 2>/dev/null || true
57
+ exit 0
58
+ fi
59
+
60
+ # --- Gitignore state check (Torvalds T2 fold) — fire only if substrate is gitignored ---
61
+ GITIGNORE=""
62
+ if [ -f "./.gitignore" ]; then
63
+ GITIGNORE=$(cat ./.gitignore 2>/dev/null || true)
64
+ fi
65
+ # Trap conditions: any of the substrate dirs listed in .gitignore
66
+ if ! echo "$GITIGNORE" | grep -qE '^\.claude/(hooks|skills|rules|agents|luminaries)/?[[:space:]]*$'; then
67
+ exit 0
68
+ fi
69
+
70
+ # --- Emit ADVISORY banner (operator pick 2026-09-23c mock 2 v2B) ---
71
+ cat <<'ADVISORY'
72
+
73
+ ⚠ Heads up — this would break bassclef.
74
+
75
+ A stash tucks changes out of sight. `-u`
76
+ also stashes untracked files, and bassclef's
77
+ files aren't committed here — so `-u` would
78
+ tuck them away too.
79
+
80
+ Use plain `git stash` instead.
81
+
82
+ ADVISORY
83
+
84
+ exit 0
@@ -0,0 +1,49 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # SessionStart fragment — adopter message channel (bassclef-upstream#1926)
5
+ #
6
+ # Prints a welcome message on first-run adopters (no state files present).
7
+ # Serves as a channel — future first-touch messages register by adding
8
+ # check-print blocks that read state and print when a gate matches.
9
+ #
10
+ # V1 registers one message — welcome text pointing at /onboard-repo.
11
+ # Fires only when ALL of these are absent: docs/whereami.md,
12
+ # substrate.config.md, .claude/settings.json (fresh install shape).
13
+ #
14
+ # Anchor luminaries — @luminary alan-cooper (Sam+Louis first-touch persona),
15
+ # @luminary donald-norman (INFO tone; no BLOCK glyph), @luminary linus-torvalds
16
+ # (adopter contract; additive-only + silent on onboarded installs).
17
+ #
18
+ # Override: SKIP_ADOPTER_MESSAGES=1 bypasses for one call.
19
+
20
+ set -uo pipefail
21
+
22
+ # --- Override — SKIP_ADOPTER_MESSAGES=1 bypasses ---
23
+ if [ "${SKIP_ADOPTER_MESSAGES:-0}" = "1" ]; then
24
+ return 0 2>/dev/null || exit 0
25
+ fi
26
+
27
+ # --- Message: V1 welcome (only registered message so far) ---
28
+ # Gate: fresh install shape — ALL 3 state files absent.
29
+ _welcome_gate_matches() {
30
+ [ ! -f "./docs/whereami.md" ] && \
31
+ [ ! -f "./substrate.config.md" ] && \
32
+ [ ! -f "./.claude/settings.json" ]
33
+ }
34
+
35
+ _welcome_print() {
36
+ echo ""
37
+ echo "Welcome to bassclef."
38
+ echo ""
39
+ echo "First, run /onboard-repo — it wires hooks, writes config,"
40
+ echo "and creates the state files this repo needs."
41
+ echo ""
42
+ }
43
+
44
+ # --- Channel: check each message's gate; print first that matches ---
45
+ if _welcome_gate_matches; then
46
+ _welcome_print
47
+ fi
48
+
49
+ return 0 2>/dev/null || exit 0
@@ -28,18 +28,57 @@ if [ "${SELF_MODE:-0}" = "1" ]; then
28
28
  return 0 2>/dev/null || exit 0
29
29
  fi
30
30
 
31
- # Fresh-install gate (#1818 Ship 3): if state/markers/hook-heartbeat/ does
32
- # not exist, no producer has ever called heartbeat_mark on this machine, so
33
- # no hook can be "stale" — a fresh install has no heartbeat history to check
34
- # against. Skip silently. Cold adopters see no false "never fired" banner
35
- # on their first skill run. The gate activates the moment a producer hook
36
- # fires and writes its first marker.
31
+ # Fresh-install gate — two layers close the #1895 self-defeat class:
32
+ #
33
+ # Layer 1: state/markers/hook-heartbeat/ does not exist — no producer has
34
+ # ever called heartbeat_mark on this machine. Skip silently. Cold
35
+ # adopters see no false "never fired" banner on their first skill run.
36
+ #
37
+ # Layer 2: dir exists BUT the oldest marker is younger than the grace
38
+ # window (default 24h). The install is still bootstrapping — the FIRST
39
+ # producer fire creates the dir + first marker, but critical hooks that
40
+ # have not had their trigger event yet would show "never fired." Sam
41
+ # sees false-positive banner at 4-minute-old install. Cure per #1895:
42
+ # use oldest marker mtime as the install activation anchor. Add a
43
+ # wallclock grace window before "never fired" reports get treated as
44
+ # real defects. New markers written after the first do NOT reset the
45
+ # anchor (find + sort + head-1 gives the oldest).
46
+ #
47
+ # Override: FRESH_INSTALL_GRACE_SECONDS=0 disables the grace layer for
48
+ # operator debugging.
49
+ #
37
50
  # Anchor: @luminary donald-norman — a banner fires only when a real problem
38
- # exists. @luminary michael-nygard — fresh state is not broken state.
39
- if [ ! -d "state/markers/hook-heartbeat" ]; then
51
+ # exists. Sam's user model: "I just installed this; give it time."
52
+ # @luminary michael-nygard — fresh state is not broken state.
53
+ # Resolve marker dir — mirror lib/hook-heartbeat.sh::_heartbeat_dir semantics
54
+ # so tests that set HEARTBEAT_MARKER_DIR get consistent behavior.
55
+ _HB_MARKER_DIR="${HEARTBEAT_MARKER_DIR:-state/markers/hook-heartbeat}"
56
+
57
+ if [ ! -d "$_HB_MARKER_DIR" ]; then
40
58
  return 0 2>/dev/null || exit 0
41
59
  fi
42
60
 
61
+ FRESH_INSTALL_GRACE_SECONDS="${FRESH_INSTALL_GRACE_SECONDS:-86400}" # 24h default per #1895
62
+ if [ "$FRESH_INSTALL_GRACE_SECONDS" -gt 0 ]; then
63
+ # Find oldest marker mtime — install activation anchor. Try GNU stat -c
64
+ # first (Linux; -f on GNU means filesystem info NOT mtime; silently returns
65
+ # a string that breaks arithmetic below). BSD stat -f second (macOS).
66
+ # Filter numeric-only output for portability safety.
67
+ _oldest_mtime=$(find "$_HB_MARKER_DIR" -maxdepth 1 -type f -name '*.marker' -exec stat -c %Y {} + 2>/dev/null | grep -E '^[0-9]+$' | sort -n | head -1)
68
+ if [ -z "$_oldest_mtime" ]; then
69
+ _oldest_mtime=$(find "$_HB_MARKER_DIR" -maxdepth 1 -type f -name '*.marker' -exec stat -f %m {} + 2>/dev/null | grep -E '^[0-9]+$' | sort -n | head -1)
70
+ fi
71
+ _now=$(date -u +%s)
72
+ # No markers yet: install has not activated any producer. Skip silently.
73
+ if [ -z "$_oldest_mtime" ]; then
74
+ return 0 2>/dev/null || exit 0
75
+ fi
76
+ # Grace window active: skip silently.
77
+ if [ $((_now - _oldest_mtime)) -lt "$FRESH_INSTALL_GRACE_SECONDS" ]; then
78
+ return 0 2>/dev/null || exit 0
79
+ fi
80
+ fi
81
+
43
82
  # Probe bundled path first (dist/lite/ ship path per ADR-056 D3), then
44
83
  # $BASSCLEF_DIR fallback for synced adopters. Pattern matches
45
84
  # session-reflection.sh:11-17. Cures #1732 Phase A silent-fail at operator
@@ -0,0 +1,242 @@
1
+ #!/bin/bash
2
+ # tier: standard
3
+ # fixtures: bassclef-upstream-self
4
+ # Tier 0 test for #1818 Ship 3 — fresh-install state gates on
5
+ # 30-metrics-staleness.sh + 80-hook-heartbeat-check.sh.
6
+ #
7
+ # Per @luminary donald-norman — a banner fires only when a real problem
8
+ # exists. Fresh state (state dir absent) is not broken state.
9
+ # Per @luminary michael-feathers — characterization tests via mktemp
10
+ # fixture repos; source fragment inside wrapper that provides the
11
+ # coordinator's helper stubs (blocked_banner, BASSCLEF_DIR).
12
+ #
13
+ # Test list per .claude/rules/test-list-discipline.md:
14
+ # [x] Test 1 — 30-metrics-staleness.sh: no docs/metrics/ dir → silent (no BLOCKED)
15
+ # [x] Test 2 — 30-metrics-staleness.sh: with docs/metrics/ dir + no latest.md → BLOCKED fires (gate does not over-scope)
16
+ # [x] Test 3 — 80-hook-heartbeat-check.sh: no state/markers/hook-heartbeat/ → silent (no BLOCKED)
17
+ # [x] Test 4 — 80-hook-heartbeat-check.sh: with state/markers/hook-heartbeat/ + aged markers past grace → BLOCKED fires
18
+ # [x] Test 5 — 81-hook-manifest-staleness.sh: no state/markers/hook-heartbeat/ → silent (bassclef-upstream#1896 cure)
19
+ # [x] Test 6 — 81-hook-manifest-staleness.sh: SELF_MODE=1 → silent (bassclef-upstream#1804 sister guard)
20
+ # [x] Test 7 — 80-hook-heartbeat-check.sh: dir exists + no markers → silent (#1895 fresh-install cure — was BLOCKED pre-fix)
21
+ # [x] Test 8 — 80-hook-heartbeat-check.sh: dir exists + fresh markers under 24h grace → silent (#1895 grace window)
22
+ # [x] Test 9 — 80-hook-heartbeat-check.sh: FRESH_INSTALL_GRACE_SECONDS=0 override → BLOCKED fires immediately (#1895 override path)
23
+
24
+ set -u
25
+
26
+ REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
27
+ METRICS_FRAG="$REPO_ROOT/.claude/hooks/session-reflection.d/30-metrics-staleness.sh"
28
+ HB_FRAG="$REPO_ROOT/.claude/hooks/session-reflection.d/80-hook-heartbeat-check.sh"
29
+ MS_FRAG="$REPO_ROOT/.claude/hooks/session-reflection.d/81-hook-manifest-staleness.sh"
30
+
31
+ PASS=0
32
+ FAIL=0
33
+ FAILED_TESTS=()
34
+
35
+ check() {
36
+ local name="$1"
37
+ local cond="$2"
38
+ if eval "$cond"; then
39
+ echo "PASS: $name"
40
+ PASS=$((PASS + 1))
41
+ else
42
+ echo "FAIL: $name"
43
+ FAIL=$((FAIL + 1))
44
+ FAILED_TESTS+=("$name")
45
+ fi
46
+ }
47
+
48
+ TMPBASE=$(mktemp -d)
49
+ # shellcheck disable=SC2064
50
+ trap "rm -rf $TMPBASE" EXIT
51
+
52
+ # Wrapper that stubs the coordinator's helpers so the fragment runs
53
+ # under conditions matching real session-reflection.sh invocation.
54
+ # blocked_banner writes to a marker file so tests can detect emission.
55
+ run_fragment_with_stubs() {
56
+ local frag="$1"
57
+ local fixture_dir="$2"
58
+ local marker_file="$3"
59
+ (
60
+ cd "$fixture_dir" || exit 99
61
+ # shellcheck disable=SC2317
62
+ blocked_banner() {
63
+ echo "$*" >> "$marker_file"
64
+ }
65
+ export -f blocked_banner 2>/dev/null || true
66
+ BASSCLEF_DIR="$REPO_ROOT"
67
+ export BASSCLEF_DIR
68
+ # Source the fragment
69
+ # shellcheck disable=SC1090
70
+ source "$frag" 2>/dev/null || true
71
+ )
72
+ }
73
+
74
+ # === Test 1 — metrics fragment: no docs/metrics/ → silent ===
75
+ FIXTURE1="$TMPBASE/fixture1-no-metrics"
76
+ mkdir -p "$FIXTURE1"
77
+ cd "$FIXTURE1" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
78
+ mkdir -p "$FIXTURE1/scripts"
79
+ touch "$FIXTURE1/scripts/metrics.sh"
80
+ BANNER1="$TMPBASE/banner1.out"
81
+ run_fragment_with_stubs "$METRICS_FRAG" "$FIXTURE1" "$BANNER1"
82
+ if [ ! -s "$BANNER1" ]; then
83
+ check "Test 1 — metrics fragment silent when no docs/metrics/ dir" "true"
84
+ else
85
+ echo " banner content: $(cat $BANNER1)"
86
+ check "Test 1 — metrics fragment silent when no docs/metrics/ dir" "false"
87
+ fi
88
+
89
+ # === Test 2 — metrics fragment: docs/metrics/ present + no latest.md → BLOCKED fires ===
90
+ FIXTURE2="$TMPBASE/fixture2-with-metrics-no-latest"
91
+ mkdir -p "$FIXTURE2/docs/metrics" "$FIXTURE2/scripts"
92
+ cd "$FIXTURE2" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
93
+ touch "$FIXTURE2/scripts/metrics.sh"
94
+ BANNER2="$TMPBASE/banner2.out"
95
+ run_fragment_with_stubs "$METRICS_FRAG" "$FIXTURE2" "$BANNER2"
96
+ if [ -s "$BANNER2" ] && grep -q "NO METRICS GENERATED" "$BANNER2"; then
97
+ check "Test 2 — metrics fragment fires BLOCKED when docs/metrics/ exists + no latest.md" "true"
98
+ else
99
+ echo " banner content: $(cat $BANNER2 2>/dev/null || echo empty)"
100
+ check "Test 2 — metrics fragment fires BLOCKED when docs/metrics/ exists + no latest.md" "false"
101
+ fi
102
+
103
+ # === Test 3 — heartbeat fragment: no state/markers/hook-heartbeat/ → silent ===
104
+ FIXTURE3="$TMPBASE/fixture3-no-heartbeat-dir"
105
+ mkdir -p "$FIXTURE3/standards" "$FIXTURE3/lib"
106
+ cd "$FIXTURE3" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
107
+ # Copy the manifest + lib so the fragment finds them but state dir absent
108
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE3/standards/" 2>/dev/null
109
+ cp "$REPO_ROOT/lib/hook-heartbeat.sh" "$FIXTURE3/lib/" 2>/dev/null
110
+ BANNER3="$TMPBASE/banner3.out"
111
+ SELF_MODE=0 run_fragment_with_stubs "$HB_FRAG" "$FIXTURE3" "$BANNER3"
112
+ if [ ! -s "$BANNER3" ]; then
113
+ check "Test 3 — heartbeat fragment silent when no state/markers/hook-heartbeat/ dir" "true"
114
+ else
115
+ echo " banner content: $(cat $BANNER3)"
116
+ check "Test 3 — heartbeat fragment silent when no state/markers/hook-heartbeat/ dir" "false"
117
+ fi
118
+
119
+ # === Test 4 — heartbeat fragment: dir + aged markers past grace → BLOCKED fires ===
120
+ # Fixture: dir exists AND has an aged marker (48h old) — install is past
121
+ # the 24h grace window AND has real staleness (marker's referenced hook
122
+ # has max_age < 48h so it counts as stale).
123
+ FIXTURE4="$TMPBASE/fixture4-heartbeat-aged-markers"
124
+ mkdir -p "$FIXTURE4/standards" "$FIXTURE4/lib" "$FIXTURE4/state/markers/hook-heartbeat"
125
+ cd "$FIXTURE4" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
126
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE4/standards/" 2>/dev/null
127
+ cp "$REPO_ROOT/lib/hook-heartbeat.sh" "$FIXTURE4/lib/" 2>/dev/null
128
+ # Write an anchor marker aged 48h old so the grace window has expired.
129
+ # Use bassclef-sync as the aged marker; max_age is 86400s (24h) so it counts as stale.
130
+ echo "2026-09-20T00:00:00Z" > "$FIXTURE4/state/markers/hook-heartbeat/bassclef-sync.marker"
131
+ # Portable mtime setup: BSD `date -u -v-48H` first, fallback to GNU `date -u -d`
132
+ old_stamp=$(date -u -v-48H +%Y%m%d%H%M 2>/dev/null || date -u -d '48 hours ago' +%Y%m%d%H%M)
133
+ touch -t "$old_stamp" "$FIXTURE4/state/markers/hook-heartbeat/bassclef-sync.marker"
134
+ BANNER4="$TMPBASE/banner4.out"
135
+ SELF_MODE=0 run_fragment_with_stubs "$HB_FRAG" "$FIXTURE4" "$BANNER4"
136
+ if [ -s "$BANNER4" ] && grep -q "hook liveness" "$BANNER4"; then
137
+ check "Test 4 — heartbeat fragment fires BLOCKED when dir + aged markers past grace" "true"
138
+ else
139
+ echo " banner content: $(cat $BANNER4 2>/dev/null || echo empty)"
140
+ check "Test 4 — heartbeat fragment fires BLOCKED when dir + aged markers past grace" "false"
141
+ fi
142
+
143
+ # === Test 5 — 81 fragment: no state/markers/hook-heartbeat/ → silent (bassclef-upstream#1896 cure) ===
144
+ FIXTURE5="$TMPBASE/fixture5-no-heartbeat-dir-ms"
145
+ mkdir -p "$FIXTURE5/standards" "$FIXTURE5/.claude/hooks"
146
+ cd "$FIXTURE5" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
147
+ # Copy manifest + a new hook that would flag as "not in manifest" without the gate
148
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE5/standards/" 2>/dev/null
149
+ touch "$FIXTURE5/.claude/hooks/some-uncatalogued-hook.sh"
150
+ BANNER5="$TMPBASE/banner5.out"
151
+ SELF_MODE=0 run_fragment_with_stubs "$MS_FRAG" "$FIXTURE5" "$BANNER5"
152
+ if [ ! -s "$BANNER5" ]; then
153
+ check "Test 5 — 81 fragment silent when no state/markers/hook-heartbeat/ dir (#1896 cure)" "true"
154
+ else
155
+ echo " banner content: $(cat $BANNER5)"
156
+ check "Test 5 — 81 fragment silent when no state/markers/hook-heartbeat/ dir (#1896 cure)" "false"
157
+ fi
158
+
159
+ # === Test 6 — 81 fragment: SELF_MODE=1 → silent (bassclef-upstream#1804 sister guard) ===
160
+ FIXTURE6="$TMPBASE/fixture6-selfmode"
161
+ mkdir -p "$FIXTURE6/standards" "$FIXTURE6/.claude/hooks" "$FIXTURE6/state/markers/hook-heartbeat"
162
+ cd "$FIXTURE6" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
163
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE6/standards/" 2>/dev/null
164
+ touch "$FIXTURE6/.claude/hooks/some-uncatalogued-hook.sh"
165
+ BANNER6="$TMPBASE/banner6.out"
166
+ SELF_MODE=1 run_fragment_with_stubs "$MS_FRAG" "$FIXTURE6" "$BANNER6"
167
+ if [ ! -s "$BANNER6" ]; then
168
+ check "Test 6 — 81 fragment silent under SELF_MODE=1 (#1804 sister guard)" "true"
169
+ else
170
+ echo " banner content: $(cat $BANNER6)"
171
+ check "Test 6 — 81 fragment silent under SELF_MODE=1 (#1804 sister guard)" "false"
172
+ fi
173
+
174
+ # === Test 7 — heartbeat fragment: dir exists + no markers → silent (#1895 fresh-install cure) ===
175
+ # Fixture: dir mkdir'd but no producer has ever fired heartbeat_mark. This is
176
+ # the exact self-defeat class from #1895 — dir gets created by first hook fire,
177
+ # but at 4 min old install with only 1 fire, remaining 6 hooks show "never fired."
178
+ # Cure: grace window (24h default) suppresses banner on fresh install.
179
+ FIXTURE7="$TMPBASE/fixture7-heartbeat-dir-no-markers"
180
+ mkdir -p "$FIXTURE7/standards" "$FIXTURE7/lib" "$FIXTURE7/state/markers/hook-heartbeat"
181
+ cd "$FIXTURE7" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
182
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE7/standards/" 2>/dev/null
183
+ cp "$REPO_ROOT/lib/hook-heartbeat.sh" "$FIXTURE7/lib/" 2>/dev/null
184
+ # dir EXISTS but no markers — pre-cure this triggers BLOCKED; post-cure silent under grace
185
+ BANNER7="$TMPBASE/banner7.out"
186
+ SELF_MODE=0 run_fragment_with_stubs "$HB_FRAG" "$FIXTURE7" "$BANNER7"
187
+ if [ ! -s "$BANNER7" ]; then
188
+ check "Test 7 — heartbeat fragment silent when dir exists + no markers (#1895 cure)" "true"
189
+ else
190
+ echo " banner content: $(cat $BANNER7)"
191
+ check "Test 7 — heartbeat fragment silent when dir exists + no markers (#1895 cure)" "false"
192
+ fi
193
+
194
+ # === Test 8 — heartbeat fragment: dir + fresh markers under 24h grace → silent (#1895 grace window) ===
195
+ # Fixture: dir exists AND has 1 fresh marker (just written) — install is
196
+ # actively booting. Grace window should suppress banner even though other
197
+ # critical hooks show "never fired."
198
+ FIXTURE8="$TMPBASE/fixture8-heartbeat-fresh-marker"
199
+ mkdir -p "$FIXTURE8/standards" "$FIXTURE8/lib" "$FIXTURE8/state/markers/hook-heartbeat"
200
+ cd "$FIXTURE8" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
201
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE8/standards/" 2>/dev/null
202
+ cp "$REPO_ROOT/lib/hook-heartbeat.sh" "$FIXTURE8/lib/" 2>/dev/null
203
+ # Write a fresh marker (now) — grace window should hold.
204
+ date -u +%Y-%m-%dT%H:%M:%SZ > "$FIXTURE8/state/markers/hook-heartbeat/bassclef-sync.marker"
205
+ BANNER8="$TMPBASE/banner8.out"
206
+ SELF_MODE=0 run_fragment_with_stubs "$HB_FRAG" "$FIXTURE8" "$BANNER8"
207
+ if [ ! -s "$BANNER8" ]; then
208
+ check "Test 8 — heartbeat fragment silent when dir + fresh markers under 24h grace (#1895)" "true"
209
+ else
210
+ echo " banner content: $(cat $BANNER8)"
211
+ check "Test 8 — heartbeat fragment silent when dir + fresh markers under 24h grace (#1895)" "false"
212
+ fi
213
+
214
+ # === Test 9 — heartbeat fragment: FRESH_INSTALL_GRACE_SECONDS=0 override → BLOCKED fires ===
215
+ # Fixture: same shape as Test 7 (dir + no markers) but grace window disabled.
216
+ # Override path exercised — operator can bypass grace to test the check itself.
217
+ FIXTURE9="$TMPBASE/fixture9-heartbeat-grace-override"
218
+ mkdir -p "$FIXTURE9/standards" "$FIXTURE9/lib" "$FIXTURE9/state/markers/hook-heartbeat"
219
+ cd "$FIXTURE9" && git init -q -b main && git config user.email test@bassclef.local && git config user.name test
220
+ cp "$REPO_ROOT/standards/hook-cadence.json" "$FIXTURE9/standards/" 2>/dev/null
221
+ cp "$REPO_ROOT/lib/hook-heartbeat.sh" "$FIXTURE9/lib/" 2>/dev/null
222
+ BANNER9="$TMPBASE/banner9.out"
223
+ FRESH_INSTALL_GRACE_SECONDS=0 SELF_MODE=0 run_fragment_with_stubs "$HB_FRAG" "$FIXTURE9" "$BANNER9"
224
+ if [ -s "$BANNER9" ] && grep -q "hook liveness" "$BANNER9"; then
225
+ check "Test 9 — heartbeat fragment fires BLOCKED when FRESH_INSTALL_GRACE_SECONDS=0 (#1895 override)" "true"
226
+ else
227
+ echo " banner content: $(cat $BANNER9 2>/dev/null || echo empty)"
228
+ check "Test 9 — heartbeat fragment fires BLOCKED when FRESH_INSTALL_GRACE_SECONDS=0 (#1895 override)" "false"
229
+ fi
230
+
231
+ # --- Summary ---
232
+ echo ""
233
+ echo "Tests passed: $PASS"
234
+ echo "Tests failed: $FAIL"
235
+ if [ "$FAIL" -gt 0 ]; then
236
+ echo "Failed tests:"
237
+ for t in "${FAILED_TESTS[@]}"; do
238
+ echo " - $t"
239
+ done
240
+ exit 1
241
+ fi
242
+ exit 0
@@ -47,6 +47,11 @@
47
47
  "type": "command",
48
48
  "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-commit-identifier-leak-scrub.sh",
49
49
  "timeout": 10
50
+ },
51
+ {
52
+ "type": "command",
53
+ "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/git-stash-u-guard.sh",
54
+ "timeout": 5
50
55
  }
51
56
  ]
52
57
  },
@@ -1,13 +1,14 @@
1
1
  ---
2
2
  tier: lite
3
3
  name: build
4
- description: "Take a buildable plan and ship it. Reads a spec from /launch. Dispatches a builder per user story. Runs /verify. Opens PRs. Gates prod deploy on a human. Refuses auth, schema, security, and tenant work. --explain narrates first."
4
+ description: "Take a plan from /launch and ship it. One builder per user story. Runs /verify. Opens PRs. Gates prod on a human. Refuses auth, schema, security, and tenant work. Two modes — full and degrade-with-note."
5
5
  problem: "A buildable plan sits on disk. Nothing ships until someone writes code and PRs and gates prod."
6
6
  value: "Reads the plan and ships it. Runs the whole cycle so you can watch instead of type."
7
7
  inputs: [A goal slug pointing to a spec on disk]
8
8
  outputs: [One PR per user story, Verify report, Reviewer sign-off, Prod deploy gated on a human]
9
9
  user_invocable: true
10
10
  disable_model_invocation: false
11
+ modes: [full, degrade-with-note]
11
12
  requires:
12
13
  cli: [git, gh]
13
14
  network: true
@@ -104,8 +105,63 @@ Open every /build invocation with:
104
105
 
105
106
  Walls of structured Phase 0-7 detail come AFTER this block.
106
107
 
108
+ ### Phase −1a — Capability probe (per bassclef-upstream#1909)
109
+
110
+ /build gets two modes: **full** and **degrade-with-note**. Per RFC B1 folded (`docs/rfcs/2026-09-23-graceful-degrade-adversarial-review.md`), a third `scratch` mode would collide with the Phase 0 blast-radius floor — /build must never run in a repo it cannot audit for auth, schema, security, or tenant work.
111
+
112
+ - **full** — git repo + gh auth + Playwright MCP + network. Full chain runs through Phase 7.
113
+ - **degrade-with-note** — one or more capabilities missing. /build emits the spec and a plain-language note ("cd into a git repo with gh auth before /build") and exits. No PR attempt.
114
+
115
+ The blast-radius floor at Phase 0 is not overridable by mode dispatch (spec Req 10 per `docs/specs/2026-09-23-graceful-degrade-skills.md`). The floor holds forever.
116
+
117
+ The mode picker short-circuits `probe_network` when other probes already downgrade the mode (RFC C2 folded).
118
+
119
+ ```bash
120
+ source "${BASSCLEF_DIR:-$REPO_ROOT}/lib/capability-probe.sh"
121
+
122
+ probe_git; GIT=$?
123
+ probe_mcp_playwright; MCP=$?
124
+ probe_gh_auth; GH=$?
125
+
126
+ if [ $GIT -eq 0 ] && [ $MCP -eq 0 ] && [ $GH -eq 0 ]; then
127
+ probe_network; NET=$?
128
+ else
129
+ NET=1
130
+ fi
131
+
132
+ G=$([ $GIT -eq 0 ] && echo 1 || echo 0)
133
+ M=$([ $MCP -eq 0 ] && echo 1 || echo 0)
134
+ H=$([ $GH -eq 0 ] && echo 1 || echo 0)
135
+ N=$([ $NET -eq 0 ] && echo 1 || echo 0)
136
+
137
+ # /build collapses the three-value probe into its two modes:
138
+ # full requires all four; anything else is degrade-with-note.
139
+ LIB_MODE=$(resolve_mode "$G" "$H" "$M" "$N")
140
+ if [ "$LIB_MODE" = "full" ]; then
141
+ MODE="full"
142
+ else
143
+ MODE="degrade-with-note"
144
+ fi
145
+
146
+ announce_mode "$MODE" "/build"
147
+
148
+ if [ "$MODE" = "degrade-with-note" ]; then
149
+ echo "" >&2
150
+ echo "/build: environment does not support the full chain." >&2
151
+ echo " - git present? $([ $GIT -eq 0 ] && echo yes || echo no)" >&2
152
+ echo " - gh authed? $([ $GH -eq 0 ] && echo yes || echo no)" >&2
153
+ echo " - Playwright MCP loaded? $([ $MCP -eq 0 ] && echo yes || echo no)" >&2
154
+ echo " - network reachable? $([ $NET -eq 0 ] && echo yes || echo no)" >&2
155
+ echo "" >&2
156
+ echo "Emitting the spec at docs/specs/<slug>.md. cd into a git repo with gh auth to run the full chain." >&2
157
+ exit 0
158
+ fi
159
+ ```
160
+
107
161
  ### Phase -1 — Pre-flight (per bassclef#408)
108
162
 
163
+ Fires only when Phase −1a picked `full` mode. In `degrade-with-note` mode the skill already exited at Phase −1a.
164
+
109
165
  Before Phase 0 (which is /build's blast-radius floor refusal), source
110
166
  the shared pre-flight helper to surface workflow prerequisites:
111
167