@promptctl/cc-candybar 1.4.0 → 1.6.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.
@@ -25,7 +25,10 @@ import type { GitInfo, GitInfoOptions } from "../segments/git.js";
25
25
  import { ABSENT, failed, type Outcome } from "../utils/outcome.js";
26
26
  import { cacheExpiresAt } from "../segments/cache.js";
27
27
  import type { DaemonLogger } from "./log.js";
28
- import type { SessionUsageStore } from "./cache/session-usage-store.js";
28
+ import type {
29
+ SessionUsageStore,
30
+ SpeedObservation,
31
+ } from "./cache/session-usage-store.js";
29
32
  import type { ContextProvider } from "../segments/context.js";
30
33
  import type { MetricsProvider } from "../segments/metrics.js";
31
34
  import type { TmuxService } from "../segments/tmux.js";
@@ -65,6 +68,7 @@ export interface RenderPayload extends ClaudeHookData {
65
68
  readonly session?: SessionPayload;
66
69
  readonly today?: TodayPayload;
67
70
  readonly burn?: BurnPayload;
71
+ readonly speed?: SpeedPayload;
68
72
  readonly block?: BlockPayload;
69
73
  readonly weekly?: WeeklyPayload;
70
74
  readonly cache?: CachePayload;
@@ -94,6 +98,16 @@ export interface GitPayload {
94
98
  readonly status?: string;
95
99
  readonly operation?: string;
96
100
  readonly timeSinceCommit?: number;
101
+ // Forge PR/MR. [LAW:no-silent-failure] Unlike every other git field (where
102
+ // `failed` collapses to a missing key), the PR's failure is surfaced as
103
+ // `prError` so the segment can render a VISIBLY DISTINCT marker — a forge
104
+ // outage must not look like "no PR". The three render-distinguishable states:
105
+ // open PR (prNumber/prState/prUrl present), lookup failed (prError present),
106
+ // no PR (all absent → segment when-gated off).
107
+ readonly prNumber?: number;
108
+ readonly prState?: string;
109
+ readonly prUrl?: string;
110
+ readonly prError?: string;
97
111
  }
98
112
 
99
113
  export interface SessionPayload {
@@ -115,6 +129,19 @@ export interface BurnPayload {
115
129
  readonly costPerHour?: number;
116
130
  }
117
131
 
132
+ // [LAW:one-type-per-behavior] Token throughput for the active turn — tokens per
133
+ // second on each of three lanes (prompt-side input, generated output, their
134
+ // total). Each lane is INDEPENDENTLY optional: during streaming `output` moves
135
+ // while `input` (fixed at turn start) is idle, so an absent `input` rate beside a
136
+ // live `output` rate is the honest shape, not a zero. Absence (no baseline yet,
137
+ // idle between turns, or a too-stale prior sample) travels as a missing field to
138
+ // the -1 default, which the `formatSpeed` helper reads as "—". [LAW:no-silent-failure]
139
+ export interface SpeedPayload {
140
+ readonly input?: number;
141
+ readonly output?: number;
142
+ readonly total?: number;
143
+ }
144
+
118
145
  export interface BlockPayload {
119
146
  readonly nativeUtilization: number;
120
147
  readonly resetsAt: number;
@@ -200,6 +227,15 @@ const MIN_PROJECTABLE_ELAPSED_MS = 5 * 60 * 1000;
200
227
  // $/hr is dominated by a single turn rather than a sustained burn.
201
228
  const MIN_BURN_SECONDS = 60;
202
229
 
230
+ // tok/s is a delta between two successive render observations. The wall-time
231
+ // between them must clear a tiny floor (the clock has to have advanced — below
232
+ // it the rate is divide-by-near-zero noise) and stay under a ceiling: a gap
233
+ // wider than this means the prior sample predates an idle stretch, so the rate
234
+ // would be diluted by dead time. Both bounds → no reading (re-baseline silently
235
+ // on the next render) rather than a misleading number. [LAW:no-silent-failure]
236
+ const MIN_SPEED_SAMPLE_MS = 50;
237
+ const MAX_SPEED_SAMPLE_MS = 10 * 1000;
238
+
203
239
  /**
204
240
  * Linearly extrapolate a rate-limit window's utilization to its 100% cap.
205
241
  * The window started `windowMs` before `resetsAtSec`; elapsed time and the
@@ -236,6 +272,64 @@ export function projectCostPerHour(
236
272
  return (cost * 3600) / durationSeconds;
237
273
  }
238
274
 
275
+ /**
276
+ * Instantaneous tokens-per-second between two successive render observations of
277
+ * one cumulative token count. Returns undefined when the sample window is too
278
+ * small or too large to be honest (see the floor/ceiling constants) or when the
279
+ * count did not advance (idle / between turns — a true 0 over a real window is
280
+ * reported as 0, but a flat count carries no throughput to report). A real
281
+ * positive rate is always >= 0, so callers use -1 as the absence default — 0
282
+ * tok/s never doubles as the "no reading" marker. [LAW:no-silent-failure]
283
+ */
284
+ export function projectTokensPerSecond(
285
+ prevTokens: number,
286
+ prevMs: number,
287
+ curTokens: number,
288
+ nowMs: number,
289
+ ): number | undefined {
290
+ const deltaMs = nowMs - prevMs;
291
+ if (deltaMs < MIN_SPEED_SAMPLE_MS || deltaMs > MAX_SPEED_SAMPLE_MS)
292
+ return undefined;
293
+ const deltaTokens = curTokens - prevTokens;
294
+ if (deltaTokens <= 0) return undefined;
295
+ return (deltaTokens * 1000) / deltaMs;
296
+ }
297
+
298
+ // [LAW:effects-at-boundaries] Pure fold of one speed observation into the
299
+ // payload's three rate lanes. No baseline (first render of a session) or every
300
+ // lane un-projectable → undefined (the whole `speed` key is dropped); otherwise
301
+ // each lane that projects contributes its rate, each that doesn't is a missing
302
+ // field → the -1 default → "—".
303
+ function projectSpeed(obs: SpeedObservation): SpeedPayload | undefined {
304
+ const { prev, cur } = obs;
305
+ if (prev === undefined) return undefined;
306
+ const input = projectTokensPerSecond(
307
+ prev.input,
308
+ prev.atMs,
309
+ cur.input,
310
+ cur.atMs,
311
+ );
312
+ const output = projectTokensPerSecond(
313
+ prev.output,
314
+ prev.atMs,
315
+ cur.output,
316
+ cur.atMs,
317
+ );
318
+ const total = projectTokensPerSecond(
319
+ prev.total,
320
+ prev.atMs,
321
+ cur.total,
322
+ cur.atMs,
323
+ );
324
+ if (input === undefined && output === undefined && total === undefined)
325
+ return undefined;
326
+ return {
327
+ ...(input !== undefined && { input }),
328
+ ...(output !== undefined && { output }),
329
+ ...(total !== undefined && { total }),
330
+ };
331
+ }
332
+
239
333
  // ─── Builder ─────────────────────────────────────────────────────────────────
240
334
 
241
335
  // ─── Config-driven provider gating ───────────────────────────────────────────
@@ -388,6 +482,12 @@ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
388
482
  ...(has("git.repoName") && { showRepoName: true }),
389
483
  ...(has("git.operation") && { showOperation: true }),
390
484
  ...(has("git.timeSinceCommit") && { showTimeSinceCommit: true }),
485
+ // Any PR field laid out turns on the (network) forge lookup. Keep these in
486
+ // lockstep with the projected `git.pr*` fields below.
487
+ ...((has("git.prNumber") ||
488
+ has("git.prState") ||
489
+ has("git.prUrl") ||
490
+ has("git.prError")) && { showPullRequest: true }),
391
491
  };
392
492
  }
393
493
 
@@ -428,6 +528,11 @@ export async function buildRenderPayload(
428
528
  const wants = (prefix: string): boolean =>
429
529
  anyPathStartsWith(neededInputPaths, prefix);
430
530
 
531
+ // [LAW:single-enforcer] One clock read feeds every projection this render —
532
+ // the ETA extrapolations below AND the tok/s sample window in the speed lane,
533
+ // so an ETA, a reset countdown, and a throughput figure all agree on "now".
534
+ const nowMs = (deps.clock ?? (() => new Date()))().getTime();
535
+
431
536
  // [LAW:dataflow-not-control-flow][LAW:one-type-per-behavior] Every provider
432
537
  // lane is ONE shape: "needed → call provider (whose contract is to never
433
538
  // reject — the catch makes the lane total against bugs, mapping a throw
@@ -443,40 +548,57 @@ export async function buildRenderPayload(
443
548
  ? run().catch((e: unknown) => failed(`${name}: ${String(e)}`))
444
549
  : Promise.resolve(ABSENT);
445
550
 
446
- const [gitOutcome, usage, today, context, metrics, tmuxSession, cacheExpiry] =
447
- await Promise.all([
448
- lane("git", wants("git"), () =>
449
- deps.gitProvider.getGitInfo(
450
- cwd ?? hookData.workspace?.current_dir,
451
- gitOptionsFromClosure(neededInputPaths),
452
- hookData.workspace?.project_dir,
453
- ),
454
- ),
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),
462
- ),
463
- lane("today", wants("today"), () =>
464
- deps.usageStore.getTodayInfo(hookData),
551
+ const [
552
+ gitOutcome,
553
+ usage,
554
+ today,
555
+ context,
556
+ metrics,
557
+ tmuxSession,
558
+ cacheExpiry,
559
+ speed,
560
+ ] = await Promise.all([
561
+ lane("git", wants("git"), () =>
562
+ deps.gitProvider.getGitInfo(
563
+ cwd ?? hookData.workspace?.current_dir,
564
+ gitOptionsFromClosure(neededInputPaths),
565
+ hookData.workspace?.project_dir,
465
566
  ),
466
- lane("context", wants("context"), () =>
467
- deps.contextProvider.getContextInfo(hookData),
468
- ),
469
- lane("metrics", wants("metrics") || wants("burn"), () =>
470
- deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
471
- ),
472
- lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
473
- // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
474
- // seam, so it runs alongside the other providers and stays in the shared
475
- // in-flight budget rather than blocking the event loop on sync fs.
476
- lane("cache", wants("cache"), () =>
477
- cacheExpiresAt(hookData.transcript_path),
567
+ ),
568
+ // [LAW:dataflow-not-control-flow] The burn segment reads `burn.costPerHour`,
569
+ // a derivative of session cost and metrics duration — so wanting `burn`
570
+ // pulls in exactly the two lanes it is folded from.
571
+ lane(
572
+ "session",
573
+ wants("session.cost") || wants("session.tokens") || wants("burn"),
574
+ () => deps.usageStore.getUsageInfo(hookData.session_id, hookData),
575
+ ),
576
+ lane("today", wants("today"), () => deps.usageStore.getTodayInfo(hookData)),
577
+ lane("context", wants("context"), () =>
578
+ deps.contextProvider.getContextInfo(hookData),
579
+ ),
580
+ lane("metrics", wants("metrics") || wants("burn"), () =>
581
+ deps.metricsProvider.getMetricsInfo(hookData.session_id, hookData),
582
+ ),
583
+ lane("tmux", wants("tmux"), () => deps.tmuxService.getSessionId()),
584
+ // Prompt-cache expiry: a bounded tail-read through the gated transcript-fs
585
+ // seam, so it runs alongside the other providers and stays in the shared
586
+ // in-flight budget rather than blocking the event loop on sync fs.
587
+ lane("cache", wants("cache"), () =>
588
+ cacheExpiresAt(hookData.transcript_path),
589
+ ),
590
+ // [LAW:one-source-of-truth] tok/s folds from the SAME store the session
591
+ // lane reads — observeSpeed both reports the prior sample and records this
592
+ // render's, so it must run every render the speed segment is laid out (the
593
+ // first establishes the baseline that the second projects from).
594
+ lane("speed", wants("speed"), () =>
595
+ deps.usageStore.observeSpeed(
596
+ hookData.session_id,
597
+ hookData.transcript_path,
598
+ nowMs,
478
599
  ),
479
- ]);
600
+ ),
601
+ ]);
480
602
  // [LAW:effects-at-boundaries] The projections are pure folds returning data
481
603
  // (payload fragment + failure descriptions); the log effect happens once,
482
604
  // here, at the edge. `take` is the total fold for the single-value lanes:
@@ -505,8 +627,6 @@ export async function buildRenderPayload(
505
627
  // the formatter func — a duplicate code path was retired.)
506
628
  const fiveHour = hookData.rate_limits?.five_hour;
507
629
  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
630
  const blockEta = fiveHour
511
631
  ? projectEtaMinutes(
512
632
  fiveHour.used_percentage,
@@ -532,6 +652,13 @@ export async function buildRenderPayload(
532
652
  wants("burn") && burnCost != null && burnDuration != null
533
653
  ? projectCostPerHour(burnCost, burnDuration)
534
654
  : undefined;
655
+ // [LAW:effects-at-boundaries] The store reported the prev+cur samples (an
656
+ // effect: it read state and advanced the baseline); the rate is a pure fold of
657
+ // that data here at the edge. Absent observation (lane skipped/failed) or no
658
+ // projectable lane → no `speed` key → every lane reads its -1 default.
659
+ const speedObs = take(speed);
660
+ const speedPayload =
661
+ speedObs !== undefined ? projectSpeed(speedObs) : undefined;
535
662
 
536
663
  // [LAW:one-source-of-truth] The theme variable surfaces the session's
537
664
  // resolved theme so the toolbar/tray DSL templates can encode it into
@@ -597,6 +724,7 @@ export async function buildRenderPayload(
597
724
  ...(sessionPayload !== undefined && { session: sessionPayload }),
598
725
  ...(todayPayload !== undefined && { today: todayPayload }),
599
726
  ...(costPerHour !== undefined && { burn: { costPerHour } }),
727
+ ...(speedPayload !== undefined && { speed: speedPayload }),
600
728
  ...(wants("block") &&
601
729
  fiveHour !== undefined && {
602
730
  block: {
@@ -697,6 +825,28 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
697
825
  const upstream = field("upstream", info.upstream);
698
826
  const repoName = field("repoName", info.repoName);
699
827
 
828
+ // [LAW:no-silent-failure] The PR deliberately breaks the `field` pattern: a
829
+ // `failed` lookup is NOT dropped to a missing key (which the template can't
830
+ // tell apart from "no PR"). It is BOTH logged AND surfaced as `prError` so
831
+ // the segment renders a distinct marker. `absent` is still a missing key (no
832
+ // PR / no forge → segment off). The reason is the gate value the template
833
+ // tests; the same reason is logged for the operator.
834
+ const pr = info.pullRequest;
835
+ const prFields: {
836
+ prNumber?: number;
837
+ prState?: string;
838
+ prUrl?: string;
839
+ prError?: string;
840
+ } = {};
841
+ if (pr?.kind === "ok") {
842
+ prFields.prNumber = pr.value.number;
843
+ prFields.prState = pr.value.state;
844
+ prFields.prUrl = pr.value.url;
845
+ } else if (pr?.kind === "failed") {
846
+ failures.push(`git.pr: ${pr.reason}`);
847
+ prFields.prError = pr.reason;
848
+ }
849
+
700
850
  return {
701
851
  git: {
702
852
  branch: info.branch,
@@ -717,6 +867,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
717
867
  ...(stash !== undefined && { stash }),
718
868
  ...(upstream !== undefined && { upstream }),
719
869
  ...(repoName !== undefined && { repoName }),
870
+ ...prFields,
720
871
  },
721
872
  failures,
722
873
  };
@@ -31,6 +31,10 @@ import type { LaunchStatsHandle } from "./stats-handle";
31
31
  // pattern. [LAW:no-mode-explosion]: no per-site escape hatch.
32
32
  export const LAUNCH_CATEGORIES = [
33
33
  "git",
34
+ // Forge CLIs (gh / glab) for the PR/MR lookup. A network-bound spawn,
35
+ // separate from "git" so daemon-stats attributes it independently and a
36
+ // future rate limit can target it without throttling local git.
37
+ "forge",
34
38
  "user-shell",
35
39
  "tmux",
36
40
  "click.pbcopy",
@@ -16,6 +16,17 @@ export interface AheadBehind {
16
16
  behind: number;
17
17
  }
18
18
 
19
+ // [LAW:types-are-the-program] The branch's open PR/MR as the forge reports it.
20
+ // `number` and `url` are the click target; `state` is the forge's status string
21
+ // (GitHub "OPEN", GitLab "opened") — carried so a consumer can color/label it,
22
+ // though resolvePullRequest only ever returns a PR whose state is open (a
23
+ // merged/closed PR for the branch is the domain's `absent`, not a value).
24
+ export interface PullRequest {
25
+ number: number;
26
+ state: string;
27
+ url: string;
28
+ }
29
+
19
30
  // [LAW:types-are-the-program] Every on-demand field is an Outcome, so "this
20
31
  // value is unknown because the fetch failed" is representable distinct from
21
32
  // a real 0/""/basename — the states the old catch-and-substitute blocks
@@ -37,6 +48,13 @@ export interface GitInfo {
37
48
  upstream?: Outcome<string>;
38
49
  repoName?: Outcome<string>;
39
50
  isWorktree?: boolean;
51
+ // [LAW:no-silent-failure] The forge lookup's three outcomes are all kept
52
+ // distinct here: `ok` is an open PR, `absent` is "this branch has none / no
53
+ // forge / no forge CLI", `failed` is "the forge was asked but couldn't
54
+ // answer" (auth, network, API error). The render boundary surfaces `failed`
55
+ // as a VISIBLE marker — collapsing it to `absent` would make a transient
56
+ // outage look like the PR vanished. Undefined = `showPullRequest` was off.
57
+ pullRequest?: Outcome<PullRequest>;
40
58
  }
41
59
 
42
60
  // [LAW:one-source-of-truth] The one shape of getGitInfo's `show*` toggles. Each
@@ -53,6 +71,11 @@ export interface GitInfoOptions {
53
71
  showStashCount?: boolean;
54
72
  showUpstream?: boolean;
55
73
  showRepoName?: boolean;
74
+ // Opts into the forge (gh/glab) PR/MR lookup — a network call, so it is the
75
+ // one option whose fetch the daemon caches on a longer, independent TTL than
76
+ // the rest of GitInfo (see src/daemon/cache/git.ts). Never resolved by the
77
+ // inner GitService's computeGitInfo; the cache layer owns the lookup+cache.
78
+ showPullRequest?: boolean;
56
79
  }
57
80
 
58
81
  // [LAW:dataflow-not-control-flow] One classifier for every git invocation.
@@ -89,6 +112,134 @@ function nonEmpty(o: Outcome<string>): Outcome<string> {
89
112
  return v ? ok(v) : ABSENT;
90
113
  }
91
114
 
115
+ // [LAW:one-type-per-behavior] `gh` and `glab` are two instances of one act:
116
+ // "ask a forge CLI for the branch's PR, fold the typed launch result into an
117
+ // Outcome<PullRequest>." The accept/reject shape table is identical across
118
+ // both — only the no-PR stderr signature and the JSON field names differ — so
119
+ // the classification lives here once and each forge supplies its own
120
+ // (noPrPattern, parse) as data.
121
+ //
122
+ // [LAW:no-silent-failure] The full shape table, enumerated so no input leaks:
123
+ // ok + parse ok (open PR) → ok (the value)
124
+ // ok + parse ok (not open) → absent (branch's PR is done)
125
+ // ok + parse fails → failed (forge answered garbage)
126
+ // non-zero + no-PR stderr → absent (genuine "none for branch")
127
+ // spawn-error ENOENT (no CLI) → absent (no forge integration)
128
+ // spawn-error other (EACCES, …) → failed (CLI present but unlaunchable)
129
+ // non-zero (auth/net/not-a-repo) → failed (forge couldn't answer)
130
+ // timeout / signal / rate-limited → failed (forge couldn't answer)
131
+ export type ForgeName = "github" | "gitlab";
132
+
133
+ // [LAW:types-are-the-program] Extract the host from a git remote, handling the
134
+ // two shapes git uses: scp-like `[user@]host:path` and URL `scheme://[user@]
135
+ // host[:port]/path`. The URL form is checked first — its `host` in a scp regex
136
+ // would mis-capture the scheme (`https` before `://`). Returns null for an
137
+ // unrecognized shape (local path, unknown syntax).
138
+ function remoteHost(remoteUrl: string): string | null {
139
+ const url = remoteUrl.trim();
140
+ const proto = url.match(/^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i);
141
+ if (proto) return proto[1]!.toLowerCase();
142
+ const scp = url.match(/^(?:[^@/]+@)?([^/:]+):/);
143
+ if (scp) return scp[1]!.toLowerCase();
144
+ return null;
145
+ }
146
+
147
+ // [LAW:types-are-the-program] Branch on the HOST, not a substring of the whole
148
+ // URL — a non-GitLab remote whose path merely contains "gitlab" (a repo named
149
+ // `gitlab`) must not dispatch to glab. Self-hosted GitLab is detected by a
150
+ // `gitlab.`-prefixed host label (gitlab.example.com); a GitLab on an arbitrary
151
+ // hostname is undetectable here and falls through to null (absent), same as
152
+ // GitHub Enterprise on a custom domain.
153
+ export function detectForge(remoteUrl: string): ForgeName | null {
154
+ const host = remoteHost(remoteUrl);
155
+ if (!host) return null;
156
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
157
+ if (/(^|\.)gitlab\./.test(host)) return "gitlab";
158
+ return null;
159
+ }
160
+
161
+ export function classifyForgePr(
162
+ label: string,
163
+ result: LaunchResult,
164
+ noPrPattern: RegExp,
165
+ parse: (stdout: string) => Outcome<PullRequest>,
166
+ ): Outcome<PullRequest> {
167
+ if (result.ok) return parse(result.stdout);
168
+ // ENOENT (no forge CLI on PATH) is a static configuration absence, not a
169
+ // transient lookup failure — it never showed a PR, so showing nothing costs
170
+ // nothing. [LAW:no-silent-failure] Every OTHER spawn failure (EACCES,
171
+ // resource limits) means the CLI is present but could not launch — a real
172
+ // failure that must stay visible, so it falls through to the `failed` path.
173
+ if (result.reason === "spawn-error" && /ENOENT/i.test(result.error ?? ""))
174
+ return ABSENT;
175
+ if (result.reason === "non-zero" && noPrPattern.test(result.stderr))
176
+ return ABSENT;
177
+ const detail = [
178
+ result.reason,
179
+ result.exitCode != null ? `exit ${result.exitCode}` : null,
180
+ result.error ?? firstLine(result.stderr),
181
+ ]
182
+ .filter(Boolean)
183
+ .join(", ");
184
+ return failed(`${label}: ${detail}`);
185
+ }
186
+
187
+ function isRecord(v: unknown): v is Record<string, unknown> {
188
+ return typeof v === "object" && v !== null && !Array.isArray(v);
189
+ }
190
+
191
+ // `gh pr view --json number,state,url` → one JSON object. Only an OPEN PR is a
192
+ // value; a MERGED/CLOSED PR for the branch is the domain's `absent`.
193
+ export function parseGithubPr(stdout: string): Outcome<PullRequest> {
194
+ let json: unknown;
195
+ try {
196
+ json = JSON.parse(stdout);
197
+ } catch (e) {
198
+ return failed(
199
+ `gh pr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
200
+ );
201
+ }
202
+ if (!isRecord(json)) return failed("gh pr view: JSON is not an object");
203
+ const { number, state, url } = json;
204
+ if (
205
+ typeof number !== "number" ||
206
+ typeof state !== "string" ||
207
+ typeof url !== "string"
208
+ ) {
209
+ return failed("gh pr view: missing number/state/url");
210
+ }
211
+ if (state.toUpperCase() !== "OPEN") return ABSENT;
212
+ return ok({ number, state, url });
213
+ }
214
+
215
+ // `glab mr view --output json` → one JSON object (iid / state / web_url). State
216
+ // "opened" is the open MR; anything else is `absent`. NOTE: verified against
217
+ // glab's documented JSON shape, not runtime-exercised here (glab not installed
218
+ // on the dev machine) — the github path is the runtime-verified one.
219
+ export function parseGitlabMr(stdout: string): Outcome<PullRequest> {
220
+ let json: unknown;
221
+ try {
222
+ json = JSON.parse(stdout);
223
+ } catch (e) {
224
+ return failed(
225
+ `glab mr view: unparseable JSON (${e instanceof Error ? e.message : String(e)})`,
226
+ );
227
+ }
228
+ if (!isRecord(json)) return failed("glab mr view: JSON is not an object");
229
+ const iid = json.iid;
230
+ const state = json.state;
231
+ const url = json.web_url;
232
+ if (
233
+ typeof iid !== "number" ||
234
+ typeof state !== "string" ||
235
+ typeof url !== "string"
236
+ ) {
237
+ return failed("glab mr view: missing iid/state/web_url");
238
+ }
239
+ if (state.toLowerCase() !== "opened") return ABSENT;
240
+ return ok({ number: iid, state, url });
241
+ }
242
+
92
243
  export class GitService {
93
244
  private isGitRepo(workingDir: string): boolean {
94
245
  try {
@@ -407,6 +558,83 @@ export class GitService {
407
558
  return ok(match?.[1] || path.basename(workingDir));
408
559
  }
409
560
 
561
+ // [LAW:locality-or-seam] Public so the daemon's GitDataProvider can read the
562
+ // remote to fold into its PR cache key (the PR value depends on the remote;
563
+ // a re-pointed origin must be a new key). Raw origin URL (unparsed) — the
564
+ // forge detector reads the host from it. `config --get` exits 1 when unset →
565
+ // `absent` (no remote, hence no forge PR concept), distinct from a failure.
566
+ async getRemoteOriginUrl(workingDir: string): Promise<Outcome<string>> {
567
+ return nonEmpty(
568
+ classify(
569
+ "git config remote.origin.url",
570
+ await this.execGitAsync(["config", "--get", "remote.origin.url"], {
571
+ cwd: workingDir,
572
+ timeout: 2000,
573
+ }),
574
+ "absent",
575
+ ),
576
+ );
577
+ }
578
+
579
+ // [LAW:single-enforcer] One boundary for forge-CLI spawns. Mirrors
580
+ // execGitAsync but carries the "forge" launch category and a longer timeout
581
+ // (this is a network call, not a local git read). Returns the typed
582
+ // LaunchResult so classifyForgePr maps every termination cause to an Outcome.
583
+ private async execForgeAsync(
584
+ bin: string,
585
+ args: readonly string[],
586
+ options: { cwd: string; timeout: number },
587
+ ): Promise<LaunchResult> {
588
+ return launch({
589
+ bin,
590
+ args: [...args],
591
+ cwd: options.cwd,
592
+ env: { ...process.env },
593
+ timeoutMs: options.timeout,
594
+ category: "forge",
595
+ });
596
+ }
597
+
598
+ // [LAW:effects-at-boundaries] Resolve the branch's open PR/MR via the forge
599
+ // CLI. Pure dispatch over a remote the CALLER has already read: pick the
600
+ // forge by host, run its CLI, fold the launch result into an Outcome. The
601
+ // remote is a parameter (not read here) so the cache layer can fold it into
602
+ // its key in the same read — no caching here; the daemon's GitDataProvider
603
+ // owns the PR cache + TTL (a network resource wants a longer, independent
604
+ // lifecycle than local git state). `absent` when the host is no recognized
605
+ // forge; the CLI dispatch then classifies the rest.
606
+ async resolvePullRequest(
607
+ workingDir: string,
608
+ remoteUrl: string,
609
+ ): Promise<Outcome<PullRequest>> {
610
+ const forge = detectForge(remoteUrl);
611
+ if (forge === "github") {
612
+ return classifyForgePr(
613
+ "gh pr view",
614
+ await this.execForgeAsync(
615
+ "gh",
616
+ ["pr", "view", "--json", "number,state,url"],
617
+ { cwd: workingDir, timeout: 5000 },
618
+ ),
619
+ /no (open )?pull requests? found/i,
620
+ parseGithubPr,
621
+ );
622
+ }
623
+ if (forge === "gitlab") {
624
+ return classifyForgePr(
625
+ "glab mr view",
626
+ await this.execForgeAsync("glab", ["mr", "view", "--output", "json"], {
627
+ cwd: workingDir,
628
+ timeout: 5000,
629
+ }),
630
+ /no (open )?merge requests? (found|available)/i,
631
+ parseGitlabMr,
632
+ );
633
+ }
634
+ // Recognized neither host → no forge integration for this remote.
635
+ return ABSENT;
636
+ }
637
+
410
638
  private isWorktree(workingDir: string): boolean {
411
639
  try {
412
640
  const gitDir = path.join(workingDir, ".git");