@brainervirus/workit-core 8.1.0 → 8.1.1

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": "@brainervirus/workit-core",
3
- "version": "8.1.0",
3
+ "version": "8.1.1",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import { readFileSync, realpathSync } from "node:fs";
3
4
  import path from "node:path";
@@ -413,6 +414,70 @@ export const validateWorkspaceGlob = (glob: string): GlobValidation => {
413
414
  return { ok: true };
414
415
  };
415
416
 
417
+ /** A workspaces.json problem for the user to fix; CLI verbs report it as `blocked`. */
418
+ export class WorkspaceConfigError extends Error {
419
+ override name = "WorkspaceConfigError";
420
+ }
421
+
422
+ const mainCheckouts = new Map<string, string | null>();
423
+
424
+ /**
425
+ * The main checkout of the linked worktree at `cwd` (null when cwd is not a
426
+ * linked worktree). A worktree outside every glob (~/.codex/worktrees/…,
427
+ * ../x, /tmp) belongs to the workspace of the checkout it was added from (B2).
428
+ */
429
+ const mainCheckoutOf = (cwd: string): string | null => {
430
+ const cached = mainCheckouts.get(cwd);
431
+ if (cached !== undefined) return cached;
432
+ // The repository at cwd, not one an inherited GIT_DIR/GIT_WORK_TREE names.
433
+ const env = { ...process.env };
434
+ for (const name of ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE"])
435
+ delete env[name];
436
+ const git = (args: string[]): string | null => {
437
+ const run = spawnSync("git", args, {
438
+ cwd,
439
+ env,
440
+ encoding: "utf8",
441
+ timeout: 5000,
442
+ windowsHide: true,
443
+ });
444
+ return run.status === 0 ? run.stdout : null;
445
+ };
446
+ let main: string | null = null;
447
+ const dirs = git(["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"]);
448
+ const [gitDir, common] = (dirs ?? "").trim().split(/\r?\n/u);
449
+ if (gitDir && common && path.resolve(gitDir) !== path.resolve(common)) {
450
+ // The first `worktree` entry is the main one, also with --separate-git-dir
451
+ // (where the common dir is not <main>/.git); a bare repo lists its own path.
452
+ const listed = /^worktree (.+)$/mu.exec(git(["worktree", "list", "--porcelain"]) ?? "")?.[1];
453
+ main = listed?.trim() || (path.basename(common) === ".git" ? path.dirname(common) : common);
454
+ }
455
+ mainCheckouts.set(cwd, main);
456
+ return main;
457
+ };
458
+
459
+ /**
460
+ * The entries whose glob matches cwd (its logical and real path); for a linked
461
+ * worktree that matches none, the entries matching its main checkout.
462
+ */
463
+ const matchingEntries = <T extends { glob: string }>(cwd: string, entries: readonly T[]): T[] => {
464
+ const match = (where: string): T[] => {
465
+ const targets = [where, realpathOf(where)].map((value) => value.replaceAll("\\", "/"));
466
+ return entries.filter((entry) => {
467
+ const glob = entry.glob.replaceAll("\\", "/");
468
+ const canonical = canonicalGlob(glob);
469
+ return targets.some(
470
+ (target) =>
471
+ matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
472
+ );
473
+ });
474
+ };
475
+ const direct = match(cwd);
476
+ if (direct.length) return direct;
477
+ const main = mainCheckoutOf(cwd);
478
+ return main ? match(main) : direct;
479
+ };
480
+
416
481
  const realpathOf = (p: string): string => {
417
482
  // realpathSync.native goes through libuv, which expands Windows 8.3 short
418
483
  // names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
@@ -453,13 +518,17 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
453
518
  const named = matches.filter((entry) => entry.name === workspaceName);
454
519
  if (named.length === 1) return named[0];
455
520
  if (named.length > 1)
456
- throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
521
+ throw new WorkspaceConfigError(
522
+ `ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`,
523
+ );
457
524
  if (matches.length > 0) {
458
- throw new Error(
525
+ throw new WorkspaceConfigError(
459
526
  `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
460
527
  );
461
528
  }
462
- throw new Error(`workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`);
529
+ throw new WorkspaceConfigError(
530
+ `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}`,
531
+ );
463
532
  }
464
533
 
465
534
  if (matches.length < 2) return matches[0];
@@ -468,7 +537,7 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
468
537
  (entry) => globSpecificity(entry.glob) === highestSpecificity,
469
538
  );
470
539
  if (mostSpecific.length > 1) {
471
- throw new Error(
540
+ throw new WorkspaceConfigError(
472
541
  `ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
473
542
  );
474
543
  }
@@ -486,23 +555,7 @@ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: stri
486
555
  // written with the logical path, so a workspace would silently stop
487
556
  // matching on macOS. Match both forms on each side; on Linux both forms
488
557
  // are identical so behavior is unchanged.
489
- const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
490
- const matches: T[] = [];
491
- for (const ws of entries) {
492
- const glob = ws.glob.replaceAll("\\", "/");
493
- const canonical = canonicalGlob(glob);
494
- let matched = false;
495
- for (const target of targets) {
496
- if (
497
- matchWorkspace(glob, target) ||
498
- (canonical !== glob && matchWorkspace(canonical, target))
499
- ) {
500
- matched = true;
501
- break;
502
- }
503
- }
504
- if (matched) matches.push(ws);
505
- }
558
+ const matches = matchingEntries(cwd, entries);
506
559
  return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
507
560
  };
508
561
 
@@ -513,7 +566,8 @@ export const resolveWorkspaceFrom = (
513
566
  workspaceName?: string,
514
567
  ): WorkspaceConfig | null => {
515
568
  const result = readWorkspacesResult(dir);
516
- if (result.status === "malformed" || result.status === "invalid") throw new Error(result.error);
569
+ if (result.status === "malformed" || result.status === "invalid")
570
+ throw new WorkspaceConfigError(result.error);
517
571
  return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
518
572
  };
519
573
 
@@ -586,7 +640,7 @@ const matchRuntimeWorkspace = (
586
640
  raw = readFileSync(file, "utf8");
587
641
  } catch (error) {
588
642
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
589
- throw new Error(
643
+ throw new WorkspaceConfigError(
590
644
  `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
591
645
  { cause: error },
592
646
  );
@@ -595,29 +649,21 @@ const matchRuntimeWorkspace = (
595
649
  try {
596
650
  parsed = JSON.parse(raw);
597
651
  } catch {
598
- throw new Error(`${file} is not valid JSON`);
652
+ throw new WorkspaceConfigError(`${file} is not valid JSON`);
599
653
  }
600
654
  const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
601
655
  if (!validated.success) {
602
656
  const detail = validated.error.issues
603
657
  .map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
604
658
  .join("; ");
605
- throw new Error(`${file} has invalid workspace matching data: ${detail}`);
659
+ throw new WorkspaceConfigError(`${file} has invalid workspace matching data: ${detail}`);
606
660
  }
607
661
  const entries = validated.data.workspaces;
608
662
  for (const entry of entries ?? []) {
609
663
  const glob = validateWorkspaceGlob(entry.glob);
610
- if (!glob.ok) throw new Error(`${file} workspace ${entry.name}: ${glob.error}`);
664
+ if (!glob.ok) throw new WorkspaceConfigError(`${file} workspace ${entry.name}: ${glob.error}`);
611
665
  }
612
- const targets = [cwd, realpathOf(cwd)].map((value) => value.replaceAll("\\", "/"));
613
- const matches = (entries ?? []).filter((entry) => {
614
- const glob = entry.glob.replaceAll("\\", "/");
615
- const canonical = canonicalGlob(glob);
616
- return targets.some(
617
- (target) =>
618
- matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
619
- );
620
- });
666
+ const matches = matchingEntries(cwd, entries ?? []);
621
667
  const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
622
668
  const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
623
669
  return selected ? { file, selected } : null;
@@ -649,7 +695,7 @@ const resolveRuntimeWorkspaceCandidate = (
649
695
  const detail = policy.error.issues
650
696
  .map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
651
697
  .join("; ");
652
- throw new Error(`${file} has invalid ${kind} policy configuration: ${detail}`);
698
+ throw new WorkspaceConfigError(`${file} has invalid ${kind} policy configuration: ${detail}`);
653
699
  }
654
700
  return policy.data as RuntimeWorkspaceCandidate;
655
701
  };
@@ -670,12 +716,12 @@ export const resolveRuntimeWorkspacePolicy = (
670
716
  candidate.defaultProfile &&
671
717
  !Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
672
718
  )
673
- throw new Error(
719
+ throw new WorkspaceConfigError(
674
720
  `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
675
721
  );
676
722
  const profile = profileName ? candidate.profiles?.[profileName] : undefined;
677
723
  if (profileName && !profile)
678
- throw new Error(
724
+ throw new WorkspaceConfigError(
679
725
  `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
680
726
  );
681
727
  const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
package/src/forge/exec.ts CHANGED
@@ -19,6 +19,8 @@ export type CliRun = {
19
19
  timedOut: boolean;
20
20
  /** The binary is not on PATH. */
21
21
  missing: boolean;
22
+ /** The timeout actually applied (a deadline may have shortened it). */
23
+ timeoutMs?: number;
22
24
  };
23
25
 
24
26
  /** Runs one gh/glab command. Injected in tests to replay recorded API fixtures. */
@@ -109,6 +111,47 @@ export const installHint = (bin: CliBin): string =>
109
111
  export const loginHint = (bin: CliBin, apiHost: string): string =>
110
112
  `${bin} auth login --hostname ${apiHost}`;
111
113
 
114
+ /** Transport trouble (DNS, TCP, TLS, a 5xx from the forge): retryable, exit 5. */
115
+ const NETWORK =
116
+ /error connecting|could not resolve|no such host|connection (refused|reset|timed out)|network is unreachable|i\/o timeout|tls handshake|unexpected eof|\bHTTP 5\d\d\b|bad gateway|service unavailable|gateway time-?out/iu;
117
+
118
+ export const isNetworkError = (text: string): boolean => NETWORK.test(text);
119
+
120
+ /**
121
+ * The forge's own reason for a refusal: `gh api`/`glab api` print the error
122
+ * body on stdout (`message`, `errors[].message`), stderr only says "HTTP 422".
123
+ */
124
+ export function forgeReason(run: Pick<CliRun, "stdout" | "stderr">): string {
125
+ const parts: string[] = [];
126
+ try {
127
+ const body = JSON.parse(run.stdout) as {
128
+ message?: unknown;
129
+ error?: unknown;
130
+ errors?: unknown;
131
+ };
132
+ const add = (value: unknown): void => {
133
+ if (typeof value === "string" && value.trim()) parts.push(value.trim());
134
+ else if (Array.isArray(value)) value.forEach(add);
135
+ else if (value && typeof value === "object") {
136
+ const record = value as Record<string, unknown>;
137
+ if (typeof record.message === "string") add(record.message);
138
+ else if (!("code" in record)) Object.values(record).forEach(add);
139
+ }
140
+ };
141
+ add(body.message);
142
+ add(body.error);
143
+ add(body.errors);
144
+ } catch {
145
+ // not a JSON body
146
+ }
147
+ const stderr = (run.stderr.split(/\r?\n/u).find((value) => value.trim()) ?? "").trim();
148
+ const unique = [...new Set(parts)];
149
+ const text = unique.length
150
+ ? `${unique.join("; ")}${stderr ? ` (${stderr.replace(/^gh: |^glab: /u, "")})` : ""}`
151
+ : stderr || (run.stdout.split(/\r?\n/u).find((value) => value.trim()) ?? "");
152
+ return redactText(text).slice(0, 300);
153
+ }
154
+
112
155
  /** Map a failed CLI run to an envelope error. */
113
156
  export function cliFailure<T>(
114
157
  bin: CliBin,
@@ -119,7 +162,13 @@ export function cliFailure<T>(
119
162
  ): ForgeResult<T> {
120
163
  if (run.missing) return failure("unavailable", `${bin} is not installed`, installHint(bin));
121
164
  if (run.timedOut)
122
- return failure("unavailable", `${bin} api ${what} timed out after ${timeoutMs} ms`);
165
+ return failure(
166
+ "unavailable",
167
+ run.timeoutMs === 0
168
+ ? `${bin} api ${what} not attempted: the command's time budget ran out`
169
+ : `${bin} api ${what} timed out after ${run.timeoutMs ?? timeoutMs} ms`,
170
+ `check the network to ${apiHost}, then retry`,
171
+ );
123
172
  const stderr = redactText(run.stderr).trim();
124
173
  if (
125
174
  /auth login|not logged in|no token|authentication required|bad credentials|\b401\b|unauthorized/iu.test(
@@ -133,6 +182,12 @@ export function cliFailure<T>(
133
182
  );
134
183
  if (/\b404\b|not found/iu.test(stderr)) return failure("not_found", `${what} was not found`);
135
184
  const first = stderr.split(/\r?\n/u).find((value) => value.trim()) ?? `exit ${run.status}`;
185
+ if (isNetworkError(stderr))
186
+ return failure(
187
+ "unavailable",
188
+ `${bin} api ${what} failed (network): ${first.slice(0, 200)}`,
189
+ `check the network to ${apiHost}, then retry`,
190
+ );
136
191
  return failure("failed", `${bin} api ${what} failed: ${first.slice(0, 200)}`);
137
192
  }
138
193
 
@@ -197,7 +252,7 @@ export function apiWrite<T>(
197
252
  const run = runner(bin, args, { timeoutMs });
198
253
  if (run.status !== 0) {
199
254
  const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
200
- const reason = (text.split(/\r?\n/u).find((value) => value.trim()) ?? "").slice(0, 200);
255
+ const reason = forgeReason(run);
201
256
  if (
202
257
  !run.missing &&
203
258
  !run.timedOut &&
@@ -210,7 +265,21 @@ export function apiWrite<T>(
210
265
  );
211
266
  if (!run.missing && !run.timedOut && /\b(405|406|422)\b/u.test(text))
212
267
  return failure("blocked", `${what} refused by the forge: ${reason}`);
213
- return cliFailure(bin, apiHost, run, what, timeoutMs);
268
+ const failed = cliFailure<T>(bin, apiHost, run, what, timeoutMs);
269
+ // A timeout, a dropped connection or a 5xx may come after the forge
270
+ // applied the write: look before writing again.
271
+ if (
272
+ !failed.ok &&
273
+ failed.code === "unavailable" &&
274
+ !run.missing &&
275
+ !/ is not authenticated for /u.test(failed.error)
276
+ )
277
+ return failure(
278
+ "unavailable",
279
+ failed.error,
280
+ "workit pr status # the write may have gone through; check it first, then retry",
281
+ );
282
+ return failed;
214
283
  }
215
284
  if (!run.stdout.trim()) return { ok: true, data: {} as T };
216
285
  try {
@@ -473,14 +473,15 @@ export function waitVerdict(
473
473
  return { state: "waiting", reason: "head_mismatch" };
474
474
  const checks = doc.checks.state;
475
475
  if (checks === "failing") return { state: "failed", reason: "checks_failing" };
476
- if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
477
- if (checks === "passing") return { state: "ready", reason: "checks_passing" };
478
- if (doc.conflicts)
476
+ // Before "pending": CI never runs on a conflicting PR, so waiting cannot help (M8).
477
+ if (doc.conflicts && checks !== "passing")
479
478
  return {
480
479
  state: "blocked",
481
480
  reason: "conflicts",
482
481
  unblock: `rebase onto ${doc.base} and push; CI does not run on a conflicting PR`,
483
482
  };
483
+ if (checks === "pending") return { state: "waiting", reason: "checks_pending" };
484
+ if (checks === "passing") return { state: "ready", reason: "checks_passing" };
484
485
  const ciRequired =
485
486
  doc.checks.missingRequired === null ||
486
487
  doc.mergeState === "ci_must_pass" ||
@@ -2,7 +2,8 @@
2
2
  // §2.0 Identity).
3
3
  //
4
4
  // - The push remote host decides GitHub vs GitLab. A workspace provider that
5
- // disagrees is `blocked`, never silently overridden.
5
+ // disagrees is `blocked` for the forge verbs, never silently overridden;
6
+ // `git push` (checkPushIdentity) only needs git, so it pushes with a note.
6
7
  // - PRs/MRs live in the base repository: an `upstream` remote on the same
7
8
  // forge, else the push repo's fork parent, else the push repo itself.
8
9
  // - Every API call carries the workspace's credential explicitly, so the
@@ -23,9 +24,10 @@ import {
23
24
  type ForgeHosts,
24
25
  type SshConfig,
25
26
  } from "../git/rev";
26
- import { FORGE_TIMEOUTS, systemRunner, type ForgeRunner } from "./exec";
27
+ import { FORGE_TIMEOUTS, isNetworkError, systemRunner, type ForgeRunner } from "./exec";
27
28
  import { createGitHubForge } from "./github";
28
29
  import { createGitLabForge } from "./gitlab";
30
+ import { redactText } from "./redact";
29
31
  import { failure, success, type Forge, type ForgeResult, type Identity } from "./types";
30
32
 
31
33
  export type CredentialSource = "workspace_token_file" | "gh_account_token" | "cli_login";
@@ -62,51 +64,103 @@ export type ResolveOptions = {
62
64
  now?: () => number;
63
65
  /** ssh_config for Host aliases (default: the user's ~/.ssh/config). */
64
66
  sshConfig?: string | SshConfig;
67
+ /** Absolute deadline (`now()` clock) for every call, resolution included; null = none. */
68
+ deadline?: number | null;
65
69
  };
66
70
 
67
- const configuredHosts = (): ForgeHosts => {
68
- const vcs = readVcsConfig();
69
- if (vcs.status !== "valid") return {};
70
- const host = (kind: "github" | "gitlab"): string[] => {
71
- const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
72
- return typeof value === "string" && value.trim() ? [value.trim()] : [];
71
+ // Per-process memo of credential lookups and /user answers (a long-lived MCP
72
+ // server re-asks after the TTL). Scoped by runner, so an injected test runner
73
+ // never sees another's answers, and keyed by the gh/glab config location, so
74
+ // another GH_CONFIG_DIR never reuses them; only successes are kept.
75
+ const MEMO_TTL_MS = 5 * 60_000;
76
+ const SYSTEM_SCOPE = {};
77
+ const memoScopes = new WeakMap<object, Map<string, { at: number; value: unknown }>>();
78
+
79
+ type MemoScope = { owner: object; key: string };
80
+
81
+ const memoScope = (runner: ForgeRunner | undefined, env: NodeJS.ProcessEnv): MemoScope => ({
82
+ owner: runner ?? SYSTEM_SCOPE,
83
+ key: [env.GH_CONFIG_DIR, env.GLAB_CONFIG_DIR, env.XDG_CONFIG_HOME, env.HOME]
84
+ .map((value) => value ?? "")
85
+ .join("|"),
86
+ });
87
+
88
+ function memo<T>(
89
+ scope: MemoScope,
90
+ key: string,
91
+ compute: () => { keep: boolean; value: T },
92
+ now = Date.now,
93
+ ): T {
94
+ let entries = memoScopes.get(scope.owner);
95
+ if (!entries) memoScopes.set(scope.owner, (entries = new Map()));
96
+ const full = `${scope.key} ${key}`;
97
+ const hit = entries.get(full);
98
+ if (hit && now() - hit.at < MEMO_TTL_MS) return hit.value as T;
99
+ const { keep, value } = compute();
100
+ if (keep) entries.set(full, { at: now(), value });
101
+ return value;
102
+ }
103
+
104
+ /** A runner whose calls are clamped to `limits.deadline`, carrying `token`. */
105
+ function clampedRunner(
106
+ base: ForgeRunner,
107
+ limits: { deadline: number | null },
108
+ now: () => number,
109
+ token?: string,
110
+ ): ForgeRunner {
111
+ return (bin, args, callOptions) => {
112
+ let timeoutMs = callOptions.timeoutMs;
113
+ if (limits.deadline !== null) {
114
+ const remaining = limits.deadline - now();
115
+ if (remaining <= 0)
116
+ return {
117
+ status: null,
118
+ stdout: "",
119
+ stderr: "",
120
+ timedOut: true,
121
+ missing: false,
122
+ timeoutMs: 0,
123
+ };
124
+ timeoutMs = Math.min(timeoutMs, remaining);
125
+ }
126
+ const run = base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
127
+ return run.timedOut && run.timeoutMs === undefined ? { ...run, timeoutMs } : run;
128
+ };
129
+ }
130
+
131
+ /**
132
+ * `forge.identity` answered once per explicit credential per TTL (push, then
133
+ * pr create, …). The CLI's own login (`cli_login`) is never memoized: a
134
+ * `glab auth login` / `gh auth switch` must show on the next call.
135
+ */
136
+ const memoizedIdentity = (forge: Forge, scope: MemoScope, credential: Credential): Forge => {
137
+ if (credential.credential === "cli_login") return forge;
138
+ const identity = (expected: string | null) => forge.identity(expected);
139
+ return {
140
+ ...forge,
141
+ identity: (expected: string | null) =>
142
+ memo(
143
+ scope,
144
+ `identity ${forge.kind} ${forge.apiHost} ${credential.credential} ${credential.token ?? ""} ${expected ?? ""}`,
145
+ () => {
146
+ const value = identity(expected);
147
+ return { keep: value.ok, value };
148
+ },
149
+ ),
73
150
  };
74
- return { github: host("github"), gitlab: host("gitlab") };
75
151
  };
76
152
 
77
- const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
78
- derived.kind === "github"
79
- ? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
80
- : createGitLabForge({ apiHost: derived.apiHost, repo, runner });
153
+ type Credential = { token: string | undefined; credential: CredentialSource };
81
154
 
82
- export function resolveForge(
83
- cwd: string,
84
- options: ResolveOptions = {},
85
- ): ForgeResult<ResolvedForge> {
86
- let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
87
- try {
88
- workspace = resolveRuntimeWorkspaceVcs(cwd);
89
- } catch (error) {
90
- return failure(
91
- "blocked",
92
- error instanceof Error ? error.message : String(error),
93
- "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
94
- );
95
- }
96
- const hosts = configuredHosts();
97
- const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
98
- if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
99
- const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
100
- if (conflict) return failure("blocked", conflict.error, conflict.unblock);
101
- const derived = pushed.forge;
155
+ /** The credential every API call carries (see the header). */
156
+ function resolveCredential(
157
+ workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>,
158
+ derived: DerivedForge,
159
+ run: ForgeRunner,
160
+ scope: MemoScope,
161
+ ): ForgeResult<Credential> {
102
162
  const { kind, apiHost } = derived;
103
- const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
104
- const now = options.now ?? Date.now;
105
163
  const expectedAccount = workspace?.vcs?.account ?? null;
106
-
107
- // The credential for every call.
108
- let token: string | undefined;
109
- let credential: CredentialSource = "cli_login";
110
164
  const tokenFile = (workspace?.vcs as { tokenFile?: unknown } | undefined)?.tokenFile;
111
165
  if (typeof tokenFile === "string" && tokenFile.trim()) {
112
166
  // A stored token goes only to the host it is for: the public forge (also
@@ -127,6 +181,7 @@ export function resolveForge(
127
181
  `token_host_unverified: push host ${derived.host} only looks like ${kind}; workspace ${workspace?.name ?? "?"} vcs.tokenFile is not sent to it`,
128
182
  `if ${apiHost} is your ${kind} server, set ${kind}.host in ~/.config/workit/vcs.json (or vcs.host for the workspace); otherwise fix the push remote`,
129
183
  );
184
+ let token = "";
130
185
  try {
131
186
  token = fs.readFileSync(tokenFile.trim(), "utf8").trim();
132
187
  } catch {
@@ -142,42 +197,108 @@ export function resolveForge(
142
197
  `workspace ${workspace?.name ?? "?"} vcs.tokenFile is empty`,
143
198
  "fix vcs.tokenFile in ~/.config/workit/workspaces.json",
144
199
  );
145
- credential = "workspace_token_file";
146
- } else if (expectedAccount && kind === "github") {
147
- const issued = base("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
200
+ return success({ token, credential: "workspace_token_file" });
201
+ }
202
+ // glab keeps one login per host, so a GitLab account is checked, not selected.
203
+ if (!expectedAccount || kind !== "github")
204
+ return success({ token: undefined, credential: "cli_login" });
205
+ return memo(scope, `token ${apiHost} ${expectedAccount}`, () => {
206
+ const issued = run("gh", ["auth", "token", "--hostname", apiHost, "--user", expectedAccount], {
148
207
  timeoutMs: FORGE_TIMEOUTS.api,
149
208
  });
150
- if (issued.missing)
151
- return failure(
152
- "unavailable",
153
- "gh is not installed",
154
- "install the GitHub CLI (https://cli.github.com) and run: gh auth login",
155
- );
156
- token = issued.status === 0 ? issued.stdout.trim() : "";
157
- if (!token)
158
- return failure(
159
- "blocked",
160
- `identity_unavailable: gh has no login for ${expectedAccount} on ${apiHost} (workspace ${workspace?.name ?? "?"})`,
161
- `gh auth login --hostname ${apiHost} # sign in as ${expectedAccount}; workit passes that account's token per call and never switches the active account`,
162
- );
163
- credential = "gh_account_token";
164
- }
209
+ const value = authTokenResult(issued, apiHost, expectedAccount, workspace?.name ?? "?");
210
+ return { keep: value.ok, value };
211
+ });
212
+ }
165
213
 
166
- const limits: { deadline: number | null } = { deadline: null };
167
- const runner: ForgeRunner = (bin, args, callOptions) => {
168
- let timeoutMs = callOptions.timeoutMs;
169
- if (limits.deadline !== null) {
170
- const remaining = limits.deadline - now();
171
- if (remaining <= 0)
172
- return { status: null, stdout: "", stderr: "", timedOut: true, missing: false };
173
- timeoutMs = Math.min(timeoutMs, remaining);
174
- }
175
- return base(bin, args, { ...callOptions, timeoutMs, ...(token ? { token } : {}) });
214
+ /**
215
+ * `gh auth token --user`: a timeout or a transport error is `unavailable`
216
+ * (exit 5, retry); only gh saying it has no such login is identity_unavailable.
217
+ */
218
+ function authTokenResult(
219
+ issued: ReturnType<ForgeRunner>,
220
+ apiHost: string,
221
+ account: string,
222
+ workspace: string,
223
+ ): ForgeResult<Credential> {
224
+ if (issued.missing)
225
+ return failure(
226
+ "unavailable",
227
+ "gh is not installed",
228
+ "install the GitHub CLI (https://cli.github.com) and run: gh auth login",
229
+ );
230
+ if (issued.timedOut)
231
+ return failure(
232
+ "unavailable",
233
+ `gh auth token ${issued.timeoutMs === 0 ? "not attempted: the time budget ran out" : `timed out after ${issued.timeoutMs ?? FORGE_TIMEOUTS.api} ms`} (looking up the ${account} login on ${apiHost})`,
234
+ "gh auth status # check gh and the network, then retry",
235
+ );
236
+ const token = issued.status === 0 ? issued.stdout.trim() : "";
237
+ if (token) return success({ token, credential: "gh_account_token" });
238
+ const stderr = redactText(issued.stderr).trim();
239
+ const first = stderr.split(/\r?\n/u).find((line) => line.trim()) ?? "";
240
+ if (issued.status !== 0 && first && !/no oauth token|not logged in|no account/iu.test(stderr))
241
+ return failure(
242
+ "unavailable",
243
+ `gh auth token failed${isNetworkError(stderr) ? " (network)" : ""}: ${first.slice(0, 200)}`,
244
+ "gh auth status # check gh and the network, then retry",
245
+ );
246
+ return failure(
247
+ "blocked",
248
+ `identity_unavailable: gh has no login for ${account} on ${apiHost} (workspace ${workspace})`,
249
+ `gh auth login --hostname ${apiHost} # sign in as ${account}; workit passes that account's token per call and never switches the active account`,
250
+ );
251
+ }
252
+
253
+ const configuredHosts = (): ForgeHosts => {
254
+ const vcs = readVcsConfig();
255
+ if (vcs.status !== "valid") return {};
256
+ const host = (kind: "github" | "gitlab"): string[] => {
257
+ const value = (vcs.config[kind] as { host?: unknown } | undefined)?.host;
258
+ return typeof value === "string" && value.trim() ? [value.trim()] : [];
176
259
  };
260
+ return { github: host("github"), gitlab: host("gitlab") };
261
+ };
262
+
263
+ const createForge = (derived: DerivedForge, repo: string, runner: ForgeRunner): Forge =>
264
+ derived.kind === "github"
265
+ ? createGitHubForge({ apiHost: derived.apiHost, repo, runner })
266
+ : createGitLabForge({ apiHost: derived.apiHost, repo, runner });
267
+
268
+ export function resolveForge(
269
+ cwd: string,
270
+ options: ResolveOptions = {},
271
+ ): ForgeResult<ResolvedForge> {
272
+ let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
273
+ try {
274
+ workspace = resolveRuntimeWorkspaceVcs(cwd);
275
+ } catch (error) {
276
+ return failure(
277
+ "blocked",
278
+ error instanceof Error ? error.message : String(error),
279
+ "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
280
+ );
281
+ }
282
+ const hosts = configuredHosts();
283
+ const pushed = pushForge(cwd, { branch: options.branch, hosts, sshConfig: options.sshConfig });
284
+ if (!pushed.ok) return failure(pushed.code, pushed.error, pushed.unblock);
285
+ const conflict = forgeConflict(pushed.forge, workspace?.vcs?.provider);
286
+ if (conflict) return failure("blocked", conflict.error, conflict.unblock);
287
+ const derived = pushed.forge;
288
+ const { kind, apiHost } = derived;
289
+ const base = options.runner ?? systemRunner(kind, apiHost, options.env ?? process.env);
290
+ const scope = memoScope(options.runner, options.env ?? process.env);
291
+ const now = options.now ?? Date.now;
292
+ const expectedAccount = workspace?.vcs?.account ?? null;
293
+ // The deadline covers resolution too, so a verb's --timeout bounds it all.
294
+ const limits: { deadline: number | null } = { deadline: options.deadline ?? null };
295
+ const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
296
+ if (!credential.ok) return credential;
297
+ const runner = clampedRunner(base, limits, now, credential.data.token);
177
298
 
178
299
  // Base repository: an `upstream` remote on the same forge, else the fork parent.
179
300
  const headRepo = derived.repo;
180
- const probe = createForge(derived, headRepo, runner);
301
+ const probe = memoizedIdentity(createForge(derived, headRepo, runner), scope, credential.data);
181
302
  const info = probe.repoInfo(headRepo);
182
303
  if (!info.ok && info.code !== "not_found") return info;
183
304
  const headProjectId = info.ok ? info.data.id : null;
@@ -201,13 +322,16 @@ export function resolveForge(
201
322
  : (remotes.find((name) => repoOfRemote(name) === baseRepo) ?? pushed.remote);
202
323
 
203
324
  return success({
204
- forge: baseRepo === headRepo ? probe : createForge(derived, baseRepo, runner),
325
+ forge:
326
+ baseRepo === headRepo
327
+ ? probe
328
+ : memoizedIdentity(createForge(derived, baseRepo, runner), scope, credential.data),
205
329
  remote: pushed.remote,
206
330
  url: pushed.url,
207
331
  derived,
208
332
  workspace: workspace?.name ?? null,
209
333
  expectedAccount,
210
- credential,
334
+ credential: credential.data.credential,
211
335
  headRepo,
212
336
  headProjectId,
213
337
  fork: baseRepo !== headRepo,
@@ -225,16 +349,142 @@ export function checkIdentity(resolved: ResolvedForge): ForgeResult<Identity> {
225
349
  const { forge, expectedAccount } = resolved;
226
350
  const identity = forge.identity(expectedAccount);
227
351
  if (!identity.ok) return identity;
228
- if (identity.data.matches === false) {
229
- const bin = forge.kind === "github" ? "gh" : "glab";
230
- const where = `workspace ${resolved.workspace ?? "?"}`;
352
+ if (identity.data.matches === false)
353
+ return mismatch(resolved, identity.data.login ?? "?", resolved.workspace);
354
+ return identity;
355
+ }
356
+
357
+ const mismatch = <T>(
358
+ resolved: Pick<ResolvedForge, "forge" | "credential" | "expectedAccount">,
359
+ login: string,
360
+ workspace: string | null,
361
+ ): ForgeResult<T> => {
362
+ const { forge, expectedAccount } = resolved;
363
+ const bin = forge.kind === "github" ? "gh" : "glab";
364
+ const where = `workspace ${workspace ?? "?"}`;
365
+ const fileFix = `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`;
366
+ return failure(
367
+ "blocked",
368
+ `identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${login} but ${where} expects ${expectedAccount}`,
369
+ resolved.credential === "workspace_token_file"
370
+ ? fileFix
371
+ : forge.kind === "github"
372
+ ? `gh auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`
373
+ : // glab keeps one login per host; switching it would change every repo.
374
+ `${fileFix} (glab keeps one login per host, so workit never switches it)`,
375
+ );
376
+ };
377
+
378
+ /** The forge could not answer at all, as opposed to answering against the account. */
379
+ const unreachable = (result: { code: string; error: string }): boolean =>
380
+ result.code === "unavailable" && !/ is not authenticated for /u.test(result.error);
381
+
382
+ /** Total budget for the identity check before a push (B1). */
383
+ export const PUSH_IDENTITY_BUDGET_MS = 5_000;
384
+
385
+ export type PushIdentity = {
386
+ /**
387
+ * verified: the forge says the credential is the workspace account.
388
+ * no_account: the workspace names no vcs.account, so there is nothing to check.
389
+ * skipped: the check could not run (forge down, slow, unknown, misconfigured);
390
+ * git pushes anyway and `reason` says why.
391
+ */
392
+ status: "verified" | "no_account" | "skipped";
393
+ forge: "github" | "gitlab" | null;
394
+ account: string | null;
395
+ login: string | null;
396
+ reason?: string;
397
+ unblock?: string;
398
+ };
399
+
400
+ /**
401
+ * The best-effort identity check before `git push`: git decides the transport,
402
+ * so a forge that cannot answer (missing, slow, offline, unknown, another
403
+ * provider) pushes with `status: "skipped"` and the reason (S10, B1). Positive
404
+ * evidence still blocks: another account, no gh login for the workspace
405
+ * account, a broken token file or a rejected credential. All calls share one
406
+ * `budgetMs`; explicit credentials are memoized.
407
+ */
408
+ export function checkPushIdentity(
409
+ cwd: string,
410
+ options: Omit<ResolveOptions, "deadline"> & { budgetMs?: number } = {},
411
+ ): ForgeResult<PushIdentity> {
412
+ let workspace: ReturnType<typeof resolveRuntimeWorkspaceVcs>;
413
+ try {
414
+ workspace = resolveRuntimeWorkspaceVcs(cwd);
415
+ } catch (error) {
231
416
  return failure(
232
417
  "blocked",
233
- `identity_mismatch: the ${resolved.credential === "workspace_token_file" ? "vcs.tokenFile token" : `${bin} login`} is ${identity.data.login} but ${where} expects ${expectedAccount}`,
234
- resolved.credential === "workspace_token_file"
235
- ? `put a token for ${expectedAccount} in the ${where} vcs.tokenFile`
236
- : `${bin} auth login --hostname ${forge.apiHost} # sign in as ${expectedAccount}`,
418
+ error instanceof Error ? error.message : String(error),
419
+ "fix ~/.config/workit/workspaces.json (workit init → Workspaces)",
237
420
  );
238
421
  }
239
- return identity;
422
+ const account = workspace?.vcs?.account ?? null;
423
+ const skipped = (forge: PushIdentity["forge"], reason: string, unblock?: string) =>
424
+ success<PushIdentity>({
425
+ status: "skipped",
426
+ forge,
427
+ account,
428
+ login: null,
429
+ reason,
430
+ ...(unblock ? { unblock } : {}),
431
+ });
432
+ const pushed = pushForge(cwd, {
433
+ branch: options.branch,
434
+ hosts: configuredHosts(),
435
+ sshConfig: options.sshConfig,
436
+ });
437
+ if (!pushed.ok) {
438
+ const host = /^unsupported_forge: .*?"([^"]+)"/u.exec(pushed.error)?.[1];
439
+ const what = /could not resolve ssh alias/u.test(pushed.error)
440
+ ? `could not resolve ssh alias ${host} to GitHub or GitLab`
441
+ : pushed.error.startsWith("unsupported_forge: ssh host")
442
+ ? `ssh host ${host} is not a GitHub/GitLab host or an alias workit can resolve`
443
+ : `${host} is not a GitHub or GitLab host workit knows`;
444
+ return skipped(
445
+ null,
446
+ host
447
+ ? `unsupported_forge: ${what}; account checks skipped, pushed with git only (no PR or CI verbs)`
448
+ : pushed.error,
449
+ pushed.unblock,
450
+ );
451
+ }
452
+ const derived = pushed.forge;
453
+ const conflict = forgeConflict(derived, workspace?.vcs?.provider);
454
+ if (conflict) return skipped(derived.kind, conflict.error, conflict.unblock);
455
+ if (!account) return success({ status: "no_account", forge: derived.kind, account, login: null });
456
+
457
+ const base =
458
+ options.runner ?? systemRunner(derived.kind, derived.apiHost, options.env ?? process.env);
459
+ const scope = memoScope(options.runner, options.env ?? process.env);
460
+ const now = options.now ?? Date.now;
461
+ const limits = { deadline: now() + (options.budgetMs ?? PUSH_IDENTITY_BUDGET_MS) };
462
+ const credential = resolveCredential(workspace, derived, clampedRunner(base, limits, now), scope);
463
+ // A forge that cannot answer (missing CLI, timeout, transport) is skipped;
464
+ // local evidence against the account (no gh login for it, a broken
465
+ // vcs.tokenFile, a rejected credential) still blocks: over HTTPS git would
466
+ // push with whatever account gh's credential helper has active.
467
+ if (!credential.ok)
468
+ return unreachable(credential)
469
+ ? skipped(derived.kind, credential.error, credential.unblock)
470
+ : credential;
471
+ const forge = memoizedIdentity(
472
+ createForge(derived, derived.repo, clampedRunner(base, limits, now, credential.data.token)),
473
+ scope,
474
+ credential.data,
475
+ );
476
+ const identity = forge.identity(account);
477
+ if (!identity.ok)
478
+ return unreachable(identity)
479
+ ? skipped(derived.kind, identity.error, identity.unblock)
480
+ : identity;
481
+ if (identity.data.matches === false)
482
+ return mismatch(
483
+ { forge, credential: credential.data.credential, expectedAccount: account },
484
+ identity.data.login ?? "?",
485
+ workspace?.name ?? null,
486
+ );
487
+ if (identity.data.matches === null)
488
+ return skipped(derived.kind, identity.data.note ?? "the credential cannot read /user");
489
+ return success({ status: "verified", forge: derived.kind, account, login: identity.data.login });
240
490
  }
package/src/git/ops.ts CHANGED
@@ -823,6 +823,8 @@ export function executePush(
823
823
  setUpstream?: boolean;
824
824
  actor: LedgerActor;
825
825
  timeoutMs?: number;
826
+ /** The pre-push identity check's outcome, kept on the ledger row. */
827
+ identity?: Record<string, unknown>;
826
828
  },
827
829
  ): ForgeResult<PushOutcome> {
828
830
  const before = remoteRefTip(cwd, plan.rawUrl, `refs/heads/${plan.branch}`);
@@ -940,6 +942,7 @@ export function executePush(
940
942
  previous: before.sha,
941
943
  pushed,
942
944
  forced,
945
+ ...(input.identity ? { identity: input.identity } : {}),
943
946
  });
944
947
  return success({
945
948
  remote: plan.remote,
package/src/git/rev.ts CHANGED
@@ -879,15 +879,20 @@ export function pushForge(
879
879
  const forge = deriveForge(url, options);
880
880
  if (!forge) {
881
881
  // Never echo the raw URL: it may carry a token (https://user:tok@…).
882
- const host = parseRemoteUrl(url)?.host || null;
882
+ const parsed = parseRemoteUrl(url);
883
+ const host = parsed?.host || null;
883
884
  return {
884
885
  ok: false,
885
886
  code: "unavailable",
886
887
  error: host
887
- ? `cannot tell whether push host "${host}" is GitHub or GitLab`
888
+ ? parsed?.protocol === "ssh" && !host.includes(".")
889
+ ? `unsupported_forge: could not resolve ssh alias "${host}" to GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
890
+ : parsed?.protocol === "ssh"
891
+ ? `unsupported_forge: ssh host "${host}" is not a GitHub/GitLab host or an alias workit can resolve; PR and CI verbs need one (workit git push still pushes with git)`
892
+ : `unsupported_forge: cannot tell whether push host "${host}" is GitHub or GitLab; PR and CI verbs need one (workit git push still pushes with git)`
888
893
  : `push URL ${redactRemote(url)} of remote "${remote}" is not a GitHub/GitLab URL`,
889
894
  unblock: host
890
- ? `map the alias in ~/.ssh/config (Host ${host} / HostName github.com|gitlab.com) or set github.host / gitlab.host in ~/.config/workit/vcs.json`
895
+ ? `if ${host} is an ssh alias of GitHub/GitLab, add "Host ${host}" with "HostName github.com" (or gitlab.com) to ~/.ssh/config; if it is a self-hosted GitHub/GitLab, set github.host or gitlab.host in ~/.config/workit/vcs.json; other forges (Bitbucket, Gitea…) have no PR/CI verbs`
891
896
  : `git remote set-url --push ${remote} <ssh-or-https-url>`,
892
897
  };
893
898
  }
@@ -906,7 +911,7 @@ export function forgeConflict(
906
911
  if (!configured || configured === derived.kind) return null;
907
912
  return {
908
913
  code: "blocked",
909
- error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}`,
910
- unblock: `set vcs.provider to "${derived.kind}" for this repo in ~/.config/workit/workspaces.json (workit-github-override), or push to a ${configured} remote`,
914
+ error: `forge_mismatch: push remote ${derived.host} is ${derived.kind} but the workspace vcs.provider is ${configured}, so its account and grants do not apply; PR and CI verbs stop here (workit git push still pushes with git)`,
915
+ unblock: `add a workspace whose glob matches only this repo, with "vcs": {"provider": "${derived.kind}"} (and its account), to ~/.config/workit/workspaces.json; the most specific glob wins`,
911
916
  };
912
917
  }