@promptctl/cc-candybar 1.0.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 (111) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +145 -0
  3. package/bin/cc-candybar +6 -0
  4. package/dist/index.mjs +185 -0
  5. package/package.json +99 -0
  6. package/plugin/.claude-plugin/plugin.json +11 -0
  7. package/plugin/bin/preview.sh +305 -0
  8. package/plugin/commands/candybar.md +403 -0
  9. package/plugin/templates/config-essential.json +36 -0
  10. package/plugin/templates/config-full.json +55 -0
  11. package/plugin/templates/config-standard.json +39 -0
  12. package/plugin/templates/config-tui-compact.json +48 -0
  13. package/plugin/templates/config-tui-full.json +89 -0
  14. package/plugin/templates/config-tui-standard.json +56 -0
  15. package/plugin/templates/config-tui.json +18 -0
  16. package/plugin/templates/nerd-fonts-sample.txt +5 -0
  17. package/schema/cc-candybar.schema.json +1379 -0
  18. package/src/click/wire.ts +113 -0
  19. package/src/config/action.ts +91 -0
  20. package/src/config/cli.ts +170 -0
  21. package/src/config/default-dsl-config.ts +661 -0
  22. package/src/config/dsl-loader.ts +265 -0
  23. package/src/config/dsl-types.ts +425 -0
  24. package/src/config/loader/actions.ts +530 -0
  25. package/src/config/loader/cache.ts +206 -0
  26. package/src/config/loader/cross-ref.ts +326 -0
  27. package/src/config/loader/cycles.ts +148 -0
  28. package/src/config/loader/diagnostics.ts +99 -0
  29. package/src/config/loader/discovery.ts +182 -0
  30. package/src/config/loader/emit-schema.ts +63 -0
  31. package/src/config/loader/globals.ts +42 -0
  32. package/src/config/loader/helpers.ts +48 -0
  33. package/src/config/loader/layout.ts +688 -0
  34. package/src/config/loader/merge.ts +40 -0
  35. package/src/config/loader/refs.ts +96 -0
  36. package/src/config/loader/segments.ts +120 -0
  37. package/src/config/loader/validate-core.ts +674 -0
  38. package/src/config/loader/variables.ts +260 -0
  39. package/src/daemon/acquire.ts +411 -0
  40. package/src/daemon/cache/git.ts +553 -0
  41. package/src/daemon/cache/render.ts +449 -0
  42. package/src/daemon/cache/session-usage-store.ts +446 -0
  43. package/src/daemon/cache/watchers.ts +245 -0
  44. package/src/daemon/client-debug.ts +120 -0
  45. package/src/daemon/client-stats.ts +129 -0
  46. package/src/daemon/client-transport.ts +273 -0
  47. package/src/daemon/client.ts +75 -0
  48. package/src/daemon/debug-types.ts +91 -0
  49. package/src/daemon/debug.ts +264 -0
  50. package/src/daemon/limits.ts +154 -0
  51. package/src/daemon/log.ts +69 -0
  52. package/src/daemon/parent-watchdog.ts +80 -0
  53. package/src/daemon/paths.ts +127 -0
  54. package/src/daemon/protocol.ts +235 -0
  55. package/src/daemon/render-payload.ts +611 -0
  56. package/src/daemon/server.ts +1103 -0
  57. package/src/daemon/session-state-file.ts +108 -0
  58. package/src/daemon/session-state.ts +237 -0
  59. package/src/daemon/stats.ts +229 -0
  60. package/src/daemon/verbs/index.ts +458 -0
  61. package/src/daemon/verbs/state-validators.ts +708 -0
  62. package/src/demo/dsl.ts +117 -0
  63. package/src/demo/mock-data.ts +67 -0
  64. package/src/demo/statusline.json5 +92 -0
  65. package/src/dsl/node-registry.ts +281 -0
  66. package/src/dsl/render.ts +558 -0
  67. package/src/index.ts +206 -0
  68. package/src/install/index.ts +410 -0
  69. package/src/proc/launch.ts +451 -0
  70. package/src/proc/stats-handle.ts +13 -0
  71. package/src/render/action.ts +458 -0
  72. package/src/render/diagnostic-style.ts +23 -0
  73. package/src/render/diagnostic-text.ts +77 -0
  74. package/src/render/error-glyph.ts +53 -0
  75. package/src/render/outcome-plan.ts +45 -0
  76. package/src/render/picker.ts +231 -0
  77. package/src/render/split-lines.ts +51 -0
  78. package/src/render/strip.ts +103 -0
  79. package/src/segments/cache.ts +131 -0
  80. package/src/segments/context.ts +190 -0
  81. package/src/segments/git.ts +561 -0
  82. package/src/segments/metrics.ts +101 -0
  83. package/src/segments/pricing.ts +452 -0
  84. package/src/segments/session.ts +188 -0
  85. package/src/segments/tmux.ts +74 -0
  86. package/src/template-engine/cells.ts +90 -0
  87. package/src/template-engine/colors.ts +102 -0
  88. package/src/template-engine/engine.ts +108 -0
  89. package/src/template-engine/funcs.ts +216 -0
  90. package/src/template-engine/index.ts +11 -0
  91. package/src/template-engine/layout.ts +112 -0
  92. package/src/template-engine/scope.ts +62 -0
  93. package/src/themes/index.ts +19 -0
  94. package/src/themes/palette-resolvers.ts +86 -0
  95. package/src/themes/policy.ts +79 -0
  96. package/src/themes/session-random.ts +88 -0
  97. package/src/utils/cache.ts +206 -0
  98. package/src/utils/claude.ts +616 -0
  99. package/src/utils/color-support.ts +118 -0
  100. package/src/utils/formatters.ts +77 -0
  101. package/src/utils/logger.ts +5 -0
  102. package/src/utils/outcome.ts +33 -0
  103. package/src/utils/schema-validator.ts +126 -0
  104. package/src/utils/single-flight.ts +57 -0
  105. package/src/utils/terminal-width.ts +43 -0
  106. package/src/utils/terminal.ts +11 -0
  107. package/src/utils/transcript-fs.ts +162 -0
  108. package/src/var-system/index.ts +24 -0
  109. package/src/var-system/sources.ts +1038 -0
  110. package/src/var-system/store.ts +223 -0
  111. package/src/var-system/types.ts +57 -0
@@ -0,0 +1,611 @@
1
+ // [LAW:single-enforcer] The single place where the daemon assembles every
2
+ // data field the DSL templates can read. The output of this function — the
3
+ // `RenderPayload` — is fed verbatim to `registry.applyInput(...)` (inside
4
+ // renderDsl), and every `kind: "input"` variable in the default DSL
5
+ // config resolves its `path` against exactly this shape.
6
+ //
7
+ // [LAW:dataflow-not-control-flow] Variability lives in the values flowing
8
+ // through here, not in hand-coded branches. The set of providers actually
9
+ // invoked is selected by inspecting the DslConfig's declared input paths —
10
+ // the *config* (data) chooses what runs. Templates use `when` predicates
11
+ // and inline guards on the resulting values to decide what renders.
12
+ //
13
+ // [LAW:one-source-of-truth] `RenderPayload` is the contract between the
14
+ // daemon's data-provider fleet and the DSL config's input declarations. The
15
+ // default config in `src/config/default-dsl-config.ts` declares input paths
16
+ // that mirror this shape; user configs MUST agree (a path that doesn't
17
+ // resolve falls back to the variable's declared default).
18
+
19
+ import path from "node:path";
20
+ import type { ClaudeHookData } from "../utils/claude.js";
21
+ import type { DslConfig, VariableDecl } from "../config/dsl-types.js";
22
+ import { walkNodes } from "../config/dsl-types.js";
23
+ import { extractTemplateRefs } from "../config/dsl-loader.js";
24
+ import type { GitInfo, GitInfoOptions } from "../segments/git.js";
25
+ import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
26
+ import { cacheExpiresAt } from "../segments/cache.js";
27
+ import type { DaemonLogger } from "./log.js";
28
+ import type { SessionUsageStore } from "./cache/session-usage-store.js";
29
+ import type { ContextProvider } from "../segments/context.js";
30
+ import type { MetricsProvider } from "../segments/metrics.js";
31
+ import type { TmuxService } from "../segments/tmux.js";
32
+ import type { GitDataProvider } from "./cache/git.js";
33
+ import type { SessionStateRW } from "./session-state.js";
34
+
35
+ // ─── Augmented payload shape ─────────────────────────────────────────────────
36
+
37
+ // [LAW:types-are-the-program] The RenderPayload extends ClaudeHookData with
38
+ // daemon-computed fields. The new keys are all OPTIONAL on the type because
39
+ // individual provider failures (no transcript, no git repo, no tmux) leave
40
+ // their slots null/missing — and the DSL templates handle absence via
41
+ // `when`/inline guards, never by branching in this code.
42
+ export interface RenderPayload extends ClaudeHookData {
43
+ // env-style values surfaced as paths so the DSL can read them via `input`
44
+ // alongside the rest of the payload. (`kind: "env"` is also available for
45
+ // arbitrary env-var lookups in user configs.)
46
+ readonly home?: string;
47
+
48
+ // ─── Daemon-computed augmentations ───────────────────────────────────────
49
+ // The provider's null/absent return becomes a missing field; the DSL's
50
+ // input-var fallback chain fills in the default.
51
+
52
+ readonly git?: GitPayload;
53
+ readonly tmux?: { readonly session: string };
54
+ readonly theme?: string;
55
+
56
+ // Usage-family. Each provider returns null when it has no data (no
57
+ // transcript yet, no rate-limit window active, etc.); we drop the field
58
+ // rather than emit zeros, so an unconfigured user sees their declared
59
+ // `default`. Individual fields inside each sub-object are ALSO optional —
60
+ // "we have a metrics object but messageCount couldn't be computed" is
61
+ // representable distinct from "metrics object zeroed because we had to
62
+ // satisfy a non-optional type." Absence flows through `applyInput`'s
63
+ // fallback chain (which writes both the default value AND a last_error)
64
+ // exactly like a missing top-level field.
65
+ readonly session?: SessionPayload;
66
+ readonly today?: TodayPayload;
67
+ readonly block?: BlockPayload;
68
+ readonly weekly?: WeeklyPayload;
69
+ readonly cache?: CachePayload;
70
+ readonly context?: ContextPayload;
71
+ readonly metrics?: MetricsPayload;
72
+ }
73
+
74
+ // Flattened projection of GitInfo: every field shape the parity bindings
75
+ // reference. [LAW:one-type-per-behavior] Same absence policy as
76
+ // MetricsPayload: every field can independently be absent (not requested,
77
+ // genuinely none, or its fetch failed), and absence is PRESERVED — the DSL
78
+ // input fallback chain emits the declared `default` and records a
79
+ // `last_error` per field. The old coerce-to-''/0 shim erased the distinction
80
+ // between "no stashes" and "stash count unknown because git failed".
81
+ export interface GitPayload {
82
+ readonly repoName?: string;
83
+ readonly branch?: string;
84
+ readonly sha?: string;
85
+ readonly ahead?: number;
86
+ readonly behind?: number;
87
+ readonly staged?: number;
88
+ readonly unstaged?: number;
89
+ readonly untracked?: number;
90
+ readonly conflicts?: number;
91
+ readonly upstream?: string;
92
+ readonly stash?: number;
93
+ readonly status?: string;
94
+ readonly operation?: string;
95
+ readonly timeSinceCommit?: number;
96
+ }
97
+
98
+ export interface SessionPayload {
99
+ readonly cost?: number;
100
+ readonly tokens?: number;
101
+ }
102
+
103
+ export interface TodayPayload {
104
+ readonly cost?: number;
105
+ readonly tokens?: number;
106
+ }
107
+
108
+ export interface BlockPayload {
109
+ readonly nativeUtilization: number;
110
+ readonly resetsAt: number;
111
+ }
112
+
113
+ export interface WeeklyPayload {
114
+ readonly percentage: number;
115
+ readonly resetsAt: number;
116
+ }
117
+
118
+ // Prompt-cache warmth. One field — the epoch-seconds expiry instant —
119
+ // mirroring block/weekly `resetsAt` so the DSL composes the countdown via
120
+ // `minutesUntilReset`. Absent when no cache-bearing transcript entry exists.
121
+ export interface CachePayload {
122
+ readonly expiresAt: number;
123
+ }
124
+
125
+ export interface ContextPayload {
126
+ readonly totalTokens: number;
127
+ readonly contextLeft: number;
128
+ }
129
+
130
+ // [LAW:types-are-the-program] Each metrics field can independently be absent
131
+ // (transcript missing, cost block absent, response-time math undefined). The
132
+ // optional fields make "no data for this dimension" distinguishable from
133
+ // "real zero" — the DSL input fallback chain emits the declared `default`
134
+ // for absent fields and records a `last_error` (`debug vars` surfaces it).
135
+ // A coerce-to-zero shim here would erase that distinction.
136
+ export interface MetricsPayload {
137
+ readonly lastResponseTime?: number;
138
+ readonly responseTime?: number;
139
+ readonly sessionDuration?: number;
140
+ readonly messageCount?: number;
141
+ readonly linesAdded?: number;
142
+ readonly linesRemoved?: number;
143
+ }
144
+
145
+ // ─── Provider dependencies ────────────────────────────────────────────────────
146
+
147
+ export interface RenderPayloadDeps {
148
+ readonly gitProvider: GitDataProvider;
149
+ // [LAW:one-source-of-truth] One store backs BOTH the `session` and `today`
150
+ // projections — they are folds over the same per-session records, not two
151
+ // independent providers that could disagree.
152
+ readonly usageStore: SessionUsageStore;
153
+ readonly contextProvider: ContextProvider;
154
+ readonly metricsProvider: MetricsProvider;
155
+ readonly tmuxService: TmuxService;
156
+ readonly sessionState: SessionStateRW;
157
+ // [LAW:single-enforcer] The log capability for every provider lane:
158
+ // buildRenderPayload is the ONE place lane failures are logged, so the
159
+ // providers' interiors never log and never double-log.
160
+ readonly log: DaemonLogger;
161
+ }
162
+
163
+ // ─── Builder ─────────────────────────────────────────────────────────────────
164
+
165
+ // ─── Config-driven provider gating ───────────────────────────────────────────
166
+ //
167
+ // [LAW:dataflow-not-control-flow] Whether a provider fires is selected by
168
+ // the active layout. Walk from `config.root` → cells nodes → their segments →
169
+ // their template strings → referenced variable names → recursive expansion through
170
+ // `template`-kind vars. The transitive closure tells us which input paths
171
+ // are actually reachable from a rendered segment; providers feeding paths
172
+ // outside that closure do not run.
173
+ //
174
+ // [LAW:single-enforcer] One reachability walk owns "is this provider
175
+ // needed." A declared-but-unreachable input variable (the default config
176
+ // declares every built-in variable for reference completeness) contributes
177
+ // no work to the hot path. Exported so the cache can compute the closure
178
+ // once at registration time (config is stable per cache entry) and reuse
179
+ // it across renders.
180
+ export function buildNeededPrefixes(config: DslConfig): ReadonlySet<string> {
181
+ // 1. Variable name → declaration index for fast lookup. Global vars first;
182
+ // per-segment vars are namespaced `segName.varName` (same as runtime).
183
+ const allDecls = new Map<string, VariableDecl>();
184
+ for (const [name, decl] of Object.entries(config.variables)) {
185
+ allDecls.set(name, decl);
186
+ }
187
+ for (const [segName, seg] of Object.entries(config.segments)) {
188
+ if (!seg.vars) continue;
189
+ for (const [varName, decl] of Object.entries(seg.vars)) {
190
+ allDecls.set(`${segName}.${varName}`, decl);
191
+ }
192
+ }
193
+
194
+ // 2. BFS from layout segments. Frontier seeds with refs from each
195
+ // rendered segment's template/when/bg/fg ONLY — segment-local vars in
196
+ // `seg.vars` are reached transitively via those refs (their declared
197
+ // names appear in the templates that need them). Seeding from
198
+ // `seg.vars` directly would mark unused per-segment template vars as
199
+ // needed and pull in their providers without justification.
200
+ // `visited` tracks vars whose own `template`-kind body we've already
201
+ // followed.
202
+ const frontier: string[] = [];
203
+ const visited = new Set<string>();
204
+
205
+ for (const node of walkNodes(config.root)) {
206
+ // A node's `when` references variables too — seed them so a provider feeding
207
+ // only a predicate (e.g. a state var gating a row/container) isn't gated out.
208
+ if (node.when)
209
+ for (const ref of extractTemplateRefs(node.when)) frontier.push(ref);
210
+ if (node.kind !== "segment") continue;
211
+ const seg = config.segments[node.name];
212
+ if (!seg) continue;
213
+ for (const src of [seg.template, seg.when, seg.bg, seg.fg]) {
214
+ if (src) for (const ref of extractTemplateRefs(src)) frontier.push(ref);
215
+ }
216
+ }
217
+
218
+ // 3. Walk the closure. A ref is the dotted form `a.b.c`. Two cases:
219
+ // - LEAF: `ref` exactly matches a declared variable. The scope proxy
220
+ // treats this as the variable read.
221
+ // - NAMESPACE: `ref` is a strict prefix of declared variable names
222
+ // (e.g. `git` when only `git.branch`, `git.sha`, … are declared).
223
+ // The scope proxy returns a nested proxy here, which the template
224
+ // can iterate / stringify / pass to functions like `toJson`. A
225
+ // namespace read implicitly reaches every leaf under it, so every
226
+ // `<ref>.*` declaration becomes reachable.
227
+ // Both cases collapse to "expand the ref to every matching declared
228
+ // name." For template-kind matches, the body refs become new frontier
229
+ // items; for input-kind matches, the path is added to the closure.
230
+ const inputPaths = new Set<string>();
231
+ while (frontier.length > 0) {
232
+ const ref = frontier.pop()!;
233
+ for (const declName of expandRef(allDecls, ref)) {
234
+ if (visited.has(declName)) continue;
235
+ visited.add(declName);
236
+ const decl = allDecls.get(declName);
237
+ if (!decl) continue;
238
+ if (decl.kind === "input") {
239
+ inputPaths.add(decl.path);
240
+ } else if (decl.kind === "template") {
241
+ for (const r of extractTemplateRefs(decl.template)) {
242
+ frontier.push(r);
243
+ }
244
+ }
245
+ // Other kinds (literal/env/file/shell/time/git/state) declare their
246
+ // own box without reading a payload path — they need no provider
247
+ // gating because the daemon's payload builder is the only thing
248
+ // this gates.
249
+ }
250
+ }
251
+
252
+ return inputPaths;
253
+ }
254
+
255
+ // [LAW:single-enforcer] Mirror of the scope proxy's read semantics: a ref
256
+ // resolves either to an exact variable (leaf) or to every variable under a
257
+ // namespace prefix (`.git` matches `git.branch`, `git.sha`, …). Yields the
258
+ // set of declared names the ref reaches.
259
+ function expandRef(
260
+ decls: ReadonlyMap<string, VariableDecl>,
261
+ ref: string,
262
+ ): readonly string[] {
263
+ // Leaf — most specific declared name wins, mirroring lookupDecl's loop.
264
+ let candidate = ref;
265
+ while (candidate.length > 0) {
266
+ if (decls.has(candidate)) return [candidate];
267
+ const dot = candidate.lastIndexOf(".");
268
+ if (dot < 0) break;
269
+ candidate = candidate.slice(0, dot);
270
+ }
271
+ // Namespace — every name starting with `${ref}.` is reachable.
272
+ const ns = `${ref}.`;
273
+ const matches: string[] = [];
274
+ for (const name of decls.keys()) {
275
+ if (name.startsWith(ns)) matches.push(name);
276
+ }
277
+ return matches;
278
+ }
279
+
280
+ function anyPathStartsWith(
281
+ paths: ReadonlySet<string>,
282
+ prefix: string,
283
+ ): boolean {
284
+ for (const p of paths) {
285
+ if (p === prefix || p.startsWith(prefix + ".")) return true;
286
+ }
287
+ return false;
288
+ }
289
+
290
+ // [LAW:dataflow-not-control-flow] Each `show*` flag is derived from a
291
+ // specific declared input path; the closure tells us exactly which fields
292
+ // the user's templates will read. Without this, GitService.getGitInfo
293
+ // silently returns "" / 0 for fields whose `show*` flag isn't set (because
294
+ // computing them requires extra git invocations), and a user who declares
295
+ // `git.sha` or `git.staged` would see their template evaluate against
296
+ // empty strings or zeros.
297
+ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
298
+ const has = (path: string): boolean => needed.has(path);
299
+ // `git.staged` / `git.unstaged` / `git.untracked` / `git.conflicts` all
300
+ // come from one `git status --porcelain` call — any one of them turning
301
+ // the flag on enables all four.
302
+ const wantsWorkingTree =
303
+ has("git.staged") ||
304
+ has("git.unstaged") ||
305
+ has("git.untracked") ||
306
+ has("git.conflicts");
307
+ return {
308
+ ...(has("git.sha") && { showSha: true }),
309
+ ...(wantsWorkingTree && { showWorkingTree: true }),
310
+ ...(has("git.stash") && { showStashCount: true }),
311
+ ...(has("git.upstream") && { showUpstream: true }),
312
+ ...(has("git.repoName") && { showRepoName: true }),
313
+ ...(has("git.operation") && { showOperation: true }),
314
+ ...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Compose every render-time data source into the augmented payload that the
320
+ * DSL applies to its input variables.
321
+ *
322
+ * Each provider runs only if its payload prefix sits in the closure
323
+ * computed by `buildNeededPrefixes(config)` — the set of `kind: "input"`
324
+ * paths transitively reachable from a segment in `config.root`. Merely
325
+ * declaring an input variable does NOT trigger provider work; the variable
326
+ * must actually be referenced by a layout-rendered segment (directly, or
327
+ * via a chain of `template`-kind vars). The default config declares many
328
+ * unused inputs for reference completeness — switching one on is a layout
329
+ * edit, not a re-declaration.
330
+ *
331
+ * All needed providers run concurrently via `Promise.all`; each one's
332
+ * failure becomes a missing field (handled by the DSL input fallback
333
+ * chain). No provider error propagates to the caller — a single broken
334
+ * source must not blank the bar.
335
+ *
336
+ * [LAW:no-silent-failure][LAW:single-enforcer] Every lane carries a typed
337
+ * outcome (ok | absent | failed) and THIS function is the one log site:
338
+ * `failed` is logged through `deps.log` and projected as a missing field,
339
+ * `absent` is a missing field with nothing to log — the default lives in
340
+ * the DSL declaration's `default` field, owned by the config, not buried
341
+ * here.
342
+ */
343
+ export async function buildRenderPayload(
344
+ hookData: ClaudeHookData,
345
+ deps: RenderPayloadDeps,
346
+ cwd: string | undefined,
347
+ // [LAW:single-enforcer] The cache pre-computes the closure once at
348
+ // registration; passing it in (rather than recomputing per render) keeps
349
+ // the hot path free of the BFS + extractTemplateRefs cost.
350
+ neededInputPaths: ReadonlySet<string>,
351
+ ): Promise<RenderPayload> {
352
+ const wants = (prefix: string): boolean =>
353
+ anyPathStartsWith(neededInputPaths, prefix);
354
+
355
+ // [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
356
+ // lane is ONE shape: "needed → call provider (whose contract is to never
357
+ // reject — the catch makes the lane total against bugs, mapping a throw
358
+ // into the same logged failure path)" or "not needed → ABSENT". The skipped
359
+ // lanes resolve immediately; no variant means lanes are present/absent at
360
+ // the array level (which would change the destructure shape).
361
+ const lane = <T>(
362
+ name: string,
363
+ needed: boolean,
364
+ run: () => Promise<Outcome<T>>,
365
+ ): Promise<Outcome<T>> =>
366
+ needed
367
+ ? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
368
+ : Promise.resolve(ABSENT);
369
+
370
+ const [gitOutcome, usage, today, context, metrics, tmuxSession, cacheExpiry] =
371
+ await Promise.all([
372
+ lane("git", wants("git"), () =>
373
+ deps.gitProvider.getGitInfo(
374
+ cwd ?? hookData.workspace?.current_dir,
375
+ gitOptionsFromClosure(neededInputPaths),
376
+ hookData.workspace?.project_dir,
377
+ ),
378
+ ),
379
+ lane("session", wants("session.cost") || wants("session.tokens"), () =>
380
+ deps.usageStore.getUsageInfo(hookData.session_id, hookData),
381
+ ),
382
+ lane("today", wants("today"), () =>
383
+ deps.usageStore.getTodayInfo(hookData),
384
+ ),
385
+ lane("context", wants("context"), () =>
386
+ deps.contextProvider.getContextInfo(hookData),
387
+ ),
388
+ lane("metrics", wants("metrics"), () =>
389
+ deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
390
+ ),
391
+ lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
392
+ // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
393
+ // seam, so it runs alongside the other providers and stays in the shared
394
+ // in-flight budget rather than blocking the event loop on sync fs.
395
+ lane("cache", wants("cache"), () =>
396
+ cacheExpiresAt(hookData.transcript_path),
397
+ ),
398
+ ]);
399
+ // [LAW:effects-at-boundaries] The projections are pure folds returning data
400
+ // (payload fragment + failure descriptions); the log effect happens once,
401
+ // here, at the edge. `take` is the total fold for the single-value lanes:
402
+ // ok → value, absent → undefined, failed → undefined + a failure to log.
403
+ const failures: string[] = [];
404
+ const take = <T>(oc: Outcome<T>): T | undefined => {
405
+ if (oc.kind === "failed") {
406
+ failures.push(oc.reason);
407
+ return undefined;
408
+ }
409
+ return oc.kind === "ok" ? oc.value : undefined;
410
+ };
411
+
412
+ const gitProjection = projectGitInfo(gitOutcome);
413
+ failures.push(...gitProjection.failures);
414
+ const usageValue = take(usage);
415
+ const todayValue = take(today);
416
+ const contextValue = take(context);
417
+ const metricsValue = take(metrics);
418
+ const tmuxValue = take(tmuxSession);
419
+ const cacheValue = take(cacheExpiry);
420
+ for (const f of failures) deps.log("warn", `provider fetch failed: ${f}`);
421
+ // [LAW:dataflow-not-control-flow] block.* reads straight from hookData
422
+ // alongside weekly. (The prior dedicated provider only re-derived
423
+ // `minutesUntilReset(resets_at)`, which the DSL template composes via
424
+ // the formatter func — a duplicate code path was retired.)
425
+ const fiveHour = hookData.rate_limits?.five_hour;
426
+
427
+ // [LAW:one-source-of-truth] The theme variable surfaces the session's
428
+ // resolved theme so the toolbar/tray DSL templates can encode it into
429
+ // cc-candybar:// URLs without re-resolving. SessionState owns the value;
430
+ // we mirror it onto the payload, not redeclare it.
431
+ const themeRaw = wants("theme")
432
+ ? deps.sessionState.get(hookData.session_id, "theme")
433
+ : undefined;
434
+ const theme = typeof themeRaw === "string" ? themeRaw : undefined;
435
+
436
+ // home is always available — it's a single env-var read, no I/O cost.
437
+ // Letting the gate skip it would add a branch with no win.
438
+ // [LAW:single-enforcer] All path-shaped payload fields are normalized
439
+ // to forward-slash separators at this boundary. The DSL's directory
440
+ // template (and any user template that does prefix/relative-path math)
441
+ // assumes POSIX separators; without normalization, Windows hookData
442
+ // (current_dir = "C:\Users\Alice") would never match a forward-slash
443
+ // home prefix. Normalize *here*, not in the template — keeps DSL
444
+ // templates platform-agnostic by construction.
445
+ const home = posixify(process.env.HOME ?? process.env.USERPROFILE);
446
+ const workspace = hookData.workspace
447
+ ? {
448
+ ...hookData.workspace,
449
+ current_dir: posixify(hookData.workspace.current_dir) ?? "",
450
+ project_dir: posixify(hookData.workspace.project_dir) ?? "",
451
+ }
452
+ : hookData.workspace;
453
+
454
+ // [LAW:types-are-the-program] Partial projections so absent provider data
455
+ // travels as missing fields all the way to applyInput. Each provider's
456
+ // null sub-fields become absent keys here; applyInput's fallback chain
457
+ // fills in the declared DSL default and records a last_error per field.
458
+ const sessionPayload: SessionPayload | undefined =
459
+ usageValue === undefined
460
+ ? undefined
461
+ : pickNonNull({
462
+ cost: usageValue.session.cost,
463
+ tokens: usageValue.session.tokens,
464
+ });
465
+ const todayPayload: TodayPayload | undefined =
466
+ todayValue === undefined
467
+ ? undefined
468
+ : { cost: todayValue.cost, tokens: todayValue.tokens };
469
+ const metricsPayload: MetricsPayload | undefined =
470
+ metricsValue === undefined
471
+ ? undefined
472
+ : pickNonNull({
473
+ lastResponseTime: metricsValue.lastResponseTime,
474
+ responseTime: metricsValue.responseTime,
475
+ sessionDuration: metricsValue.sessionDuration,
476
+ messageCount: metricsValue.messageCount,
477
+ linesAdded: metricsValue.linesAdded,
478
+ linesRemoved: metricsValue.linesRemoved,
479
+ });
480
+
481
+ return {
482
+ ...hookData,
483
+ ...(workspace !== undefined && { workspace }),
484
+ ...(home !== undefined && { home }),
485
+ ...(gitProjection.git !== undefined && { git: gitProjection.git }),
486
+ ...(tmuxValue !== undefined && { tmux: { session: tmuxValue } }),
487
+ ...(theme !== undefined && { theme }),
488
+ ...(sessionPayload !== undefined && { session: sessionPayload }),
489
+ ...(todayPayload !== undefined && { today: todayPayload }),
490
+ ...(wants("block") &&
491
+ fiveHour !== undefined && {
492
+ block: {
493
+ // [LAW:one-source-of-truth] Both fields read straight from the
494
+ // hookData rate-limit window; the DSL composes minutesUntilReset
495
+ // against .block.resetsAt the same way weekly does. One
496
+ // projection rule, two segments.
497
+ nativeUtilization: fiveHour.used_percentage,
498
+ resetsAt: fiveHour.resets_at,
499
+ },
500
+ }),
501
+ ...(hookData.rate_limits?.seven_day !== undefined && {
502
+ weekly: {
503
+ percentage: hookData.rate_limits.seven_day.used_percentage,
504
+ resetsAt: hookData.rate_limits.seven_day.resets_at,
505
+ },
506
+ }),
507
+ ...(cacheValue !== undefined && {
508
+ cache: { expiresAt: cacheValue },
509
+ }),
510
+ ...(contextValue !== undefined && {
511
+ context: {
512
+ totalTokens: contextValue.totalTokens,
513
+ contextLeft: contextValue.contextLeftPercentage,
514
+ },
515
+ }),
516
+ ...(metricsPayload !== undefined && { metrics: metricsPayload }),
517
+ };
518
+ }
519
+
520
+ // [LAW:types-are-the-program] Project an object with possibly-null fields
521
+ // down to a partial whose nulls have been dropped. If every field is null
522
+ // the result is undefined — caller treats that as "provider returned but
523
+ // had nothing usable" and omits the sub-object entirely.
524
+ function pickNonNull<T extends Readonly<Record<string, number | null>>>(
525
+ src: T,
526
+ ): { [K in keyof T]?: number } | undefined {
527
+ const out: { [K in keyof T]?: number } = {};
528
+ let any = false;
529
+ for (const k of Object.keys(src) as Array<keyof T>) {
530
+ const v = src[k];
531
+ if (v !== null && v !== undefined) {
532
+ out[k] = v;
533
+ any = true;
534
+ }
535
+ }
536
+ return any ? out : undefined;
537
+ }
538
+
539
+ // [LAW:single-enforcer] Convert backslash-separator path strings to
540
+ // forward-slash separators so DSL templates can rely on POSIX path math
541
+ // (prefix checks, trimPrefix) on every platform. Undefined and empty
542
+ // inputs pass through unchanged. The function is platform-conditional
543
+ // only on whether `\` *could* be a separator (it never can on POSIX, so
544
+ // no harm in always converting — but the explicit guard avoids touching
545
+ // strings on non-Windows callers where backslash is meaningful
546
+ // inside path components).
547
+ function posixify(s: string | undefined): string | undefined {
548
+ if (s === undefined || s.length === 0) return s;
549
+ if (path.sep !== "\\") return s;
550
+ return s.replace(/\\/g, "/");
551
+ }
552
+
553
+ // [LAW:types-are-the-program] Project the outcome-carrying GitInfo down to
554
+ // the flat shape the DSL input paths read. A pure fold: `ok` fields become
555
+ // values, `absent` and `failed` fields become MISSING keys (the DSL input
556
+ // fallback chain fills the declared default and records a last_error), and
557
+ // every `failed` contributes a description for the boundary to log — this
558
+ // function performs no effect itself ([LAW:effects-at-boundaries]).
559
+ function projectGitInfo(outcome: Outcome<GitInfo>): {
560
+ readonly git?: GitPayload;
561
+ readonly failures: readonly string[];
562
+ } {
563
+ if (outcome.kind === "absent") return { failures: [] };
564
+ if (outcome.kind === "failed") return { failures: [outcome.reason] };
565
+
566
+ const info = outcome.value;
567
+ const failures: string[] = [];
568
+ const field = <T>(
569
+ name: string,
570
+ oc: Outcome<T> | undefined,
571
+ ): T | undefined => {
572
+ if (oc === undefined || oc.kind === "absent") return undefined;
573
+ if (oc.kind === "failed") {
574
+ failures.push(`git.${name}: ${oc.reason}`);
575
+ return undefined;
576
+ }
577
+ return oc.value;
578
+ };
579
+
580
+ const aheadBehind = field("aheadBehind", info.aheadBehind);
581
+ const sha = field("sha", info.sha);
582
+ const operation = field("operation", info.operation);
583
+ const timeSinceCommit = field("timeSinceCommit", info.timeSinceCommit);
584
+ const stash = field("stash", info.stashCount);
585
+ const upstream = field("upstream", info.upstream);
586
+ const repoName = field("repoName", info.repoName);
587
+
588
+ return {
589
+ git: {
590
+ branch: info.branch,
591
+ status: info.status,
592
+ ...(aheadBehind !== undefined && {
593
+ ahead: aheadBehind.ahead,
594
+ behind: aheadBehind.behind,
595
+ }),
596
+ ...(info.workingTree !== undefined && {
597
+ staged: info.workingTree.staged,
598
+ unstaged: info.workingTree.unstaged,
599
+ untracked: info.workingTree.untracked,
600
+ conflicts: info.workingTree.conflicts,
601
+ }),
602
+ ...(sha !== undefined && { sha }),
603
+ ...(operation !== undefined && { operation }),
604
+ ...(timeSinceCommit !== undefined && { timeSinceCommit }),
605
+ ...(stash !== undefined && { stash }),
606
+ ...(upstream !== undefined && { upstream }),
607
+ ...(repoName !== undefined && { repoName }),
608
+ },
609
+ failures,
610
+ };
611
+ }