@brainervirus/workit-core 8.0.1 → 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.0.1",
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": [
@@ -9,17 +9,34 @@ const PREFIXES = {
9
9
  hotfix: "hotfix/*",
10
10
  };
11
11
 
12
+ /**
13
+ * Local branch names plus origin's remote-tracking ones (`origin/develop`
14
+ * counts as `develop`; a fork or mirror remote's branches do not).
15
+ */
16
+ const branchNames = (workspaceRoot: string): Set<string> => {
17
+ const r = spawnSync(
18
+ "git",
19
+ ["for-each-ref", "--format=%(refname)", "refs/heads", "refs/remotes/origin"],
20
+ { cwd: workspaceRoot, encoding: "utf8" },
21
+ );
22
+ if (r.status !== 0) return new Set();
23
+ return new Set(
24
+ (r.stdout ?? "")
25
+ .split("\n")
26
+ .filter(Boolean)
27
+ .map((ref) =>
28
+ ref.startsWith("refs/heads/")
29
+ ? ref.slice("refs/heads/".length)
30
+ : ref.slice("refs/remotes/origin/".length),
31
+ ),
32
+ );
33
+ };
34
+
12
35
  export const detectBranchPolicy = (workspaceRoot: string) => {
13
- const branchExists = (name: string): boolean => {
14
- const r = spawnSync("git", ["branch", "--list", name], {
15
- cwd: workspaceRoot,
16
- encoding: "utf8",
17
- });
18
- return r.status === 0 && (r.stdout ?? "").trim() !== "";
19
- };
20
- const develop = branchExists("develop");
21
- const main = branchExists("main");
22
- const master = branchExists("master");
36
+ const names = branchNames(workspaceRoot);
37
+ const develop = names.has("develop");
38
+ const main = names.has("main");
39
+ const master = names.has("master");
23
40
  const root = main ? "main" : master ? "master" : null;
24
41
 
25
42
  if (develop) {
@@ -61,3 +78,13 @@ export const detectBranchPolicy = (workspaceRoot: string) => {
61
78
  prefixes: PREFIXES,
62
79
  };
63
80
  };
81
+
82
+ /**
83
+ * The preset this repository's branches imply (develop -> gitflow, main ->
84
+ * github-flow, master -> trunk-based), or null when they imply none. Used
85
+ * only when no branch policy is configured anywhere (core/config.ts).
86
+ */
87
+ export const repoBranchPreset = (workspaceRoot: string): BranchPreset | null => {
88
+ const detected = detectBranchPolicy(workspaceRoot);
89
+ return detected.developBranch || detected.allowed.length ? detected.preset : null;
90
+ };
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import path from "node:path";
5
+ import { repoBranchPreset } from "./branch-policy";
5
6
  import { matchCommitFlavor } from "./commit-flavors";
6
7
  import { gitContext } from "./git";
7
8
  import {
@@ -100,6 +101,7 @@ function effectiveWorkspacePolicy(
100
101
  resolveConfiguredBranchPolicy(
101
102
  config,
102
103
  selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
104
+ () => repoBranchPreset(workspaceRoot),
103
105
  ),
104
106
  ),
105
107
  provenance: { branchPolicy: selected.source },
@@ -138,6 +138,12 @@ const DEFAULTS: ToolkitConfig = {
138
138
  commitPolicy: { preset: "conventional" },
139
139
  };
140
140
 
141
+ // Configs whose branch policy nobody chose (no config.json, or one without a
142
+ // valid branchPolicy.preset). For them the repository's own branches pick the
143
+ // preset (resolveBranchPolicy's `repoPreset`), so a main-only repository is
144
+ // not held to gitflow's `develop`.
145
+ const unchosenBranchPolicy = new WeakSet<ToolkitConfig>([DEFAULTS]);
146
+
141
147
  const readSafe = (p: string): string | null => {
142
148
  try {
143
149
  return readFileSync(p, "utf8");
@@ -191,36 +197,35 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
191
197
  ? input.commitPolicy?.preset
192
198
  : "conventional"
193
199
  ) as CommitFlavorPreset;
194
- return {
195
- status: "valid",
196
- path: file,
197
- config: {
198
- locale,
199
- localeOptions: Array.isArray(input.localeOptions)
200
- ? input.localeOptions
201
- : DEFAULTS.localeOptions,
202
- // RL-02/CA-23: the persisted preset is authoritative; derived allowed /
203
- // protected fields always reset from PRESETS via the one shared merge.
204
- branchPolicy: mergePreset(
205
- preset,
206
- {
207
- allowed: Array.isArray(input.branchPolicy?.allowed)
208
- ? input.branchPolicy.allowed
209
- : undefined,
210
- protectedNames: Array.isArray(input.branchPolicy?.protected)
211
- ? input.branchPolicy.protected
212
- : undefined,
213
- },
214
- DEFAULTS,
215
- ),
216
- commitPolicy: {
217
- preset: commitPreset,
218
- ...(typeof input.commitPolicy?.pattern === "string"
219
- ? { pattern: input.commitPolicy.pattern }
220
- : {}),
200
+ const config: ToolkitConfig = {
201
+ locale,
202
+ localeOptions: Array.isArray(input.localeOptions)
203
+ ? input.localeOptions
204
+ : DEFAULTS.localeOptions,
205
+ // RL-02/CA-23: the persisted preset is authoritative; derived allowed /
206
+ // protected fields always reset from PRESETS via the one shared merge.
207
+ branchPolicy: mergePreset(
208
+ preset,
209
+ {
210
+ allowed: Array.isArray(input.branchPolicy?.allowed)
211
+ ? input.branchPolicy.allowed
212
+ : undefined,
213
+ protectedNames: Array.isArray(input.branchPolicy?.protected)
214
+ ? input.branchPolicy.protected
215
+ : undefined,
221
216
  },
217
+ DEFAULTS,
218
+ ),
219
+ commitPolicy: {
220
+ preset: commitPreset,
221
+ ...(typeof input.commitPolicy?.pattern === "string"
222
+ ? { pattern: input.commitPolicy.pattern }
223
+ : {}),
222
224
  },
223
225
  };
226
+ if (!Object.hasOwn(PRESETS, input.branchPolicy?.preset as string))
227
+ unchosenBranchPolicy.add(config);
228
+ return { status: "valid", path: file, config };
224
229
  };
225
230
 
226
231
  export const readConfigTyped = (dir?: string): ReaderResult<ToolkitConfig> => {
@@ -298,19 +303,28 @@ export const branchGlobPattern = (glob: string): RegExp =>
298
303
  export const resolveBranchPolicy = (
299
304
  config: ToolkitConfig,
300
305
  workspace?: { branchPolicy?: Record<string, any> } | null,
306
+ /** The preset the repository's branches imply (core/branch-policy.ts repoBranchPreset). */
307
+ repoPreset?: () => BranchPreset | null,
301
308
  ): {
302
309
  preset: BranchPreset;
303
310
  allowed: RegExp[];
304
311
  protected: Set<string>;
305
312
  integration: "pr" | "merge";
306
313
  defaultTargetBranch: string;
314
+ /** No policy was configured; the repository's branches chose the preset. */
315
+ detected: boolean;
307
316
  } => {
308
317
  const wp = workspace?.branchPolicy ?? {};
309
318
  // An invalid workspace preset (e.g. a typo) falls back to the global preset,
310
319
  // preserving resolution order workspace > global > preset, instead of
311
320
  // crashing on PRESETS[preset] (mirrors parseConfigResult's Object.hasOwn).
321
+ const workspacePreset = Object.hasOwn(PRESETS, wp.preset);
322
+ // With no policy chosen anywhere, the repository's branches pick the preset
323
+ // (M10); the gitflow protected names stay protected regardless.
324
+ const detected =
325
+ !workspacePreset && unchosenBranchPolicy.has(config) ? (repoPreset?.() ?? null) : null;
312
326
  const preset = (
313
- Object.hasOwn(PRESETS, wp.preset) ? wp.preset : (config.branchPolicy?.preset ?? "gitflow")
327
+ workspacePreset ? wp.preset : (detected ?? config.branchPolicy?.preset ?? "gitflow")
314
328
  ) as BranchPreset;
315
329
  // RL-02: the preset is authoritative — allowed/protected re-derive from the
316
330
  // workspace's own values or the preset table, never the global config's, and
@@ -327,11 +341,16 @@ export const resolveBranchPolicy = (
327
341
  return {
328
342
  preset,
329
343
  allowed,
330
- protected: new Set(merged.protected.map((p) => p.toLowerCase())),
344
+ protected: new Set(
345
+ [...merged.protected, ...(detected ? PRESETS.gitflow.protected : [])].map((p) =>
346
+ p.toLowerCase(),
347
+ ),
348
+ ),
331
349
  integration: wp.integration === "merge" ? "merge" : "pr",
332
350
  // CA-05: preset-aware default target when vcs.defaultTargetBranch is unset.
333
351
  defaultTargetBranch:
334
352
  preset === "github-flow" ? "main" : preset === "trunk-based" ? "master" : "develop",
353
+ detected: detected !== null,
335
354
  };
336
355
  };
337
356
 
@@ -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";
@@ -14,6 +15,7 @@ import {
14
15
  type ToolkitConfig,
15
16
  } from "./config";
16
17
  import type { CommitFlavorPreset } from "./commit-flavors";
18
+ import { repoBranchPreset } from "./branch-policy";
17
19
 
18
20
  export type VcsProvider = "gitlab" | "github";
19
21
 
@@ -412,6 +414,70 @@ export const validateWorkspaceGlob = (glob: string): GlobValidation => {
412
414
  return { ok: true };
413
415
  };
414
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
+
415
481
  const realpathOf = (p: string): string => {
416
482
  // realpathSync.native goes through libuv, which expands Windows 8.3 short
417
483
  // names (C:\Users\RUNNER~1 -> C:\Users\runneradmin); the plain realpathSync
@@ -452,13 +518,17 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
452
518
  const named = matches.filter((entry) => entry.name === workspaceName);
453
519
  if (named.length === 1) return named[0];
454
520
  if (named.length > 1)
455
- throw new Error(`ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`);
521
+ throw new WorkspaceConfigError(
522
+ `ambiguous workspace ${JSON.stringify(workspaceName)} for ${cwd}`,
523
+ );
456
524
  if (matches.length > 0) {
457
- throw new Error(
525
+ throw new WorkspaceConfigError(
458
526
  `workspace ${JSON.stringify(workspaceName)} does not match ${cwd}; matching choices: ${matches.map((entry) => entry.name).join(", ")}`,
459
527
  );
460
528
  }
461
- 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
+ );
462
532
  }
463
533
 
464
534
  if (matches.length < 2) return matches[0];
@@ -467,7 +537,7 @@ const selectWorkspaceMatch = <T extends { name: string; glob: string }>(
467
537
  (entry) => globSpecificity(entry.glob) === highestSpecificity,
468
538
  );
469
539
  if (mostSpecific.length > 1) {
470
- throw new Error(
540
+ throw new WorkspaceConfigError(
471
541
  `ambiguous workspace for ${cwd}; choose one of: ${mostSpecific.map((entry) => entry.name).join(", ")}`,
472
542
  );
473
543
  }
@@ -485,23 +555,7 @@ export const resolveWorkspaceFromEntries = <T extends { name: string; glob: stri
485
555
  // written with the logical path, so a workspace would silently stop
486
556
  // matching on macOS. Match both forms on each side; on Linux both forms
487
557
  // are identical so behavior is unchanged.
488
- const targets = [cwd, realpathOf(cwd)].map((p) => p.replaceAll("\\", "/"));
489
- const matches: T[] = [];
490
- for (const ws of entries) {
491
- const glob = ws.glob.replaceAll("\\", "/");
492
- const canonical = canonicalGlob(glob);
493
- let matched = false;
494
- for (const target of targets) {
495
- if (
496
- matchWorkspace(glob, target) ||
497
- (canonical !== glob && matchWorkspace(canonical, target))
498
- ) {
499
- matched = true;
500
- break;
501
- }
502
- }
503
- if (matched) matches.push(ws);
504
- }
558
+ const matches = matchingEntries(cwd, entries);
505
559
  return selectWorkspaceMatch(matches, cwd, workspaceName) ?? null;
506
560
  };
507
561
 
@@ -512,7 +566,8 @@ export const resolveWorkspaceFrom = (
512
566
  workspaceName?: string,
513
567
  ): WorkspaceConfig | null => {
514
568
  const result = readWorkspacesResult(dir);
515
- 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);
516
571
  return resolveWorkspaceFromEntries(cwd, result.entries, workspaceName);
517
572
  };
518
573
 
@@ -585,7 +640,7 @@ const matchRuntimeWorkspace = (
585
640
  raw = readFileSync(file, "utf8");
586
641
  } catch (error) {
587
642
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
588
- throw new Error(
643
+ throw new WorkspaceConfigError(
589
644
  `${file} could not be read: ${error instanceof Error ? error.message : String(error)}`,
590
645
  { cause: error },
591
646
  );
@@ -594,29 +649,21 @@ const matchRuntimeWorkspace = (
594
649
  try {
595
650
  parsed = JSON.parse(raw);
596
651
  } catch {
597
- throw new Error(`${file} is not valid JSON`);
652
+ throw new WorkspaceConfigError(`${file} is not valid JSON`);
598
653
  }
599
654
  const validated = runtimeWorkspaceIndexSchema.safeParse(parsed);
600
655
  if (!validated.success) {
601
656
  const detail = validated.error.issues
602
657
  .map((issue) => `${issue.path.map(String).join(".") || "workspaces"}: ${issue.message}`)
603
658
  .join("; ");
604
- throw new Error(`${file} has invalid workspace matching data: ${detail}`);
659
+ throw new WorkspaceConfigError(`${file} has invalid workspace matching data: ${detail}`);
605
660
  }
606
661
  const entries = validated.data.workspaces;
607
662
  for (const entry of entries ?? []) {
608
663
  const glob = validateWorkspaceGlob(entry.glob);
609
- 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}`);
610
665
  }
611
- const targets = [cwd, realpathOf(cwd)].map((value) => value.replaceAll("\\", "/"));
612
- const matches = (entries ?? []).filter((entry) => {
613
- const glob = entry.glob.replaceAll("\\", "/");
614
- const canonical = canonicalGlob(glob);
615
- return targets.some(
616
- (target) =>
617
- matchWorkspace(glob, target) || (canonical !== glob && matchWorkspace(canonical, target)),
618
- );
619
- });
666
+ const matches = matchingEntries(cwd, entries ?? []);
620
667
  const workspaceName = process.env.WORKFLOW_WORKSPACE_NAME?.trim() || undefined;
621
668
  const selected = selectWorkspaceMatch(matches, cwd, workspaceName);
622
669
  return selected ? { file, selected } : null;
@@ -648,7 +695,7 @@ const resolveRuntimeWorkspaceCandidate = (
648
695
  const detail = policy.error.issues
649
696
  .map((issue) => `${issue.path.map(String).join(".") || "workspace"}: ${issue.message}`)
650
697
  .join("; ");
651
- throw new Error(`${file} has invalid ${kind} policy configuration: ${detail}`);
698
+ throw new WorkspaceConfigError(`${file} has invalid ${kind} policy configuration: ${detail}`);
652
699
  }
653
700
  return policy.data as RuntimeWorkspaceCandidate;
654
701
  };
@@ -669,12 +716,12 @@ export const resolveRuntimeWorkspacePolicy = (
669
716
  candidate.defaultProfile &&
670
717
  !Object.hasOwn(candidate.profiles ?? {}, candidate.defaultProfile)
671
718
  )
672
- throw new Error(
719
+ throw new WorkspaceConfigError(
673
720
  `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(candidate.defaultProfile)}`,
674
721
  );
675
722
  const profile = profileName ? candidate.profiles?.[profileName] : undefined;
676
723
  if (profileName && !profile)
677
- throw new Error(
724
+ throw new WorkspaceConfigError(
678
725
  `workspace ${JSON.stringify(candidate.name)} has no profile ${JSON.stringify(profileName)}`,
679
726
  );
680
727
  const profilePolicy = profile?.[key] as WorkspaceBranchPolicy | WorkspaceCommitPolicy | undefined;
@@ -731,6 +778,8 @@ export const resolveWorkspacePolicy = (
731
778
  config: ToolkitConfig,
732
779
  workspace: WorkspaceConfig | null,
733
780
  profileName?: string,
781
+ /** The preset the checkout's branches imply, used when no policy is configured. */
782
+ repoPreset?: () => BranchPreset | null,
734
783
  ): WorkspacePolicyResolution | { status: "invalid"; error: string } => {
735
784
  const selected = workspace
736
785
  ? resolveWorkspaceProfile(workspace, profileName)
@@ -745,7 +794,7 @@ export const resolveWorkspacePolicy = (
745
794
  status: "resolved",
746
795
  workspace: selected.workspace,
747
796
  profileName: selected.profileName,
748
- branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace),
797
+ branchPolicy: resolveConfiguredBranchPolicy(config, selected.workspace, repoPreset),
749
798
  commitPolicy: resolveConfiguredCommitPolicy(config, selected.workspace),
750
799
  provenance: selected.provenance,
751
800
  };
@@ -792,6 +841,7 @@ export function workspaceDefaultTarget(cwd: string, globalDefault: unknown): str
792
841
  const policyDefault = resolveConfiguredBranchPolicy(
793
842
  readConfig(),
794
843
  selected.policy ? { branchPolicy: selected.policy as WorkspaceBranchPolicy } : null,
844
+ () => repoBranchPreset(cwd),
795
845
  ).defaultTargetBranch;
796
846
  return String(
797
847
  (ws?.vcs as { defaultTargetBranch?: unknown } | undefined)?.defaultTargetBranch ??
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" ||