@thebassclef/lite 1.9.16 → 1.9.18

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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/lite/.claude/agents/builder.md +7 -4
  6. package/dist/lite/.claude/bassclef-orientation.md +22 -0
  7. package/dist/lite/.claude/hooks/artifact-ingestion-gate.sh +11 -3
  8. package/dist/lite/.claude/hooks/assert-verify-steering.sh +1 -19
  9. package/dist/lite/.claude/hooks/plain-english-steering.sh +86 -41
  10. package/dist/lite/.claude/hooks/pre-commit-gate.sh +10 -2
  11. package/dist/lite/.claude/hooks/pre-push-shellcheck.sh +144 -0
  12. package/dist/lite/.claude/hooks/reviewer-dispatch-check.sh +153 -0
  13. package/dist/lite/.claude/hooks/session-reflection.d/83-install-written-paths-rebuild.sh +15 -32
  14. package/dist/lite/.claude/hooks/turn-prose-density-measure.sh +109 -0
  15. package/dist/lite/.claude/hooks/turn-prose-kiss-check.sh +28 -0
  16. package/dist/lite/.claude/rules/branching.md +2 -0
  17. package/dist/lite/.claude/rules/identifier-leak-prevention.md +2 -1
  18. package/dist/lite/.claude/rules/lite-manifest-schema-change-discipline.md +23 -0
  19. package/dist/lite/.claude/rules/reviewer-dispatch.md +95 -0
  20. package/dist/lite/.claude/settings.json +10 -0
  21. package/dist/lite/.claude/skills/build/SKILL.md +81 -6
  22. package/dist/lite/.claude/skills/eisenhower/SKILL.md +26 -0
  23. package/dist/lite/.claude/skills/launch/SKILL.md +45 -2
  24. package/dist/lite/.claude/skills/onboard-repo/SKILL.md +204 -40
  25. package/dist/lite/.claude/skills/onboard-repo/concise-prompt.sh +73 -0
  26. package/dist/lite/.claude/skills/onboard-repo/write-state-marker.sh +81 -0
  27. package/dist/lite/.claude/skills/pre-mortem/SKILL.md +56 -0
  28. package/dist/lite/.claude/skills/sprint/SKILL.md +24 -3
  29. package/dist/lite/.claude/skills/temperance/SKILL.md +15 -4
  30. package/dist/lite/README.md +2 -0
  31. package/dist/lite/bassclef-version.json +5 -5
  32. package/dist/lite/lib/install-written-paths.sh +250 -3
  33. package/dist/lite/lib/prose-marker.sh +203 -0
  34. package/dist/lite/lib/tier-dependency-audit.sh +52 -25
  35. package/dist/lite/presence/install/bassclef-sync.template.sh +86 -8
  36. package/dist/lite/scripts/generate-lite-manifest.sh +15 -0
  37. package/dist/lite/scripts/launch/local-serve.sh +149 -8
  38. package/dist/lite/standards/bassclef-wiring-manifest.json +12 -0
  39. package/dist/lite/standards/lite-manifest-schema-changes.md +49 -0
  40. package/dist/lite/standards/lite-manifest.json +90 -27
  41. package/dist/lite/standards/prose-marker-format.md +105 -0
  42. package/package.json +1 -1
@@ -112,6 +112,100 @@ _iwp_manifest_valid() {
112
112
  return 0
113
113
  }
114
114
 
115
+ # ---- Ajv-runtime schema validator (bassclef-upstream#2157 step 6) ----
116
+ #
117
+ # Wraps the install-written-paths manifest in ajv-based schema validation.
118
+ # Called from install_written_paths_verify (read-side gate) + from
119
+ # install_written_paths_register (post-write gate).
120
+ #
121
+ # Reads via is_install_written_path stay cheap (no ajv — hot path).
122
+ # Writes are rare, so post-write ajv cost is acceptable.
123
+ #
124
+ # Fail-soft on ajv missing (adopter machine without ajv-cli): returns 0.
125
+ # Mirrors the step 2 validator pattern at scripts/validate-cross-repo-contracts.sh:139-144.
126
+ # This keeps lite-tier adopters working when ajv is not installed; the step 3
127
+ # CI gate catches drift at every PR regardless of adopter ajv presence.
128
+ #
129
+ # @pattern patterns/code/ousterhout/deep-module.md — one wrapper, many callers
130
+ # @pattern patterns/code/vernon/anticorruption-layer.md — ajv at the write boundary
131
+
132
+ _iwp_schema_path() {
133
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
134
+ echo "$root/standards/state-spine/schemas/install-written-paths.schema.json"
135
+ }
136
+
137
+ # _iwp_ajv_validate <manifest-path>
138
+ # Returns 0 when ajv absent (fail-soft) OR when ajv confirms the manifest
139
+ # conforms to standards/state-spine/schemas/install-written-paths.schema.json.
140
+ # Returns 1 when ajv is present AND the manifest fails schema validation.
141
+ _iwp_ajv_validate() {
142
+ local manifest_file="${1:-}"
143
+ [ -n "$manifest_file" ] || return 1
144
+ [ -f "$manifest_file" ] || return 1
145
+
146
+ if ! command -v ajv >/dev/null 2>&1; then
147
+ _iwp_log "ajv-validate skip reason=ajv-missing manifest=$manifest_file"
148
+ return 0
149
+ fi
150
+
151
+ local schema
152
+ schema=$(_iwp_schema_path)
153
+ if [ ! -f "$schema" ]; then
154
+ _iwp_log "ajv-validate skip reason=schema-missing path=$schema"
155
+ return 0
156
+ fi
157
+
158
+ if ajv validate -s "$schema" -d "$manifest_file" --spec=draft2020 -c ajv-formats >/dev/null 2>&1; then
159
+ _iwp_log "ajv-validate pass manifest=$manifest_file"
160
+ return 0
161
+ fi
162
+
163
+ _iwp_log "ajv-validate fail manifest=$manifest_file"
164
+ return 1
165
+ }
166
+
167
+ # ---- CLI init.manifest.json fallback (bassclef-upstream#2138) ----
168
+ #
169
+ # bassclef-cli's `bassclef init` writes `.bassclef/init.manifest.json`
170
+ # with its own schema (per bassclef-cli/src/lib/manifest-types.ts):
171
+ # { schema_version: 2|3, entries: [{path, template, outcome, ...}] }
172
+ #
173
+ # When upstream's state/install-written-paths.json doesn't have an
174
+ # entry for a path, fall back to this CLI manifest. Treat outcome in
175
+ # {created, updated, unchanged} as install-written. Treat refused |
176
+ # error as NOT install-written (the write didn't actually land, so
177
+ # discipline should still fire).
178
+ #
179
+ # No hash verification against the CLI manifest. The CLI schema uses
180
+ # `content_hash_sha256` without the `sha256:` prefix; shape-mismatch
181
+ # is tolerated because the fallback is a presence + outcome check,
182
+ # not an integrity check. The upstream manifest keeps strict hash
183
+ # verification per Hoare.
184
+ _iwp_init_manifest_path() {
185
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
186
+ echo "$root/.bassclef/init.manifest.json"
187
+ }
188
+
189
+ # Returns 0 when the path is present in the CLI init manifest with
190
+ # outcome in {created, updated, unchanged}. Returns 1 otherwise
191
+ # (missing file, malformed JSON, path absent, or outcome refused/error).
192
+ _iwp_init_manifest_has_path() {
193
+ local tgt="${1:-}"
194
+ [ -n "$tgt" ] || return 1
195
+ local init_manifest
196
+ init_manifest=$(_iwp_init_manifest_path)
197
+ [ -f "$init_manifest" ] || return 1
198
+ command -v jq >/dev/null 2>&1 || return 1
199
+ # Guard against malformed JSON: jq -e . exits non-zero on parse failure.
200
+ jq -e . "$init_manifest" >/dev/null 2>&1 || return 1
201
+ local outcome
202
+ outcome=$(jq -r --arg path "$tgt" '.entries[]? | select(.path == $path) | .outcome' "$init_manifest" 2>/dev/null)
203
+ case "$outcome" in
204
+ created|updated|unchanged) return 0 ;;
205
+ *) return 1 ;;
206
+ esac
207
+ }
208
+
115
209
  # ---- Public API ----
116
210
 
117
211
  # install_written_paths_register <path> <writer>
@@ -182,7 +276,19 @@ install_written_paths_register() {
182
276
  fi
183
277
 
184
278
  rm -f "$tmp" 2>/dev/null || true
185
- _iwp_log "register path=$tgt writer=$writer"
279
+
280
+ # Post-write ajv check (step 6 — bassclef-upstream#2157). On fail, surface
281
+ # a warning but keep the write — fail-soft so adopters never lose data
282
+ # over schema drift. The step 2 validator + step 3 CI gate + step 5 release
283
+ # gate catch drift at later layers; this layer is advisory at write-time.
284
+ if ! _iwp_ajv_validate "$mpath"; then
285
+ echo "install-written-paths: WARNING — manifest at $mpath fails ajv schema validation after register" >&2
286
+ echo "install-written-paths: entry kept; run 'install_written_paths_verify' for details" >&2
287
+ _iwp_log "register path=$tgt writer=$writer post-write-ajv=fail"
288
+ else
289
+ _iwp_log "register path=$tgt writer=$writer post-write-ajv=pass"
290
+ fi
291
+
186
292
  return 0
187
293
  }
188
294
 
@@ -204,10 +310,17 @@ is_install_written_path() {
204
310
  local mpath
205
311
  mpath=$(_iwp_manifest_path)
206
312
 
207
- # Fail-soft: manifest missing or malformed → not install-written
313
+ # Fail-soft: upstream manifest missing or malformed. Before giving
314
+ # up, consult the CLI init manifest (bassclef-upstream#2138). Cold
315
+ # adopters that ran only `bassclef init` have no upstream manifest
316
+ # yet; the CLI manifest is their only install-written source.
208
317
  if ! _iwp_manifest_valid; then
209
- _iwp_log "check path=$tgt result=false reason=no-manifest-or-malformed"
210
318
  [ -f "$mpath" ] && echo "install-written-paths: manifest malformed; treating as not-install-written" >&2
319
+ if _iwp_init_manifest_has_path "$tgt"; then
320
+ _iwp_log "check path=$tgt result=true reason=cli-init-manifest-fallback-no-upstream-manifest"
321
+ return 0
322
+ fi
323
+ _iwp_log "check path=$tgt result=false reason=no-manifest-or-malformed"
211
324
  return 1
212
325
  fi
213
326
 
@@ -220,6 +333,14 @@ is_install_written_path() {
220
333
  local recorded_hash
221
334
  recorded_hash=$(jq -r --arg path "$tgt" '.entries[] | select(.path == $path) | .content_hash' "$mpath" 2>/dev/null)
222
335
  if [ -z "$recorded_hash" ] || [ "$recorded_hash" = "null" ]; then
336
+ # Fallback per bassclef-upstream#2138 — the CLI's `bassclef init`
337
+ # writes `.bassclef/init.manifest.json` with its own shape. Lite
338
+ # adopters hit the lead-lens 3-marker gate because the two manifests
339
+ # never agreed. Consult the CLI manifest before giving up.
340
+ if _iwp_init_manifest_has_path "$tgt"; then
341
+ _iwp_log "check path=$tgt result=true reason=cli-init-manifest-fallback"
342
+ return 0
343
+ fi
223
344
  _iwp_log "check path=$tgt result=false reason=not-in-manifest"
224
345
  return 1
225
346
  fi
@@ -335,6 +456,125 @@ install_written_paths_rebuild() {
335
456
  return 0
336
457
  }
337
458
 
459
+ # install_written_paths_rebuild_from_lite_manifest <lite-manifest-path>
460
+ #
461
+ # Bulk-rebuild cure per bassclef-upstream#2200.
462
+ #
463
+ # Replaces the fragment's 451 serial `install_written_paths_register` loop
464
+ # with one atomic rebuild. Reads lite-manifest.json, computes SHA256 per
465
+ # path that exists on disk, writes the full entries array in one jq pass
466
+ # to a tmp file, runs ajv once, then atomically moves tmp over manifest.
467
+ #
468
+ # Deep module per @luminary john-ousterhout — one verb hides the whole
469
+ # rebuild path. Caller stays dumb. Register's per-path semantics stay
470
+ # untouched for ad-hoc callers (hook-connect, migrations) that need
471
+ # per-call ajv (PR #2174 intent preserved).
472
+ #
473
+ # Postcondition (per @luminary tony-hoare):
474
+ # On return 0: manifest at _iwp_manifest_path() carries one entry per
475
+ # lite-manifest.json path that passes _iwp_validate_path AND exists on
476
+ # disk; writer=bassclef-sync-template; content_hash=sha256:<hex>;
477
+ # written_at=current UTC timestamp. Schema-valid per ajv when ajv present.
478
+ # On return 1: lite-manifest missing OR malformed; existing manifest
479
+ # untouched (atomic mv never ran).
480
+ #
481
+ # Returns:
482
+ # 0 success (manifest written)
483
+ # 1 lite-manifest missing | malformed | jq missing
484
+ install_written_paths_rebuild_from_lite_manifest() {
485
+ local lite_manifest="${1:-}"
486
+
487
+ if [ -z "$lite_manifest" ] || [ ! -f "$lite_manifest" ]; then
488
+ _iwp_log "bulk-rebuild fail reason=lite-manifest-missing path=$lite_manifest"
489
+ return 1
490
+ fi
491
+
492
+ if ! command -v jq >/dev/null 2>&1; then
493
+ echo "install-written-paths: jq not available; cannot bulk-rebuild" >&2
494
+ return 1
495
+ fi
496
+
497
+ # Precondition: lite-manifest.json is valid JSON
498
+ if ! jq -e . "$lite_manifest" >/dev/null 2>&1; then
499
+ _iwp_log "bulk-rebuild fail reason=lite-manifest-malformed path=$lite_manifest"
500
+ return 1
501
+ fi
502
+
503
+ local mpath
504
+ mpath=$(_iwp_manifest_path)
505
+ mkdir -p "$(dirname "$mpath")"
506
+
507
+ local root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
508
+ local now
509
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
510
+
511
+ # Build entries array in a tmp jq-input file: one JSON object per line.
512
+ # Each line is a {path, writer, written_at, content_hash} record.
513
+ # Skip entries that fail path validation OR have no file on disk.
514
+ local jq_input
515
+ jq_input=$(mktemp)
516
+ # shellcheck disable=SC2064
517
+ trap "rm -f \"$jq_input\"" RETURN
518
+
519
+ local registered=0
520
+ local skipped=0
521
+ while IFS= read -r rel_path; do
522
+ [ -z "$rel_path" ] && continue
523
+ if ! _iwp_validate_path "$rel_path"; then
524
+ skipped=$((skipped + 1))
525
+ continue
526
+ fi
527
+ local full_path
528
+ case "$rel_path" in
529
+ /*) full_path="$rel_path" ;;
530
+ *) full_path="$root/$rel_path" ;;
531
+ esac
532
+ if [ ! -f "$full_path" ]; then
533
+ skipped=$((skipped + 1))
534
+ continue
535
+ fi
536
+ local hash
537
+ hash=$(_iwp_sha256 "$full_path")
538
+ if [ -z "$hash" ]; then
539
+ skipped=$((skipped + 1))
540
+ continue
541
+ fi
542
+ jq -cn --arg p "$rel_path" --arg w "bassclef-sync-template" --arg ts "$now" --arg h "$hash" \
543
+ '{path:$p, writer:$w, written_at:$ts, content_hash:$h}' >> "$jq_input"
544
+ registered=$((registered + 1))
545
+ done < <(jq -r '.entries[]?.path // empty' "$lite_manifest")
546
+
547
+ # Build the manifest from the per-line records using jq --slurp.
548
+ # Tmp filename MUST end in .json so ajv's auto-detect reads it as JSON
549
+ # instead of trying to require() it as a module (macOS mktemp without
550
+ # --suffix produces extensionless files that fail ajv's parse).
551
+ local tmp
552
+ tmp="$mpath.rebuild.$$.json"
553
+ if ! jq -s --arg sv "$_IWP_SCHEMA_VERSION" \
554
+ '{schema_version:$sv, entries:.}' "$jq_input" > "$tmp"; then
555
+ rm -f "$tmp" 2>/dev/null || true
556
+ _iwp_log "bulk-rebuild fail reason=jq-slurp-failed registered=$registered"
557
+ return 1
558
+ fi
559
+
560
+ # Post-write ajv validate; on fail, keep old manifest (atomic safety per Nygard)
561
+ if ! _iwp_ajv_validate "$tmp"; then
562
+ echo "install-written-paths: WARNING — bulk-rebuild output fails ajv; keeping prior manifest" >&2
563
+ rm -f "$tmp" 2>/dev/null || true
564
+ _iwp_log "bulk-rebuild fail reason=ajv-validate registered=$registered"
565
+ return 1
566
+ fi
567
+
568
+ if ! mv "$tmp" "$mpath"; then
569
+ rm -f "$tmp" 2>/dev/null || true
570
+ _iwp_log "bulk-rebuild fail reason=mv-failed registered=$registered"
571
+ return 1
572
+ fi
573
+
574
+ _iwp_log "bulk-rebuild ok registered=$registered skipped=$skipped mpath=$mpath"
575
+ return 0
576
+ }
577
+
338
578
  # install_written_paths_verify
339
579
  # Returns 0 on well-formed manifest, 1 on schema violation.
340
580
  install_written_paths_verify() {
@@ -365,5 +605,12 @@ install_written_paths_verify() {
365
605
  return 1
366
606
  fi
367
607
 
608
+ # Ajv-runtime schema check (step 6 — bassclef-upstream#2157).
609
+ # Fail-soft on ajv missing (returns 0); strict reject on schema failure.
610
+ if ! _iwp_ajv_validate "$mpath"; then
611
+ echo "install-written-paths: manifest fails ajv schema validation" >&2
612
+ return 1
613
+ fi
614
+
368
615
  return 0
369
616
  }
@@ -0,0 +1,203 @@
1
+ #!/bin/bash
2
+ # tier: lite
3
+ #
4
+ # lib/prose-marker.sh — shared marker write + read helpers for the bassclef
5
+ # prose scorer stack. Pins the marker format from
6
+ # standards/prose-marker-format.md v1.0.
7
+ #
8
+ # Producers (Stop hooks that score prose dimensions):
9
+ # - turn-prose-density-measure.sh (Step 2b of Longrun 2)
10
+ # - turn-prose-kiss-check.sh (Step 2c extension)
11
+ # - turn-prose-grade-measure.sh (future retrofit; writes raw today)
12
+ #
13
+ # Consumer (UserPromptSubmit hook that reads markers + injects numbers):
14
+ # - plain-english-steering.sh (Step 2d extension)
15
+ #
16
+ # Per @luminary john-ousterhout — deep module hides JSON shape + atomic
17
+ # write + expiry logic behind a narrow two-function interface.
18
+ # Per @luminary tony-hoare — pre/postcondition contract per
19
+ # standards/prose-marker-format.md §Hoare.
20
+ # Per @luminary michael-nygard — fail-soft on write permission denied.
21
+
22
+ set +e
23
+
24
+ # === BASSCLEF_STATE_ROOT resolution ===
25
+ # When tests isolate state via BASSCLEF_STATE_ROOT env var, honor it.
26
+ # Otherwise resolve to the repo root (walk up from this file's location).
27
+ _prose_marker_resolve_state_root() {
28
+ if [ -n "${BASSCLEF_STATE_ROOT:-}" ]; then
29
+ echo "$BASSCLEF_STATE_ROOT"
30
+ return
31
+ fi
32
+ local script_dir
33
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
34
+ echo "$(cd "$script_dir/.." && pwd)"
35
+ }
36
+
37
+ # === write_prose_marker <scorer-name> <value> [notes] ===
38
+ # Preconditions:
39
+ # - scorer-name is non-empty and matches [a-z0-9-]+
40
+ # - value is non-empty
41
+ # Postconditions:
42
+ # - file at state/markers/<scorer-name>/last-turn.json exists
43
+ # - JSON matches standards/prose-marker-format.md shape
44
+ # - atomic write (temp file + mv)
45
+ # Returns: 0 on success; 1 on invalid input; 2 on write failure (fail-soft).
46
+ write_prose_marker() {
47
+ local scorer="$1"
48
+ local value="$2"
49
+ local notes="${3:-}"
50
+
51
+ # Validate scorer-name
52
+ if [ -z "$scorer" ]; then
53
+ return 1
54
+ fi
55
+ if ! echo "$scorer" | grep -qE '^[a-z0-9-]+$'; then
56
+ return 1
57
+ fi
58
+
59
+ # Validate value
60
+ if [ -z "$value" ]; then
61
+ return 1
62
+ fi
63
+
64
+ local state_root
65
+ state_root="$(_prose_marker_resolve_state_root)"
66
+ local marker_dir="${state_root}/state/markers/${scorer}"
67
+ local marker_path="${marker_dir}/last-turn.json"
68
+
69
+ if ! mkdir -p "$marker_dir" 2>/dev/null; then
70
+ return 2
71
+ fi
72
+
73
+ local timestamp
74
+ timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
75
+
76
+ local tmp
77
+ tmp="$(mktemp "${marker_dir}/last-turn.XXXXXX.tmp" 2>/dev/null)" || return 2
78
+
79
+ # Build JSON. Use jq when available for proper escaping; fall back to raw.
80
+ if command -v jq >/dev/null 2>&1; then
81
+ if [ -n "$notes" ]; then
82
+ jq -n \
83
+ --arg metric "$scorer" \
84
+ --argjson value "$value" \
85
+ --arg measured_at "$timestamp" \
86
+ --arg source "lib/prose-marker.sh" \
87
+ --arg notes "$notes" \
88
+ '{metric: $metric, value: $value, measured_at: $measured_at, source: $source, notes: $notes}' \
89
+ > "$tmp" 2>/dev/null
90
+ else
91
+ jq -n \
92
+ --arg metric "$scorer" \
93
+ --argjson value "$value" \
94
+ --arg measured_at "$timestamp" \
95
+ --arg source "lib/prose-marker.sh" \
96
+ '{metric: $metric, value: $value, measured_at: $measured_at, source: $source}' \
97
+ > "$tmp" 2>/dev/null
98
+ fi
99
+ local rc=$?
100
+ if [ "$rc" -ne 0 ]; then
101
+ rm -f "$tmp" 2>/dev/null
102
+ return 2
103
+ fi
104
+ else
105
+ # Fallback: raw JSON. Scorer + notes get basic escaping (no quotes in input).
106
+ if [ -n "$notes" ]; then
107
+ printf '{"metric":"%s","value":%s,"measured_at":"%s","source":"lib/prose-marker.sh","notes":"%s"}\n' \
108
+ "$scorer" "$value" "$timestamp" "$notes" > "$tmp" 2>/dev/null
109
+ else
110
+ printf '{"metric":"%s","value":%s,"measured_at":"%s","source":"lib/prose-marker.sh"}\n' \
111
+ "$scorer" "$value" "$timestamp" > "$tmp" 2>/dev/null
112
+ fi
113
+ fi
114
+
115
+ # Atomic rename
116
+ if ! mv -f "$tmp" "$marker_path" 2>/dev/null; then
117
+ rm -f "$tmp" 2>/dev/null
118
+ return 2
119
+ fi
120
+ return 0
121
+ }
122
+
123
+ # === read_prose_markers [--max-age-minutes N] <scorer-name> [<scorer-name> ...] ===
124
+ # Preconditions:
125
+ # - zero or more scorer-name arguments
126
+ # - optional --max-age-minutes N (default 10)
127
+ # Postconditions:
128
+ # - stdout emits one JSON object mapping scorer-name → marker object OR null
129
+ # - null when: marker missing, older than max-age, or malformed JSON
130
+ # - fail-soft: never crashes on bad input
131
+ # Returns: 0 always (fail-soft).
132
+ read_prose_markers() {
133
+ local max_age_min=10
134
+ local scorers=()
135
+
136
+ while [ "$#" -gt 0 ]; do
137
+ case "$1" in
138
+ --max-age-minutes)
139
+ shift
140
+ max_age_min="${1:-10}"
141
+ shift
142
+ ;;
143
+ *)
144
+ scorers+=("$1")
145
+ shift
146
+ ;;
147
+ esac
148
+ done
149
+
150
+ local state_root
151
+ state_root="$(_prose_marker_resolve_state_root)"
152
+
153
+ # Build JSON object. If jq missing, emit raw.
154
+ if command -v jq >/dev/null 2>&1; then
155
+ # Start with empty object
156
+ local result='{}'
157
+ local scorer
158
+ for scorer in "${scorers[@]}"; do
159
+ local marker_path="${state_root}/state/markers/${scorer}/last-turn.json"
160
+ local marker_json="null"
161
+ if [ -f "$marker_path" ]; then
162
+ # Age check
163
+ local file_age_sec
164
+ if stat -f %m "$marker_path" >/dev/null 2>&1; then
165
+ # BSD stat (macOS)
166
+ file_age_sec=$(( $(date +%s) - $(stat -f %m "$marker_path") ))
167
+ else
168
+ # GNU stat (Linux)
169
+ file_age_sec=$(( $(date +%s) - $(stat -c %Y "$marker_path") ))
170
+ fi
171
+ local max_age_sec=$(( max_age_min * 60 ))
172
+ if [ "$file_age_sec" -le "$max_age_sec" ]; then
173
+ # Parse marker — null on malformed JSON
174
+ marker_json=$(jq -c '.' "$marker_path" 2>/dev/null)
175
+ if [ -z "$marker_json" ]; then
176
+ marker_json="null"
177
+ fi
178
+ fi
179
+ fi
180
+ result=$(echo "$result" | jq --arg k "$scorer" --argjson v "$marker_json" '. + {($k): $v}' 2>/dev/null)
181
+ done
182
+ echo "$result"
183
+ else
184
+ # Fallback without jq — emit a flat null map
185
+ printf '{'
186
+ local first=1
187
+ local scorer
188
+ for scorer in "${scorers[@]}"; do
189
+ if [ "$first" -eq 0 ]; then
190
+ printf ','
191
+ fi
192
+ printf '"%s":null' "$scorer"
193
+ first=0
194
+ done
195
+ printf '}\n'
196
+ fi
197
+ return 0
198
+ }
199
+
200
+ # Export functions when sourced
201
+ export -f write_prose_marker 2>/dev/null
202
+ export -f read_prose_markers 2>/dev/null
203
+ export -f _prose_marker_resolve_state_root 2>/dev/null
@@ -814,37 +814,64 @@ build_reverse_index() {
814
814
  walk_graph_bfs_inbound() {
815
815
  local seeds_file="$1"
816
816
  local rev_index="$2"
817
- local visited_file="" queue_file=""
818
- visited_file="$(mktemp)"
819
- queue_file="$(mktemp)"
820
- # shellcheck disable=SC2064
821
- trap "rm -f '$visited_file' '$queue_file'" RETURN
822
817
 
823
818
  if [ ! -s "$seeds_file" ]; then
824
- : > "$visited_file"
825
- sort -u "$visited_file"
826
819
  return 0
827
820
  fi
828
- cp "$seeds_file" "$queue_file"
829
821
 
830
- local max_iter=1000 iter=0
831
- while [ -s "$queue_file" ] && [ "$iter" -lt "$max_iter" ]; do
832
- iter=$((iter + 1))
833
- local next_file; next_file="$(mktemp)"
834
- while IFS= read -r p; do
835
- [ -z "$p" ] && continue
836
- if grep -Fxq "$p" "$visited_file" 2>/dev/null; then continue; fi
837
- echo "$p" >> "$visited_file"
838
- # Look up callers of $p in the reverse index (path is column 1; caller is column 2)
839
- awk -F'\t' -v p="$p" '$1 == p { print $2 }' "$rev_index" | while IFS= read -r caller; do
840
- [ -n "$caller" ] && echo "$caller" >> "$next_file"
841
- done
842
- done < "$queue_file"
843
- mv "$next_file" "$queue_file"
844
- sort -u -o "$queue_file" "$queue_file"
845
- done
822
+ if [ ! -s "$rev_index" ]; then
823
+ # No edges — output just the seeds, deduped
824
+ sort -u "$seeds_file"
825
+ return 0
826
+ fi
846
827
 
847
- sort -u "$visited_file"
828
+ # Single-pass awk: build reverse-index hash during Phase 1 (reading
829
+ # rev_index), then run BFS entirely in-memory during Phase 2 (reading
830
+ # seeds_file via END block). Visited set prevents re-visiting; queue
831
+ # grows as new callers are found.
832
+ #
833
+ # Complexity: O(N + M) where N = seeds + M = edges in rev_index.
834
+ # Prior per-item awk was O(N × M) — unusable at 1.1MB index (ticket
835
+ # bassclef-upstream#2109 observed 15+ min hang during /release).
836
+ #
837
+ # Per @luminary john-ousterhout — deep module: one awk hides BFS.
838
+ # Per @luminary michael-feathers — behavior pinned by Tier 0 tests
839
+ # at lib/tests/tier-dependency-audit-inbound.test.sh.
840
+ awk -F'\t' '
841
+ # Phase 1: load reverse index into callers map
842
+ # Each line: <path>\t<caller>; multiple callers per path concatenated
843
+ # with SUBSEP as the separator (safe since SUBSEP is non-printable).
844
+ NR == FNR {
845
+ if (NF >= 2 && $1 != "") {
846
+ callers[$1] = (callers[$1] == "" ? $2 : callers[$1] SUBSEP $2)
847
+ }
848
+ next
849
+ }
850
+ # Phase 2: enqueue seeds (file 2)
851
+ {
852
+ if ($0 != "" && !($0 in visited)) {
853
+ queue[++qlen] = $0
854
+ visited[$0] = 1
855
+ }
856
+ }
857
+ # Phase 3: BFS, draining the queue
858
+ END {
859
+ head = 1
860
+ while (head <= qlen) {
861
+ p = queue[head++]
862
+ if (p in callers) {
863
+ n = split(callers[p], c, SUBSEP)
864
+ for (i = 1; i <= n; i++) {
865
+ if (c[i] != "" && !(c[i] in visited)) {
866
+ visited[c[i]] = 1
867
+ queue[++qlen] = c[i]
868
+ }
869
+ }
870
+ }
871
+ }
872
+ for (p in visited) print p
873
+ }
874
+ ' "$rev_index" "$seeds_file" | sort -u
848
875
  return 0
849
876
  }
850
877