@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.
- package/README.md +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lite/.claude/agents/builder.md +7 -4
- package/dist/lite/.claude/bassclef-orientation.md +22 -0
- package/dist/lite/.claude/hooks/artifact-ingestion-gate.sh +11 -3
- package/dist/lite/.claude/hooks/assert-verify-steering.sh +1 -19
- package/dist/lite/.claude/hooks/plain-english-steering.sh +86 -41
- package/dist/lite/.claude/hooks/pre-commit-gate.sh +10 -2
- package/dist/lite/.claude/hooks/pre-push-shellcheck.sh +144 -0
- package/dist/lite/.claude/hooks/reviewer-dispatch-check.sh +153 -0
- package/dist/lite/.claude/hooks/session-reflection.d/83-install-written-paths-rebuild.sh +15 -32
- package/dist/lite/.claude/hooks/turn-prose-density-measure.sh +109 -0
- package/dist/lite/.claude/hooks/turn-prose-kiss-check.sh +28 -0
- package/dist/lite/.claude/rules/branching.md +2 -0
- package/dist/lite/.claude/rules/identifier-leak-prevention.md +2 -1
- package/dist/lite/.claude/rules/lite-manifest-schema-change-discipline.md +23 -0
- package/dist/lite/.claude/rules/reviewer-dispatch.md +95 -0
- package/dist/lite/.claude/settings.json +10 -0
- package/dist/lite/.claude/skills/build/SKILL.md +81 -6
- package/dist/lite/.claude/skills/eisenhower/SKILL.md +26 -0
- package/dist/lite/.claude/skills/launch/SKILL.md +45 -2
- package/dist/lite/.claude/skills/onboard-repo/SKILL.md +204 -40
- package/dist/lite/.claude/skills/onboard-repo/concise-prompt.sh +73 -0
- package/dist/lite/.claude/skills/onboard-repo/write-state-marker.sh +81 -0
- package/dist/lite/.claude/skills/pre-mortem/SKILL.md +56 -0
- package/dist/lite/.claude/skills/sprint/SKILL.md +24 -3
- package/dist/lite/.claude/skills/temperance/SKILL.md +15 -4
- package/dist/lite/README.md +2 -0
- package/dist/lite/bassclef-version.json +5 -5
- package/dist/lite/lib/install-written-paths.sh +250 -3
- package/dist/lite/lib/prose-marker.sh +203 -0
- package/dist/lite/lib/tier-dependency-audit.sh +52 -25
- package/dist/lite/presence/install/bassclef-sync.template.sh +86 -8
- package/dist/lite/scripts/generate-lite-manifest.sh +15 -0
- package/dist/lite/scripts/launch/local-serve.sh +149 -8
- package/dist/lite/standards/bassclef-wiring-manifest.json +12 -0
- package/dist/lite/standards/lite-manifest-schema-changes.md +49 -0
- package/dist/lite/standards/lite-manifest.json +90 -27
- package/dist/lite/standards/prose-marker-format.md +105 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
-
|
|
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
|
|