@promptctl/cc-candybar 1.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.5.0",
3
+ "version": "1.6.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.5.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.5.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.5.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.5.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.6.0",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.6.0",
96
+ "@promptctl/cc-candybar-linux-x64": "1.6.0",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.6.0"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -280,6 +280,21 @@ export const DEFAULT_DSL_CONFIG = {
280
280
  default: 0,
281
281
  },
282
282
 
283
+ // Forge PR/MR — the daemon's git provider resolves the branch's open PR via
284
+ // gh/glab and projects it here. Declaring any of these turns on the network
285
+ // lookup. [LAW:no-silent-failure] prError is non-empty ONLY when the forge
286
+ // was asked but couldn't answer (auth/network) — distinct from "no PR"
287
+ // (every field empty). prNumber 0 (default) ⇒ no open PR.
288
+ "git.prNumber": {
289
+ kind: "input",
290
+ path: "git.prNumber",
291
+ type: "number",
292
+ default: 0,
293
+ },
294
+ "git.prState": { kind: "input", path: "git.prState", default: "" },
295
+ "git.prUrl": { kind: "input", path: "git.prUrl", default: "" },
296
+ "git.prError": { kind: "input", path: "git.prError", default: "" },
297
+
283
298
  // Prompt-cache expiry — epoch seconds, projected by the cache provider.
284
299
  // Same unit/shape as block/weekly resetsAt so the cacheTimer segment
285
300
  // composes `minutesUntilReset` identically. 0 (default) ⇒ no cache
@@ -542,6 +557,24 @@ export const DEFAULT_DSL_CONFIG = {
542
557
  fg: "foreground",
543
558
  when: '{{ ne .git.branch "" }}',
544
559
  },
560
+ // Git PR/MR — the branch's open pull/merge request as a clickable link.
561
+ // OPT-IN: declared but NOT in the default root (it adds a network gh/glab
562
+ // call). Add "gitPr" to a container's children to enable it. The `{{ link
563
+ // url text }}` emits ONE OSC-8 region carrying the https PR url, so the
564
+ // CLICK is handled by the terminal/OS (opens the browser) — no daemon verb.
565
+ // [LAW:no-silent-failure] Three render states from the data: an open PR
566
+ // (prUrl set) renders the link; a lookup FAILURE (prError set, prUrl empty)
567
+ // renders a distinct ⚠ marker so an outage is not mistaken for "no PR";
568
+ // no PR (both empty) leaves the `when` gate false and the segment absent.
569
+ gitPr: {
570
+ template:
571
+ '{{ if ne .git.prUrl "" }}' +
572
+ '{{ link .git.prUrl (printf " ⇆ #%v " .git.prNumber) }}' +
573
+ "{{ else }} ⚠ PR {{ end }}",
574
+ bg: "surface-active",
575
+ fg: "foreground",
576
+ when: '{{ or (ne .git.prUrl "") (ne .git.prError "") }}',
577
+ },
545
578
  // Quick-action tray — copy the session id / cwd, open the project dir /
546
579
  // transcript in the editor. [LAW:locality-or-seam] The glyph is the
547
580
  // REPRESENTATION; the named action (below) is the BEHAVIOR; the action
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { GitService, type GitInfo } from "../../segments/git";
4
- import { ok, type Outcome } from "../../utils/outcome";
3
+ import { GitService, type GitInfo, type PullRequest } from "../../segments/git";
4
+ import { ABSENT, ok, type Outcome } from "../../utils/outcome";
5
5
  import { debug } from "../../utils/logger";
6
6
  import { WatcherRegistry, type WatcherHandle } from "./watchers";
7
7
 
@@ -44,6 +44,17 @@ const DEFAULT_TTL_MS = 30_000;
44
44
  const DEFAULT_MAX_ENTRIES = 64;
45
45
  const SANITY_INTERVAL_MS = 5 * 60_000;
46
46
 
47
+ // [LAW:decomposition] The forge PR lookup is a network resource with a
48
+ // different lifecycle than local git state: it changes ~twice in a PR's life
49
+ // and the fs watchers on .git/HEAD|index say nothing about remote PR state. So
50
+ // it gets its OWN cache + TTL — independent of the 30s GitInfo TTL — so the
51
+ // per-render git fetch stays all-local and the gh/glab spawn happens at most
52
+ // once per window (or on branch switch, via the branch in the key). A `failed`
53
+ // lookup caches for a SHORTER window so a transient forge outage is retried
54
+ // soon, not pinned to the bar for the full ok-TTL.
55
+ const PR_TTL_OK_MS = 5 * 60_000;
56
+ const PR_TTL_FAIL_MS = 45_000;
57
+
47
58
  // Options applied to subscribe()'s internal getInfo() call. var-system's six
48
59
  // GitField values (branch, sha, dirty, ahead, behind, stash) all project from
49
60
  // these flags — keep them in lockstep with the projection in
@@ -60,6 +71,11 @@ interface MtimeSnapshot {
60
71
  index: number;
61
72
  }
62
73
 
74
+ interface PrCacheEntry {
75
+ pr: Outcome<PullRequest>;
76
+ computedAt: number;
77
+ }
78
+
63
79
  interface GitCacheEntry {
64
80
  info: GitInfo;
65
81
  computedAt: number;
@@ -147,6 +163,16 @@ export class GitDataProvider extends GitService {
147
163
  // here; subsequent concurrent callers await the same promise and resolve in
148
164
  // lockstep.
149
165
  private readonly fetchInFlight = new Map<string, Promise<Outcome<GitInfo>>>();
166
+ // [LAW:one-source-of-truth] The forge PR cache, keyed by `repoRoot|branch`
167
+ // (a branch switch is a new key, so its PR is fetched fresh; the old branch's
168
+ // entry ages out). Separate from `entries` because it carries its own TTL.
169
+ private readonly prCache = new Map<string, PrCacheEntry>();
170
+ // [LAW:single-enforcer] Coalesce concurrent PR misses on the same key — same
171
+ // role as fetchInFlight for the GitInfo path, but for the network forge call.
172
+ private readonly prFetchInFlight = new Map<
173
+ string,
174
+ Promise<Outcome<PullRequest>>
175
+ >();
150
176
  // [LAW:single-enforcer] Coalesce overlapping refreshes for the same repo.
151
177
  // `refreshing` holds the repoRoots whose refresh loop is currently
152
178
  // executing; `refreshAgain` is the trailing-edge flag: if a new
@@ -292,6 +318,15 @@ export class GitDataProvider extends GitService {
292
318
  if (outcome.kind !== "ok") return outcome;
293
319
  const info = outcome.value;
294
320
 
321
+ // [LAW:decomposition] The inner GitService never resolves the PR — the
322
+ // cache layer owns that lookup so it can give it an independent TTL. On the
323
+ // 30s GitInfo refetch the prCache is almost always warm, so attaching the
324
+ // PR here is a memory read; the gh/glab spawn only fires when the prCache
325
+ // entry has aged past its (outcome-dependent) TTL or the branch changed.
326
+ if (options.showPullRequest) {
327
+ info.pullRequest = await this.getPullRequestCached(repoRoot, info.branch);
328
+ }
329
+
295
330
  // Drop any prior entry for this exact key before re-inserting (so we
296
331
  // release its watcher refcount cleanly).
297
332
  this.dropEntry(key);
@@ -315,6 +350,63 @@ export class GitDataProvider extends GitService {
315
350
  return outcome;
316
351
  }
317
352
 
353
+ // [LAW:single-enforcer] The one read path for a branch's PR. Reads the remote
354
+ // (cheap local git) so the cache key reflects every input the PR value
355
+ // depends on — `repoRoot|branch|remote` — and a re-pointed origin is a fresh
356
+ // key, not a stale hit ([LAW:one-source-of-truth]). TTL is outcome-dependent
357
+ // (a `failed` retries sooner). Concurrent misses coalesce through
358
+ // prFetchInFlight. The forge dispatch delegates to inner.resolvePullRequest —
359
+ // this layer is cache + lifecycle only, never forge knowledge.
360
+ private async getPullRequestCached(
361
+ repoRoot: string,
362
+ branch: string,
363
+ ): Promise<Outcome<PullRequest>> {
364
+ // [LAW:no-silent-failure] A `failed` remote read (git couldn't run) must
365
+ // surface; only `absent` (no remote configured) means "no forge PR".
366
+ const remote = await this.inner.getRemoteOriginUrl(repoRoot);
367
+ if (remote.kind === "failed") return remote;
368
+ if (remote.kind === "absent") return ABSENT;
369
+ const remoteUrl = remote.value;
370
+
371
+ const key = `${repoRoot}|${branch}|${remoteUrl}`;
372
+ const now = Date.now();
373
+
374
+ const existing = this.prCache.get(key);
375
+ if (existing) {
376
+ const ttl = existing.pr.kind === "failed" ? PR_TTL_FAIL_MS : PR_TTL_OK_MS;
377
+ if (now - existing.computedAt < ttl) {
378
+ // LRU touch so the active branch's PR survives eviction.
379
+ this.prCache.delete(key);
380
+ this.prCache.set(key, existing);
381
+ return existing.pr;
382
+ }
383
+ }
384
+
385
+ const pending = this.prFetchInFlight.get(key);
386
+ if (pending) return pending;
387
+
388
+ const promise = this.inner
389
+ .resolvePullRequest(repoRoot, remoteUrl)
390
+ .then((pr) => {
391
+ this.prCache.set(key, { pr, computedAt: Date.now() });
392
+ this.evictPrIfNeeded();
393
+ return pr;
394
+ })
395
+ .finally(() => {
396
+ this.prFetchInFlight.delete(key);
397
+ });
398
+ this.prFetchInFlight.set(key, promise);
399
+ return promise;
400
+ }
401
+
402
+ private evictPrIfNeeded(): void {
403
+ while (this.prCache.size > this.maxEntries) {
404
+ const oldest = this.prCache.keys().next().value;
405
+ if (oldest === undefined) break;
406
+ this.prCache.delete(oldest);
407
+ }
408
+ }
409
+
318
410
  // [LAW:effects-at-boundaries] The subscribe surface's edge: fold the typed
319
411
  // outcome into the GitInfo|null the var-system callback contract expects.
320
412
  // `failed` is logged HERE — the one log site for this consumption path
@@ -548,6 +640,10 @@ export class GitDataProvider extends GitService {
548
640
  this.refreshing.clear();
549
641
  this.refreshAgain.clear();
550
642
  this.fetchInFlight.clear();
643
+ // In-flight PR fetches resolve naturally; clearing the maps means the next
644
+ // caller starts fresh (the prCache is rebuilt cold like every other cache).
645
+ this.prCache.clear();
646
+ this.prFetchInFlight.clear();
551
647
  if (this.ownsWatchers) this.watchers.closeAll();
552
648
  }
553
649
  }
@@ -98,6 +98,16 @@ export interface GitPayload {
98
98
  readonly status?: string;
99
99
  readonly operation?: string;
100
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;
101
111
  }
102
112
 
103
113
  export interface SessionPayload {
@@ -472,6 +482,12 @@ function gitOptionsFromClosure(needed: ReadonlySet<string>): GitInfoOptions {
472
482
  ...(has("git.repoName") && { showRepoName: true }),
473
483
  ...(has("git.operation") && { showOperation: true }),
474
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 }),
475
491
  };
476
492
  }
477
493
 
@@ -809,6 +825,28 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
809
825
  const upstream = field("upstream", info.upstream);
810
826
  const repoName = field("repoName", info.repoName);
811
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
+
812
850
  return {
813
851
  git: {
814
852
  branch: info.branch,
@@ -829,6 +867,7 @@ function projectGitInfo(outcome: Outcome<GitInfo>): {
829
867
  ...(stash !== undefined && { stash }),
830
868
  ...(upstream !== undefined && { upstream }),
831
869
  ...(repoName !== undefined && { repoName }),
870
+ ...prFields,
832
871
  },
833
872
  failures,
834
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");