@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,561 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { launch, type LaunchResult } from "../proc/launch";
4
+ import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
5
+ import { debug } from "../utils/logger";
6
+
7
+ export interface WorkingTree {
8
+ staged: number;
9
+ unstaged: number;
10
+ untracked: number;
11
+ conflicts: number;
12
+ }
13
+
14
+ export interface AheadBehind {
15
+ ahead: number;
16
+ behind: number;
17
+ }
18
+
19
+ // [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
20
+ // value is unknown because the fetch failed" is representable distinct from
21
+ // a real 0/""/basename — the states the old catch-and-substitute blocks
22
+ // erased. An undefined field means "not requested" (its `show*` flag was
23
+ // off); `absent` means the domain genuinely has none (no upstream, no tags,
24
+ // no stash); `failed` carries the reason to the consuming boundary, which
25
+ // owns the log effect. branch/status stay plain: a fetch that cannot
26
+ // determine them is a failed fetch, not a GitInfo.
27
+ export interface GitInfo {
28
+ branch: string;
29
+ status: "clean" | "dirty" | "conflicts";
30
+ aheadBehind: Outcome<AheadBehind>;
31
+ workingTree?: WorkingTree;
32
+ sha?: Outcome<string>;
33
+ operation?: Outcome<string>;
34
+ tag?: Outcome<string>;
35
+ timeSinceCommit?: Outcome<number>;
36
+ stashCount?: Outcome<number>;
37
+ upstream?: Outcome<string>;
38
+ repoName?: Outcome<string>;
39
+ isWorktree?: boolean;
40
+ }
41
+
42
+ // [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
43
+ // flag opts into an extra git invocation; an unset flag leaves its GitInfo field
44
+ // undefined (not requested). Every caller that builds these options
45
+ // (render-payload's gitOptionsFromClosure, the cache override) references THIS
46
+ // type, so the toggle set cannot drift between producer and consumer.
47
+ export interface GitInfoOptions {
48
+ showSha?: boolean;
49
+ showWorkingTree?: boolean;
50
+ showOperation?: boolean;
51
+ showTag?: boolean;
52
+ showTimeSinceCommit?: boolean;
53
+ showStashCount?: boolean;
54
+ showUpstream?: boolean;
55
+ showRepoName?: boolean;
56
+ }
57
+
58
+ // [LAW:dataflow-not-control-flow] One classifier for every git invocation.
59
+ // Whether a non-zero exit is the domain answering "there is none" (describe
60
+ // with no tags, rev-parse @{u} with no upstream) or a real failure is
61
+ // per-command knowledge — it enters here as data, not as a catch block at
62
+ // every callsite. Transport failures (timeout, spawn error, signal) are
63
+ // always `failed`: git did not answer.
64
+ function classify(
65
+ label: string,
66
+ result: LaunchResult,
67
+ nonZero: "absent" | "failed",
68
+ ): Outcome<string> {
69
+ if (result.ok) return ok(result.stdout);
70
+ if (result.reason === "non-zero" && nonZero === "absent") return ABSENT;
71
+ const detail = [
72
+ result.reason,
73
+ result.exitCode != null ? `exit ${result.exitCode}` : null,
74
+ result.error ?? firstLine(result.stderr),
75
+ ]
76
+ .filter(Boolean)
77
+ .join(", ");
78
+ return failed(`${label}: ${detail}`);
79
+ }
80
+
81
+ function firstLine(s: string): string {
82
+ return s.trim().split("\n", 1)[0] ?? "";
83
+ }
84
+
85
+ // Trim an ok stdout; an empty answer is the domain's "there is none".
86
+ function nonEmpty(o: Outcome<string>): Outcome<string> {
87
+ if (o.kind !== "ok") return o;
88
+ const v = o.value.trim();
89
+ return v ? ok(v) : ABSENT;
90
+ }
91
+
92
+ export class GitService {
93
+ private isGitRepo(workingDir: string): boolean {
94
+ try {
95
+ return fs.existsSync(path.join(workingDir, ".git"));
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // [LAW:types-are-the-program] args is a string[] so the boundary type
102
+ // forbids the only-space-free-arguments contract the prior whitespace-split
103
+ // implementation relied on. Returns the full LaunchResult — the typed
104
+ // termination cause `launch` already computed — so `classify` can map it to
105
+ // an Outcome without a thrown Error flattening that information away.
106
+ private async execGitAsync(
107
+ args: readonly string[],
108
+ options: { cwd: string; timeout: number },
109
+ ): Promise<LaunchResult> {
110
+ return launch({
111
+ bin: "git",
112
+ args: [...args],
113
+ cwd: options.cwd,
114
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" },
115
+ timeoutMs: options.timeout,
116
+ category: "git",
117
+ });
118
+ }
119
+
120
+ // [LAW:locality-or-seam] Public so the daemon's GitDataProvider can key its
121
+ // cache + watcher on the *effective* git directory — the same directory the
122
+ // shell-runner will run git commands in. Caching on `findGitRoot(workingDir)`
123
+ // alone is wrong when `projectDir` is set and is itself a git repo: the
124
+ // shell-runner picks `projectDir` (see `computeGitInfo`'s gitDir resolution
125
+ // below), but a workingDir-keyed cache would store that data under a
126
+ // different key and wire invalidation to the wrong watcher. The contract:
127
+ // `resolveEffectiveGitDir(workingDir, projectDir)` returns exactly the
128
+ // directory `computeGitInfo` will use as `gitDir`. Both surfaces must agree.
129
+ async resolveEffectiveGitDir(
130
+ workingDir: string,
131
+ projectDir?: string,
132
+ ): Promise<Outcome<string>> {
133
+ if (this.isWorktree(workingDir)) return ok(workingDir);
134
+ if (projectDir && this.isGitRepo(projectDir)) return ok(projectDir);
135
+ if (this.isGitRepo(workingDir)) return ok(workingDir);
136
+ return this.findGitRoot(workingDir);
137
+ }
138
+
139
+ // [LAW:locality-or-seam] public so daemon-side caches can key on the
140
+ // repoRoot they'd otherwise have to re-derive. `absent` is rev-parse's
141
+ // non-zero exit — "not in a git repository", the everyday domain answer.
142
+ async findGitRoot(workingDir: string): Promise<Outcome<string>> {
143
+ return nonEmpty(
144
+ classify(
145
+ "git rev-parse --show-toplevel",
146
+ await this.execGitAsync(["rev-parse", "--show-toplevel"], {
147
+ cwd: workingDir,
148
+ timeout: 2000,
149
+ }),
150
+ "absent",
151
+ ),
152
+ );
153
+ }
154
+
155
+ // [LAW:one-source-of-truth] No inner cache here. The daemon-side
156
+ // GitDataProvider (src/daemon/cache/git.ts) is the single cache. Layering
157
+ // a per-process cache on top of an already-cached call would double the
158
+ // invalidation surface — exactly the trap that kz8.3 collapses.
159
+ //
160
+ // [LAW:no-silent-failure] Never rejects: helpers return outcomes by
161
+ // construction, and an unexpected throw (a bug) is surfaced as a `failed`
162
+ // outcome whose reason reaches the consuming boundary's log — not a blank
163
+ // bar, not a swallowed branch.
164
+ async getGitInfo(
165
+ workingDir: string,
166
+ options: GitInfoOptions = {},
167
+ projectDir?: string,
168
+ ): Promise<Outcome<GitInfo>> {
169
+ try {
170
+ return await this.computeGitInfo(workingDir, options, projectDir);
171
+ } catch (e) {
172
+ return failed(`git: ${e instanceof Error ? e.message : String(e)}`);
173
+ }
174
+ }
175
+
176
+ private async computeGitInfo(
177
+ workingDir: string,
178
+ options: GitInfoOptions = {},
179
+ projectDir?: string,
180
+ ): Promise<Outcome<GitInfo>> {
181
+ let gitDir: string;
182
+ const isWorktreeDir = this.isWorktree(workingDir);
183
+
184
+ if (isWorktreeDir) {
185
+ // Worktree's .git is a file pointing to the main repo;
186
+ // git commands must run from the worktree directory.
187
+ gitDir = workingDir;
188
+ } else if (projectDir && this.isGitRepo(projectDir)) {
189
+ gitDir = projectDir;
190
+ } else if (this.isGitRepo(workingDir)) {
191
+ gitDir = workingDir;
192
+ } else {
193
+ const foundGitRoot = await this.findGitRoot(workingDir);
194
+ if (foundGitRoot.kind !== "ok") return foundGitRoot;
195
+ gitDir = foundGitRoot.value;
196
+ }
197
+
198
+ // branch/status are the core: without them there is no useful GitInfo,
199
+ // so a failed core fetch fails the whole outcome rather than dressing
200
+ // up as a clean repo on a fallback branch.
201
+ const core = await this.getStatusWithBranchAsync(gitDir);
202
+ if (core.kind !== "ok") return core;
203
+ const aheadBehind = await this.getAheadBehindAsync(gitDir);
204
+
205
+ const result: GitInfo = {
206
+ branch: core.value.branch,
207
+ status: core.value.status,
208
+ aheadBehind,
209
+ };
210
+
211
+ if (options.showWorkingTree) {
212
+ result.workingTree = core.value.workingTree;
213
+ }
214
+
215
+ // Heavy operations stay serial — each is an expensive git invocation and
216
+ // running them one at a time bounds concurrent git load per fetch.
217
+ if (options.showSha) {
218
+ result.sha = await this.getShaAsync(gitDir);
219
+ }
220
+ if (options.showTag) {
221
+ result.tag = await this.getNearestTagAsync(gitDir);
222
+ }
223
+ if (options.showTimeSinceCommit) {
224
+ result.timeSinceCommit = await this.getTimeSinceLastCommitAsync(gitDir);
225
+ }
226
+
227
+ // Light operations run in parallel. Helpers never reject — failure is a
228
+ // value in the outcome — so plain Promise.all replaces the allSettled +
229
+ // untyped resultMap machinery the swallowing design required.
230
+ const [stashCount, upstream, repoName] = await Promise.all([
231
+ options.showStashCount ? this.getStashCountAsync(gitDir) : undefined,
232
+ options.showUpstream ? this.getUpstreamAsync(gitDir) : undefined,
233
+ options.showRepoName ? this.getRepoNameAsync(gitDir) : undefined,
234
+ ]);
235
+ if (stashCount !== undefined) result.stashCount = stashCount;
236
+ if (upstream !== undefined) result.upstream = upstream;
237
+ if (repoName !== undefined) {
238
+ result.repoName = repoName;
239
+ result.isWorktree = isWorktreeDir;
240
+ }
241
+
242
+ if (options.showOperation) {
243
+ result.operation = this.getOngoingOperation(gitDir);
244
+ }
245
+
246
+ return ok(result);
247
+ }
248
+
249
+ private async getShaAsync(workingDir: string): Promise<Outcome<string>> {
250
+ // non-zero = no HEAD to resolve (empty repo) — a domain answer.
251
+ return nonEmpty(
252
+ classify(
253
+ "git rev-parse HEAD",
254
+ await this.execGitAsync(["rev-parse", "--short=7", "HEAD"], {
255
+ cwd: workingDir,
256
+ timeout: 2000,
257
+ }),
258
+ "absent",
259
+ ),
260
+ );
261
+ }
262
+
263
+ // [LAW:locality-or-seam] Public so the daemon-side provider can watch the
264
+ // real HEAD/index files even for git worktrees. For a regular repo this is
265
+ // `<workingDir>/.git`. For a worktree, `<workingDir>/.git` is a *file*
266
+ // containing `gitdir: <abs-path-to-worktree-metadata-dir>` and the actual
267
+ // HEAD/index live inside that metadata dir — watching `<workingDir>/.git/HEAD`
268
+ // would fail (no such path) and the cache would never invalidate. Returning
269
+ // the resolved gitDir lets the provider point watchers at real files.
270
+ //
271
+ // [LAW:no-defensive-null-guards] The try/catch is a trust-boundary guard,
272
+ // not a silent skip — fs races (file removed between existsSync and
273
+ // statSync), permission errors, or unreadable .git files fall back to the
274
+ // dotGit path so callers always get a string, never a throw.
275
+ resolveGitDir(workingDir: string): string {
276
+ const dotGit = path.join(workingDir, ".git");
277
+ try {
278
+ if (fs.existsSync(dotGit) && fs.statSync(dotGit).isFile()) {
279
+ const content = fs.readFileSync(dotGit, "utf-8");
280
+ const match = content.match(/^gitdir:\s*(.+)$/m);
281
+ if (match?.[1]) {
282
+ return path.resolve(workingDir, match[1].trim());
283
+ }
284
+ }
285
+ } catch {
286
+ // Fall through to the dotGit fallback below.
287
+ }
288
+ return dotGit;
289
+ }
290
+
291
+ private getOngoingOperation(workingDir: string): Outcome<string> {
292
+ try {
293
+ const gitDir = this.resolveGitDir(workingDir);
294
+
295
+ if (fs.existsSync(path.join(gitDir, "MERGE_HEAD"))) return ok("MERGE");
296
+ if (fs.existsSync(path.join(gitDir, "CHERRY_PICK_HEAD")))
297
+ return ok("CHERRY-PICK");
298
+ if (fs.existsSync(path.join(gitDir, "REVERT_HEAD"))) return ok("REVERT");
299
+ if (fs.existsSync(path.join(gitDir, "BISECT_LOG"))) return ok("BISECT");
300
+ if (
301
+ fs.existsSync(path.join(gitDir, "rebase-merge")) ||
302
+ fs.existsSync(path.join(gitDir, "rebase-apply"))
303
+ )
304
+ return ok("REBASE");
305
+
306
+ return ABSENT;
307
+ } catch (e) {
308
+ return failed(
309
+ `git operation probe: ${e instanceof Error ? e.message : String(e)}`,
310
+ );
311
+ }
312
+ }
313
+
314
+ private async getNearestTagAsync(
315
+ workingDir: string,
316
+ ): Promise<Outcome<string>> {
317
+ // non-zero = no tags reachable — describe's domain answer.
318
+ return nonEmpty(
319
+ classify(
320
+ "git describe --tags",
321
+ await this.execGitAsync(["describe", "--tags", "--abbrev=0"], {
322
+ cwd: workingDir,
323
+ timeout: 2000,
324
+ }),
325
+ "absent",
326
+ ),
327
+ );
328
+ }
329
+
330
+ private async getTimeSinceLastCommitAsync(
331
+ workingDir: string,
332
+ ): Promise<Outcome<number>> {
333
+ // non-zero = no commits yet (empty repo) — a domain answer.
334
+ const r = nonEmpty(
335
+ classify(
336
+ "git log -1",
337
+ await this.execGitAsync(["log", "-1", "--format=%ct"], {
338
+ cwd: workingDir,
339
+ timeout: 2000,
340
+ }),
341
+ "absent",
342
+ ),
343
+ );
344
+ if (r.kind !== "ok") return r;
345
+
346
+ const commitTime = parseInt(r.value) * 1000;
347
+ if (Number.isNaN(commitTime)) {
348
+ return failed(`git log -1: unparseable timestamp "${r.value}"`);
349
+ }
350
+ const now = Date.now();
351
+ return ok(Math.floor((now - commitTime) / 1000));
352
+ }
353
+
354
+ private async getStashCountAsync(
355
+ workingDir: string,
356
+ ): Promise<Outcome<number>> {
357
+ // An empty stash list is a REAL count of 0; only a transport/exit failure
358
+ // is `failed` — the meaning-erasure the old catch-to-0 created is
359
+ // unrepresentable now. `stash list` never exits non-zero as an answer.
360
+ const r = classify(
361
+ "git stash list",
362
+ await this.execGitAsync(["stash", "list"], {
363
+ cwd: workingDir,
364
+ timeout: 2000,
365
+ }),
366
+ "failed",
367
+ );
368
+ if (r.kind !== "ok") return r;
369
+ const stashList = r.value.trim();
370
+ return ok(stashList ? stashList.split("\n").length : 0);
371
+ }
372
+
373
+ private async getUpstreamAsync(workingDir: string): Promise<Outcome<string>> {
374
+ // non-zero = no upstream configured — the everyday domain answer.
375
+ return nonEmpty(
376
+ classify(
377
+ "git rev-parse @{u}",
378
+ await this.execGitAsync(["rev-parse", "--abbrev-ref", "@{u}"], {
379
+ cwd: workingDir,
380
+ timeout: 2000,
381
+ }),
382
+ "absent",
383
+ ),
384
+ );
385
+ }
386
+
387
+ private async getRepoNameAsync(workingDir: string): Promise<Outcome<string>> {
388
+ const r = classify(
389
+ "git config remote.origin.url",
390
+ await this.execGitAsync(["config", "--get", "remote.origin.url"], {
391
+ cwd: workingDir,
392
+ timeout: 2000,
393
+ }),
394
+ // `config --get` exits 1 when the key is unset — "no remote", a domain
395
+ // answer, not a failure.
396
+ "absent",
397
+ );
398
+ if (r.kind === "failed") return r;
399
+
400
+ // A local-only repo's name is its directory name BY POLICY (the display
401
+ // contract for repos without a remote) — never as an error fallback; a
402
+ // failed `git config` above stays failed instead of borrowing this rule.
403
+ const remoteUrl = r.kind === "ok" ? r.value.trim() : "";
404
+ if (!remoteUrl) return ok(path.basename(workingDir));
405
+
406
+ const match = remoteUrl.match(/\/([^/]+?)(\.git)?$/);
407
+ return ok(match?.[1] || path.basename(workingDir));
408
+ }
409
+
410
+ private isWorktree(workingDir: string): boolean {
411
+ try {
412
+ const gitDir = path.join(workingDir, ".git");
413
+ if (fs.existsSync(gitDir) && fs.statSync(gitDir).isFile()) {
414
+ return true;
415
+ }
416
+ return false;
417
+ } catch {
418
+ return false;
419
+ }
420
+ }
421
+
422
+ private async getStatusWithBranchAsync(workingDir: string): Promise<
423
+ Outcome<{
424
+ branch: string;
425
+ status: "clean" | "dirty" | "conflicts";
426
+ workingTree: WorkingTree;
427
+ }>
428
+ > {
429
+ debug(`[GIT-EXEC] Running git status in ${workingDir}`);
430
+ const r = classify(
431
+ "git status --porcelain -b",
432
+ await this.execGitAsync(["status", "--porcelain", "-b"], {
433
+ cwd: workingDir,
434
+ timeout: 2000,
435
+ }),
436
+ // `git status` has no non-zero domain answer; any failure means the
437
+ // core state is unknown — no fabricated "clean" on a fallback branch.
438
+ "failed",
439
+ );
440
+ if (r.kind !== "ok") return r;
441
+
442
+ const lines = r.value.split("\n");
443
+
444
+ let branch: string | null = null;
445
+ let status: "clean" | "dirty" | "conflicts" = "clean";
446
+ let staged = 0;
447
+ let unstaged = 0;
448
+ let untracked = 0;
449
+ let conflicts = 0;
450
+
451
+ for (const line of lines) {
452
+ if (!line) continue;
453
+
454
+ if (line.startsWith("## ")) {
455
+ const branchLine = line.substring(3);
456
+ const branchMatch = branchLine.split("...")[0];
457
+ if (branchMatch && branchMatch !== "HEAD (no branch)") {
458
+ branch = branchMatch;
459
+ }
460
+ continue;
461
+ }
462
+
463
+ if (line.length >= 2) {
464
+ const indexStatus = line.charAt(0);
465
+ const worktreeStatus = line.charAt(1);
466
+
467
+ if (indexStatus === "?" && worktreeStatus === "?") {
468
+ untracked++;
469
+ if (status === "clean") status = "dirty";
470
+ continue;
471
+ }
472
+
473
+ const statusPair = indexStatus + worktreeStatus;
474
+ if (["DD", "AU", "UD", "UA", "DU", "AA", "UU"].includes(statusPair)) {
475
+ conflicts++;
476
+ status = "conflicts";
477
+ continue;
478
+ }
479
+
480
+ if (indexStatus !== " " && indexStatus !== "?") {
481
+ staged++;
482
+ if (status === "clean") status = "dirty";
483
+ }
484
+ if (worktreeStatus !== " " && worktreeStatus !== "?") {
485
+ unstaged++;
486
+ if (status === "clean") status = "dirty";
487
+ }
488
+ }
489
+ }
490
+
491
+ if (branch === null) {
492
+ const fallback = await this.getFallbackBranch(workingDir);
493
+ // A transport failure resolving the branch fails the core: rendering
494
+ // a fake "detached" for a repo whose branch merely couldn't be read
495
+ // would be the same meaning-erasure this type exists to forbid.
496
+ if (fallback.kind === "failed") return fallback;
497
+ branch = fallback.kind === "ok" ? fallback.value : "detached";
498
+ }
499
+
500
+ return ok({
501
+ branch,
502
+ status,
503
+ workingTree: { staged, unstaged, untracked, conflicts },
504
+ });
505
+ }
506
+
507
+ private async getFallbackBranch(
508
+ workingDir: string,
509
+ ): Promise<Outcome<string>> {
510
+ // Both commands answer "detached" with a non-zero exit (symbolic-ref) or
511
+ // empty output (show-current) — `absent` means genuinely detached.
512
+ const primary = nonEmpty(
513
+ classify(
514
+ "git branch --show-current",
515
+ await this.execGitAsync(["branch", "--show-current"], {
516
+ cwd: workingDir,
517
+ timeout: 2000,
518
+ }),
519
+ "absent",
520
+ ),
521
+ );
522
+ if (primary.kind !== "absent") return primary;
523
+ return nonEmpty(
524
+ classify(
525
+ "git symbolic-ref HEAD",
526
+ await this.execGitAsync(["symbolic-ref", "--short", "HEAD"], {
527
+ cwd: workingDir,
528
+ timeout: 2000,
529
+ }),
530
+ "absent",
531
+ ),
532
+ );
533
+ }
534
+
535
+ private async getAheadBehindAsync(
536
+ workingDir: string,
537
+ ): Promise<Outcome<AheadBehind>> {
538
+ debug(`[GIT-EXEC] Running git ahead/behind in ${workingDir}`);
539
+ const [aheadResult, behindResult] = await Promise.all([
540
+ this.execGitAsync(["rev-list", "--count", "@{u}..HEAD"], {
541
+ cwd: workingDir,
542
+ timeout: 2000,
543
+ }),
544
+ this.execGitAsync(["rev-list", "--count", "HEAD..@{u}"], {
545
+ cwd: workingDir,
546
+ timeout: 2000,
547
+ }),
548
+ ]);
549
+ // non-zero = no upstream to compare against — the domain answer for any
550
+ // local-only branch, distinct from a transport failure.
551
+ const ahead = classify("git rev-list @{u}..HEAD", aheadResult, "absent");
552
+ const behind = classify("git rev-list HEAD..@{u}", behindResult, "absent");
553
+ if (ahead.kind === "failed") return ahead;
554
+ if (behind.kind === "failed") return behind;
555
+ if (ahead.kind === "absent" || behind.kind === "absent") return ABSENT;
556
+ return ok({
557
+ ahead: parseInt(ahead.value.trim()) || 0,
558
+ behind: parseInt(behind.value.trim()) || 0,
559
+ });
560
+ }
561
+ }
@@ -0,0 +1,101 @@
1
+ import type { ClaudeHookData, ParsedEntry } from "../utils/claude";
2
+
3
+ // [LAW:one-source-of-truth] Metrics reads the transcript through the shared
4
+ // mtime-keyed parse LRU — the same parse the session/context segments already
5
+ // performed this render — instead of a private readFile+parse. One parse path,
6
+ // one cache; the multi-MB content arrays are dropped at parse time.
7
+ import { parseJsonlFile } from "../utils/claude";
8
+ import { debug } from "../utils/logger";
9
+ import { ABSENT, failed, ok, type Outcome } from "../utils/outcome";
10
+
11
+ // [LAW:types-are-the-program] An `ok` MetricsInfo carries real values — "no
12
+ // cost data at all" is the `absent` outcome arm, not a bag of nulls. The one
13
+ // remaining null is lastResponseTime, where the domain genuinely has no
14
+ // answer (no qualifying user→assistant pair in the recent window).
15
+ export interface MetricsInfo {
16
+ responseTime: number;
17
+ lastResponseTime: number | null;
18
+ sessionDuration: number;
19
+ messageCount: number;
20
+ linesAdded: number;
21
+ linesRemoved: number;
22
+ }
23
+
24
+ // A real user turn vs. a tool_result echoed back as a "user" line. The
25
+ // discriminator is metrics-local policy over the shared ParsedEntry scalars.
26
+ function isRealUserMessage(entry: ParsedEntry): boolean {
27
+ const messageType = entry.type || entry.message?.role || entry.message?.type;
28
+ const isToolResult =
29
+ entry.type === "user" && entry.message?.firstContentType === "tool_result";
30
+ return messageType === "user" && !isToolResult;
31
+ }
32
+
33
+ export class MetricsProvider {
34
+ private calculateMessageCount(entries: ParsedEntry[]): number {
35
+ return entries.filter(isRealUserMessage).length;
36
+ }
37
+
38
+ private calculateLastResponseTime(entries: ParsedEntry[]): number | null {
39
+ if (entries.length === 0) return null;
40
+
41
+ const recentEntries = entries.slice(-20);
42
+
43
+ let lastUserTime: Date | null = null;
44
+ let bestResponseTime: number | null = null;
45
+
46
+ for (const entry of recentEntries) {
47
+ const messageType =
48
+ entry.type || entry.message?.role || entry.message?.type;
49
+
50
+ if (isRealUserMessage(entry)) {
51
+ lastUserTime = entry.timestamp;
52
+ } else if (messageType === "assistant" && lastUserTime) {
53
+ const responseTime =
54
+ (entry.timestamp.getTime() - lastUserTime.getTime()) / 1000;
55
+ if (responseTime > 0.1 && responseTime < 300) {
56
+ bestResponseTime = responseTime;
57
+ }
58
+ }
59
+ }
60
+
61
+ return bestResponseTime;
62
+ }
63
+
64
+ // [LAW:no-silent-failure] A hook payload with no cost block is `absent`
65
+ // (old clients); a transcript parse error is `failed`, carried to the
66
+ // payload boundary — the old catch dressed it as the same all-null record
67
+ // as "no data".
68
+ async getMetricsInfo(
69
+ sessionId: string,
70
+ hookData: ClaudeHookData,
71
+ ): Promise<Outcome<MetricsInfo>> {
72
+ try {
73
+ debug(`Getting metrics from hook data for session: ${sessionId}`);
74
+
75
+ if (!hookData.cost) {
76
+ return ABSENT;
77
+ }
78
+
79
+ // parseJsonlFile keeps sidechain entries (usage needs them); metrics
80
+ // counts only main-thread turns, so the sidechain exclusion is local.
81
+ const entries = (await parseJsonlFile(hookData.transcript_path)).filter(
82
+ (entry) => !entry.isSidechain,
83
+ );
84
+ const messageCount = this.calculateMessageCount(entries);
85
+ const lastResponseTime = this.calculateLastResponseTime(entries);
86
+
87
+ return ok({
88
+ responseTime: hookData.cost.total_api_duration_ms / 1000,
89
+ lastResponseTime,
90
+ sessionDuration: hookData.cost.total_duration_ms / 1000,
91
+ messageCount,
92
+ linesAdded: hookData.cost.total_lines_added,
93
+ linesRemoved: hookData.cost.total_lines_removed,
94
+ });
95
+ } catch (error) {
96
+ return failed(
97
+ `metrics (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
98
+ );
99
+ }
100
+ }
101
+ }