@thebassclef/lite 1.9.10 → 1.9.12

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
@@ -67,7 +67,7 @@ your browser.
67
67
 
68
68
  ## Current release
69
69
 
70
- <!-- version-start -->1.9.10<!-- version-end -->
70
+ <!-- version-start -->1.9.12<!-- version-end -->
71
71
 
72
72
  See [CHANGELOG.md](CHANGELOG.md) for the full changelog. Adopter-facing
73
73
  release notes live at [docs.bassclef.dev/docs/whats-new/releases](https://docs.bassclef.dev/docs/whats-new/releases).
package/dist/cli.cjs CHANGED
@@ -272,6 +272,47 @@ function hashContent(content) {
272
272
  normalized = normalized.replace(/\r\n/g, "\n");
273
273
  return node_crypto.createHash("sha256").update(normalized, "utf8").digest("hex");
274
274
  }
275
+ const SCHEMA_VERSION = "1.0.0";
276
+ const WRITER_SLUG = "bassclef-init-npm";
277
+ function registerInstallWrittenPaths(targetDir, paths, now = /* @__PURE__ */ new Date()) {
278
+ if (paths.length === 0) return;
279
+ const manifestPath = node_path.join(targetDir, "state", "install-written-paths.json");
280
+ let manifest = {
281
+ schema_version: SCHEMA_VERSION,
282
+ entries: []
283
+ };
284
+ if (node_fs.existsSync(manifestPath)) {
285
+ try {
286
+ const parsed = JSON.parse(node_fs.readFileSync(manifestPath, "utf8"));
287
+ if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
288
+ manifest = parsed;
289
+ manifest.schema_version = SCHEMA_VERSION;
290
+ }
291
+ } catch {
292
+ }
293
+ }
294
+ const writtenAt = now.toISOString().replace(/\.\d{3}Z$/, "Z");
295
+ const uniqueInputs = /* @__PURE__ */ new Map();
296
+ for (const p of paths) {
297
+ uniqueInputs.set(p.relativePath, p);
298
+ }
299
+ const incomingPaths = new Set(uniqueInputs.keys());
300
+ manifest.entries = manifest.entries.filter((e) => !incomingPaths.has(e.path));
301
+ for (const p of uniqueInputs.values()) {
302
+ manifest.entries.push({
303
+ path: p.relativePath,
304
+ writer: WRITER_SLUG,
305
+ written_at: writtenAt,
306
+ content_hash: `sha256:${p.contentHashSha256}`
307
+ });
308
+ }
309
+ node_fs.mkdirSync(node_path.dirname(manifestPath), { recursive: true });
310
+ node_fs.writeFileSync(
311
+ manifestPath,
312
+ JSON.stringify(manifest, null, 2) + "\n",
313
+ "utf8"
314
+ );
315
+ }
275
316
  const SUBSTRATE_CONFIG_TEMPLATE_VERSION = "0.0.1";
276
317
  function substrateConfigMdTemplate(pkgVersion) {
277
318
  return [
@@ -549,6 +590,7 @@ function copySubstrate(targetDir, options = {}) {
549
590
  copied: [],
550
591
  copiedEntries: [],
551
592
  refused: [],
593
+ unchanged: [],
552
594
  errored: [],
553
595
  erroredMessages: [],
554
596
  hookCount: 0,
@@ -736,6 +778,16 @@ function copyOne(relPath, bundleRoot, options, result, scopeDecision) {
736
778
  } catch (e) {
737
779
  if (e instanceof WriteError) {
738
780
  if (e.kind === "AlreadyExists") {
781
+ if (scope === "user") {
782
+ try {
783
+ const existing = node_fs.readFileSync(targetPath, "utf8");
784
+ if (hashContent(existing) === hashContent(outputContent)) {
785
+ result.unchanged.push(adopterRelPath);
786
+ return "unchanged";
787
+ }
788
+ } catch {
789
+ }
790
+ }
739
791
  result.refused.push(adopterRelPath);
740
792
  return "refused";
741
793
  }
@@ -1079,13 +1131,14 @@ function buildInitReport(input) {
1079
1131
  else project += 1;
1080
1132
  }
1081
1133
  const refused = input.refused.length;
1134
+ const unchanged = input.unchanged?.length ?? 0;
1082
1135
  const errored = input.errored.length;
1083
1136
  const written = input.configs.length + input.entries.length;
1084
1137
  return {
1085
1138
  schema_version: 3,
1086
1139
  tier: input.tier,
1087
1140
  totals: {
1088
- files: written + refused + errored,
1141
+ files: written + refused + unchanged + errored,
1089
1142
  written,
1090
1143
  project,
1091
1144
  user,
@@ -1098,6 +1151,7 @@ function buildInitReport(input) {
1098
1151
  files: catalog.hooks
1099
1152
  },
1100
1153
  refused,
1154
+ unchanged,
1101
1155
  errored,
1102
1156
  failed: refused + errored
1103
1157
  };
@@ -1203,6 +1257,7 @@ function runInit(argv) {
1203
1257
  entries: outcome.result?.wouldCopyEntries ?? [],
1204
1258
  configs: plans.map((pl) => ({ path: pl.relativePath })),
1205
1259
  refused: outcome.result?.refused ?? [],
1260
+ unchanged: outcome.result?.unchanged ?? [],
1206
1261
  // A dry run still reads every source file, so it can still fail
1207
1262
  // to read one. Hard-coding this empty hid those failures from
1208
1263
  // the JSON (RFC-0005 A-3).
@@ -1301,6 +1356,7 @@ function dispatchSubstrateCopy(targetDir, force, verbose, dryRun, allowRoot, say
1301
1356
  }
1302
1357
  const parts = [];
1303
1358
  if (result.copied.length > 0) parts.push(`${result.copied.length} substrate files copied`);
1359
+ if (result.unchanged.length > 0) parts.push(`${result.unchanged.length} already-present`);
1304
1360
  if (result.refused.length > 0) parts.push(`${result.refused.length} refused`);
1305
1361
  if (result.errored.length > 0) parts.push(`${result.errored.length} error(s)`);
1306
1362
  say(`bassclef init: ${parts.join(", ")}.
@@ -1531,12 +1587,25 @@ function runReal$1(plans, force, verbose, targetDir, allowRoot, json, skipStatus
1531
1587
  entries: walker.result?.copiedEntries ?? [],
1532
1588
  configs: results.map((r) => ({ path: r.plan.relativePath })),
1533
1589
  refused: walker.result?.refused ?? [],
1590
+ unchanged: walker.result?.unchanged ?? [],
1534
1591
  errored: walker.result?.errored ?? [],
1535
1592
  hookCount: walker.result?.hookCount ?? 0,
1536
1593
  declaredHooksCopied,
1537
1594
  tier: RESOLVED_TIER
1538
1595
  });
1539
1596
  writeManifest(targetDir, results, walker.result);
1597
+ const createdConfigPaths = results.filter((r) => r.outcome === "created").map((r) => ({
1598
+ relativePath: r.plan.relativePath,
1599
+ contentHashSha256: hashContent(r.plan.content)
1600
+ }));
1601
+ const createdBundlePaths = (walker.result?.copiedEntries ?? []).filter((e) => typeof e.content_hash_sha256 === "string").map((e) => ({
1602
+ relativePath: e.path,
1603
+ contentHashSha256: e.content_hash_sha256
1604
+ }));
1605
+ registerInstallWrittenPaths(targetDir, [
1606
+ ...createdConfigPaths,
1607
+ ...createdBundlePaths
1608
+ ]);
1540
1609
  if (walker.code === 0) {
1541
1610
  say(
1542
1611
  `bassclef init: your substrate lives under .claude/. Add .claude/ to .gitignore if you have not.
@@ -1606,6 +1675,15 @@ function writeManifest(targetDir, results, walkerResult) {
1606
1675
  source: "bundle"
1607
1676
  });
1608
1677
  }
1678
+ for (const path of walkerResult.unchanged) {
1679
+ entries.push({
1680
+ path,
1681
+ template: `bundle:${path}`,
1682
+ template_version: index.version,
1683
+ outcome: "unchanged",
1684
+ source: "bundle"
1685
+ });
1686
+ }
1609
1687
  for (const path of walkerResult.errored) {
1610
1688
  entries.push({
1611
1689
  path,
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { version } from "./index.js";
3
- import { realpathSync, statSync, constants, lstatSync, mkdirSync, accessSync, unlinkSync, openSync, writeSync, closeSync, readlinkSync, readFileSync, chmodSync, readdirSync, existsSync, writeFileSync, renameSync } from "node:fs";
3
+ import { realpathSync, statSync, constants, lstatSync, mkdirSync, accessSync, unlinkSync, openSync, writeSync, closeSync, readlinkSync, existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, renameSync } from "node:fs";
4
4
  import { isAbsolute, resolve, dirname, join, normalize, relative, basename as basename$1 } from "node:path";
5
5
  import { homedir } from "node:os";
6
6
  import { createHash } from "node:crypto";
@@ -249,6 +249,47 @@ function hashContent(content) {
249
249
  normalized = normalized.replace(/\r\n/g, "\n");
250
250
  return createHash("sha256").update(normalized, "utf8").digest("hex");
251
251
  }
252
+ const SCHEMA_VERSION = "1.0.0";
253
+ const WRITER_SLUG = "bassclef-init-npm";
254
+ function registerInstallWrittenPaths(targetDir, paths, now = /* @__PURE__ */ new Date()) {
255
+ if (paths.length === 0) return;
256
+ const manifestPath = join(targetDir, "state", "install-written-paths.json");
257
+ let manifest = {
258
+ schema_version: SCHEMA_VERSION,
259
+ entries: []
260
+ };
261
+ if (existsSync(manifestPath)) {
262
+ try {
263
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
264
+ if (parsed && typeof parsed === "object" && "entries" in parsed && Array.isArray(parsed.entries)) {
265
+ manifest = parsed;
266
+ manifest.schema_version = SCHEMA_VERSION;
267
+ }
268
+ } catch {
269
+ }
270
+ }
271
+ const writtenAt = now.toISOString().replace(/\.\d{3}Z$/, "Z");
272
+ const uniqueInputs = /* @__PURE__ */ new Map();
273
+ for (const p of paths) {
274
+ uniqueInputs.set(p.relativePath, p);
275
+ }
276
+ const incomingPaths = new Set(uniqueInputs.keys());
277
+ manifest.entries = manifest.entries.filter((e) => !incomingPaths.has(e.path));
278
+ for (const p of uniqueInputs.values()) {
279
+ manifest.entries.push({
280
+ path: p.relativePath,
281
+ writer: WRITER_SLUG,
282
+ written_at: writtenAt,
283
+ content_hash: `sha256:${p.contentHashSha256}`
284
+ });
285
+ }
286
+ mkdirSync(dirname(manifestPath), { recursive: true });
287
+ writeFileSync(
288
+ manifestPath,
289
+ JSON.stringify(manifest, null, 2) + "\n",
290
+ "utf8"
291
+ );
292
+ }
252
293
  const SUBSTRATE_CONFIG_TEMPLATE_VERSION = "0.0.1";
253
294
  function substrateConfigMdTemplate(pkgVersion) {
254
295
  return [
@@ -526,6 +567,7 @@ function copySubstrate(targetDir, options = {}) {
526
567
  copied: [],
527
568
  copiedEntries: [],
528
569
  refused: [],
570
+ unchanged: [],
529
571
  errored: [],
530
572
  erroredMessages: [],
531
573
  hookCount: 0,
@@ -713,6 +755,16 @@ function copyOne(relPath, bundleRoot, options, result, scopeDecision) {
713
755
  } catch (e) {
714
756
  if (e instanceof WriteError) {
715
757
  if (e.kind === "AlreadyExists") {
758
+ if (scope === "user") {
759
+ try {
760
+ const existing = readFileSync(targetPath, "utf8");
761
+ if (hashContent(existing) === hashContent(outputContent)) {
762
+ result.unchanged.push(adopterRelPath);
763
+ return "unchanged";
764
+ }
765
+ } catch {
766
+ }
767
+ }
716
768
  result.refused.push(adopterRelPath);
717
769
  return "refused";
718
770
  }
@@ -1056,13 +1108,14 @@ function buildInitReport(input) {
1056
1108
  else project += 1;
1057
1109
  }
1058
1110
  const refused = input.refused.length;
1111
+ const unchanged = input.unchanged?.length ?? 0;
1059
1112
  const errored = input.errored.length;
1060
1113
  const written = input.configs.length + input.entries.length;
1061
1114
  return {
1062
1115
  schema_version: 3,
1063
1116
  tier: input.tier,
1064
1117
  totals: {
1065
- files: written + refused + errored,
1118
+ files: written + refused + unchanged + errored,
1066
1119
  written,
1067
1120
  project,
1068
1121
  user,
@@ -1075,6 +1128,7 @@ function buildInitReport(input) {
1075
1128
  files: catalog.hooks
1076
1129
  },
1077
1130
  refused,
1131
+ unchanged,
1078
1132
  errored,
1079
1133
  failed: refused + errored
1080
1134
  };
@@ -1180,6 +1234,7 @@ function runInit(argv) {
1180
1234
  entries: outcome.result?.wouldCopyEntries ?? [],
1181
1235
  configs: plans.map((pl) => ({ path: pl.relativePath })),
1182
1236
  refused: outcome.result?.refused ?? [],
1237
+ unchanged: outcome.result?.unchanged ?? [],
1183
1238
  // A dry run still reads every source file, so it can still fail
1184
1239
  // to read one. Hard-coding this empty hid those failures from
1185
1240
  // the JSON (RFC-0005 A-3).
@@ -1278,6 +1333,7 @@ function dispatchSubstrateCopy(targetDir, force, verbose, dryRun, allowRoot, say
1278
1333
  }
1279
1334
  const parts = [];
1280
1335
  if (result.copied.length > 0) parts.push(`${result.copied.length} substrate files copied`);
1336
+ if (result.unchanged.length > 0) parts.push(`${result.unchanged.length} already-present`);
1281
1337
  if (result.refused.length > 0) parts.push(`${result.refused.length} refused`);
1282
1338
  if (result.errored.length > 0) parts.push(`${result.errored.length} error(s)`);
1283
1339
  say(`bassclef init: ${parts.join(", ")}.
@@ -1508,12 +1564,25 @@ function runReal$1(plans, force, verbose, targetDir, allowRoot, json, skipStatus
1508
1564
  entries: walker.result?.copiedEntries ?? [],
1509
1565
  configs: results.map((r) => ({ path: r.plan.relativePath })),
1510
1566
  refused: walker.result?.refused ?? [],
1567
+ unchanged: walker.result?.unchanged ?? [],
1511
1568
  errored: walker.result?.errored ?? [],
1512
1569
  hookCount: walker.result?.hookCount ?? 0,
1513
1570
  declaredHooksCopied,
1514
1571
  tier: RESOLVED_TIER
1515
1572
  });
1516
1573
  writeManifest(targetDir, results, walker.result);
1574
+ const createdConfigPaths = results.filter((r) => r.outcome === "created").map((r) => ({
1575
+ relativePath: r.plan.relativePath,
1576
+ contentHashSha256: hashContent(r.plan.content)
1577
+ }));
1578
+ const createdBundlePaths = (walker.result?.copiedEntries ?? []).filter((e) => typeof e.content_hash_sha256 === "string").map((e) => ({
1579
+ relativePath: e.path,
1580
+ contentHashSha256: e.content_hash_sha256
1581
+ }));
1582
+ registerInstallWrittenPaths(targetDir, [
1583
+ ...createdConfigPaths,
1584
+ ...createdBundlePaths
1585
+ ]);
1517
1586
  if (walker.code === 0) {
1518
1587
  say(
1519
1588
  `bassclef init: your substrate lives under .claude/. Add .claude/ to .gitignore if you have not.
@@ -1583,6 +1652,15 @@ function writeManifest(targetDir, results, walkerResult) {
1583
1652
  source: "bundle"
1584
1653
  });
1585
1654
  }
1655
+ for (const path of walkerResult.unchanged) {
1656
+ entries.push({
1657
+ path,
1658
+ template: `bundle:${path}`,
1659
+ template_version: version,
1660
+ outcome: "unchanged",
1661
+ source: "bundle"
1662
+ });
1663
+ }
1586
1664
  for (const path of walkerResult.errored) {
1587
1665
  entries.push({
1588
1666
  path,
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.9.10";
3
+ const version = "1.9.12";
4
4
  exports.version = version;
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const version: "1.9.10";
1
+ export declare const version: "1.9.12";
2
2
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- const version = "1.9.10";
1
+ const version = "1.9.12";
2
2
  export {
3
3
  version
4
4
  };
@@ -143,9 +143,15 @@ if [ "$HAS_SOURCES_READ" -eq 1 ]; then
143
143
  exit
144
144
  }
145
145
  ')
146
- if [ -z "$PARENT_VALUE" ]; then
147
- continue
148
- fi
146
+ # cli#311 cure — treat null / ~ / empty as "no parent declared" (Peirce three-case).
147
+ # YAML null shorthand is `null`, `~`, or an empty value. Prior code caught empty
148
+ # only; `parent_bet: null` tripped the gate on root goals that genuinely have no
149
+ # parent. Mirrors the #70 cure in bet-doc-gate.sh.
150
+ case "$PARENT_VALUE" in
151
+ "" | "null" | "Null" | "NULL" | "~" | "None")
152
+ continue
153
+ ;;
154
+ esac
149
155
 
150
156
  # Check if the parent path (or its basename without extension) appears
151
157
  # in the Sources-read section. Accepts either:
@@ -209,7 +209,12 @@ DIRTY=$(git status --porcelain 2>/dev/null | grep -v '\.DS_Store' | grep -v 'SES
209
209
  if [ -n "$DIRTY" ]; then
210
210
  # Stage tracked modified files + any docs/ or chronicle/ or runtime state
211
211
  git add -u 2>/dev/null || true
212
- git add docs/ chronicle/ specs/ 2>/dev/null || true
212
+ # docs/ except docs/prototypes/ — prototypes are review-gated mocks per
213
+ # .claude/rules/prototype-workflow.md; auto-save must not commit them
214
+ # before the operator confirms the rendered output (cli#323).
215
+ # Same pathspec pattern used for state/markers below.
216
+ git add 'docs/' ':!docs/prototypes' ':!docs/prototypes/**' 2>/dev/null || true
217
+ git add chronicle/ specs/ 2>/dev/null || true
213
218
  # state/ except state/markers/ — markers are runtime gate evidence that
214
219
  # trigger session-rescue false-positives if committed (bassclef#341 / WU-A2).
215
220
  # Negative pathspec syntax requires git >= 2.13 (stable since 2017).
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # install-class: dual
4
+ # Session-reflection fragment — surface last-block marker count.
5
+ #
6
+ # Per @luminary don-norman signifier: when BLOCK banners from prior
7
+ # sessions sit in state/markers/last-block/, this fragment tells the
8
+ # adopter they exist and names /last-block as the retrieval path.
9
+ #
10
+ # Fires once at session-start. Reads marker count. Prints one line to
11
+ # stdout when count > 0. Silent on empty.
12
+ #
13
+ # Composition — sourced by session-reflection.sh under set -e. Must use
14
+ # `return`, not `exit`, else the coordinator dies. Per test-sufficiency
15
+ # criterion 12 (invocation-mode variation).
16
+ #
17
+ # Per bassclef-upstream#2036 Finding #10 + UC-skill-last-block.md.
18
+
19
+ # Resolve directory (same convention as lib/last-block.sh).
20
+ _last_block_count_dir() {
21
+ if [ -n "${LAST_BLOCK_DIR:-}" ]; then
22
+ echo "$LAST_BLOCK_DIR"
23
+ else
24
+ echo "${CLAUDE_PROJECT_DIR:-.}/state/markers/last-block"
25
+ fi
26
+ }
27
+
28
+ LBC_DIR="$(_last_block_count_dir)"
29
+
30
+ if [ ! -d "$LBC_DIR" ]; then
31
+ # Fresh install or dir never created. Silent.
32
+ # shellcheck disable=SC2317 # dual-use sourced-or-standalone exit path
33
+ return 0 2>/dev/null || exit 0
34
+ fi
35
+
36
+ LBC_COUNT=$(find "$LBC_DIR" -maxdepth 1 -name '*.marker' -type f 2>/dev/null | wc -l | tr -d ' ')
37
+
38
+ if [ "$LBC_COUNT" -gt 0 ]; then
39
+ if [ "$LBC_COUNT" = "1" ]; then
40
+ echo "### LAST BLOCK"
41
+ echo ""
42
+ echo "1 BLOCK banner stored. Read it with /last-block."
43
+ echo ""
44
+ else
45
+ echo "### LAST BLOCKS"
46
+ echo ""
47
+ echo "${LBC_COUNT} BLOCK banners stored. Read the newest with /last-block, or list all with /last-block --list."
48
+ echo ""
49
+ fi
50
+ fi
51
+
52
+ # shellcheck disable=SC2317 # dual-use sourced-or-standalone exit path
53
+ return 0 2>/dev/null || exit 0
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # fixtures: bassclef-upstream-self
4
+ # shellcheck disable=SC2034
5
+ # Tier 0 test for .claude/hooks/session-reflection.d/97-last-block-count.sh
6
+ #
7
+ # Covers invocation-mode variation (criterion 12 of test-sufficiency rubric):
8
+ # both standalone and sourced-under-set-e paths.
9
+ #
10
+ # Ticket: bassclef-upstream#2036 Finding #10
11
+ #
12
+ # test-list:
13
+ # [x] T01: standalone + no dir -> silent, exit 0, no return error
14
+ # [x] T02: standalone + empty dir -> silent, exit 0
15
+ # [x] T03: standalone + 1 marker -> prints "1 BLOCK banner stored"
16
+ # [x] T04: standalone + 3 markers -> prints "3 BLOCK banners stored"
17
+ # [x] T05: sourced under set -e -> parent survives (no exit)
18
+ # [x] T06: honors LAST_BLOCK_DIR env override
19
+
20
+ set -euo pipefail
21
+
22
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23
+ FRAG_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
24
+ FRAG="$FRAG_DIR/97-last-block-count.sh"
25
+
26
+ if [ ! -f "$FRAG" ]; then
27
+ echo "RED: $FRAG does not exist yet" >&2
28
+ exit 1
29
+ fi
30
+
31
+ FIXTURE_ROOT="$(mktemp -d)"
32
+ RESULTS_FILE="$FIXTURE_ROOT/results.log"
33
+ touch "$RESULTS_FILE"
34
+ trap 'rm -rf "$FIXTURE_ROOT"' EXIT
35
+
36
+ pass() { echo "PASS" >> "$RESULTS_FILE"; echo "PASS: $1"; }
37
+ fail() { echo "FAIL" >> "$RESULTS_FILE"; echo "FAIL: $1"; }
38
+
39
+ # T01: standalone + no dir
40
+ ( LAST_BLOCK_DIR="$FIXTURE_ROOT/nonexistent"
41
+ export LAST_BLOCK_DIR
42
+ OUT=$(bash "$FRAG" 2>&1)
43
+ RC=$?
44
+ if [ "$RC" = "0" ] && [ -z "$OUT" ]; then
45
+ pass "T01: standalone + no dir -> silent"
46
+ else
47
+ fail "T01: standalone + no dir -> silent (rc=$RC out=$OUT)"
48
+ fi
49
+ )
50
+
51
+ # T02: standalone + empty dir
52
+ ( LAST_BLOCK_DIR="$(mktemp -d)"
53
+ export LAST_BLOCK_DIR
54
+ OUT=$(bash "$FRAG" 2>&1)
55
+ RC=$?
56
+ if [ "$RC" = "0" ] && [ -z "$OUT" ]; then
57
+ pass "T02: standalone + empty dir -> silent"
58
+ else
59
+ fail "T02: standalone + empty dir -> silent (rc=$RC out=$OUT)"
60
+ fi
61
+ )
62
+
63
+ # T03: standalone + 1 marker
64
+ ( DIR="$(mktemp -d)"
65
+ LAST_BLOCK_DIR="$DIR"
66
+ export LAST_BLOCK_DIR
67
+ echo "hook: a" > "$DIR/a.marker"
68
+ OUT=$(bash "$FRAG" 2>&1)
69
+ if echo "$OUT" | grep -qF "1 BLOCK banner stored"; then
70
+ pass "T03: 1 marker -> prints singular"
71
+ else
72
+ fail "T03: 1 marker (got: $OUT)"
73
+ fi
74
+ )
75
+
76
+ # T04: standalone + 3 markers
77
+ ( DIR="$(mktemp -d)"
78
+ LAST_BLOCK_DIR="$DIR"
79
+ export LAST_BLOCK_DIR
80
+ echo "hook: a" > "$DIR/a.marker"
81
+ echo "hook: b" > "$DIR/b.marker"
82
+ echo "hook: c" > "$DIR/c.marker"
83
+ OUT=$(bash "$FRAG" 2>&1)
84
+ if echo "$OUT" | grep -qF "3 BLOCK banners stored"; then
85
+ pass "T04: 3 markers -> prints plural with count"
86
+ else
87
+ fail "T04: 3 markers (got: $OUT)"
88
+ fi
89
+ )
90
+
91
+ # T05: sourced under set -e -> parent survives (criterion 12)
92
+ ( DIR="$(mktemp -d)"
93
+ LAST_BLOCK_DIR="$DIR"
94
+ export LAST_BLOCK_DIR
95
+ echo "hook: a" > "$DIR/a.marker"
96
+ # Simulate coordinator: set -e wrapper + source
97
+ CANARY_FILE="$FIXTURE_ROOT/canary-t05"
98
+ (
99
+ set -e
100
+ # shellcheck source=/dev/null
101
+ source "$FRAG"
102
+ # If frag used `exit` instead of `return`, this line never runs.
103
+ touch "$CANARY_FILE"
104
+ ) >/dev/null 2>&1 || true
105
+ if [ -f "$CANARY_FILE" ]; then
106
+ pass "T05: sourced under set -e survives (canary ran)"
107
+ else
108
+ fail "T05: sourced under set -e survives (canary missing)"
109
+ fi
110
+ )
111
+
112
+ # T06: honors LAST_BLOCK_DIR env override
113
+ ( DIR="$(mktemp -d)"
114
+ LAST_BLOCK_DIR="$DIR"
115
+ export LAST_BLOCK_DIR
116
+ echo "hook: a" > "$DIR/a.marker"
117
+ OUT=$(bash "$FRAG" 2>&1)
118
+ if echo "$OUT" | grep -qF "1 BLOCK banner stored"; then
119
+ pass "T06: honors LAST_BLOCK_DIR override"
120
+ else
121
+ fail "T06: honors LAST_BLOCK_DIR override (got: $OUT)"
122
+ fi
123
+ )
124
+
125
+ echo
126
+ echo "=== Results ==="
127
+ PASS=$(awk '/^PASS$/ {c++} END {print c+0}' "$RESULTS_FILE")
128
+ FAIL=$(awk '/^FAIL$/ {c++} END {print c+0}' "$RESULTS_FILE")
129
+ echo "PASS: $PASS"
130
+ echo "FAIL: $FAIL"
131
+ if [ "$FAIL" -gt 0 ]; then exit 1; fi
@@ -74,6 +74,14 @@ else
74
74
  fi
75
75
  export BASSCLEF_DIR
76
76
 
77
+ # Source lib/last-block.sh so banner calls persist markers. Per Finding
78
+ # #10 — adopters need a first-class way to retrieve BLOCK text they saw.
79
+ # The lib fails soft; missing lib leaves the banners untouched.
80
+ # shellcheck source=/dev/null
81
+ if [ -f "${BASSCLEF_DIR}/lib/last-block.sh" ]; then
82
+ source "${BASSCLEF_DIR}/lib/last-block.sh"
83
+ fi
84
+
77
85
  # High-contrast BLOCKED banner; used by every module that raises a gap.
78
86
  # RFC-0008 F3 — "Silence is not deferral" is a commitment device. It works
79
87
  # because it forecloses the easy out, and it is consumable: spend it on a
@@ -81,9 +89,13 @@ export BASSCLEF_DIR
81
89
  # Callers that can prove their own state is benign pass a second argument
82
90
  # and get a softer close. Callers that pass one argument behave exactly as
83
91
  # before, so the other 27 sites are untouched.
92
+ #
93
+ # Per Finding #10 — each call now also writes a marker so the adopter can
94
+ # retrieve the exact banner text later via /last-block.
84
95
  blocked_banner() {
85
96
  local msg="$1"
86
97
  local severity="${2:-blocking}"
98
+ local hook_name="${BASSCLEF_CALLING_HOOK:-session-reflection}"
87
99
  echo ""
88
100
  case "$severity" in
89
101
  info)
@@ -106,9 +118,14 @@ blocked_banner() {
106
118
  echo "────────────────────────────────────────────"
107
119
  echo "ACTION: resolve OR explicitly defer (per .claude/rules/blocked-items.md)."
108
120
  echo "Silence is not deferral. Propose this as item #1 in your session plan."
121
+ echo "(Retrieve this banner later with: /last-block ${hook_name})"
109
122
  ;;
110
123
  esac
111
124
  echo ""
125
+ # Persist marker for /last-block retrieval. Fail-soft.
126
+ if type -t record_last_block >/dev/null 2>&1; then
127
+ record_last_block "$hook_name" "$severity" "$msg" 2>/dev/null || true
128
+ fi
112
129
  }
113
130
 
114
131
  # Vendor-swap constant — the AI in the harness running bassclef. Adopter
@@ -131,7 +148,12 @@ guardrail_banner() {
131
148
  echo "────────────────────────────────────────────"
132
149
  echo "bassclef is steering $AI_NAME to $step — $retry"
133
150
  echo "────────────────────────────────────────────"
151
+ echo "(Retrieve this banner later with: /last-block ${hook_name})"
134
152
  echo ""
153
+ # Persist marker for /last-block retrieval. Fail-soft.
154
+ if type -t record_last_block >/dev/null 2>&1; then
155
+ record_last_block "$hook_name" "guardrail" "$step — $retry" 2>/dev/null || true
156
+ fi
135
157
  }
136
158
 
137
159
  # RFC-0008 F6 — this block is steering aimed at the agent, but the adopter