@thebassclef/lite 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (182) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +85 -4
  3. package/dist/cli.cjs +1610 -0
  4. package/dist/cli.js +1587 -0
  5. package/dist/index.cjs +4 -0
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +4 -0
  8. package/package.json +51 -3
  9. package/substrate/.bassclef/lite-manifest.json +1794 -0
  10. package/substrate/.claude/agents/architect.md +70 -0
  11. package/substrate/.claude/agents/builder.md +114 -0
  12. package/substrate/.claude/agents/designer.md +156 -0
  13. package/substrate/.claude/agents/reviewer.md +88 -0
  14. package/substrate/.claude/hooks/artifact-ingestion-gate.sh +357 -0
  15. package/substrate/.claude/hooks/assert-verify-steering.sh +77 -0
  16. package/substrate/.claude/hooks/bassclef-source-config-validate.sh +215 -0
  17. package/substrate/.claude/hooks/bassclef-sync.sh +634 -0
  18. package/substrate/.claude/hooks/compound-noun-scrub.sh +292 -0
  19. package/substrate/.claude/hooks/kiss-expansion-inject.sh +69 -0
  20. package/substrate/.claude/hooks/plain-english-steering.sh +156 -0
  21. package/substrate/.claude/hooks/post-skill-friction-check.sh +177 -0
  22. package/substrate/.claude/hooks/post-skill-telemetry.sh +62 -0
  23. package/substrate/.claude/hooks/pre-build-gate.sh +511 -0
  24. package/substrate/.claude/hooks/pre-commit-gate.sh +452 -0
  25. package/substrate/.claude/hooks/session-end.sh +433 -0
  26. package/substrate/.claude/hooks/session-reflection.sh +303 -0
  27. package/substrate/.claude/hooks/skill-body-grade-gate.sh +219 -0
  28. package/substrate/.claude/hooks/skill-body-intent-drift.sh +107 -0
  29. package/substrate/.claude/hooks/skill-step-list-check.sh +171 -0
  30. package/substrate/.claude/hooks/state-validate.sh +271 -0
  31. package/substrate/.claude/hooks/substrate-clarity-gate.sh +1110 -0
  32. package/substrate/.claude/hooks/temperance-gate.sh +147 -0
  33. package/substrate/.claude/hooks/testing-tier-enforce.sh +233 -0
  34. package/substrate/.claude/hooks/turn-prose-grade-measure.sh +219 -0
  35. package/substrate/.claude/hooks/turn-prose-kiss-check.sh +434 -0
  36. package/substrate/.claude/hooks/vocabulary-migration-check.sh +171 -0
  37. package/substrate/.claude/hooks/whereami-utc-gate.sh +142 -0
  38. package/substrate/.claude/luminaries/alan-cooper.md +170 -0
  39. package/substrate/.claude/luminaries/alistair-cockburn.md +140 -0
  40. package/substrate/.claude/luminaries/amazon-pr-faq.md +34 -0
  41. package/substrate/.claude/luminaries/ash-maurya.md +121 -0
  42. package/substrate/.claude/luminaries/bill-buxton.md +210 -0
  43. package/substrate/.claude/luminaries/charles-sanders-peirce.md +150 -0
  44. package/substrate/.claude/luminaries/david-ogilvy.md +192 -0
  45. package/substrate/.claude/luminaries/don-norman.md +173 -0
  46. package/substrate/.claude/luminaries/edward-tufte.md +179 -0
  47. package/substrate/.claude/luminaries/eric-evans.md +160 -0
  48. package/substrate/.claude/luminaries/frederick-brooks.md +50 -0
  49. package/substrate/.claude/luminaries/gang-of-four.md +157 -0
  50. package/substrate/.claude/luminaries/jason-fried-dhh.md +46 -0
  51. package/substrate/.claude/luminaries/jesse-james-garrett.md +154 -0
  52. package/substrate/.claude/luminaries/john-ousterhout.md +94 -0
  53. package/substrate/.claude/luminaries/karl-popper.md +132 -0
  54. package/substrate/.claude/luminaries/kent-beck.md +168 -0
  55. package/substrate/.claude/luminaries/linus-torvalds.md +218 -0
  56. package/substrate/.claude/luminaries/martin-fowler.md +164 -0
  57. package/substrate/.claude/luminaries/michael-feathers.md +125 -0
  58. package/substrate/.claude/luminaries/michael-nygard.md +118 -0
  59. package/substrate/.claude/luminaries/robert-c-martin.md +164 -0
  60. package/substrate/.claude/luminaries/saltzer-schroeder.md +194 -0
  61. package/substrate/.claude/luminaries/sophia-prater.md +193 -0
  62. package/substrate/.claude/luminaries/stephen-toulmin.md +131 -0
  63. package/substrate/.claude/rules/artifact-ingestion.md +179 -0
  64. package/substrate/.claude/rules/assert-only-after-verify.md +137 -0
  65. package/substrate/.claude/rules/blocked-items.md +146 -0
  66. package/substrate/.claude/rules/branching.md +28 -0
  67. package/substrate/.claude/rules/commit-conventions.md +22 -0
  68. package/substrate/.claude/rules/context-engineering.md +202 -0
  69. package/substrate/.claude/rules/context-management.md +85 -0
  70. package/substrate/.claude/rules/destructive-operations.md +69 -0
  71. package/substrate/.claude/rules/diagnosis.md +38 -0
  72. package/substrate/.claude/rules/guardrails.md +73 -0
  73. package/substrate/.claude/rules/identifier-leak-prevention.md +104 -0
  74. package/substrate/.claude/rules/lite-manifest-schema-change-discipline.md +98 -0
  75. package/substrate/.claude/rules/oo-ad-entry-point.md +117 -0
  76. package/substrate/.claude/rules/operator-facing-prose.md +196 -0
  77. package/substrate/.claude/rules/plain-english-discipline.md +154 -0
  78. package/substrate/.claude/rules/pr-body-shape.md +317 -0
  79. package/substrate/.claude/rules/pr-strategy.md +167 -0
  80. package/substrate/.claude/rules/sdlc-gates.md +149 -0
  81. package/substrate/.claude/rules/session-artifacts.md +236 -0
  82. package/substrate/.claude/rules/state-schema-validation.md +162 -0
  83. package/substrate/.claude/rules/whereami-load-bearing.md +202 -0
  84. package/substrate/.claude/rules/writing-craft-discipline.md +92 -0
  85. package/substrate/.claude/skills/build/SKILL.md +640 -0
  86. package/substrate/.claude/skills/decompose/SKILL.md +280 -0
  87. package/substrate/.claude/skills/diagnose/SKILL.md +297 -0
  88. package/substrate/.claude/skills/howdoi/SKILL.md +105 -0
  89. package/substrate/.claude/skills/ia-model/SKILL.md +108 -0
  90. package/substrate/.claude/skills/interaction-design/SKILL.md +112 -0
  91. package/substrate/.claude/skills/interpret-input/SKILL.md +180 -0
  92. package/substrate/.claude/skills/kiss/SKILL.md +449 -0
  93. package/substrate/.claude/skills/launch/SKILL.md +924 -0
  94. package/substrate/.claude/skills/lean-canvas/SKILL.md +332 -0
  95. package/substrate/.claude/skills/longrun/SKILL.md +426 -0
  96. package/substrate/.claude/skills/luminary/SKILL.md +481 -0
  97. package/substrate/.claude/skills/ogilvy-writing-audit/SKILL.md +177 -0
  98. package/substrate/.claude/skills/onboard-repo/SKILL.md +2190 -0
  99. package/substrate/.claude/skills/pattern-review/SKILL.md +99 -0
  100. package/substrate/.claude/skills/personas/SKILL.md +207 -0
  101. package/substrate/.claude/skills/promote/SKILL.md +283 -0
  102. package/substrate/.claude/skills/requirement/SKILL.md +98 -0
  103. package/substrate/.claude/skills/retro/SKILL.md +117 -0
  104. package/substrate/.claude/skills/riff/SKILL.md +114 -0
  105. package/substrate/.claude/skills/roadmap-reconcile/SKILL.md +163 -0
  106. package/substrate/.claude/skills/session-end/SKILL.md +309 -0
  107. package/substrate/.claude/skills/session-log/SKILL.md +299 -0
  108. package/substrate/.claude/skills/skills/SKILL.md +228 -0
  109. package/substrate/.claude/skills/spec/SKILL.md +105 -0
  110. package/substrate/.claude/skills/sprint/SKILL.md +392 -0
  111. package/substrate/.claude/skills/stage/SKILL.md +384 -0
  112. package/substrate/.claude/skills/state-a-problem/SKILL.md +185 -0
  113. package/substrate/.claude/skills/temperance/SKILL.md +108 -0
  114. package/substrate/.claude/skills/use-case/SKILL.md +417 -0
  115. package/substrate/.claude/skills/user-stories/SKILL.md +268 -0
  116. package/substrate/.claude/skills/value-prop/SKILL.md +251 -0
  117. package/substrate/.claude/skills/verify/SKILL.md +160 -0
  118. package/substrate/.claude/skills/whereami/SKILL.md +307 -0
  119. package/substrate/AGENTS.md +79 -0
  120. package/substrate/CLAUDE-lite.md +85 -0
  121. package/substrate/CODE_OF_CONDUCT.md +28 -0
  122. package/substrate/CONTRIBUTING.md +177 -0
  123. package/substrate/README.md +173 -0
  124. package/substrate/SECURITY.md +19 -0
  125. package/substrate/architecture/decisions/ADR-029-release-pipeline.md +79 -0
  126. package/substrate/architecture/decisions/ADR-031-non-breaking-changes-adopter-discipline.md +139 -0
  127. package/substrate/architecture/decisions/ADR-032-adopter-sync-dispatcher-architecture.md +192 -0
  128. package/substrate/architecture/decisions/ADR-039-release-tagging-scheme.md +145 -0
  129. package/substrate/architecture/decisions/ADR-040-planning-skill-vocabulary-and-lite-profile.md +155 -0
  130. package/substrate/architecture/decisions/ADR-044-unified-skill-body-template.md +162 -0
  131. package/substrate/lib/clean-artifacts-sweep.sh +112 -0
  132. package/substrate/lib/code-comment-discipline.sh +144 -0
  133. package/substrate/lib/composer-preflight.sh +459 -0
  134. package/substrate/lib/hook-inject.sh +255 -0
  135. package/substrate/lib/luminary-pick.sh +96 -0
  136. package/substrate/lib/output-discipline.sh +143 -0
  137. package/substrate/lib/rewrite-check.sh +214 -0
  138. package/substrate/lib/state.sh +1372 -0
  139. package/substrate/lib/telemetry.sh +205 -0
  140. package/substrate/lib/tier-check.sh +138 -0
  141. package/substrate/lib/tier-dependency-audit.sh +933 -0
  142. package/substrate/presence/install/bassclef-hook-connect.sh +178 -0
  143. package/substrate/presence/install/bassclef-sync.dispatcher.template.sh +841 -0
  144. package/substrate/presence/install/bassclef-sync.template.sh +2076 -0
  145. package/substrate/presence/install/schedule-auto-save.cron.sh +88 -0
  146. package/substrate/presence/install/schedule-auto-save.taskscheduler.md +122 -0
  147. package/substrate/scripts/aggregate-telemetry.sh +217 -0
  148. package/substrate/scripts/analyze-tier-dependencies.sh +239 -0
  149. package/substrate/scripts/generate-lite-manifest.sh +489 -0
  150. package/substrate/scripts/generate-tier-manifest.sh +28 -0
  151. package/substrate/scripts/intent-drift-check.sh +456 -0
  152. package/substrate/scripts/lite-manifest-drift-check.sh +146 -0
  153. package/substrate/scripts/render-lite-manifest-doc.sh +150 -0
  154. package/substrate/standards/bassclef-configs-schema.md +232 -0
  155. package/substrate/standards/bassclef-evolution.md +143 -0
  156. package/substrate/standards/bassclef-internal-jargon.md +244 -0
  157. package/substrate/standards/bassclef-managed-sentinel.md +96 -0
  158. package/substrate/standards/bassclef-source-config.md +228 -0
  159. package/substrate/standards/deferred-actions-schema.md +204 -0
  160. package/substrate/standards/docs-sync-allowlist.md +76 -0
  161. package/substrate/standards/graceful-exit.md +227 -0
  162. package/substrate/standards/hook-idempotency.md +102 -0
  163. package/substrate/standards/lite-manifest-schema-changes.md +75 -0
  164. package/substrate/standards/luminary-problem-patterns.md +481 -0
  165. package/substrate/standards/ogilvy-writing-rules.md +225 -0
  166. package/substrate/standards/opener-discipline.md +96 -0
  167. package/substrate/standards/operator-facing-prose-discipline.md +201 -0
  168. package/substrate/standards/project-directory-layout.md +276 -0
  169. package/substrate/standards/release-tagging.md +137 -0
  170. package/substrate/standards/sdlc-compliance.md +286 -0
  171. package/substrate/standards/secrets-lifecycle.md +210 -0
  172. package/substrate/standards/security-scanner-adapter.md +145 -0
  173. package/substrate/standards/skill-output-discipline.md +90 -0
  174. package/substrate/standards/state-spine.md +511 -0
  175. package/substrate/standards/tech-stack-config.md +109 -0
  176. package/substrate/standards/tier-dependency-analysis.md +167 -0
  177. package/substrate/standards/tier-runtime-deps/lite.md +57 -0
  178. package/substrate/standards/tier-tag-schema.md +155 -0
  179. package/substrate/standards/whereami-schema.md +301 -0
  180. package/substrate/standards/writing-guide.md +213 -0
  181. package/substrate/templates/deferred-action-template.md +45 -0
  182. package/substrate/templates/whereami-template.md +101 -0
@@ -0,0 +1,1372 @@
1
+ #!/usr/bin/env bash
2
+ # tier: lite
3
+ # lib/state.sh — typed accessor library over bassclef's state spine.
4
+ #
5
+ # Repository pattern (Fowler PoEAA): hides storage backend (JSON-on-git
6
+ # for v0; cxdb for opt-in adopters per bassclef#300) from consumers.
7
+ # Adapter discipline preserves the swap surface — see exit criteria in
8
+ # standards/state-spine-contract.md §"Adapter discipline".
9
+ #
10
+ # Source via: source /path/to/lib/state.sh
11
+ #
12
+ # Naming convention: state_<entity>_<verb>[_<modifier>]
13
+ # Return semantics per state-spine-contract.md §"Return semantics":
14
+ # exit 0 = success, JSON/scalar to stdout
15
+ # exit 1 = not found (empty stdout)
16
+ # exit 2 = invalid input (stderr message)
17
+ # exit 3 = schema validation failed (stderr ajv error)
18
+ # exit 4 = filesystem error (stderr message)
19
+ # exit 5 = unsupported entity / verb (stderr message)
20
+ #
21
+ # Replay-determinism rule: counters/state derived from history on every
22
+ # call; never cache derived values as side state. Same input history →
23
+ # same output. (Per canvas v4 SWF mining.)
24
+ #
25
+ # Write-once-immutability rule (Helland): Markers, Chronicles, Sessions
26
+ # write-once. Updates produce NEW records via supersession; no in-place
27
+ # mutation. Forbidden verbs documented in contract.
28
+
29
+ # === Internal helpers ===
30
+
31
+ # __state_check_binary_deps — fail loud + early when required binaries
32
+ # are missing from $PATH. Per bassclef#598: previously, accessor calls
33
+ # emitted cryptic "command not found: awk / python3" errors with no
34
+ # resolution hint. Now they emit a structured error pointing at the
35
+ # install command.
36
+ #
37
+ # Idempotent: caches result in __STATE_DEPS_CHECKED so we don't grep
38
+ # PATH on every accessor call.
39
+ __state_check_binary_deps() {
40
+ if [ "${__STATE_DEPS_CHECKED:-}" = "1" ]; then
41
+ return 0
42
+ fi
43
+ local missing=""
44
+ for bin in awk python3 jq; do
45
+ command -v "$bin" >/dev/null 2>&1 || missing="$missing $bin"
46
+ done
47
+ if [ -n "$missing" ]; then
48
+ cat >&2 <<EOF
49
+ STATE ACCESSOR FAILURE
50
+ Required binary missing from PATH:$missing
51
+
52
+ These binaries are load-bearing for lib/state.sh accessors (Shape B
53
+ YAML frontmatter extraction + JSON tooling).
54
+
55
+ Resolution:
56
+ macOS: brew install gawk python3 jq
57
+ Debian: sudo apt-get install gawk python3 jq
58
+
59
+ Closes the bassclef#598 silent-failure class.
60
+ EOF
61
+ return 4
62
+ fi
63
+ export __STATE_DEPS_CHECKED=1
64
+ return 0
65
+ }
66
+
67
+ # yq2json — extract YAML frontmatter from markdown file, emit JSON.
68
+ # Used by Shape B (markdown + YAML frontmatter + body) entity accessors.
69
+ #
70
+ # Hoare postcondition (per #1497 silent-empty class cure):
71
+ # stdout is a valid JSON object with at least one field, OR
72
+ # exit code names the reason (1 file missing / 3 parse error /
73
+ # 4 deps missing / 5 empty parse).
74
+ #
75
+ # Exit 5 fires when parse succeeds but the result is empty — either no
76
+ # `---` fences in the file OR fences present with empty body between.
77
+ # The `or {}` coerce that used to mask this class is now explicit in the
78
+ # postcondition check.
79
+ #
80
+ # Adopter opt-in: STATE_ALLOW_EMPTY_PARSE=1 skips the postcondition,
81
+ # returns `{}` exit 0 (legacy behavior). Use during migration window.
82
+ #
83
+ # Anchor: @luminary tony-hoare (postcondition contract) +
84
+ # @luminary john-ousterhout (deep module — fix at helper covers all
85
+ # 3 accessors + migration script) + @luminary michael-nygard (fail-fast
86
+ # on invalid input) + @luminary michael-feathers (characterization
87
+ # tests RED-first via fixtures/2026-09-06-silent-empty/).
88
+ __state_yq2json() {
89
+ __state_check_binary_deps || return 4
90
+ local file="$1"
91
+ if [ ! -f "$file" ]; then
92
+ return 1
93
+ fi
94
+
95
+ # Parse cache per bassclef-upstream#1519 (Option D). Cache the JSON
96
+ # output per file+mtime+size within one shell process. Session-end
97
+ # fires N Edit calls to whereami; each triggers state-validate.sh
98
+ # which calls this helper. Parse cost dominates on dense frontmatter.
99
+ # Uses parallel indexed arrays for bash 3+ compat (macOS default is
100
+ # bash 3.2; no associative-array assumption).
101
+ # Opt-out env: STATE_DISABLE_PARSE_CACHE=1 skips cache lookup + store.
102
+ # Fail-safe: any stat error falls through to parse path.
103
+ local cache_key=""
104
+ if [ "${STATE_DISABLE_PARSE_CACHE:-0}" != "1" ]; then
105
+ # Lazy-init arrays. Caller may run under `set -u` (nounset), which
106
+ # trips on `${#unset_array[@]}` — initialize first if not declared.
107
+ if [ -z "${__STATE_YQ2JSON_CACHE_KEYS+x}" ]; then
108
+ __STATE_YQ2JSON_CACHE_KEYS=()
109
+ __STATE_YQ2JSON_CACHE_VALS=()
110
+ fi
111
+ local mtime_size
112
+ mtime_size=$(stat -c '%Y|%s' "$file" 2>/dev/null || stat -f '%m|%z' "$file" 2>/dev/null || echo "")
113
+ if [ -n "$mtime_size" ]; then
114
+ cache_key="${file}|${mtime_size}"
115
+ # Linear scan of parallel arrays. N is small per session-end (< 10).
116
+ local __c_i=0
117
+ local __c_n=${#__STATE_YQ2JSON_CACHE_KEYS[@]}
118
+ while [ $__c_i -lt $__c_n ]; do
119
+ if [ "${__STATE_YQ2JSON_CACHE_KEYS[$__c_i]}" = "$cache_key" ]; then
120
+ printf '%s\n' "${__STATE_YQ2JSON_CACHE_VALS[$__c_i]}"
121
+ return 0
122
+ fi
123
+ __c_i=$((__c_i + 1))
124
+ done
125
+ fi
126
+ fi
127
+
128
+ local out
129
+ local ec
130
+ out=$(awk '/^---$/{c++; next} c==1' "$file" | python3 -c "
131
+ import sys, yaml, json
132
+ try:
133
+ data = yaml.safe_load(sys.stdin) or {}
134
+ print(json.dumps(data, default=str))
135
+ except Exception as e:
136
+ print(f'yaml parse failed: {e}', file=sys.stderr)
137
+ sys.exit(3)
138
+ ")
139
+ ec=$?
140
+ # Instrument real-parse count for #1519 tests + observability.
141
+ __STATE_YQ2JSON_PARSE_COUNT=$(( ${__STATE_YQ2JSON_PARSE_COUNT:-0} + 1 ))
142
+ if [ "$ec" -ne 0 ]; then
143
+ printf '%s\n' "$out" >&2
144
+ return "$ec"
145
+ fi
146
+ # Hoare postcondition — stdout must be a non-empty JSON object.
147
+ # Empty parse means: no frontmatter fences OR fences with empty body.
148
+ # Both shapes silently coerced to `{}` before this cure (per #1497).
149
+ if [ "$out" = "{}" ]; then
150
+ if [ "${STATE_ALLOW_EMPTY_PARSE:-0}" = "1" ]; then
151
+ # Adopter opt-in — legacy behavior, no signal.
152
+ printf '%s\n' "$out"
153
+ return 0
154
+ fi
155
+ echo "state accessor: no YAML frontmatter fences OR empty payload in $file (expected Shape A JSON OR Shape B markdown+frontmatter with fields)" >&2
156
+ return 5
157
+ fi
158
+ # Store in parse cache on successful parse (bassclef-upstream#1519).
159
+ # Empty cache_key means STATE_DISABLE_PARSE_CACHE=1 OR stat failed —
160
+ # skip store. Parallel arrays; append via +=.
161
+ if [ -n "$cache_key" ]; then
162
+ __STATE_YQ2JSON_CACHE_KEYS+=("$cache_key")
163
+ __STATE_YQ2JSON_CACHE_VALS+=("$out")
164
+ fi
165
+ printf '%s\n' "$out"
166
+ return 0
167
+ }
168
+
169
+ # Atomic JSON write: temp file + rename.
170
+ __state_atomic_write() {
171
+ local target="$1"
172
+ local content="$2"
173
+ local tmp="${target}.tmp.$$"
174
+ echo "$content" > "$tmp" || return 4
175
+ mv "$tmp" "$target" || { rm -f "$tmp"; return 4; }
176
+ }
177
+
178
+ # yaml_write — write a single {field: value} document as safe YAML.
179
+ # Value strings containing YAML-unsafe chars (`:`, `#`, `"`, `'`, newlines)
180
+ # get quoted or emitted as block scalars via python yaml.safe_dump.
181
+ # Round-trips cleanly through yaml.safe_load.
182
+ # Sister to __state_yq2json (read side); used by session-end.sh write path
183
+ # for the operator_recap block per goal 2026-09-06d Cure 6.
184
+ # Anchor: @luminary tony-hoare (postcondition — yaml.safe_load succeeds on
185
+ # output) + @luminary michael-nygard (silent-fail wrap; write does not
186
+ # crash caller).
187
+ __state_yaml_write() {
188
+ __state_check_binary_deps || return 4
189
+ local path="$1"
190
+ local field="$2"
191
+ local value="$3"
192
+ if [ -z "$path" ] || [ -z "$field" ]; then
193
+ echo "__state_yaml_write: requires <path> <field> <value>" >&2
194
+ return 2
195
+ fi
196
+ local dir
197
+ dir="$(dirname "$path")"
198
+ [ -d "$dir" ] || mkdir -p "$dir" || return 4
199
+ local tmp="${path}.tmp.$$"
200
+ BASSCLEF_YAML_FIELD="$field" \
201
+ BASSCLEF_YAML_VALUE="$value" \
202
+ BASSCLEF_YAML_TMP="$tmp" \
203
+ python3 -c '
204
+ import os, yaml, sys
205
+ field = os.environ["BASSCLEF_YAML_FIELD"]
206
+ value = os.environ["BASSCLEF_YAML_VALUE"]
207
+ tmp = os.environ["BASSCLEF_YAML_TMP"]
208
+ data = {field: value}
209
+ try:
210
+ with open(tmp, "w") as f:
211
+ yaml.safe_dump(data, f, allow_unicode=True, default_flow_style=False, width=10000)
212
+ except Exception as e:
213
+ print(f"__state_yaml_write: safe_dump failed: {e}", file=sys.stderr)
214
+ sys.exit(3)
215
+ ' 2>&1 || { rm -f "$tmp"; return 3; }
216
+ mv "$tmp" "$path" || { rm -f "$tmp"; return 4; }
217
+ }
218
+
219
+ # === Whereami (Shape A — JSON authoritative + markdown rendered) ===
220
+
221
+ state_whereami_get() {
222
+ # Cure 2 (per ADR-054 D3 fallback discipline): three-tier read.
223
+ # Tier 0 — docs/whereami.json (authoritative when present)
224
+ # Tier 1 — docs/whereami.md YAML frontmatter (fallback via __state_yq2json)
225
+ # Tier 2 — return 1 only when both files missing
226
+ local json_path="docs/whereami.json"
227
+ local md_path="docs/whereami.md"
228
+ if [ -f "$json_path" ]; then
229
+ cat "$json_path"
230
+ return 0
231
+ fi
232
+ if [ -f "$md_path" ]; then
233
+ __state_yq2json "$md_path"
234
+ return $?
235
+ fi
236
+ return 1
237
+ }
238
+
239
+ state_whereami_set() {
240
+ local field="$1"
241
+ local value="$2"
242
+ local path="docs/whereami.json"
243
+ if [ -z "$field" ] || [ -z "$value" ]; then
244
+ echo "state_whereami_set: requires <field> <value>" >&2
245
+ return 2
246
+ fi
247
+ if [ ! -f "$path" ]; then
248
+ echo "state_whereami_set: docs/whereami.json does not exist" >&2
249
+ return 4
250
+ fi
251
+ local updated
252
+ # Use jq's setpath for nested field paths (e.g., 'phase.value')
253
+ updated="$(jq --arg path "$field" --arg val "$value" '
254
+ setpath($path | split("."); $val)
255
+ ' "$path")" || return 3
256
+ __state_atomic_write "$path" "$updated"
257
+ }
258
+
259
+ # === IterationBet (Shape B — markdown + YAML frontmatter + body) ===
260
+
261
+ state_iteration_bet_get() {
262
+ local slug="$1"
263
+ if [ -z "$slug" ]; then
264
+ echo "state_iteration_bet_get: requires <slug>" >&2
265
+ return 2
266
+ fi
267
+ local path="docs/iteration-bets/${slug}.md"
268
+ if [ ! -f "$path" ]; then
269
+ return 1
270
+ fi
271
+ __state_yq2json "$path"
272
+ }
273
+
274
+ # Special-purpose: returns the single active iteration bet (status=active),
275
+ # or exit 1 if none. If multiple are active (parallel mode), returns the
276
+ # first by filename order (stable, sorted lexicographically).
277
+ state_iteration_bet_active() {
278
+ local found=""
279
+ if [ ! -d docs/iteration-bets ]; then
280
+ return 1
281
+ fi
282
+ for f in docs/iteration-bets/*.md; do
283
+ [ -f "$f" ] || continue
284
+ local status
285
+ status="$(__state_yq2json "$f" 2>/dev/null | jq -r '.status // empty')"
286
+ if [ "$status" = "active" ]; then
287
+ __state_yq2json "$f"
288
+ return 0
289
+ fi
290
+ done
291
+ return 1
292
+ }
293
+
294
+ # List all iteration bets. Returns JSON array of frontmatter objects to
295
+ # stdout; empty array `[]` if directory absent or empty. Stable order
296
+ # (lexicographic by filename — chronologically earliest first since slugs
297
+ # carry dates).
298
+ state_iteration_bet_list() {
299
+ if [ ! -d docs/iteration-bets ]; then
300
+ echo "[]"
301
+ return 0
302
+ fi
303
+ local result="["
304
+ local first=1
305
+ for f in docs/iteration-bets/*.md; do
306
+ [ -f "$f" ] || continue
307
+ local entry_json
308
+ entry_json="$(__state_yq2json "$f" 2>/dev/null)" || continue
309
+ [ -z "$entry_json" ] && continue
310
+ [ "$entry_json" = "{}" ] && continue
311
+ [ "$entry_json" = "null" ] && continue
312
+ if [ "$first" -eq 1 ]; then
313
+ first=0
314
+ else
315
+ result="${result},"
316
+ fi
317
+ result="${result}${entry_json}"
318
+ done
319
+ result="${result}]"
320
+ echo "$result" | jq -c .
321
+ }
322
+
323
+ # === Roadmap (Shape B — markdown + YAML frontmatter + body) ===
324
+ #
325
+ # Per bassclef#337: distributed agents coordinate on roadmap work via
326
+ # state management. Two primitives: last_event (singular — "where were we")
327
+ # + next_work_units (task list — "what's queued"). Workers claim atomically
328
+ # via state_roadmap_claim_wu, release on completion via state_roadmap_release_wu.
329
+
330
+ state_roadmap_get() {
331
+ local slug="$1"
332
+ if [ -z "$slug" ]; then
333
+ echo "state_roadmap_get: requires <slug>" >&2
334
+ return 2
335
+ fi
336
+ local path="docs/roadmaps/${slug}.md"
337
+ if [ ! -f "$path" ]; then
338
+ return 1
339
+ fi
340
+ __state_yq2json "$path"
341
+ }
342
+
343
+ # Internal: rewrite a roadmap's frontmatter from JSON, preserving the body.
344
+ __state_roadmap_write_frontmatter() {
345
+ local slug="$1"
346
+ local frontmatter_json="$2"
347
+ local path="docs/roadmaps/${slug}.md"
348
+ if [ ! -f "$path" ]; then
349
+ echo "__state_roadmap_write_frontmatter: roadmap $path not found" >&2
350
+ return 1
351
+ fi
352
+ python3 -c "
353
+ import sys, yaml, json
354
+ path = '$path'
355
+ fm = json.loads('''$frontmatter_json''')
356
+ with open(path, 'r') as f:
357
+ content = f.read()
358
+ # Split off the frontmatter block (between the two leading '---' lines)
359
+ parts = content.split('---', 2)
360
+ if len(parts) < 3:
361
+ print('roadmap missing frontmatter delimiter', file=sys.stderr)
362
+ sys.exit(2)
363
+ body = parts[2]
364
+ new_fm = yaml.safe_dump(fm, default_flow_style=False, sort_keys=False, allow_unicode=True)
365
+ new_content = '---\n' + new_fm + '---' + body
366
+ with open(path, 'w') as f:
367
+ f.write(new_content)
368
+ " 2>&1
369
+ }
370
+
371
+ # Returns the next pending work unit whose depends_on are all completed
372
+ # (status=completed in next_work_units), as JSON. Returns exit 1 if none.
373
+ state_roadmap_next_pending() {
374
+ local slug="$1"
375
+ if [ -z "$slug" ]; then
376
+ echo "state_roadmap_next_pending: requires <slug>" >&2
377
+ return 2
378
+ fi
379
+ local roadmap_json
380
+ roadmap_json="$(state_roadmap_get "$slug")" || return $?
381
+ local result
382
+ result="$(echo "$roadmap_json" | jq -c '
383
+ .next_work_units as $wus |
384
+ [$wus[]? | select(.status == "completed") | .slug] as $done |
385
+ [$wus[]? | select(.status == "pending")] |
386
+ map(select(
387
+ ((.depends_on // []) | length == 0) or
388
+ ((.depends_on // []) | all(IN($done[])))
389
+ )) |
390
+ first // empty
391
+ ' 2>/dev/null)"
392
+ if [ -z "$result" ] || [ "$result" = "null" ]; then
393
+ return 1
394
+ fi
395
+ echo "$result"
396
+ }
397
+
398
+ # Atomically claim the next pending WU (or a specific WU by slug).
399
+ # Args: <roadmap-slug> [wu-slug] [session-id]
400
+ # - wu-slug omitted → claim next_pending result
401
+ # - session-id defaults to ${CLAUDE_SESSION_ID:-unknown}
402
+ # Output: the claimed WU as JSON, or exit 1 if none claimable.
403
+ state_roadmap_claim_wu() {
404
+ local roadmap_slug="$1"
405
+ local wu_slug="${2:-}"
406
+ local session_id="${3:-${CLAUDE_SESSION_ID:-unknown}}"
407
+ if [ -z "$roadmap_slug" ]; then
408
+ echo "state_roadmap_claim_wu: requires <roadmap-slug> [wu-slug] [session-id]" >&2
409
+ return 2
410
+ fi
411
+ local lockfile=".state-roadmap-${roadmap_slug}.lock"
412
+ (
413
+ flock -x 9 2>/dev/null || true
414
+ local roadmap_json
415
+ roadmap_json="$(state_roadmap_get "$roadmap_slug")" || exit 1
416
+
417
+ # If wu_slug not given, compute next_pending
418
+ if [ -z "$wu_slug" ]; then
419
+ wu_slug="$(state_roadmap_next_pending "$roadmap_slug" | jq -r '.slug // empty')"
420
+ if [ -z "$wu_slug" ]; then
421
+ exit 1
422
+ fi
423
+ fi
424
+
425
+ # Check current status — only claim if pending
426
+ local current_status
427
+ current_status="$(echo "$roadmap_json" | jq -r --arg slug "$wu_slug" '.next_work_units[]? | select(.slug == $slug) | .status // empty')"
428
+ if [ "$current_status" != "pending" ]; then
429
+ # Already claimed or completed — return empty (atomicity boundary)
430
+ exit 1
431
+ fi
432
+
433
+ local now
434
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
435
+ # Advance last_event AND mutate WU status atomically (bassclef#349
436
+ # tail-1: claim is a real Event in workflow history, not just a
437
+ # status mutation — recording it in last_event means polling sessions
438
+ # can tell when work is in progress, not just completed).
439
+ local updated
440
+ updated="$(echo "$roadmap_json" | jq --arg slug "$wu_slug" --arg sid "$session_id" --arg ts "$now" '
441
+ .next_work_units = ((.next_work_units // []) | map(
442
+ if .slug == $slug and .status == "pending" then
443
+ . + {status: "in_progress", claimed_by: $sid, claimed_at: $ts}
444
+ else . end
445
+ )) |
446
+ .last_event = {ts: $ts, event: "task-claimed", refs: [$slug], actor: $sid}
447
+ ')" || exit 4
448
+
449
+ # Write back
450
+ __state_roadmap_write_frontmatter "$roadmap_slug" "$updated" || exit 4
451
+
452
+ # Echo the claimed WU
453
+ echo "$updated" | jq --arg slug "$wu_slug" '.next_work_units[] | select(.slug == $slug)' -c
454
+ ) 9>"$lockfile"
455
+ }
456
+
457
+ # Release a WU on completion. Updates the WU's status to completed AND
458
+ # advances last_event to record the completion.
459
+ # Args: <roadmap-slug> <wu-slug> [event-name] [refs-json-array]
460
+ state_roadmap_release_wu() {
461
+ local roadmap_slug="$1"
462
+ local wu_slug="$2"
463
+ local event_name="${3:-}"
464
+ local refs_json="${4:-[]}"
465
+ if [ -z "$roadmap_slug" ] || [ -z "$wu_slug" ]; then
466
+ echo "state_roadmap_release_wu: requires <roadmap-slug> <wu-slug> [event-name] [refs-json]" >&2
467
+ return 2
468
+ fi
469
+ local lockfile=".state-roadmap-${roadmap_slug}.lock"
470
+ (
471
+ flock -x 9 2>/dev/null || true
472
+ local roadmap_json
473
+ roadmap_json="$(state_roadmap_get "$roadmap_slug")" || exit 1
474
+
475
+ if [ -z "$event_name" ]; then
476
+ event_name="${wu_slug}_completed"
477
+ fi
478
+
479
+ local now
480
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
481
+
482
+ local updated
483
+ updated="$(echo "$roadmap_json" | \
484
+ jq --arg slug "$wu_slug" --arg ts "$now" --arg ev "$event_name" --argjson refs "$refs_json" '
485
+ .next_work_units = ((.next_work_units // []) | map(
486
+ if .slug == $slug then . + {status: "completed", completed_at: $ts}
487
+ else . end
488
+ )) |
489
+ .last_event = {ts: $ts, event: $ev, refs: $refs, actor: ((env.CLAUDE_SESSION_ID // "unknown")|tostring)}
490
+ ')" || exit 4
491
+
492
+ __state_roadmap_write_frontmatter "$roadmap_slug" "$updated" || exit 4
493
+
494
+ # Echo the new last_event
495
+ echo "$updated" | jq -c '.last_event'
496
+ ) 9>"$lockfile"
497
+ }
498
+
499
+ # Add a new task to a roadmap's next_work_units (bassclef#349 tail-3).
500
+ # Validates slug uniqueness within roadmap and that any depends_on slugs
501
+ # already exist in next_work_units. Advances last_event with task-added.
502
+ # Args: <roadmap-slug> <wu-slug> <issue> <wu-id> <title> [depends_on-json]
503
+ # - issue: GitHub-style ref like "bassclef#354"
504
+ # - wu-id: WU identifier like "WU-A" or "tail-3"
505
+ # - depends_on-json: JSON array of dependency slugs; defaults to []
506
+ # Output: the new task as JSON, or exits 4 on validation failure.
507
+ state_roadmap_add_task() {
508
+ local roadmap_slug="${1:-}"
509
+ local wu_slug="${2:-}"
510
+ local issue="${3:-}"
511
+ local wu_id="${4:-}"
512
+ local title="${5:-}"
513
+ local depends_on_json="${6:-[]}"
514
+ if [ -z "$roadmap_slug" ] || [ -z "$wu_slug" ] || [ -z "$issue" ] || [ -z "$wu_id" ] || [ -z "$title" ]; then
515
+ echo "state_roadmap_add_task: requires <roadmap-slug> <wu-slug> <issue> <wu-id> <title> [depends_on-json]" >&2
516
+ return 2
517
+ fi
518
+ # Validate depends_on is a JSON array
519
+ if ! echo "$depends_on_json" | jq -e 'type == "array"' >/dev/null 2>&1; then
520
+ echo "state_roadmap_add_task: depends_on must be a JSON array (got: $depends_on_json)" >&2
521
+ return 2
522
+ fi
523
+ local lockfile=".state-roadmap-${roadmap_slug}.lock"
524
+ (
525
+ flock -x 9 2>/dev/null || true
526
+ local roadmap_json
527
+ roadmap_json="$(state_roadmap_get "$roadmap_slug")" || exit 1
528
+
529
+ # Slug uniqueness check
530
+ local existing
531
+ existing="$(echo "$roadmap_json" | jq -r --arg slug "$wu_slug" '.next_work_units[]? | select(.slug == $slug) | .slug // empty')"
532
+ if [ -n "$existing" ]; then
533
+ echo "state_roadmap_add_task: WU '$wu_slug' already exists in roadmap '$roadmap_slug'" >&2
534
+ exit 4
535
+ fi
536
+
537
+ # depends_on slugs must already exist in roadmap
538
+ local depends_on_count
539
+ depends_on_count="$(echo "$depends_on_json" | jq 'length')"
540
+ if [ "$depends_on_count" -gt 0 ]; then
541
+ local missing
542
+ missing="$(echo "$roadmap_json" | jq -c --argjson dep "$depends_on_json" '
543
+ ([.next_work_units[]?.slug] // []) as $existing |
544
+ $dep | map(select(. as $d | ($existing | index($d)) == null))
545
+ ')"
546
+ if [ "$(echo "$missing" | jq 'length')" != "0" ]; then
547
+ echo "state_roadmap_add_task: depends_on contains slugs not in roadmap: $missing" >&2
548
+ exit 4
549
+ fi
550
+ fi
551
+
552
+ local now
553
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
554
+ local updated
555
+ updated="$(echo "$roadmap_json" | \
556
+ jq --arg slug "$wu_slug" --arg issue "$issue" --arg wu_id "$wu_id" \
557
+ --arg title "$title" --arg ts "$now" --argjson dep "$depends_on_json" '
558
+ .next_work_units = ((.next_work_units // []) + [{
559
+ slug: $slug,
560
+ issue: $issue,
561
+ wu: $wu_id,
562
+ title: $title,
563
+ status: "pending",
564
+ depends_on: $dep
565
+ }]) |
566
+ .last_event = {ts: $ts, event: "task-added", refs: [$slug], actor: ((env.CLAUDE_SESSION_ID // "unknown")|tostring)}
567
+ ')" || exit 4
568
+
569
+ __state_roadmap_write_frontmatter "$roadmap_slug" "$updated" || exit 4
570
+
571
+ # Echo the new task as JSON
572
+ echo "$updated" | jq -c --arg slug "$wu_slug" '.next_work_units[] | select(.slug == $slug)'
573
+ ) 9>"$lockfile"
574
+ }
575
+
576
+ # Abandon a claimed WU (bassclef#349 tail-2). Two modes:
577
+ # - Default (return-to-queue): in_progress → pending; clears claimed_by/claimed_at
578
+ # - Terminal (--terminal flag): in_progress → abandoned; clears claimed_by/claimed_at
579
+ # In both modes, advances last_event with event="task-abandoned" and reason in note.
580
+ # Args: <roadmap-slug> <wu-slug> [reason] [--terminal]
581
+ state_roadmap_abandon_wu() {
582
+ local roadmap_slug="$1"
583
+ local wu_slug="$2"
584
+ local reason="${3:-}"
585
+ local mode="pending" # default: return to queue
586
+ if [ "${4:-}" = "--terminal" ] || [ "${3:-}" = "--terminal" ]; then
587
+ mode="abandoned"
588
+ [ "${3:-}" = "--terminal" ] && reason="" # if --terminal was 3rd arg, no reason
589
+ fi
590
+ if [ -z "$roadmap_slug" ] || [ -z "$wu_slug" ]; then
591
+ echo "state_roadmap_abandon_wu: requires <roadmap-slug> <wu-slug> [reason] [--terminal]" >&2
592
+ return 2
593
+ fi
594
+ local lockfile=".state-roadmap-${roadmap_slug}.lock"
595
+ (
596
+ flock -x 9 2>/dev/null || true
597
+ local roadmap_json
598
+ roadmap_json="$(state_roadmap_get "$roadmap_slug")" || exit 1
599
+
600
+ # Verify WU exists and is in_progress; only abandon active claims
601
+ local current_status
602
+ current_status="$(echo "$roadmap_json" | jq -r --arg slug "$wu_slug" '.next_work_units[]? | select(.slug == $slug) | .status // empty')"
603
+ if [ -z "$current_status" ]; then
604
+ echo "state_roadmap_abandon_wu: WU '$wu_slug' not found in roadmap '$roadmap_slug'" >&2
605
+ exit 1
606
+ fi
607
+ if [ "$current_status" != "in_progress" ]; then
608
+ echo "state_roadmap_abandon_wu: WU '$wu_slug' is '$current_status', not in_progress; cannot abandon" >&2
609
+ exit 1
610
+ fi
611
+
612
+ local now
613
+ now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
614
+ local updated
615
+ updated="$(echo "$roadmap_json" | \
616
+ jq --arg slug "$wu_slug" --arg ts "$now" --arg mode "$mode" --arg reason "$reason" '
617
+ .next_work_units = ((.next_work_units // []) | map(
618
+ if .slug == $slug then
619
+ (. | del(.claimed_by) | del(.claimed_at)) +
620
+ (if $mode == "abandoned" then {status: "abandoned", abandoned_at: $ts, abandoned_reason: $reason}
621
+ else {status: "pending"} end)
622
+ else . end
623
+ )) |
624
+ .last_event = {ts: $ts, event: "task-abandoned", refs: [$slug], actor: ((env.CLAUDE_SESSION_ID // "unknown")|tostring), note: $reason}
625
+ ')" || exit 4
626
+
627
+ __state_roadmap_write_frontmatter "$roadmap_slug" "$updated" || exit 4
628
+
629
+ # Echo the new last_event
630
+ echo "$updated" | jq -c '.last_event'
631
+ ) 9>"$lockfile"
632
+ }
633
+
634
+ # === Marker (Shape C — pure JSON) ===
635
+
636
+ state_marker_get() {
637
+ local uuid="$1"
638
+ if [ -z "$uuid" ]; then
639
+ echo "state_marker_get: requires <uuid>" >&2
640
+ return 2
641
+ fi
642
+ if [ ! -d state/markers ]; then
643
+ return 1
644
+ fi
645
+ local file
646
+ file="$(find state/markers -name "${uuid}.json" -type f -print -quit 2>/dev/null)"
647
+ if [ -z "$file" ]; then
648
+ return 1
649
+ fi
650
+ cat "$file"
651
+ }
652
+
653
+ # Counter: derived from history on every call (replay-determinism).
654
+ # Args: [category] [since-days]
655
+ # Returns integer to stdout (0 if none).
656
+ #
657
+ # Per state-spine-contract.md: "since-N-sessions" semantics deferred to
658
+ # spine v1 (requires session-boundary mapping from chronicle history).
659
+ # v0 implements `since-days` (mtime-based) — sufficient for the
660
+ # bassclef#296 floor-lift fix (window-bounded marker count). Override
661
+ # the implementation in spine v1 to use session boundaries; consumers
662
+ # don't change.
663
+ #
664
+ # Also accepts both .json (spine v0) and .marker (legacy) extensions —
665
+ # legacy markers from .claude/verify-markers/ and pre-spine
666
+ # state/markers/ retain .marker extension; this accessor unions
667
+ # both per pre-spine + post-spine reads.
668
+ state_marker_count() {
669
+ local category="${1:-}"
670
+ local since_days="${2:-}"
671
+ if [ ! -d state/markers ]; then
672
+ echo "0"
673
+ return 0
674
+ fi
675
+ local search_dir="state/markers"
676
+ if [ -n "$category" ]; then
677
+ if [ ! -d "state/markers/$category" ]; then
678
+ echo "0"
679
+ return 0
680
+ fi
681
+ search_dir="state/markers/$category"
682
+ fi
683
+ if [ -n "$since_days" ]; then
684
+ if ! [[ "$since_days" =~ ^[0-9]+$ ]]; then
685
+ echo "state_marker_count: since-days must be integer (got: $since_days)" >&2
686
+ return 2
687
+ fi
688
+ find "$search_dir" -type f \( -name "*.json" -o -name "*.marker" \) -mtime "-${since_days}" 2>/dev/null | wc -l | tr -d ' '
689
+ else
690
+ find "$search_dir" -type f \( -name "*.json" -o -name "*.marker" \) 2>/dev/null | wc -l | tr -d ' '
691
+ fi
692
+ }
693
+
694
+ # Append a new marker. Reads marker JSON on stdin; extracts id + category;
695
+ # writes to state/markers/<category>/<id>.json. Returns id to stdout.
696
+ state_marker_add() {
697
+ local marker_json
698
+ marker_json="$(cat)"
699
+ if [ -z "$marker_json" ]; then
700
+ echo "state_marker_add: requires marker JSON on stdin" >&2
701
+ return 2
702
+ fi
703
+ local id category
704
+ id="$(echo "$marker_json" | jq -r '.id // empty')"
705
+ category="$(echo "$marker_json" | jq -r '.category // empty')"
706
+ if [ -z "$id" ] || [ -z "$category" ]; then
707
+ echo "state_marker_add: marker missing id or category" >&2
708
+ return 2
709
+ fi
710
+ local dir="state/markers/$category"
711
+ mkdir -p "$dir" || return 4
712
+ __state_atomic_write "${dir}/${id}.json" "$marker_json" || return 4
713
+ echo "$id"
714
+ }
715
+
716
+ # === Chronicle (Shape B/hybrid — markdown + YAML frontmatter; gates_fired
717
+ # in frontmatter for v0; sidecar JSON ships in WU-4) ===
718
+
719
+ # Special-purpose: extract gates_fired array from a chronicle.
720
+ # Returns JSON array of gate objects to stdout (empty array `[]` if no gates).
721
+ # Per bassclef#298 fix — schema-required field; this accessor reads it deterministically.
722
+ state_chronicle_gate_evidence() {
723
+ local id="$1"
724
+ if [ -z "$id" ]; then
725
+ echo "state_chronicle_gate_evidence: requires <id>" >&2
726
+ return 2
727
+ fi
728
+ # Try bassclef (chronicle/) and consumer-repo (docs/chronicle/) paths
729
+ local path=""
730
+ if [ -f "chronicle/${id}.md" ]; then
731
+ path="chronicle/${id}.md"
732
+ elif [ -f "docs/chronicle/${id}.md" ]; then
733
+ path="docs/chronicle/${id}.md"
734
+ else
735
+ return 1
736
+ fi
737
+ __state_yq2json "$path" | jq -c '.gates_fired // []'
738
+ }
739
+
740
+ # List chronicle IDs (filename without .md extension), optionally limited
741
+ # to the most-recent N entries. Returns one ID per line on stdout (NOT
742
+ # JSON array — chronicles are typically iterated, and bash-friendly
743
+ # output keeps the consumer simple).
744
+ #
745
+ # Args: [--recent N]
746
+ #
747
+ # Tries bassclef (chronicle/) first, then consumer-repo
748
+ # (docs/chronicle/) per the same path convention as
749
+ # state_chronicle_gate_evidence. Sorts lexicographically (chronicles
750
+ # carry YYYY-MM-DD prefix, so this is chronological).
751
+ state_chronicle_list() {
752
+ local limit=""
753
+ if [ "${1:-}" = "--recent" ]; then
754
+ limit="${2:-}"
755
+ if ! [[ "$limit" =~ ^[0-9]+$ ]]; then
756
+ echo "state_chronicle_list: --recent requires integer (got: $limit)" >&2
757
+ return 2
758
+ fi
759
+ elif [ -n "${1:-}" ]; then
760
+ echo "state_chronicle_list: unknown arg '$1' (expected --recent N)" >&2
761
+ return 2
762
+ fi
763
+ local dir=""
764
+ if [ -d chronicle ]; then
765
+ dir="chronicle"
766
+ elif [ -d docs/chronicle ]; then
767
+ dir="docs/chronicle"
768
+ else
769
+ return 0
770
+ fi
771
+ local files
772
+ files="$(find "$dir" -maxdepth 1 -name "*.md" -type f 2>/dev/null | sort)"
773
+ [ -z "$files" ] && return 0
774
+ if [ -n "$limit" ]; then
775
+ files="$(echo "$files" | tail -n "$limit")"
776
+ fi
777
+ while IFS= read -r f; do
778
+ [ -z "$f" ] && continue
779
+ local base
780
+ base="$(basename "$f" .md)"
781
+ echo "$base"
782
+ done <<< "$files"
783
+ }
784
+
785
+ # === DeferredAction (Shape B — markdown + YAML frontmatter) ===
786
+
787
+ # Special-purpose: list pending deferred actions, optionally filtered by
788
+ # capability. Pending = files in docs/deferred-actions/ (NOT in completed/).
789
+ # Filter args: zero or more capability tokens; entry matches if its
790
+ # requires_capability set is a SUBSET of the supplied capabilities.
791
+ state_deferred_action_pending() {
792
+ local -a caps=("$@")
793
+ local -a entries=()
794
+ if [ ! -d docs/deferred-actions ]; then
795
+ echo "[]"
796
+ return 0
797
+ fi
798
+ for f in docs/deferred-actions/*.md; do
799
+ [ -f "$f" ] || continue
800
+ [ "$(basename "$f")" = "README.md" ] && continue
801
+ [ "$(basename "$f")" = "template.md" ] && continue
802
+ entries+=("$f")
803
+ done
804
+ if [ "${#entries[@]}" -eq 0 ]; then
805
+ echo "[]"
806
+ return 0
807
+ fi
808
+ # Build JSON array
809
+ local result="["
810
+ local first=1
811
+ for f in "${entries[@]}"; do
812
+ local entry_json
813
+ entry_json="$(__state_yq2json "$f" 2>/dev/null)" || continue
814
+ # Apply capability filter if any caps supplied
815
+ if [ "${#caps[@]}" -gt 0 ]; then
816
+ local match=1
817
+ local entry_caps
818
+ entry_caps="$(echo "$entry_json" | jq -r '.requires_capability // [] | .[]')"
819
+ for needed in $entry_caps; do
820
+ local found=0
821
+ for supplied in "${caps[@]}"; do
822
+ if [ "$needed" = "$supplied" ]; then
823
+ found=1
824
+ break
825
+ fi
826
+ done
827
+ if [ "$found" -eq 0 ]; then
828
+ match=0
829
+ break
830
+ fi
831
+ done
832
+ if [ "$match" -eq 0 ]; then
833
+ continue
834
+ fi
835
+ fi
836
+ if [ "$first" -eq 1 ]; then
837
+ first=0
838
+ else
839
+ result="${result},"
840
+ fi
841
+ result="${result}${entry_json}"
842
+ done
843
+ result="${result}]"
844
+ echo "$result" | jq -c .
845
+ }
846
+
847
+ # List deferred actions. Optional first arg selects scope:
848
+ # pending (default) → docs/deferred-actions/*.md (NOT completed/)
849
+ # completed → docs/deferred-actions/completed/*.md
850
+ # all → both
851
+ # Returns JSON array of frontmatter objects to stdout; empty `[]` if none.
852
+ state_deferred_action_list() {
853
+ local scope="${1:-pending}"
854
+ case "$scope" in
855
+ pending|completed|all) ;;
856
+ *)
857
+ echo "state_deferred_action_list: scope must be pending|completed|all (got: $scope)" >&2
858
+ return 2
859
+ ;;
860
+ esac
861
+ local -a paths=()
862
+ if [ "$scope" = "pending" ] || [ "$scope" = "all" ]; then
863
+ if [ -d docs/deferred-actions ]; then
864
+ for f in docs/deferred-actions/*.md; do
865
+ [ -f "$f" ] || continue
866
+ local base="$(basename "$f")"
867
+ [ "$base" = "README.md" ] && continue
868
+ [ "$base" = "template.md" ] && continue
869
+ paths+=("$f")
870
+ done
871
+ fi
872
+ fi
873
+ if [ "$scope" = "completed" ] || [ "$scope" = "all" ]; then
874
+ if [ -d docs/deferred-actions/completed ]; then
875
+ for f in docs/deferred-actions/completed/*.md; do
876
+ [ -f "$f" ] || continue
877
+ local base="$(basename "$f")"
878
+ [ "$base" = "README.md" ] && continue
879
+ paths+=("$f")
880
+ done
881
+ fi
882
+ fi
883
+ if [ "${#paths[@]}" -eq 0 ]; then
884
+ echo "[]"
885
+ return 0
886
+ fi
887
+ local result="["
888
+ local first=1
889
+ for f in "${paths[@]}"; do
890
+ local entry_json
891
+ entry_json="$(__state_yq2json "$f" 2>/dev/null)" || continue
892
+ [ -z "$entry_json" ] && continue
893
+ if [ "$first" -eq 1 ]; then
894
+ first=0
895
+ else
896
+ result="${result},"
897
+ fi
898
+ result="${result}${entry_json}"
899
+ done
900
+ result="${result}]"
901
+ echo "$result" | jq -c .
902
+ }
903
+
904
+ # === Validation accessor (special-purpose; wraps state-validate.sh) ===
905
+
906
+ # Validates an arbitrary file at <path> against its entity schema by
907
+ # invoking the state-validate.sh PreToolUse hook with a synthesized
908
+ # event. Useful for ad-hoc /state validate <path> from operators or
909
+ # scripts.
910
+ #
911
+ # Returns:
912
+ # 0 — file is a state-spine entity AND validates clean (or hook chose
913
+ # legacy pass-through)
914
+ # 2 — invalid input (no path, missing arg)
915
+ # 3 — schema validation failed (BLOCK message on stderr)
916
+ # 4 — filesystem error (file or hook missing)
917
+ #
918
+ # stdout: empty on success; ajv-cli BLOCK output on failure
919
+ # stderr: structured BLOCK message (forwarded from state-validate.sh)
920
+ state_validate() {
921
+ local target="${1:-}"
922
+ if [ -z "$target" ]; then
923
+ echo "state_validate: requires <path>" >&2
924
+ return 2
925
+ fi
926
+ if [ ! -f "$target" ]; then
927
+ echo "state_validate: file not found: $target" >&2
928
+ return 4
929
+ fi
930
+ local hook=".claude/hooks/state-validate.sh"
931
+ if [ ! -x "$hook" ]; then
932
+ echo "state_validate: hook not executable at $hook" >&2
933
+ return 4
934
+ fi
935
+ # Synthesize PreToolUse event JSON; the hook only reads tool_name + file_path.
936
+ printf '{"tool_name":"Edit","tool_input":{"file_path":"%s"}}\n' "$target" | "$hook"
937
+ local hook_exit=$?
938
+ case "$hook_exit" in
939
+ 0) return 0 ;;
940
+ 2|4|5) return "$hook_exit" ;;
941
+ *) return 3 ;; # any non-zero non-input-error = validation block
942
+ esac
943
+ }
944
+
945
+ # === Reference resolver (typed cross-entity refs) ===
946
+
947
+ # Resolves a typed reference to its entity. Returns JSON of the entity to
948
+ # stdout; exit 1 if dangling; exit 5 if entity type unknown.
949
+ # Args: <type> <id>
950
+ state_reference_get() {
951
+ local type="$1"
952
+ local id="$2"
953
+ if [ -z "$type" ] || [ -z "$id" ]; then
954
+ echo "state_reference_get: requires <type> <id>" >&2
955
+ return 2
956
+ fi
957
+ case "$type" in
958
+ chronicle)
959
+ if [ -f "chronicle/${id}.md" ]; then
960
+ __state_yq2json "chronicle/${id}.md"
961
+ elif [ -f "docs/chronicle/${id}.md" ]; then
962
+ __state_yq2json "docs/chronicle/${id}.md"
963
+ else
964
+ return 1
965
+ fi
966
+ ;;
967
+ discovery)
968
+ if [ -f "design/discoveries/${id}.md" ]; then
969
+ __state_yq2json "design/discoveries/${id}.md"
970
+ else
971
+ return 1
972
+ fi
973
+ ;;
974
+ adr)
975
+ # ADRs are markdown without YAML frontmatter; return path as JSON
976
+ if [ -f "architecture/decisions/${id}.md" ]; then
977
+ printf '{"path":"architecture/decisions/%s.md","type":"adr","id":"%s"}\n' "$id" "$id"
978
+ else
979
+ return 1
980
+ fi
981
+ ;;
982
+ decomposition)
983
+ if [ -f "docs/decompositions/${id}.md" ]; then
984
+ __state_yq2json "docs/decompositions/${id}.md"
985
+ else
986
+ return 1
987
+ fi
988
+ ;;
989
+ spec)
990
+ if [ -f "docs/specs/${id}.md" ]; then
991
+ __state_yq2json "docs/specs/${id}.md"
992
+ else
993
+ return 1
994
+ fi
995
+ ;;
996
+ session)
997
+ if [ -f "docs/sessions/${id}.md" ]; then
998
+ __state_yq2json "docs/sessions/${id}.md"
999
+ elif [ -f "docs/sessions/${id}.json" ]; then
1000
+ cat "docs/sessions/${id}.json"
1001
+ else
1002
+ return 1
1003
+ fi
1004
+ ;;
1005
+ iteration-bet)
1006
+ state_iteration_bet_get "$id"
1007
+ ;;
1008
+ roadmap)
1009
+ if [ -f "docs/roadmaps/${id}.md" ]; then
1010
+ __state_yq2json "docs/roadmaps/${id}.md"
1011
+ else
1012
+ return 1
1013
+ fi
1014
+ ;;
1015
+ workunit)
1016
+ # Workunits live inline in stack manifests; not directly addressable in v0
1017
+ echo "state_reference_get: workunit type requires Stack lookup; not implemented in v0" >&2
1018
+ return 5
1019
+ ;;
1020
+ stack)
1021
+ if [ -f "docs/stacks/${id}.md" ]; then
1022
+ __state_yq2json "docs/stacks/${id}.md"
1023
+ else
1024
+ return 1
1025
+ fi
1026
+ ;;
1027
+ marker)
1028
+ state_marker_get "$id"
1029
+ ;;
1030
+ gate)
1031
+ # Gates derive from Markers; not addressable directly in v0
1032
+ echo "state_reference_get: gate type derives from Marker; not directly addressable in v0" >&2
1033
+ return 5
1034
+ ;;
1035
+ *)
1036
+ echo "state_reference_get: unknown entity type '$type'" >&2
1037
+ return 5
1038
+ ;;
1039
+ esac
1040
+ }
1041
+
1042
+ # Returns 0 if reference exists, 1 if dangling, 5 if type unknown.
1043
+ # No JSON output (silent on success and not-found; stderr on type error).
1044
+ state_reference_check() {
1045
+ local type="$1"
1046
+ local id="$2"
1047
+ state_reference_get "$type" "$id" >/dev/null 2>&1
1048
+ local exit_code=$?
1049
+ # Translate: success → 0; not-found → 1; unknown-type → 5; other → 1
1050
+ case "$exit_code" in
1051
+ 0) return 0 ;;
1052
+ 5) return 5 ;;
1053
+ *) return 1 ;;
1054
+ esac
1055
+ }
1056
+
1057
+ # === BassclefPlatform (Shape D — pure YAML at sibling-repo root) ===
1058
+ #
1059
+ # Org-baseline platform-engineering primitive. Lives at the root of a
1060
+ # sibling repo (e.g., sunj-labs/platform-config) so it can be forked
1061
+ # and edited per adopter without forking bassclef itself.
1062
+ #
1063
+ # Resolution order for the file path:
1064
+ # 1. BASSCLEF_PLATFORM_CONFIG_PATH env var (explicit override)
1065
+ # 2. ~/src/<github_org>/platform-config/bassclef-platform.yml
1066
+ # (default sunj-labs convention; <github_org> from substrate.config.md)
1067
+ # 3. Sibling-repo path declared in .bassclef-source.json
1068
+ # (`platform_config_source` field — V2 thin-pointer extension)
1069
+ #
1070
+ # v0 implementation honors options 1 + 2. Option 3 lands when
1071
+ # .bassclef-source.json schema gains the field (bassclef#418-style
1072
+ # follow-up).
1073
+
1074
+ __state_platform_resolve_path() {
1075
+ if [ -n "${BASSCLEF_PLATFORM_CONFIG_PATH:-}" ]; then
1076
+ echo "$BASSCLEF_PLATFORM_CONFIG_PATH"
1077
+ return 0
1078
+ fi
1079
+ echo "${HOME}/src/sunj-labs/platform-config/bassclef-platform.yml"
1080
+ }
1081
+
1082
+ state_platform_get() {
1083
+ local key="${1:-}"
1084
+ local path
1085
+ path="$(__state_platform_resolve_path)"
1086
+ if [ ! -f "$path" ]; then
1087
+ return 1
1088
+ fi
1089
+ local json
1090
+ json="$(python3 -c "
1091
+ import sys, yaml, json
1092
+ try:
1093
+ with open('$path') as f:
1094
+ data = yaml.safe_load(f) or {}
1095
+ print(json.dumps(data, default=str))
1096
+ except Exception as e:
1097
+ print(f'yaml parse failed: {e}', file=sys.stderr)
1098
+ sys.exit(3)
1099
+ ")" || return 3
1100
+
1101
+ if [ -z "$key" ]; then
1102
+ echo "$json"
1103
+ return 0
1104
+ fi
1105
+ # Dot-path key (e.g., "org.github_org", "deploy_tiers.prototype.hosting_platform")
1106
+ echo "$json" | jq -r --arg key "$key" '
1107
+ getpath($key | split(".")) // empty
1108
+ '
1109
+ }
1110
+
1111
+ # Returns the resolved file path (for tooling that needs to display
1112
+ # where state_platform_get is reading from, without reading the file).
1113
+ state_platform_path() {
1114
+ __state_platform_resolve_path
1115
+ }
1116
+
1117
+ # === Pattern Implementation accessors (bassclef#478 Layer 3) ===
1118
+ #
1119
+ # Maps source files to the @pattern catalog entries they consciously
1120
+ # instantiate. Manifest at state/pattern-implementations/<sanitized-path>.json.
1121
+ # Auto-populated by .claude/hooks/pattern-annotation-validate.sh on Edit/Write.
1122
+ # Consumed by /pattern-trace skill for forward (which file uses pattern X)
1123
+ # and reverse (which patterns does file Y use) lookups.
1124
+
1125
+ __state_pattern_implementation_path() {
1126
+ # Sanitize a file path to a manifest filename: replace / with _.
1127
+ local file="$1"
1128
+ local sanitized
1129
+ sanitized=$(echo "$file" | tr '/' '_')
1130
+ echo "state/pattern-implementations/${sanitized}.json"
1131
+ }
1132
+
1133
+ # state_pattern_implementation_get <file_path>
1134
+ # Read the manifest entry for a source file. Returns JSON or empty (not found).
1135
+ state_pattern_implementation_get() {
1136
+ local file="$1"
1137
+ if [ -z "$file" ]; then
1138
+ echo "state_pattern_implementation_get: requires <file>" >&2
1139
+ return 2
1140
+ fi
1141
+ local manifest_path
1142
+ manifest_path="$(__state_pattern_implementation_path "$file")"
1143
+ if [ ! -f "$manifest_path" ]; then
1144
+ return 1
1145
+ fi
1146
+ cat "$manifest_path"
1147
+ }
1148
+
1149
+ # state_pattern_implementation_index_pattern <pattern_path>
1150
+ # Reverse lookup: list all source files that have a @pattern annotation
1151
+ # pointing to <pattern_path>. Returns one file path per line.
1152
+ state_pattern_implementation_index_pattern() {
1153
+ local pattern_path="$1"
1154
+ if [ -z "$pattern_path" ]; then
1155
+ echo "state_pattern_implementation_index_pattern: requires <pattern_path>" >&2
1156
+ return 2
1157
+ fi
1158
+ if [ ! -d "state/pattern-implementations" ]; then
1159
+ return 1
1160
+ fi
1161
+ for manifest in state/pattern-implementations/*.json; do
1162
+ [ -f "$manifest" ] || continue
1163
+ if jq -e --arg p "$pattern_path" '.annotations[] | select(.pattern_path == $p)' "$manifest" >/dev/null 2>&1; then
1164
+ jq -r '.file' "$manifest"
1165
+ fi
1166
+ done
1167
+ }
1168
+
1169
+ # state_pattern_implementation_list
1170
+ # List all manifest entries (one file path per line).
1171
+ state_pattern_implementation_list() {
1172
+ if [ ! -d "state/pattern-implementations" ]; then
1173
+ return 1
1174
+ fi
1175
+ for manifest in state/pattern-implementations/*.json; do
1176
+ [ -f "$manifest" ] || continue
1177
+ jq -r '.file' "$manifest"
1178
+ done
1179
+ }
1180
+
1181
+ # state_pattern_implementation_set <file_path> <pattern_path> <line> [context]
1182
+ # Write/upsert a single @pattern annotation entry. Used by the validate hook
1183
+ # during Write/Edit operations on source files that contain @pattern annotations.
1184
+ # This is the only WRITE accessor for this entity; reads use the get/index/list
1185
+ # methods above.
1186
+ state_pattern_implementation_set() {
1187
+ local file="$1"
1188
+ local pattern_path="$2"
1189
+ local line="$3"
1190
+ local context="${4:-}"
1191
+ if [ -z "$file" ] || [ -z "$pattern_path" ] || [ -z "$line" ]; then
1192
+ echo "state_pattern_implementation_set: requires <file> <pattern_path> <line> [context]" >&2
1193
+ return 2
1194
+ fi
1195
+ local manifest_path
1196
+ manifest_path="$(__state_pattern_implementation_path "$file")"
1197
+ mkdir -p "$(dirname "$manifest_path")"
1198
+ local timestamp
1199
+ timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1200
+ if [ -f "$manifest_path" ]; then
1201
+ # Upsert: replace any existing annotation with same pattern_path+line.
1202
+ jq --arg p "$pattern_path" --argjson l "$line" --arg c "$context" --arg ts "$timestamp" '
1203
+ .last_scanned = $ts |
1204
+ .annotations |= (map(select(.pattern_path != $p or .line != $l)) + [{pattern_path: $p, line: $l} + (if $c != "" then {context: $c} else {} end)])
1205
+ ' "$manifest_path" > "${manifest_path}.tmp" && mv "${manifest_path}.tmp" "$manifest_path"
1206
+ else
1207
+ # Create fresh.
1208
+ local entry
1209
+ if [ -n "$context" ]; then
1210
+ entry=$(jq -n --arg p "$pattern_path" --argjson l "$line" --arg c "$context" '{pattern_path: $p, line: $l, context: $c}')
1211
+ else
1212
+ entry=$(jq -n --arg p "$pattern_path" --argjson l "$line" '{pattern_path: $p, line: $l}')
1213
+ fi
1214
+ jq -n --arg f "$file" --argjson a "[$entry]" --arg ts "$timestamp" '{file: $f, annotations: $a, last_scanned: $ts, scanner_version: "1.0"}' > "$manifest_path"
1215
+ fi
1216
+ }
1217
+
1218
+ # === SubstrateHardening (Shape C — pure JSON; per bassclef bet 2026-06-17a Phase Z) ===
1219
+
1220
+ # state_substrate_hardening_get
1221
+ # Read the substrate-hardening inventory. Returns the full inventory JSON
1222
+ # on stdout. Returns empty object `{}` if file does not exist.
1223
+ state_substrate_hardening_get() {
1224
+ local inventory="state/substrate-hardening/inventory.json"
1225
+ if [ ! -f "$inventory" ]; then
1226
+ echo "{}"
1227
+ return 0
1228
+ fi
1229
+ cat "$inventory"
1230
+ }
1231
+
1232
+ # state_substrate_hardening_update_hook <path> <field-json>
1233
+ # Upsert a single hook entry in the inventory. <field-json> is a JSON object
1234
+ # of fields to set (e.g., '{"tier": 0, "has_test": true, "hardened_at": "..."}').
1235
+ # Fields not in the JSON keep their existing values; new fields are added.
1236
+ # If the inventory does not exist, creates it with schema_version=v0 + this entry.
1237
+ state_substrate_hardening_update_hook() {
1238
+ local hook_path="$1"
1239
+ local field_json="$2"
1240
+ if [ -z "$hook_path" ] || [ -z "$field_json" ]; then
1241
+ echo "state_substrate_hardening_update_hook: requires <path> <field-json>" >&2
1242
+ return 2
1243
+ fi
1244
+ local inventory="state/substrate-hardening/inventory.json"
1245
+ mkdir -p "$(dirname "$inventory")"
1246
+ local timestamp
1247
+ timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
1248
+ if [ ! -f "$inventory" ]; then
1249
+ jq -n \
1250
+ --arg ts "$timestamp" \
1251
+ --arg path "$hook_path" \
1252
+ --argjson fields "$field_json" \
1253
+ '{schema_version: "v0", generated_at: $ts, hooks: [({path: $path} + $fields)]}' \
1254
+ > "$inventory"
1255
+ return 0
1256
+ fi
1257
+ jq \
1258
+ --arg ts "$timestamp" \
1259
+ --arg path "$hook_path" \
1260
+ --argjson fields "$field_json" \
1261
+ '
1262
+ .generated_at = $ts |
1263
+ .hooks = (
1264
+ if (.hooks | map(select(.path == $path)) | length) > 0 then
1265
+ .hooks | map(if .path == $path then (. + $fields) else . end)
1266
+ else
1267
+ .hooks + [({path: $path} + $fields)]
1268
+ end
1269
+ )
1270
+ ' "$inventory" > "${inventory}.tmp" && mv "${inventory}.tmp" "$inventory"
1271
+ }
1272
+
1273
+ # === Release status accessor (per bet 2026-06-29c WU-2) ===
1274
+ #
1275
+ # state_whereami_release_status — returns JSON of release pipeline state.
1276
+ # Live (not cached). Composes data from:
1277
+ # - public bassclef HEAD sha (via bassclef-version.json on public bassclef)
1278
+ # - commits-ahead-of-public (local git rev-list)
1279
+ # - open release PR if any (number, age in hours, CI status)
1280
+ #
1281
+ # Shape:
1282
+ # {
1283
+ # "public_bassclef_head_sha": "<sha>" or null,
1284
+ # "commits_ahead_of_public": <int>,
1285
+ # "open_release_pr": null OR {
1286
+ # "number": <int>,
1287
+ # "age_hours": <int>,
1288
+ # "ci_status": "pass" | "fail" | "pending" | "unknown",
1289
+ # "title": "<str>",
1290
+ # "url": "<str>"
1291
+ # }
1292
+ # }
1293
+ #
1294
+ # Test-only overrides:
1295
+ # COMMITS_AHEAD_OVERRIDE — skip git computation; use this value
1296
+ # PUBLIC_SHA_OVERRIDE — skip gh api call for sha; use this value
1297
+ #
1298
+ # Returns empty when gh missing or fails (graceful).
1299
+ state_whereami_release_status() {
1300
+ if ! command -v gh >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then
1301
+ return 0
1302
+ fi
1303
+
1304
+ # Resolve public_bassclef_head_sha
1305
+ local sha
1306
+ if [ -n "${PUBLIC_SHA_OVERRIDE:-}" ]; then
1307
+ sha="$PUBLIC_SHA_OVERRIDE"
1308
+ else
1309
+ sha=$(gh api repos/sunj-labs/bassclef/contents/bassclef-version.json --jq '.content' 2>/dev/null | base64 -d 2>/dev/null | jq -r '.release_sha // empty' 2>/dev/null)
1310
+ [ -z "$sha" ] && sha=""
1311
+ fi
1312
+
1313
+ # Resolve commits_ahead_of_public
1314
+ local commits_ahead
1315
+ if [ -n "${COMMITS_AHEAD_OVERRIDE:-}" ]; then
1316
+ commits_ahead="$COMMITS_AHEAD_OVERRIDE"
1317
+ elif [ -n "$sha" ]; then
1318
+ commits_ahead=$(git rev-list --count "${sha}..HEAD" 2>/dev/null || echo "0")
1319
+ else
1320
+ commits_ahead=0
1321
+ fi
1322
+
1323
+ # Resolve open_release_pr
1324
+ local pr_json="null"
1325
+ local pr_list
1326
+ pr_list=$(gh pr list -R sunj-labs/bassclef --state open --json number,title,createdAt,headRefName,statusCheckRollup,url 2>/dev/null || echo "[]")
1327
+ local release_pr_entry
1328
+ release_pr_entry=$(echo "$pr_list" | jq -c '
1329
+ [.[] | select(.headRefName | startswith("release-"))][0] // null
1330
+ ' 2>/dev/null)
1331
+ if [ -n "$release_pr_entry" ] && [ "$release_pr_entry" != "null" ]; then
1332
+ local pr_date pr_epoch now_epoch age_hours pr_num pr_title pr_url ci_status
1333
+ pr_date=$(echo "$release_pr_entry" | jq -r '.createdAt')
1334
+ pr_num=$(echo "$release_pr_entry" | jq -r '.number')
1335
+ pr_title=$(echo "$release_pr_entry" | jq -r '.title')
1336
+ pr_url=$(echo "$release_pr_entry" | jq -r '.url')
1337
+ pr_epoch=$(date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$pr_date" +%s 2>/dev/null || date -u -d "$pr_date" +%s 2>/dev/null || echo "0")
1338
+ now_epoch=$(date -u +%s)
1339
+ age_hours=$(( (now_epoch - pr_epoch) / 3600 ))
1340
+
1341
+ # CI status: derive from statusCheckRollup
1342
+ local rollup_states
1343
+ rollup_states=$(echo "$release_pr_entry" | jq -r '.statusCheckRollup[]?.conclusion // empty' 2>/dev/null | sort -u)
1344
+ if echo "$rollup_states" | grep -qiE "FAILURE|CANCELLED"; then
1345
+ ci_status="fail"
1346
+ elif echo "$rollup_states" | grep -qi "SUCCESS"; then
1347
+ ci_status="pass"
1348
+ elif [ -z "$rollup_states" ]; then
1349
+ ci_status="pending"
1350
+ else
1351
+ ci_status="unknown"
1352
+ fi
1353
+
1354
+ pr_json=$(jq -n \
1355
+ --argjson num "$pr_num" \
1356
+ --argjson age "$age_hours" \
1357
+ --arg ci "$ci_status" \
1358
+ --arg title "$pr_title" \
1359
+ --arg url "$pr_url" \
1360
+ '{number: $num, age_hours: $age, ci_status: $ci, title: $title, url: $url}')
1361
+ fi
1362
+
1363
+ # Compose final JSON
1364
+ local sha_json="null"
1365
+ [ -n "$sha" ] && sha_json=$(jq -n --arg s "$sha" '$s')
1366
+
1367
+ jq -n \
1368
+ --argjson sha "$sha_json" \
1369
+ --argjson commits "$commits_ahead" \
1370
+ --argjson pr "$pr_json" \
1371
+ '{public_bassclef_head_sha: $sha, commits_ahead_of_public: $commits, open_release_pr: $pr}'
1372
+ }