@promptctl/cc-candybar 1.1.1 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -91,10 +91,10 @@
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.1.1",
95
- "@promptctl/cc-candybar-darwin-x64": "1.1.1",
96
- "@promptctl/cc-candybar-linux-x64": "1.1.1",
97
- "@promptctl/cc-candybar-linux-arm64": "1.1.1"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.3.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.3.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.3.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.3.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -108,6 +108,29 @@ function blockLikeFg(pctRef: string): string {
108
108
  );
109
109
  }
110
110
 
111
+ // [LAW:dataflow-not-control-flow] The burn segment heats as the cap NEARS, so
112
+ // the cascade reads the projected minutes-to-cap (smaller = hotter), the
113
+ // inverse direction of blockLikeBg's fuller-is-hotter. The not-projectable
114
+ // sentinel (-1, sorts below every threshold) is caught first so "we cannot
115
+ // project" colors calm, never error. Thresholds are var refs so a user
116
+ // overrides them through the same by-name variables cascade.
117
+ function etaHeatBg(etaRef: string, warnRef: string, errRef: string): string {
118
+ return (
119
+ `{{ if lt ${etaRef} 0 }}panel` +
120
+ `{{ else }}{{ if lt ${etaRef} ${errRef} }}error` +
121
+ `{{ else }}{{ if lt ${etaRef} ${warnRef} }}warning` +
122
+ `{{ else }}panel{{ end }}{{ end }}{{ end }}`
123
+ );
124
+ }
125
+
126
+ function etaHeatFg(etaRef: string, warnRef: string): string {
127
+ return (
128
+ `{{ if lt ${etaRef} 0 }}foreground` +
129
+ `{{ else }}{{ if lt ${etaRef} ${warnRef} }}button-color-foreground` +
130
+ `{{ else }}foreground{{ end }}{{ end }}`
131
+ );
132
+ }
133
+
111
134
  // ─── The default config ──────────────────────────────────────────────────────
112
135
 
113
136
  export const DEFAULT_DSL_CONFIG = {
@@ -136,6 +159,14 @@ export const DEFAULT_DSL_CONFIG = {
136
159
  path: "workspace.project_dir",
137
160
  default: "",
138
161
  },
162
+ // Transcript path (a top-level hookData field, spread onto the payload
163
+ // root by buildRenderPayload). Read by the quick-action tray's
164
+ // openTranscript action — pass-through, no projection.
165
+ transcript_path: {
166
+ kind: "input",
167
+ path: "transcript_path",
168
+ default: "",
169
+ },
139
170
  "model.display_name": {
140
171
  kind: "input",
141
172
  path: "model.display_name",
@@ -327,6 +358,34 @@ export const DEFAULT_DSL_CONFIG = {
327
358
  },
328
359
  "weekly.budget.warningThreshold": { kind: "literal", value: 80 },
329
360
 
361
+ // Burn rate + cap projection — daemon-derived (see render-payload.ts).
362
+ // Each projection is ABSENT when not projectable; the var-system fills the
363
+ // -1 default, a structurally-impossible value the burnrate helpers read as
364
+ // "—" [LAW:no-silent-failure] (0 minutes / $0-per-hr are real, displayable
365
+ // values, so they cannot double as the absence marker).
366
+ "burn.costPerHour": {
367
+ kind: "input",
368
+ path: "burn.costPerHour",
369
+ type: "number",
370
+ default: -1,
371
+ },
372
+ "block.etaMinutes": {
373
+ kind: "input",
374
+ path: "block.etaMinutes",
375
+ type: "number",
376
+ default: -1,
377
+ },
378
+ "weekly.etaMinutes": {
379
+ kind: "input",
380
+ path: "weekly.etaMinutes",
381
+ type: "number",
382
+ default: -1,
383
+ },
384
+ // ETA-heat thresholds (minutes-to-cap) — overridable per-config through the
385
+ // variables-merge-by-name cascade, like the *.budget.warningThreshold knobs.
386
+ "burn.eta.warnMinutes": { kind: "literal", value: 60 },
387
+ "burn.eta.errorMinutes": { kind: "literal", value: 30 },
388
+
330
389
  // Context — daemon fetches via ContextProvider; contextLeftPercentage.
331
390
  "context.totalTokens": {
332
391
  kind: "input",
@@ -446,10 +505,16 @@ export const DEFAULT_DSL_CONFIG = {
446
505
  fg: "foreground",
447
506
  when: '{{ ne .git.branch "" }}',
448
507
  },
508
+ // Quick-action tray — copy the session id / cwd, open the project dir /
509
+ // transcript in the editor. [LAW:locality-or-seam] The glyph is the
510
+ // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
511
+ // name is the seam between them. Re-glyph without touching behavior;
512
+ // re-target without touching this template. Each `{{ action … }}` emits
513
+ // one OSC-8 clickable region whose URL the wire codec owns end-to-end.
449
514
  toolbar: {
450
515
  template:
451
- ' {{ link (printf "cc-candybar://open-vscode/%s" (urlEncode .current_dir)) "\u{1F4C2}" }}' +
452
- ' {{ link (printf "cc-candybar://copy/%s" (urlEncode (trunc 8 .session.id))) "" }} ',
516
+ ' {{ action "copySession" "⎘ id" }} {{ action "copyDir" "⎘ cwd" }}' +
517
+ ' {{ action "openProject" "↗ proj" }} {{ action "openTranscript" "↗ log" }} ',
453
518
  bg: "surface",
454
519
  fg: "foreground",
455
520
  },
@@ -486,6 +551,24 @@ export const DEFAULT_DSL_CONFIG = {
486
551
  fg: blockLikeFg(".weekly.percentage"),
487
552
  when: "{{ gt .weekly.resetsAt 0 }}",
488
553
  },
554
+ // Burn rate + cap projection: "$X/hr · Nm to 5h · Nd to wk". The headline
555
+ // number of a usage monitor — how fast you are spending and when you hit
556
+ // the wall. All math is daemon-side (render-payload.ts); the template only
557
+ // formats. Heats as the 5h cap nears (etaHeat*). Shown when either
558
+ // rate-limit window is active — the same signal block/weekly gate on.
559
+ burnrate: {
560
+ template:
561
+ ' ⚡ {{ template "formatRate" .burn.costPerHour }} · ' +
562
+ '{{ template "formatEta" .block.etaMinutes }} to 5h · ' +
563
+ '{{ template "formatEta" .weekly.etaMinutes }} to wk ',
564
+ bg: etaHeatBg(
565
+ ".block.etaMinutes",
566
+ ".burn.eta.warnMinutes",
567
+ ".burn.eta.errorMinutes",
568
+ ),
569
+ fg: etaHeatFg(".block.etaMinutes", ".burn.eta.warnMinutes"),
570
+ when: "{{ or (gt .block.resetsAt 0) (gt .weekly.resetsAt 0) }}",
571
+ },
489
572
  // Prompt-cache warmth countdown. minutesUntilReset clamps a past expiry
490
573
  // to 0, so an expired cache renders "cold" (and reads red via the ≤8
491
574
  // arm) rather than a negative number. [LAW:dataflow-not-control-flow]
@@ -543,7 +626,7 @@ export const DEFAULT_DSL_CONFIG = {
543
626
  },
544
627
 
545
628
  // Default layout — a single horizontal row of segment refs.
546
- // A-grammar equivalent: { h: ["directory","git","model","session","today","context"] }
629
+ // A-grammar equivalent: { h: ["directory","git","model","session","today","context","toolbar"] }
547
630
  // [LAW:one-source-of-truth] The bundled default now authors the same terse
548
631
  // surface every user config lowers to, so the default is the reference spelling.
549
632
  // Adding rows = wrapping in { kind:"container", direction:"vertical", children:[...] };
@@ -558,14 +641,30 @@ export const DEFAULT_DSL_CONFIG = {
558
641
  { kind: "segment", name: "session" },
559
642
  { kind: "segment", name: "today" },
560
643
  { kind: "segment", name: "context" },
644
+ { kind: "segment", name: "toolbar" },
561
645
  ],
562
646
  },
563
647
 
564
- // [LAW:locality-or-seam] No decoupled actions in the bundled default either —
565
- // the baseline statusline binds no clickable regions. A user config declares
566
- // named actions and binds them from a segment template via
567
- // `{{ action "name" }}`; the merge cascade adds them by name.
568
- actions: {},
648
+ // [LAW:locality-or-seam] The quick-action tray's behaviors, decoupled by NAME
649
+ // from the `toolbar` segment's glyphs above. copy/open evaluate a Go-template
650
+ // against the live render scope at click time and write NO SessionState, so
651
+ // they derive no state validator (no gate) they are pure click effects.
652
+ //
653
+ // [LAW:single-enforcer] Each template emits a RAW value; the click-wire codec
654
+ // (effectsUrl → encodeSegments) owns ALL percent-encoding and the verb's
655
+ // `oneArg` owns the single matching decode — so the template never hand-rolls
656
+ // a `urlEncode`, and the path round-trips untouched through one codec.
657
+ //
658
+ // open* route through the open-vscode verb (`open -a "Visual Studio Code"
659
+ // <path>`), so they pass a bare filesystem path — a directory or a file the
660
+ // editor opens directly — NOT a `vscode://` URL (which `open -a` would treat
661
+ // as a literal filename, not a deep link).
662
+ actions: {
663
+ copySession: { copy: "{{ .session.id }}" },
664
+ copyDir: { copy: "{{ .current_dir }}" },
665
+ openProject: { open: "{{ .project_dir }}" },
666
+ openTranscript: { open: "{{ .transcript_path }}" },
667
+ },
569
668
 
570
669
  // [LAW:single-enforcer] / [LAW:one-source-of-truth] Display-formatting policy
571
670
  // for the cost/token/budget family lives here as named template helpers, each
@@ -592,6 +691,16 @@ export const DEFAULT_DSL_CONFIG = {
592
691
  '{{ else if ge . 1000 }}{{ printf "%.1f" (divf . 1000) }}K' +
593
692
  "{{ else }}{{ . }}{{ end }}",
594
693
  formatTokens: '{{ template "formatTokenCount" . }} tokens',
694
+ // Burn rate: "$X.XX/hr" when projectable, "—/hr" otherwise. The daemon
695
+ // emits -1 (a structurally-impossible rate) for not-projectable, so the
696
+ // branch reads a VALUE, never a hidden control-flow flag. Reuses formatCost
697
+ // so the dollar policy has one home.
698
+ formatRate:
699
+ '{{ if lt . 0 }}—/hr{{ else }}{{ template "formatCost" . }}/hr{{ end }}',
700
+ // ETA to a rate-limit cap: humanized minutes when projectable, "—" when the
701
+ // daemon could not project (-1 sentinel). Reuses the long-remaining cascade.
702
+ formatEta:
703
+ '{{ if lt . 0 }}—{{ else }}{{ template "formatLongTimeRemaining" . }}{{ end }}',
595
704
  // Breakdown over a dict {input, output, cacheCreation, cacheRead}; each present
596
705
  // part is formatted by the shared formatTokenCount and joined with " + ". A
597
706
  // `$first` flag (reassigned across if-frames) inserts the separator before all
@@ -300,12 +300,11 @@ export class RenderCache {
300
300
  // reloadInto preserves the prior last-known-good with nothing half-installed.
301
301
  const validatorDisposers: Array<() => void> = [];
302
302
  try {
303
- // [LAW:locality-or-seam] Pass the store so the config's `widget`
304
- // references can read session.id + current picker values from the same
305
- // source the rest of the render reads.
303
+ // [LAW:one-source-of-truth] The action runtime reads session.id + current
304
+ // picker values from registry.variableStore the same store this entry's
305
+ // registry declares into so no store reference is threaded separately.
306
306
  compiled = registerDslConfig(config, registry, {
307
307
  cwd: entry.cwd,
308
- store,
309
308
  });
310
309
  // [LAW:one-source-of-truth] Derive the writable-key validators from the
311
310
  // config's action table (the sole interaction authority) through one
@@ -64,6 +64,7 @@ export interface RenderPayload extends ClaudeHookData {
64
64
  // exactly like a missing top-level field.
65
65
  readonly session?: SessionPayload;
66
66
  readonly today?: TodayPayload;
67
+ readonly burn?: BurnPayload;
67
68
  readonly block?: BlockPayload;
68
69
  readonly weekly?: WeeklyPayload;
69
70
  readonly cache?: CachePayload;
@@ -105,14 +106,31 @@ export interface TodayPayload {
105
106
  readonly tokens?: number;
106
107
  }
107
108
 
109
+ // [LAW:one-type-per-behavior] The burn rate is the session's spend velocity —
110
+ // dollars per wall-clock hour — a derivative of the same cost the `session`
111
+ // segment totals, so it is its own concept, not a field bolted onto the
112
+ // totals. Optional because a too-young session yields no honest rate
113
+ // ([LAW:no-silent-failure] — absence over a single-turn artifact).
114
+ export interface BurnPayload {
115
+ readonly costPerHour?: number;
116
+ }
117
+
108
118
  export interface BlockPayload {
109
119
  readonly nativeUtilization: number;
110
120
  readonly resetsAt: number;
121
+ // [LAW:types-are-the-program] Linear projection of nativeUtilization → 100%
122
+ // at the current rate, in whole minutes. Absent (not 0, not a sentinel
123
+ // in the type) when the window is too young or shows no usage to project
124
+ // from — the ETA's "we cannot say" state is unrepresentable as a number,
125
+ // so it travels as a missing field to the DSL default. [LAW:no-silent-failure]
126
+ readonly etaMinutes?: number;
111
127
  }
112
128
 
113
129
  export interface WeeklyPayload {
114
130
  readonly percentage: number;
115
131
  readonly resetsAt: number;
132
+ // Same projection as BlockPayload.etaMinutes over the seven-day window.
133
+ readonly etaMinutes?: number;
116
134
  }
117
135
 
118
136
  // Prompt-cache warmth. One field — the epoch-seconds expiry instant —
@@ -158,6 +176,64 @@ export interface RenderPayloadDeps {
158
176
  // buildRenderPayload is the ONE place lane failures are logged, so the
159
177
  // providers' interiors never log and never double-log.
160
178
  readonly log: DaemonLogger;
179
+ // [LAW:single-enforcer] The one clock the projection math reads "now" from —
180
+ // the same seam threaded to the template engine's `minutesUntilReset`, so
181
+ // an ETA and the reset countdown beside it agree on the instant. Omitted ⇒
182
+ // wall clock; tests inject a frozen clock for determinism.
183
+ readonly clock?: () => Date;
184
+ }
185
+
186
+ // ─── Rate-limit projection (pure) ──────────────────────────────────────────────
187
+ //
188
+ // [LAW:effects-at-boundaries] The math is pure — utilization, reset instant,
189
+ // window length and `now` in; minutes-to-cap out. The only effect (reading the
190
+ // clock) stays in buildRenderPayload; these stay testable in isolation.
191
+
192
+ // Window lengths are facts of Claude's rate-limit cadence, not config: the
193
+ // five-hour block and the seven-day window.
194
+ const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
195
+ const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
196
+ // Below this much elapsed in a window, a linear projection from one early data
197
+ // point is noise — surface no ETA rather than a confidently-wrong number.
198
+ const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
199
+ // The same floor for the spend rate: under a minute of session wall-clock,
200
+ // $/hr is dominated by a single turn rather than a sustained burn.
201
+ const MIN_BURN_SECONDS = 60;
202
+
203
+ /**
204
+ * Linearly extrapolate a rate-limit window's utilization to its 100% cap.
205
+ * The window started `windowMs` before `resetsAtSec`; elapsed time and the
206
+ * used-% give a rate, and the remaining headroom divided by that rate is the
207
+ * minutes-to-cap. Returns undefined when the window is too young to project
208
+ * or shows no usage yet — the caller drops the field and the segment renders
209
+ * "—" rather than a fabricated ETA. [LAW:no-silent-failure]
210
+ */
211
+ export function projectEtaMinutes(
212
+ usedPercentage: number,
213
+ resetsAtSec: number,
214
+ windowMs: number,
215
+ nowMs: number,
216
+ ): number | undefined {
217
+ const elapsedMs = windowMs - (resetsAtSec * 1000 - nowMs);
218
+ if (elapsedMs < MIN_PROJECTABLE_ELAPSED_MS || usedPercentage <= 0)
219
+ return undefined;
220
+ const pctPerMs = usedPercentage / elapsedMs;
221
+ const etaMs = (100 - usedPercentage) / pctPerMs;
222
+ return Math.max(0, Math.round(etaMs / 60000));
223
+ }
224
+
225
+ /**
226
+ * Session spend rate in dollars per hour: cost over wall-clock duration.
227
+ * Returns undefined under a wall-clock floor where the rate is a single-turn
228
+ * artifact, not a sustained burn. A real $0 over enough time is a true 0/hr,
229
+ * not absence. [LAW:no-silent-failure]
230
+ */
231
+ export function projectCostPerHour(
232
+ cost: number,
233
+ durationSeconds: number,
234
+ ): number | undefined {
235
+ if (durationSeconds < MIN_BURN_SECONDS) return undefined;
236
+ return (cost * 3600) / durationSeconds;
161
237
  }
162
238
 
163
239
  // ─── Builder ─────────────────────────────────────────────────────────────────
@@ -376,8 +452,13 @@ export async function buildRenderPayload(
376
452
  hookData.workspace?.project_dir,
377
453
  ),
378
454
  ),
379
- lane("session", wants("session.cost") || wants("session.tokens"), () =>
380
- deps.usageStore.getUsageInfo(hookData.session_id, hookData),
455
+ // [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
456
+ // a derivative of session cost and metrics duration — so wanting `burn`
457
+ // pulls in exactly the two lanes it is folded from.
458
+ lane(
459
+ "session",
460
+ wants("session.cost") || wants("session.tokens") || wants("burn"),
461
+ () => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
381
462
  ),
382
463
  lane("today", wants("today"), () =>
383
464
  deps.usageStore.getTodayInfo(hookData),
@@ -385,7 +466,7 @@ export async function buildRenderPayload(
385
466
  lane("context", wants("context"), () =>
386
467
  deps.contextProvider.getContextInfo(hookData),
387
468
  ),
388
- lane("metrics", wants("metrics"), () =>
469
+ lane("metrics", wants("metrics") || wants("burn"), () =>
389
470
  deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
390
471
  ),
391
472
  lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
@@ -423,6 +504,34 @@ export async function buildRenderPayload(
423
504
  // `minutesUntilReset(resets_at)`, which the DSL template composes via
424
505
  // the formatter func — a duplicate code path was retired.)
425
506
  const fiveHour = hookData.rate_limits?.five_hour;
507
+ const sevenDay = hookData.rate_limits?.seven_day;
508
+ // [LAW:single-enforcer] One clock read feeds every projection this render.
509
+ const nowMs = (deps.clock ?? (() => new Date()))().getTime();
510
+ const blockEta = fiveHour
511
+ ? projectEtaMinutes(
512
+ fiveHour.used_percentage,
513
+ fiveHour.resets_at,
514
+ FIVE_HOUR_MS,
515
+ nowMs,
516
+ )
517
+ : undefined;
518
+ const weeklyEta = sevenDay
519
+ ? projectEtaMinutes(
520
+ sevenDay.used_percentage,
521
+ sevenDay.resets_at,
522
+ SEVEN_DAY_MS,
523
+ nowMs,
524
+ )
525
+ : undefined;
526
+ // [LAW:dataflow-not-control-flow] Gated by `wants("burn")` so the rate is
527
+ // computed only when a layout segment reads it; absent cost/duration (lane
528
+ // skipped or provider empty) yields no rate, never a fabricated one.
529
+ const burnCost = usageValue?.session.cost;
530
+ const burnDuration = metricsValue?.sessionDuration;
531
+ const costPerHour =
532
+ wants("burn") && burnCost != null && burnDuration != null
533
+ ? projectCostPerHour(burnCost, burnDuration)
534
+ : undefined;
426
535
 
427
536
  // [LAW:one-source-of-truth] The theme variable surfaces the session's
428
537
  // resolved theme so the toolbar/tray DSL templates can encode it into
@@ -487,6 +596,7 @@ export async function buildRenderPayload(
487
596
  ...(theme !== undefined && { theme }),
488
597
  ...(sessionPayload !== undefined && { session: sessionPayload }),
489
598
  ...(todayPayload !== undefined && { today: todayPayload }),
599
+ ...(costPerHour !== undefined && { burn: { costPerHour } }),
490
600
  ...(wants("block") &&
491
601
  fiveHour !== undefined && {
492
602
  block: {
@@ -496,12 +606,14 @@ export async function buildRenderPayload(
496
606
  // projection rule, two segments.
497
607
  nativeUtilization: fiveHour.used_percentage,
498
608
  resetsAt: fiveHour.resets_at,
609
+ ...(blockEta !== undefined && { etaMinutes: blockEta }),
499
610
  },
500
611
  }),
501
- ...(hookData.rate_limits?.seven_day !== undefined && {
612
+ ...(sevenDay !== undefined && {
502
613
  weekly: {
503
- percentage: hookData.rate_limits.seven_day.used_percentage,
504
- resetsAt: hookData.rate_limits.seven_day.resets_at,
614
+ percentage: sevenDay.used_percentage,
615
+ resetsAt: sevenDay.resets_at,
616
+ ...(weeklyEta !== undefined && { etaMinutes: weeklyEta }),
505
617
  },
506
618
  }),
507
619
  ...(cacheValue !== undefined && {
@@ -1076,6 +1076,9 @@ const payloadDeps = {
1076
1076
  // [LAW:single-enforcer] buildRenderPayload is the one log site for the
1077
1077
  // outcome-carrying provider lanes (git, cache).
1078
1078
  log: dlog,
1079
+ // [LAW:single-enforcer] The daemon's wall clock — the same instant source
1080
+ // the rate-limit ETA projection and the template's reset countdown read.
1081
+ clock: () => new Date(),
1079
1082
  };
1080
1083
 
1081
1084
  function handleClick(verb: string, value: string): Response {
package/src/demo/dsl.ts CHANGED
@@ -79,7 +79,6 @@ const registry = new SourceRegistry(store);
79
79
  try {
80
80
  const compiled = registerDslConfig(config, registry, {
81
81
  cwd: process.cwd(),
82
- store,
83
82
  });
84
83
 
85
84
  process.stdout.write(
package/src/dsl/render.ts CHANGED
@@ -252,7 +252,7 @@ function compileHelperPreamble(
252
252
  export function registerDslConfig(
253
253
  config: ValidatedConfig,
254
254
  registry: SourceRegistry,
255
- opts?: { cwd?: string; store?: VariableStore; clock?: () => Date },
255
+ opts?: { cwd?: string; clock?: () => Date },
256
256
  ): CompiledConfig {
257
257
  const cwd = opts?.cwd ?? process.cwd();
258
258
 
@@ -262,11 +262,12 @@ export function registerDslConfig(
262
262
  // because the action set is config-scoped. The runtime holder is populated below
263
263
  // — the `action`/`picker` funcs reference the engine, and the compiled actions
264
264
  // reference the engine, so the holder breaks that cycle.
265
- // [LAW:no-defensive-null-guards] store may be absent for compile-only callers
266
- // with no actions; renderAction throws loudly if an action is actually used
267
- // without a store, rather than silently rendering an empty click.
265
+ // [LAW:one-source-of-truth] The action runtime reads through the SAME store the
266
+ // registry declares into and the renderer reads back sourced from the registry
267
+ // itself, not a redundant opts field a caller could forget (or pass a divergent
268
+ // store for). Every config has a registry, so the action store is never null.
268
269
  const actionRuntime: ActionRuntime = {
269
- store: opts?.store ?? null,
270
+ store: registry.variableStore,
270
271
  compiled: new Map(),
271
272
  };
272
273
  // [LAW:one-way-deps] Inject action + picker feature funcs as data — the engine
@@ -117,7 +117,11 @@ export function optionDomain(src: OptionSource): readonly string[] {
117
117
  // reads session.id and the current value from the same source the rest of the
118
118
  // render does.
119
119
  export interface ActionRuntime {
120
- store: VariableStore | null;
120
+ // [LAW:types-are-the-program] Always present — registerDslConfig sources it
121
+ // from the registry it is handed (registry.variableStore), so "no store" is
122
+ // structurally unrepresentable. The action reads session.id and current
123
+ // values from the same store the renderer reads.
124
+ store: VariableStore;
121
125
  compiled: CompiledActions;
122
126
  }
123
127
 
@@ -416,11 +420,6 @@ export function renderAction(
416
420
  throw new Error(`action "${name}" is not declared in this config`);
417
421
  }
418
422
  const store = runtime.store;
419
- if (!store) {
420
- throw new Error(
421
- `action "${name}" rendered without a VariableStore — registerDslConfig was not given one`,
422
- );
423
- }
424
423
  const { display, boundValue } = selectDisplay(name, action, displays, store);
425
424
  const sessionId = readVar(store, "session.id");
426
425
  const { effect, active } = realize(
@@ -144,11 +144,6 @@ function renderPicker(
144
144
  "an int action ({ set, int: true })",
145
145
  );
146
146
  const store = runtime.store;
147
- if (!store) {
148
- throw new Error(
149
- `picker "${applyName}" rendered without a VariableStore — registerDslConfig was not given one`,
150
- );
151
- }
152
147
  const sessionId = readVar(store, "session.id");
153
148
  const current = readVar(store, apply.stateVar);
154
149
  const widths = apply.options.map(cellWidth);
@@ -580,6 +580,15 @@ export class SourceRegistry {
580
580
  this.sessionState = sessionState;
581
581
  }
582
582
 
583
+ // [LAW:one-source-of-truth] The registry IS the owner of its store — every
584
+ // variable it declares lives there, and the renderer reads back through it.
585
+ // Exposing it read-only lets a caller that already holds the registry obtain
586
+ // the one store without threading a second reference that could diverge (the
587
+ // action runtime reads session.id/current values from this exact store).
588
+ get variableStore(): VariableStore {
589
+ return this.store;
590
+ }
591
+
583
592
  // ─── Synchronous source kinds ─────────────────────────────────────────────
584
593
 
585
594
  // literal: type inferred from value; box written once at declaration and never again.