@thebassclef/lite 1.0.2 → 1.0.4

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 (44) hide show
  1. package/dist/cli.cjs +51 -7
  2. package/dist/cli.js +51 -7
  3. package/dist/index.cjs +1 -1
  4. package/dist/index.d.ts +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/lite/.claude/hooks/session-reflection.d/00-bassclef-hook-connect.sh +63 -0
  7. package/dist/lite/.claude/hooks/session-reflection.d/05-active-bet.sh +113 -0
  8. package/dist/lite/.claude/hooks/session-reflection.d/08-settings-drift.sh +62 -0
  9. package/dist/lite/.claude/hooks/session-reflection.d/10-abrupt-stop-recovery.sh +78 -0
  10. package/dist/lite/.claude/hooks/session-reflection.d/12-session-lock.sh +63 -0
  11. package/dist/lite/.claude/hooks/session-reflection.d/15-substrate-config-validate.sh +91 -0
  12. package/dist/lite/.claude/hooks/session-reflection.d/20-artifact-staleness.sh +98 -0
  13. package/dist/lite/.claude/hooks/session-reflection.d/30-metrics-staleness.sh +61 -0
  14. package/dist/lite/.claude/hooks/session-reflection.d/35-post-merge-whereami-self-heal.sh +180 -0
  15. package/dist/lite/.claude/hooks/session-reflection.d/36-whereami-size-check.sh +117 -0
  16. package/dist/lite/.claude/hooks/session-reflection.d/40-gate-compliance.sh +108 -0
  17. package/dist/lite/.claude/hooks/session-reflection.d/45-auto-sync-status.sh +99 -0
  18. package/dist/lite/.claude/hooks/session-reflection.d/50-evolution-issues.sh +44 -0
  19. package/dist/lite/.claude/hooks/session-reflection.d/55-orientation-gate.sh +187 -0
  20. package/dist/lite/.claude/hooks/session-reflection.d/56-roadmap-reconcile-gate.sh +88 -0
  21. package/dist/lite/.claude/hooks/session-reflection.d/60-deferred-actions.sh +212 -0
  22. package/dist/lite/.claude/hooks/session-reflection.d/70-recent-strategic-artifacts.sh +101 -0
  23. package/dist/lite/.claude/hooks/session-reflection.d/75-prior-r8-findings.sh +100 -0
  24. package/dist/lite/.claude/hooks/session-reflection.d/80-hook-heartbeat-check.sh +87 -0
  25. package/dist/lite/.claude/hooks/session-reflection.d/80-workflow-staleness.sh +141 -0
  26. package/dist/lite/.claude/hooks/session-reflection.d/81-hook-manifest-staleness.sh +67 -0
  27. package/dist/lite/.claude/hooks/session-reflection.d/90-release-backlog.sh +140 -0
  28. package/dist/lite/.claude/hooks/session-reflection.d/90-secrets-expiry.sh +104 -0
  29. package/dist/lite/.claude/hooks/session-reflection.d/95-settings-hook-verify.sh +131 -0
  30. package/dist/lite/.claude/hooks/session-reflection.d/95-telemetry-emit.sh +66 -0
  31. package/dist/lite/.claude/hooks/session-reflection.d/97-install-class-dispatch.sh +57 -0
  32. package/dist/lite/.claude/hooks/session-reflection.d/tests/00-bassclef-hook-connect.test.sh +492 -0
  33. package/dist/lite/.claude/hooks/session-reflection.d/tests/05-active-bet.test.sh +336 -0
  34. package/dist/lite/.claude/hooks/session-reflection.d/tests/12-session-lock.test.sh +216 -0
  35. package/dist/lite/.claude/hooks/session-reflection.d/tests/15-substrate-config-validate.test.sh +50 -0
  36. package/dist/lite/.claude/hooks/session-reflection.d/tests/20-artifact-staleness.test.sh +348 -0
  37. package/dist/lite/.claude/hooks/session-reflection.d/tests/36-whereami-size-check.test.sh +189 -0
  38. package/dist/lite/.claude/hooks/session-reflection.d/tests/45-auto-sync-status.test.sh +263 -0
  39. package/dist/lite/.claude/hooks/session-reflection.d/tests/55-orientation-gate.test.sh +595 -0
  40. package/dist/lite/.claude/hooks/session-reflection.d/tests/56-roadmap-reconcile-gate.test.sh +227 -0
  41. package/dist/lite/.claude/hooks/session-reflection.d/tests/75-prior-r8-findings.test.sh +142 -0
  42. package/dist/lite/.claude/hooks/session-reflection.d/tests/90-release-backlog.test.sh +44 -0
  43. package/dist/lite/.claude/hooks/trace-helper.sh +22 -0
  44. package/package.json +1 -1
package/dist/cli.cjs CHANGED
@@ -548,8 +548,11 @@ function copySubstrate(targetDir, options = {}) {
548
548
  for (const [directory, groupFiles] of groups) {
549
549
  let completedInGroup = 0;
550
550
  for (const relPath of groupFiles) {
551
- const outcome = copyOne(relPath, bundleRoot, targetDir, options, result, scopeMap);
552
- if (outcome !== "skipped") completedInGroup += 1;
551
+ const decisions = decisionsForFile(relPath, scopeMap, targetDir, options);
552
+ for (const decision of decisions) {
553
+ const outcome = copyOne(relPath, bundleRoot, options, result, decision);
554
+ if (outcome !== "skipped") completedInGroup += 1;
555
+ }
553
556
  }
554
557
  if (options.onProgress) options.onProgress(directory, completedInGroup);
555
558
  }
@@ -656,12 +659,30 @@ function buildScopeMap(bundleRoot, targetDir, options, bundleHookRelPaths) {
656
659
  function isHookFile(relPath) {
657
660
  return relPath.startsWith(HOOKS_SUBPATH) && relPath.endsWith(".sh");
658
661
  }
659
- function copyOne(relPath, bundleRoot, targetDir, options, result, scopeMap) {
662
+ function decisionsForFile(relPath, scopeMap, targetDir, options) {
663
+ if (!isHookFile(relPath)) {
664
+ return [{ scope: "project", targetPath: node_path.join(targetDir, mapAdopterPath(relPath)) }];
665
+ }
666
+ const declared = scopeMap.get(relPath);
667
+ if (declared) {
668
+ return [declared];
669
+ }
670
+ const allowRoot = options.allowRoot ?? false;
671
+ const userDecision = classify$1(
672
+ { command: `$HOME/${relPath}` },
673
+ { targetDir, allowRoot }
674
+ );
675
+ const projectDecision = classify$1(
676
+ { command: `$CLAUDE_PROJECT_DIR/${relPath}` },
677
+ { targetDir, allowRoot }
678
+ );
679
+ return [userDecision, projectDecision];
680
+ }
681
+ function copyOne(relPath, bundleRoot, options, result, scopeDecision) {
660
682
  const sourcePath = node_path.join(bundleRoot, relPath);
661
683
  const adopterRelPath = mapAdopterPath(relPath);
662
- const scopeDecision = isHookFile(relPath) ? scopeMap.get(relPath) : void 0;
663
- const targetPath = scopeDecision ? scopeDecision.targetPath : node_path.join(targetDir, adopterRelPath);
664
- const scope = scopeDecision ? scopeDecision.scope : "project";
684
+ const targetPath = scopeDecision.targetPath;
685
+ const scope = scopeDecision.scope;
665
686
  let content;
666
687
  try {
667
688
  content = node_fs.readFileSync(sourcePath, "utf8");
@@ -919,8 +940,11 @@ function dispatchSubstrateCopy(targetDir, force, verbose, dryRun, allowRoot, jso
919
940
  `bassclef init: ${grandTotal} files total (1 config + ${result.copied.length} substrate).
920
941
  `
921
942
  );
943
+ const declaredCommandLeaves = readDeclaredCommandLeaves(
944
+ node_path.join(targetDir, ".claude", "settings.json")
945
+ );
922
946
  const copiedHookEntries = result.copiedEntries.filter(
923
- (e) => e.path.startsWith(HOOKS_SUBPATH) && e.path.endsWith(".sh")
947
+ (e) => e.path.startsWith(HOOKS_SUBPATH) && e.path.endsWith(".sh") && declaredCommandLeaves.has(node_path.basename(e.path))
924
948
  );
925
949
  const copiedCount = copiedHookEntries.length;
926
950
  const declaredCount = result.hookCount;
@@ -1148,6 +1172,26 @@ function usage$2() {
1148
1172
  ""
1149
1173
  ].join("\n");
1150
1174
  }
1175
+ function readDeclaredCommandLeaves(settingsPath) {
1176
+ try {
1177
+ const parsed = JSON.parse(node_fs.readFileSync(settingsPath, "utf8"));
1178
+ const leaves = /* @__PURE__ */ new Set();
1179
+ for (const matcherBlocks of Object.values(parsed.hooks ?? {})) {
1180
+ for (const block of matcherBlocks) {
1181
+ for (const entry of block.hooks ?? []) {
1182
+ const cmd = entry.command;
1183
+ if (typeof cmd !== "string" || cmd.length === 0) continue;
1184
+ if (!cmd.startsWith("$")) continue;
1185
+ const leaf = cmd.slice(cmd.lastIndexOf("/") + 1);
1186
+ if (leaf.endsWith(".sh")) leaves.add(leaf);
1187
+ }
1188
+ }
1189
+ }
1190
+ return leaves;
1191
+ } catch {
1192
+ return /* @__PURE__ */ new Set();
1193
+ }
1194
+ }
1151
1195
  const DEFAULTS$1 = {
1152
1196
  force: false,
1153
1197
  replaceEdits: false,
package/dist/cli.js CHANGED
@@ -525,8 +525,11 @@ function copySubstrate(targetDir, options = {}) {
525
525
  for (const [directory, groupFiles] of groups) {
526
526
  let completedInGroup = 0;
527
527
  for (const relPath of groupFiles) {
528
- const outcome = copyOne(relPath, bundleRoot, targetDir, options, result, scopeMap);
529
- if (outcome !== "skipped") completedInGroup += 1;
528
+ const decisions = decisionsForFile(relPath, scopeMap, targetDir, options);
529
+ for (const decision of decisions) {
530
+ const outcome = copyOne(relPath, bundleRoot, options, result, decision);
531
+ if (outcome !== "skipped") completedInGroup += 1;
532
+ }
530
533
  }
531
534
  if (options.onProgress) options.onProgress(directory, completedInGroup);
532
535
  }
@@ -633,12 +636,30 @@ function buildScopeMap(bundleRoot, targetDir, options, bundleHookRelPaths) {
633
636
  function isHookFile(relPath) {
634
637
  return relPath.startsWith(HOOKS_SUBPATH) && relPath.endsWith(".sh");
635
638
  }
636
- function copyOne(relPath, bundleRoot, targetDir, options, result, scopeMap) {
639
+ function decisionsForFile(relPath, scopeMap, targetDir, options) {
640
+ if (!isHookFile(relPath)) {
641
+ return [{ scope: "project", targetPath: join(targetDir, mapAdopterPath(relPath)) }];
642
+ }
643
+ const declared = scopeMap.get(relPath);
644
+ if (declared) {
645
+ return [declared];
646
+ }
647
+ const allowRoot = options.allowRoot ?? false;
648
+ const userDecision = classify$1(
649
+ { command: `$HOME/${relPath}` },
650
+ { targetDir, allowRoot }
651
+ );
652
+ const projectDecision = classify$1(
653
+ { command: `$CLAUDE_PROJECT_DIR/${relPath}` },
654
+ { targetDir, allowRoot }
655
+ );
656
+ return [userDecision, projectDecision];
657
+ }
658
+ function copyOne(relPath, bundleRoot, options, result, scopeDecision) {
637
659
  const sourcePath = join(bundleRoot, relPath);
638
660
  const adopterRelPath = mapAdopterPath(relPath);
639
- const scopeDecision = isHookFile(relPath) ? scopeMap.get(relPath) : void 0;
640
- const targetPath = scopeDecision ? scopeDecision.targetPath : join(targetDir, adopterRelPath);
641
- const scope = scopeDecision ? scopeDecision.scope : "project";
661
+ const targetPath = scopeDecision.targetPath;
662
+ const scope = scopeDecision.scope;
642
663
  let content;
643
664
  try {
644
665
  content = readFileSync(sourcePath, "utf8");
@@ -896,8 +917,11 @@ function dispatchSubstrateCopy(targetDir, force, verbose, dryRun, allowRoot, jso
896
917
  `bassclef init: ${grandTotal} files total (1 config + ${result.copied.length} substrate).
897
918
  `
898
919
  );
920
+ const declaredCommandLeaves = readDeclaredCommandLeaves(
921
+ join(targetDir, ".claude", "settings.json")
922
+ );
899
923
  const copiedHookEntries = result.copiedEntries.filter(
900
- (e) => e.path.startsWith(HOOKS_SUBPATH) && e.path.endsWith(".sh")
924
+ (e) => e.path.startsWith(HOOKS_SUBPATH) && e.path.endsWith(".sh") && declaredCommandLeaves.has(basename$1(e.path))
901
925
  );
902
926
  const copiedCount = copiedHookEntries.length;
903
927
  const declaredCount = result.hookCount;
@@ -1125,6 +1149,26 @@ function usage$2() {
1125
1149
  ""
1126
1150
  ].join("\n");
1127
1151
  }
1152
+ function readDeclaredCommandLeaves(settingsPath) {
1153
+ try {
1154
+ const parsed = JSON.parse(readFileSync(settingsPath, "utf8"));
1155
+ const leaves = /* @__PURE__ */ new Set();
1156
+ for (const matcherBlocks of Object.values(parsed.hooks ?? {})) {
1157
+ for (const block of matcherBlocks) {
1158
+ for (const entry of block.hooks ?? []) {
1159
+ const cmd = entry.command;
1160
+ if (typeof cmd !== "string" || cmd.length === 0) continue;
1161
+ if (!cmd.startsWith("$")) continue;
1162
+ const leaf = cmd.slice(cmd.lastIndexOf("/") + 1);
1163
+ if (leaf.endsWith(".sh")) leaves.add(leaf);
1164
+ }
1165
+ }
1166
+ }
1167
+ return leaves;
1168
+ } catch {
1169
+ return /* @__PURE__ */ new Set();
1170
+ }
1171
+ }
1128
1172
  const DEFAULTS$1 = {
1129
1173
  force: false,
1130
1174
  replaceEdits: false,
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.0.2";
3
+ const version = "1.0.4";
4
4
  exports.version = version;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const version: "1.0.2";
1
+ export declare const version: "1.0.4";
2
2
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const version = "1.0.2";
1
+ const version = "1.0.4";
2
2
  export {
3
3
  version
4
4
  };
@@ -0,0 +1,63 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ # 00-bassclef-hook-connect.sh — Auto-connect bassclef hooks to adopter settings.json.
4
+ #
5
+ # Closes sunj-labs/bassclef-upstream#644 — sync-cure peer path fix.
6
+ #
7
+ # Runs at every session-start via the session-reflection dispatcher.
8
+ # Sources presence/install/bassclef-hook-connect.sh (shared function)
9
+ # and calls bassclef_hook_connect_apply. Fires regardless of which sync
10
+ # path (peer, clone, npm, curl) delivered bassclef to the adopter.
11
+ #
12
+ # Sorts BEFORE 08-settings-drift.sh in the dispatcher's lexicographic
13
+ # scan (session-reflection.sh L135), so hooks are merged before drift
14
+ # is reported. First POA session after release shows 0 missing entries.
15
+ #
16
+ # SOURCED (not exec'd) by session-reflection.sh — uses `return 0` NOT
17
+ # `exit 0` so a skip path does not kill sister fragments. The tail
18
+ # `|| true` on `return` is the standard sourced-fragment idiom (from
19
+ # 08-settings-drift.sh) — lets the file also work when bash-invoked
20
+ # directly (in tests) where `return` at top level is an error.
21
+ #
22
+ # Anchor luminaries — @luminary michael-nygard (steady state — merge
23
+ # self-heals per session-start), @luminary alistair-cockburn (walking
24
+ # skeleton — thinnest end-to-end path), @luminary john-ousterhout
25
+ # (signal visibility — every merge run emits stderr line), @luminary
26
+ # michael-feathers (characterization test with fixture parent path
27
+ # OUTSIDE ~/src/sunj-labs/ closes the specific hole).
28
+ #
29
+ # Bootstrap pair — this fragment closes the peer-path gap. Sister:
30
+ # presence/install/bassclef-sync.template.sh:682 retains the clone-path
31
+ # call as belt + suspenders.
32
+
33
+ # BASSCLEF_DIR is set by the parent dispatcher (session-reflection.sh L33)
34
+ # when this fragment is sourced. CWD is set by the parent dispatcher
35
+ # (session-reflection.sh L14). When executed directly (bash <fragment>),
36
+ # env vars come from the caller.
37
+
38
+ if [ -z "${BASSCLEF_DIR:-}" ]; then
39
+ # No bassclef root resolved. Fragment cannot run. Silent skip.
40
+ return 0 2>/dev/null || exit 0
41
+ fi
42
+
43
+ # Ousterhout signal-visibility — emit one stderr line per session-start
44
+ # whenever BASSCLEF_DIR is set, before any conditional skip. Silent
45
+ # success is as dangerous as silent failure.
46
+ echo "[bassclef-hook-connect] session-reflection.d: attempting merge for CWD=${CWD:-$PWD}" >&2
47
+
48
+ __bhc_lib="${BASSCLEF_DIR}/presence/install/bassclef-hook-connect.sh"
49
+ if [ ! -f "$__bhc_lib" ]; then
50
+ echo "[bassclef-hook-connect] skip — shared function not found at $__bhc_lib" >&2
51
+ unset __bhc_lib
52
+ return 0 2>/dev/null || exit 0
53
+ fi
54
+
55
+ # Source the shared function (defines bassclef_hook_connect_apply).
56
+ # shellcheck disable=SC1090
57
+ source "$__bhc_lib"
58
+
59
+ # Apply the merge. Never break the dispatcher — return 0 on all paths.
60
+ bassclef_hook_connect_apply "${CWD:-$PWD}" "$BASSCLEF_DIR" || true
61
+
62
+ unset __bhc_lib
63
+ return 0 2>/dev/null || exit 0
@@ -0,0 +1,113 @@
1
+ # tier: lite
2
+ # install-class: dual
3
+ # 05-active-bet.sh — Surface in_flight_bet from whereami at SessionStart
4
+ #
5
+ # Per architecture/audits/2026-06-30-session-orientation-blind-spot.md cure 1
6
+ # (sister cures: 55-orientation-gate.sh fires on main + /longrun SKILL Step 0.4).
7
+ #
8
+ # The audit's root cause was the agent narrowing scope to "bet 29e wrap" from
9
+ # a stale branch name. Whereami had `in_flight_bet: bet 30a` and the recent
10
+ # commits named bet 30a, but the agent did not read either. This hook puts
11
+ # the in_flight_bet at the TOP of the SessionStart output so the agent sees
12
+ # it before any subsequent reasoning.
13
+ #
14
+ # Behavior:
15
+ # - Fires at SessionStart in every repo with .claude/ wired
16
+ # - Reads docs/whereami.md from CWD (the project's whereami, not $HOME)
17
+ # - When in_flight_bet block present: emits "### ACTIVE BET" header + slug + status + remaining_wus + goal doc pointer + cure source citation
18
+ # - When in_flight_bet absent: emits "### ACTIVE BET — no in-flight bet" one-line note
19
+ # - When whereami absent: emits "### ACTIVE BET — no in-flight bet (whereami absent)" one-line note
20
+ # - Always exit 0 (informational; SessionStart hooks do not block)
21
+ # - SKIP_ACTIVE_BET=1 silences output entirely
22
+ #
23
+ # Override (logged):
24
+ # SKIP_ACTIVE_BET=1 <command>
25
+ #
26
+ # Closes 1 of 5 sub-cures under sunj-labs/bassclef-upstream#540.
27
+
28
+ # Override path — silent, log via trace-helper if present
29
+ if [ "${SKIP_ACTIVE_BET:-0}" = "1" ]; then
30
+ if [ -x ".claude/hooks/trace-helper.sh" ]; then
31
+ bash .claude/hooks/trace-helper.sh "active-bet" "skipped via SKIP_ACTIVE_BET=1" 2>/dev/null || true
32
+ fi
33
+ return 0 2>/dev/null || exit 0
34
+ fi
35
+
36
+ # Resolve whereami path. The hook reads from the project's whereami, not
37
+ # from $HOME or the operator-install location. Per criterion 11 (path
38
+ # resolution under both install classes): CWD/docs/whereami.md works under
39
+ # both project install (CLAUDE_PROJECT_DIR=CWD) and operator install
40
+ # (HOME=/some/other; CWD=project root).
41
+ WHEREAMI="docs/whereami.md"
42
+
43
+ # Silent-but-print one-liner when whereami absent
44
+ if [ ! -f "$WHEREAMI" ]; then
45
+ echo ""
46
+ echo "### ACTIVE BET — no in-flight bet (whereami absent)"
47
+ echo ""
48
+ return 0 2>/dev/null || exit 0
49
+ fi
50
+
51
+ # Extract the in_flight_bet block. Whereami uses Shape B markdown YAML
52
+ # frontmatter. The in_flight_bet block looks like:
53
+ #
54
+ # in_flight_bet:
55
+ # bet: docs/iteration-bets/<slug>.md
56
+ # status: <status>
57
+ # remaining_wus: [<list>]
58
+ #
59
+ # awk extracts lines from `in_flight_bet:` until the next top-level key
60
+ # (a line starting with a non-space character that ends in `:`).
61
+ BLOCK=$(awk '
62
+ /^in_flight_bet:/ { in_block=1; next }
63
+ in_block && /^[a-zA-Z][^:]*:/ { in_block=0 }
64
+ in_block { print }
65
+ ' "$WHEREAMI" 2>/dev/null)
66
+
67
+ # When block is empty, the field is absent
68
+ if [ -z "$BLOCK" ]; then
69
+ echo ""
70
+ echo "### ACTIVE BET — no in-flight bet"
71
+ echo ""
72
+ echo "Whereami present but no in_flight_bet field. If you expect one, check"
73
+ echo " docs/whereami.md for the in_flight_bet: block."
74
+ echo ""
75
+ return 0 2>/dev/null || exit 0
76
+ fi
77
+
78
+ # Extract goal slug: the goal. line under in_flight_bet, then strip path + .md
79
+ BET_PATH=$(echo "$BLOCK" | grep -E "^[[:space:]]+bet:" | head -1 | sed -E 's/^[[:space:]]+bet:[[:space:]]*//')
80
+ BET_SLUG=$(echo "$BET_PATH" | sed -E 's|^.*/||; s|\.md$||')
81
+
82
+ # Extract status: best-effort, may carry parenthetical suffix
83
+ STATUS=$(echo "$BLOCK" | grep -E "^[[:space:]]+status:" | head -1 | sed -E 's/^[[:space:]]+status:[[:space:]]*//')
84
+
85
+ # Extract remaining_wus: best-effort, the bracketed list
86
+ REMAINING=$(echo "$BLOCK" | grep -E "^[[:space:]]+remaining_wus:" | head -1 | sed -E 's/^[[:space:]]+remaining_wus:[[:space:]]*//')
87
+
88
+ # Emit the ACTIVE BET block — header + tagline + fields + cure source
89
+ echo ""
90
+ echo "### ACTIVE BET — read before scoping work"
91
+ echo ""
92
+ if [ -n "$BET_SLUG" ]; then
93
+ echo " bet: ${BET_SLUG}"
94
+ fi
95
+ if [ -n "$STATUS" ]; then
96
+ echo " status: ${STATUS}"
97
+ fi
98
+ if [ -n "$REMAINING" ]; then
99
+ echo " remaining_wus: ${REMAINING}"
100
+ fi
101
+ if [ -n "$BET_PATH" ]; then
102
+ echo ""
103
+ echo " Read the goal doc before picking scope:"
104
+ echo " ${BET_PATH}"
105
+ fi
106
+ echo ""
107
+ echo " Cure source: architecture/audits/2026-06-30-session-orientation-blind-spot.md"
108
+ echo " Sister cures: 55-orientation-gate.sh fires on main + /longrun SKILL Step 0.4"
109
+ echo " Closes 1 of 5 sub-cures under sunj-labs/bassclef-upstream#540."
110
+ echo ""
111
+ echo "Override (logged): SKIP_ACTIVE_BET=1"
112
+ echo ""
113
+ return 0 2>/dev/null || exit 0
@@ -0,0 +1,62 @@
1
+ # tier: lite
2
+ # Drift detector — fires at every SessionStart via session-reflection.sh.
3
+ # Compares required wirings (per project settings.json) against loaded
4
+ # wirings (per scope-stack-merged settings). Emits BLOCKED banner if drift.
5
+ #
6
+ # Closes the bug class diagnosed 2026-06-24: substrate ships new wiring,
7
+ # operator's user-scope settings.json never picks it up, session-reflection.d/*
8
+ # modules silently never fire.
9
+ #
10
+ # Sourced (not exec'd) by session-reflection.sh. Inherits:
11
+ # - blocked_banner() function
12
+ # - trace_log() function (via trace-helper.sh sourced in parent)
13
+ # - BASSCLEF_DIR environment variable
14
+ #
15
+ # Per .claude/rules/blocked-items.md: BLOCKED items must be resolved
16
+ # or explicitly deferred. Per .claude/rules/defensive-bash.md: trace
17
+ # logging at every exit; quote variables; local in functions.
18
+ # Per docs/decompositions/2026-06-24-adopter-update-distribution.md.
19
+ # WU-2 of bet 2026-06-24a-settings-hardening (#313).
20
+
21
+ # Override path — operator can bypass per-session.
22
+ if [ "${SKIP_SETTINGS_DRIFT:-0}" = "1" ]; then
23
+ trace_log "skip" "settings-drift" "SKIP_SETTINGS_DRIFT=1" 2>/dev/null || true
24
+ return 0 2>/dev/null || true
25
+ fi
26
+
27
+ # Source the shared wirings accessor (per decomposition § Interface Registry).
28
+ __sd_wirings_lib="${BASSCLEF_DIR:-${PWD}}/.claude/hooks/_lib/wirings.sh"
29
+ if [ ! -f "$__sd_wirings_lib" ]; then
30
+ # Substrate not fully synced; sister modules surface the larger issue.
31
+ trace_log "skip" "settings-drift" "wirings lib not yet synced" 2>/dev/null || true
32
+ unset __sd_wirings_lib
33
+ return 0 2>/dev/null || true
34
+ fi
35
+ # shellcheck source=/dev/null
36
+ source "$__sd_wirings_lib"
37
+
38
+ __sd_project_settings="${BASSCLEF_DIR:-${PWD}}/.claude/settings.json"
39
+
40
+ __sd_required=$(read_required_wirings "$__sd_project_settings")
41
+ __sd_loaded=$(read_loaded_wirings)
42
+ __sd_diff=$(compare_wirings "$__sd_required" "$__sd_loaded")
43
+ __sd_ok=$(echo "$__sd_diff" | jq -r '.ok')
44
+
45
+ if [ "$__sd_ok" = "true" ]; then
46
+ trace_log "ok" "settings-drift" "no drift" 2>/dev/null || true
47
+ else
48
+ __sd_missing_count=$(echo "$__sd_diff" | jq '.missing | length')
49
+ __sd_missing_list=$(echo "$__sd_diff" | jq -r '.missing[] | " - " + .event + " → " + .command')
50
+ blocked_banner "settings-drift — ${__sd_missing_count} required hook wiring(s) missing from harness-loaded settings:
51
+
52
+ ${__sd_missing_list}
53
+
54
+ Root cause class: substrate ships new wiring; operator's settings.json never picked it up.
55
+ Resolve: bassclef-sync.sh self-heal will additively merge on next session-start once it ships (#313 WU-3). For now, run manually: bash ~/.claude/hooks/bassclef-sync.sh
56
+ Override: SKIP_SETTINGS_DRIFT=1 (one-off bypass; logged to trace)"
57
+ trace_log "blocked" "settings-drift" "missing=${__sd_missing_count}" 2>/dev/null || true
58
+ unset __sd_missing_count __sd_missing_list
59
+ fi
60
+
61
+ unset __sd_wirings_lib __sd_project_settings __sd_required __sd_loaded __sd_diff __sd_ok
62
+ return 0 2>/dev/null || true
@@ -0,0 +1,78 @@
1
+ # tier: lite
2
+ # Abrupt-stop detection: dirty working tree indicates the previous
3
+ # session-end protocol was skipped. Surface for recovery.
4
+ #
5
+ # bassclef#888 fix (Phase B WU-B2): filter routine bookkeeping marker
6
+ # writes that happen every session-start. Without this filter, the
7
+ # session-timing marker bumped at every boot surfaces as a false
8
+ # "abrupt stop" gate, training operators + future Claude sessions to
9
+ # disregard the banner as noise.
10
+ #
11
+ # bassclef-web#103 fix: extend allowlist to cover two more false-positive
12
+ # classes surfaced by adopter sessions:
13
+ # Class A — .playwright-mcp/ transient exhaust from Playwright MCP browser
14
+ # sessions (visual-review, visual-qa, synthetic-user skills)
15
+ # Class C — docs/deferred-actions/*-session-rescue.md files that the
16
+ # session-end hook writes on abrupt stop; the detector then
17
+ # sees them as untracked and fires the banner about itself
18
+ #
19
+ # Class B (sync-lag on new substrate primitives landing before .gitignore
20
+ # settles) is DEFERRED to a follow-on ticket — that class needs a
21
+ # sync-complete marker sequenced from bassclef-sync, wider scope than
22
+ # this cure.
23
+ #
24
+ # Allowlisted path prefixes (substrate runtime bookkeeping that's
25
+ # expected to be dirty at session-start):
26
+ # state/markers/session-timing/ — bumped by session-timing hook
27
+ # state/markers/edit-count/ — budget-tracking, auto-written
28
+ # state/markers/verify/ — /verify writes
29
+ # state/markers/temperance/ — /temperance writes
30
+ # state/markers/diagnose/ — /diagnose writes
31
+ # state/markers/compliance-history/ — gate-compliance counter writes
32
+ # .playwright-mcp/ — Playwright MCP transient (Class A)
33
+ # docs/deferred-actions/*-session-rescue.md — rescue files (Class C)
34
+ #
35
+ # Real work (src/, docs/ outside the rescue pattern, .claude/, etc.)
36
+ # still surfaces the banner. Non-rescue deferred-action files (e.g.,
37
+ # journal-backfill, wiki-refresh) still fire the banner.
38
+
39
+ # -uall expands untracked-directory summaries to individual file paths
40
+ # so the per-file allowlist match below can recognize each path.
41
+ # Without -uall, a new file inside a previously empty marker dir would
42
+ # appear as "?? state/" (dir summary), bypassing the allowlist filter.
43
+ DIRTY_TREE_RAW=$(git status --porcelain -uall 2>/dev/null)
44
+
45
+ # Strip the 3-char porcelain status prefix (XY + space) before matching
46
+ # path prefixes, then re-emit the original line so the user sees the
47
+ # git-status-shaped output if banner fires.
48
+ DIRTY_TREE=$(echo "$DIRTY_TREE_RAW" | awk '
49
+ {
50
+ path = substr($0, 4)
51
+ # Class 0 (bassclef#888): substrate runtime bookkeeping markers
52
+ if (path ~ /^state\/markers\/(session-timing|edit-count|verify|temperance|diagnose|compliance-history)\//) {
53
+ next
54
+ }
55
+ # Class A (bassclef-web#103): Playwright MCP transient exhaust
56
+ if (path ~ /^\.playwright-mcp\//) {
57
+ next
58
+ }
59
+ # Class C (bassclef-web#103): session-rescue files written by session-end.sh
60
+ if (path ~ /^docs\/deferred-actions\/.*-session-rescue\.md$/) {
61
+ next
62
+ }
63
+ print
64
+ }' | head -5)
65
+
66
+ if [ -n "$DIRTY_TREE" ]; then
67
+ echo "### ABRUPT STOP DETECTED — RECOVERY NEEDED"
68
+ echo ""
69
+ echo "Uncommitted changes found:"
70
+ echo "$DIRTY_TREE"
71
+ echo ""
72
+ echo "→ Ask the user: commit these, stash, or discard?"
73
+ echo ""
74
+ echo "Session-end protocol was likely skipped. Check:"
75
+ echo "- Are any issues stuck in 'In Progress' that should be Done?"
76
+ echo "- Was memory saved from the last session?"
77
+ echo ""
78
+ fi
@@ -0,0 +1,63 @@
1
+ # tier: lite
2
+ # SESSION_LOCK presence: surface to operator + future agents that
3
+ # auto-save is suppressed and another session may be active. Without
4
+ # this signal, the next session opens, sees "up-to-date," removes the
5
+ # lock thinking it's stale → auto-save resumes silently → atomic-PR
6
+ # bypass returns. Per bassclef#582 (Phase B WU-B4).
7
+ #
8
+ # Self-recognition (bassclef#1155, WU-1 of bet 2026-06-18a):
9
+ # Back-port the pgid match shipped in save-state.sh L123-130 and
10
+ # session-end.sh Guard 4. When the lock's pid is in the SAME process
11
+ # group as the current shell, the lock is ours — emit an informational
12
+ # "is ours" line instead of the (misleading) "another session" warning.
13
+
14
+ LOCK_FILE=".claude/SESSION_LOCK"
15
+
16
+ if [ -f "$LOCK_FILE" ]; then
17
+ LOCK_CONTENT=$(head -3 "$LOCK_FILE" 2>/dev/null)
18
+ LOCK_PID=$(grep -E '^pid:' "$LOCK_FILE" 2>/dev/null | head -1 | sed -E 's/^pid:[[:space:]]*//' | tr -d '[:space:]')
19
+
20
+ # Try pgid self-recognition first. If our pgid matches the lock pid's
21
+ # pgid, the lock was written by our session (Claude harness writes the
22
+ # lock; this hook runs as descendant in the same job/pgid).
23
+ if [ -n "$LOCK_PID" ] \
24
+ && LOCK_PGID=$(ps -o pgid= -p "$LOCK_PID" 2>/dev/null | tr -d ' ') \
25
+ && OUR_PGID=$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ') \
26
+ && [ -n "$LOCK_PGID" ] && [ -n "$OUR_PGID" ] \
27
+ && [ "$LOCK_PGID" = "$OUR_PGID" ]; then
28
+ echo "### SESSION_LOCK is ours (same pgid $OUR_PGID) — proceeding"
29
+ echo ""
30
+ else
31
+ # Foreign pid, dead pid, or malformed lock — surface the warning.
32
+ # Try to read mtime as the lock-create time.
33
+ if LOCK_MTIME=$(stat -c %Y "$LOCK_FILE" 2>/dev/null) || \
34
+ LOCK_MTIME=$(stat -f %m "$LOCK_FILE" 2>/dev/null); then
35
+ NOW=$(date -u +%s)
36
+ AGE_SEC=$(( NOW - LOCK_MTIME ))
37
+ AGE_MIN=$(( AGE_SEC / 60 ))
38
+ AGE_HOUR=$(( AGE_MIN / 60 ))
39
+ if [ "$AGE_HOUR" -gt 0 ]; then
40
+ AGE_LABEL="${AGE_HOUR}h"
41
+ elif [ "$AGE_MIN" -gt 0 ]; then
42
+ AGE_LABEL="${AGE_MIN}m"
43
+ else
44
+ AGE_LABEL="${AGE_SEC}s"
45
+ fi
46
+ else
47
+ AGE_LABEL="(age unknown)"
48
+ fi
49
+
50
+ echo "### SESSION_LOCK PRESENT — auto-save suppressed"
51
+ echo ""
52
+ echo " Path: $LOCK_FILE"
53
+ echo " Age: $AGE_LABEL"
54
+ if [ -n "$LOCK_CONTENT" ]; then
55
+ echo " Content:"
56
+ echo "$LOCK_CONTENT" | sed 's/^/ /'
57
+ fi
58
+ echo ""
59
+ echo " → Another session may be active. Confirm before removing the"
60
+ echo " lock; removing it resumes auto-save in this repo."
61
+ echo ""
62
+ fi
63
+ fi
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # 15-substrate-config-validate.sh
4
+ #
5
+ # bassclef#591 — substrate.config.md presence + basic-coherence check.
6
+ #
7
+ # Today: thin presence check + required-section presence verification.
8
+ # Full JSON Schema validation deferred per bassclef#643 (migration of
9
+ # substrate.config.md to YAML frontmatter; schema already exists at
10
+ # standards/state-spine/schemas/substrate-config.schema.json).
11
+ #
12
+ # Fires at session-start as part of session-reflection.d/* chain.
13
+ # Surfaces BLOCKED if substrate.config.md is missing OR missing key
14
+ # sections that downstream hooks/skills depend on.
15
+ #
16
+ # Override: SKIP_SUBSTRATE_CONFIG_VALIDATE=1
17
+ #
18
+ # Per bassclef#639 — operates ONLY on $CWD; no cross-repo execution.
19
+
20
+ set -u
21
+
22
+ if [ "${SKIP_SUBSTRATE_CONFIG_VALIDATE:-0}" = "1" ]; then
23
+ return 0 2>/dev/null || exit 0
24
+ fi
25
+
26
+ # Some sessions provide CWD via stdin JSON; fall back to $PWD if not.
27
+ CWD="${CWD:-$PWD}"
28
+ CONFIG_FILE="$CWD/substrate.config.md"
29
+
30
+ # Not all repos have substrate.config.md (fresh consumer pre-onboarding).
31
+ # Skip silently if absent; bassclef-sync's onboard path prompts when needed.
32
+ if [ ! -f "$CONFIG_FILE" ]; then
33
+ return 0 2>/dev/null || exit 0
34
+ fi
35
+
36
+ # Required sections (header style: `## SectionName`).
37
+ # Downstream hooks (session-reflection.sh + others) grep for these section
38
+ # fields; absence is observable behavior, not crash.
39
+ REQUIRED_SECTIONS="Configuration"
40
+ MISSING=""
41
+
42
+ for section in $REQUIRED_SECTIONS; do
43
+ if ! grep -q "^## $section" "$CONFIG_FILE" 2>/dev/null; then
44
+ MISSING="$MISSING $section"
45
+ fi
46
+ done
47
+
48
+ # Required fields by line-prefix (legacy markdown style — pre-YAML-frontmatter).
49
+ REQUIRED_FIELDS="gating execution_mode"
50
+ for field in $REQUIRED_FIELDS; do
51
+ if ! grep -q "^$field:" "$CONFIG_FILE" 2>/dev/null; then
52
+ MISSING="$MISSING $field:"
53
+ fi
54
+ done
55
+
56
+ if [ -n "$MISSING" ]; then
57
+ cat >&2 <<EOF
58
+
59
+ ================================================================
60
+ ⚠️ SUBSTRATE.CONFIG DRIFT — INFORM (Class B cure per #1157)
61
+ ================================================================
62
+
63
+ File: $CONFIG_FILE
64
+ Missing:$MISSING
65
+
66
+ substrate.config.md is the single source of truth for external
67
+ resource references (per standards/substrate-config-schema.md).
68
+ Missing sections/fields above are expected by downstream hooks.
69
+ Session continues — this WARN does not block SessionStart.
70
+
71
+ Resolution (do at your convenience):
72
+ - If field/section was intentionally removed, restore it (set to a
73
+ placeholder value if not yet decided)
74
+ - If file is being bootstrapped fresh, see presence/install/
75
+ for the substrate-config.md template
76
+
77
+ Override (logged): SKIP_SUBSTRATE_CONFIG_VALIDATE=1
78
+
79
+ Full JSON Schema validation (against
80
+ standards/state-spine/schemas/substrate-config.schema.json) is
81
+ deferred — substrate.config.md needs migration to YAML frontmatter
82
+ first; tracked at bassclef#643.
83
+
84
+ Closes bassclef#1157 (BLOCK→WARN severity ladder).
85
+ Prior contract: bassclef#591 (thin-validator half; full-schema half deferred).
86
+ ================================================================
87
+ EOF
88
+ return 0 2>/dev/null || exit 0
89
+ fi
90
+
91
+ return 0 2>/dev/null || exit 0