@workit-dev/core 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,385 @@
1
+ // Bounded gh/glab invocation (design §0 #8: `gh api` / `glab api` only, never
2
+ // porcelain whose flags vary by CLI version). Every call has a timeout, runs
3
+ // with prompts and update checks disabled, and targets the API host derived
4
+ // from the push remote (GH_HOST / GITLAB_HOST). Failures map to the envelope
5
+ // codes; stderr is redacted before it reaches a message.
6
+ import { spawnSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import type { ForgeKind } from "../git/rev";
10
+ import { redactText } from "./redact";
11
+ import { failure, type ForgeResult } from "./types";
12
+
13
+ export type CliBin = "gh" | "glab";
14
+
15
+ export type CliRun = {
16
+ status: number | null;
17
+ stdout: string;
18
+ stderr: string;
19
+ timedOut: boolean;
20
+ /** The binary is not on PATH. */
21
+ missing: boolean;
22
+ /** The timeout actually applied (a deadline may have shortened it). */
23
+ timeoutMs?: number;
24
+ };
25
+
26
+ /** Runs one gh/glab command. Injected in tests to replay recorded API fixtures. */
27
+ export type ForgeRunner = (
28
+ bin: CliBin,
29
+ args: readonly string[],
30
+ options: {
31
+ timeoutMs: number;
32
+ /** Credential for this call (GH_TOKEN / GITLAB_TOKEN); never logged. */
33
+ token?: string;
34
+ /** Written to stdin (a JSON body for `api --input -`); argv has a size limit. */
35
+ input?: string;
36
+ },
37
+ ) => CliRun;
38
+
39
+ /** Default per-call bounds (ms). */
40
+ export const FORGE_TIMEOUTS = { api: 20_000, log: 30_000 } as const;
41
+
42
+ const MAX_BUFFER = 64 * 1024 * 1024;
43
+
44
+ /** Locate `bin` on `env.PATH` without running a shell. */
45
+ export function findExecutable(bin: string, env: NodeJS.ProcessEnv): string | null {
46
+ const dirs = (env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean);
47
+ // Only real executables: spawning a .cmd/.bat shim would need a shell.
48
+ const exts = process.platform === "win32" ? [".exe"] : [""];
49
+ for (const dir of dirs)
50
+ for (const ext of exts) {
51
+ const candidate = path.join(dir, `${bin}${ext}`);
52
+ try {
53
+ const stat = fs.statSync(candidate);
54
+ if (stat.isFile()) return candidate;
55
+ } catch {
56
+ // not here
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ /** The real gh/glab, bound to one API host. */
63
+ export function systemRunner(
64
+ kind: ForgeKind,
65
+ apiHost: string,
66
+ env: NodeJS.ProcessEnv = process.env,
67
+ ): ForgeRunner {
68
+ const childEnv: NodeJS.ProcessEnv = {
69
+ ...env,
70
+ ...(kind === "github" ? { GH_HOST: apiHost } : { GITLAB_HOST: apiHost }),
71
+ GH_PROMPT_DISABLED: "1",
72
+ GH_NO_UPDATE_NOTIFIER: "1",
73
+ GLAB_CHECK_UPDATE: "false",
74
+ NO_PROMPT: "1",
75
+ NO_COLOR: "1",
76
+ GH_PAGER: "",
77
+ GLAB_PAGER: "",
78
+ PAGER: "",
79
+ };
80
+ return (bin, args, options) => {
81
+ const exe = findExecutable(bin, env);
82
+ if (!exe) return { status: null, stdout: "", stderr: "", timedOut: false, missing: true };
83
+ const token = options.token
84
+ ? kind === "github"
85
+ ? { GH_TOKEN: options.token, GH_ENTERPRISE_TOKEN: options.token }
86
+ : { GITLAB_TOKEN: options.token }
87
+ : {};
88
+ const result = spawnSync(exe, [...args], {
89
+ encoding: "utf8",
90
+ env: { ...childEnv, ...token },
91
+ maxBuffer: MAX_BUFFER,
92
+ timeout: options.timeoutMs,
93
+ killSignal: "SIGKILL",
94
+ windowsHide: true,
95
+ ...(options.input === undefined
96
+ ? { stdio: ["ignore", "pipe", "pipe"] as const }
97
+ : { input: options.input, stdio: ["pipe", "pipe", "pipe"] as const }),
98
+ });
99
+ const error: NodeJS.ErrnoException | undefined = result.error;
100
+ return {
101
+ status: result.status,
102
+ stdout: result.stdout ?? "",
103
+ stderr: result.stderr ?? "",
104
+ timedOut: error?.code === "ETIMEDOUT",
105
+ missing: error?.code === "ENOENT",
106
+ };
107
+ };
108
+ }
109
+
110
+ export const installHint = (bin: CliBin): string =>
111
+ bin === "gh"
112
+ ? "install the GitHub CLI (https://cli.github.com) and run: gh auth login"
113
+ : "install glab (https://gitlab.com/gitlab-org/cli) and run: glab auth login";
114
+
115
+ export const loginHint = (bin: CliBin, apiHost: string): string =>
116
+ `${bin} auth login --hostname ${apiHost}`;
117
+
118
+ /** Transport trouble (DNS, TCP, TLS, a 5xx from the forge): retryable, exit 5. */
119
+ const NETWORK =
120
+ /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;
121
+
122
+ export const isNetworkError = (text: string): boolean => NETWORK.test(text);
123
+
124
+ /**
125
+ * The forge's own reason for a refusal: `gh api`/`glab api` print the error
126
+ * body on stdout (`message`, `errors[].message`), stderr only says "HTTP 422".
127
+ */
128
+ export function forgeReason(run: Pick<CliRun, "stdout" | "stderr">): string {
129
+ const parts: string[] = [];
130
+ try {
131
+ const body = JSON.parse(run.stdout) as {
132
+ message?: unknown;
133
+ error?: unknown;
134
+ errors?: unknown;
135
+ };
136
+ const add = (value: unknown): void => {
137
+ if (typeof value === "string" && value.trim()) parts.push(value.trim());
138
+ else if (Array.isArray(value)) value.forEach(add);
139
+ else if (value && typeof value === "object") {
140
+ const record = value as Record<string, unknown>;
141
+ if (typeof record.message === "string") add(record.message);
142
+ else if (!("code" in record)) Object.values(record).forEach(add);
143
+ }
144
+ };
145
+ add(body.message);
146
+ add(body.error);
147
+ add(body.errors);
148
+ } catch {
149
+ // not a JSON body
150
+ }
151
+ const stderr = (run.stderr.split(/\r?\n/u).find((value) => value.trim()) ?? "").trim();
152
+ const unique = [...new Set(parts)];
153
+ const text = unique.length
154
+ ? `${unique.join("; ")}${stderr ? ` (${stderr.replace(/^gh: |^glab: /u, "")})` : ""}`
155
+ : stderr || (run.stdout.split(/\r?\n/u).find((value) => value.trim()) ?? "");
156
+ return redactText(text).slice(0, 300);
157
+ }
158
+
159
+ /** A forge rate limit: HTTP 429, or a 403 that says so (GitHub primary and secondary limits). */
160
+ const RATE_LIMIT_TEXT = /rate limit|too many requests|retry-after|abuse detection/iu;
161
+
162
+ export const isRateLimited = (run: Pick<CliRun, "status" | "stderr" | "stdout">): boolean => {
163
+ if (run.status === 0) return false;
164
+ const text = `${run.stderr}\n${run.stdout}`;
165
+ return (
166
+ /HTTP 429|429 Too Many Requests/iu.test(text) ||
167
+ (/HTTP 403|403 Forbidden|RATE_LIMITED/iu.test(text) && RATE_LIMIT_TEXT.test(text))
168
+ );
169
+ };
170
+
171
+ /**
172
+ * How long the forge asked to wait (ms): `Retry-After` seconds, else the
173
+ * `X-RateLimit-Reset` epoch; null when the response names neither.
174
+ */
175
+ export function rateLimitWaitMs(
176
+ run: Pick<CliRun, "stderr" | "stdout">,
177
+ now: number = Date.now(),
178
+ ): number | null {
179
+ const text = `${run.stderr}\n${run.stdout}`;
180
+ const after = /retry-after"?\s*[:=]\s*"?(\d+)/iu.exec(text);
181
+ if (after) return Number(after[1]) * 1000;
182
+ const reset = /x-ratelimit-reset"?\s*[:=]\s*"?(\d+)/iu.exec(text);
183
+ if (reset) return Math.max(0, Number(reset[1]) * 1000 - now);
184
+ return null;
185
+ }
186
+
187
+ /** The one bounded backoff on a rate limit (#284 N4); tests replace `sleep`. */
188
+ export const rateLimitDeps = {
189
+ /** Wait used when the forge names no Retry-After / reset. */
190
+ defaultWaitMs: 2_000,
191
+ /** A longer requested wait is not slept: the call fails as rate_limited at once. */
192
+ maxWaitMs: 10_000,
193
+ sleep: (ms: number): void => {
194
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
195
+ },
196
+ now: (): number => Date.now(),
197
+ };
198
+
199
+ /** HTTP 429: the forge refused the request before acting on it. */
200
+ const isTooManyRequests = (run: Pick<CliRun, "stderr" | "stdout">): boolean =>
201
+ /HTTP 429|429 Too Many Requests/iu.test(`${run.stderr}\n${run.stdout}`);
202
+
203
+ /**
204
+ * Run one gh/glab call; on a rate limit, wait once (Retry-After, capped) and
205
+ * run it again. A write retries only on HTTP 429, which the forge refuses
206
+ * before acting; a 403 secondary limit may already have applied it, so a
207
+ * write is not repeated there (no double comment or double merge).
208
+ */
209
+ export function runLimited(
210
+ runner: ForgeRunner,
211
+ bin: CliBin,
212
+ args: readonly string[],
213
+ options: Parameters<ForgeRunner>[2],
214
+ write = false,
215
+ ): CliRun {
216
+ const run = runner(bin, args, options);
217
+ if (run.missing || run.timedOut || !isRateLimited(run)) return run;
218
+ if (write && !isTooManyRequests(run)) return run;
219
+ const wait = rateLimitWaitMs(run, rateLimitDeps.now()) ?? rateLimitDeps.defaultWaitMs;
220
+ if (wait > rateLimitDeps.maxWaitMs) return run;
221
+ rateLimitDeps.sleep(wait);
222
+ return runner(bin, args, options);
223
+ }
224
+
225
+ const rateLimitFailure = <T>(
226
+ bin: CliBin,
227
+ apiHost: string,
228
+ run: CliRun,
229
+ what: string,
230
+ ): ForgeResult<T> => {
231
+ const wait = rateLimitWaitMs(run, rateLimitDeps.now());
232
+ const seconds = wait === null ? null : Math.ceil(wait / 1000);
233
+ return failure(
234
+ "unavailable",
235
+ `rate_limited: ${bin} api ${what} hit the ${apiHost} rate limit (${forgeReason(run) || "HTTP 429"})`,
236
+ `${seconds === null ? "wait a minute" : `wait ${seconds}s`} for the rate limit to reset, then retry${bin === "gh" ? " (gh api rate_limit shows the reset)" : ""}`,
237
+ );
238
+ };
239
+
240
+ /** Map a failed CLI run to an envelope error. */
241
+ export function cliFailure<T>(
242
+ bin: CliBin,
243
+ apiHost: string,
244
+ run: CliRun,
245
+ what: string,
246
+ timeoutMs: number,
247
+ ): ForgeResult<T> {
248
+ if (run.missing) return failure("unavailable", `${bin} is not installed`, installHint(bin));
249
+ if (run.timedOut)
250
+ return failure(
251
+ "unavailable",
252
+ run.timeoutMs === 0
253
+ ? `${bin} api ${what} not attempted: the command's time budget ran out`
254
+ : `${bin} api ${what} timed out after ${run.timeoutMs ?? timeoutMs} ms`,
255
+ `check the network to ${apiHost}, then retry`,
256
+ );
257
+ if (isRateLimited(run)) return rateLimitFailure(bin, apiHost, run, what);
258
+ const stderr = redactText(run.stderr).trim();
259
+ if (
260
+ /auth login|not logged in|no token|authentication required|bad credentials|\b401\b|unauthorized/iu.test(
261
+ stderr,
262
+ )
263
+ )
264
+ return failure(
265
+ "unavailable",
266
+ `${bin} is not authenticated for ${apiHost}`,
267
+ loginHint(bin, apiHost),
268
+ );
269
+ if (/\b404\b|not found/iu.test(stderr)) return failure("not_found", `${what} was not found`);
270
+ const first = stderr.split(/\r?\n/u).find((value) => value.trim()) ?? `exit ${run.status}`;
271
+ if (isNetworkError(stderr))
272
+ return failure(
273
+ "unavailable",
274
+ `${bin} api ${what} failed (network): ${first.slice(0, 200)}`,
275
+ `check the network to ${apiHost}, then retry`,
276
+ );
277
+ return failure("failed", `${bin} api ${what} failed: ${first.slice(0, 200)}`);
278
+ }
279
+
280
+ /** Run and parse a JSON response. */
281
+ export function apiJson<T>(
282
+ runner: ForgeRunner,
283
+ bin: CliBin,
284
+ apiHost: string,
285
+ args: readonly string[],
286
+ what: string,
287
+ timeoutMs: number = FORGE_TIMEOUTS.api,
288
+ options: { graphql?: boolean; write?: boolean } = {},
289
+ ): ForgeResult<T> {
290
+ const run = runLimited(runner, bin, args, { timeoutMs }, options.write === true);
291
+ if (run.status !== 0) {
292
+ // `gh api graphql` exits 1 on GraphQL errors but still prints the
293
+ // response; the caller reads its `errors` array.
294
+ if (options.graphql && !run.missing && !run.timedOut)
295
+ try {
296
+ const body = JSON.parse(run.stdout) as { errors?: unknown };
297
+ if (Array.isArray(body.errors) && !isRateLimited(run)) return { ok: true, data: body as T };
298
+ } catch {
299
+ // fall through
300
+ }
301
+ return cliFailure(bin, apiHost, run, what, timeoutMs);
302
+ }
303
+ try {
304
+ return { ok: true, data: JSON.parse(run.stdout) as T };
305
+ } catch {
306
+ return failure("failed", `${bin} api ${what} returned invalid JSON`);
307
+ }
308
+ }
309
+
310
+ /** Run and return raw text (job logs). */
311
+ export function apiText(
312
+ runner: ForgeRunner,
313
+ bin: CliBin,
314
+ apiHost: string,
315
+ args: readonly string[],
316
+ what: string,
317
+ timeoutMs: number = FORGE_TIMEOUTS.log,
318
+ ): ForgeResult<string> {
319
+ const run = runLimited(runner, bin, args, { timeoutMs });
320
+ if (run.status !== 0) return cliFailure(bin, apiHost, run, what, timeoutMs);
321
+ return { ok: true, data: run.stdout };
322
+ }
323
+
324
+ /**
325
+ * A forge write (create, merge, retarget). Like apiJson, plus: an HTTP 409 or
326
+ * a head-moved/SHA-mismatch message is `blocked` (`head_moved`), and 405/406/
327
+ * 422 refusals are `blocked` with the forge's own reason, so a refused
328
+ * mutation never reads as a transient failure.
329
+ */
330
+ export function apiWrite<T>(
331
+ runner: ForgeRunner,
332
+ bin: CliBin,
333
+ apiHost: string,
334
+ args: readonly string[],
335
+ what: string,
336
+ timeoutMs: number = FORGE_TIMEOUTS.api,
337
+ /** A request body sent on stdin (pair it with `--input -`). */
338
+ input?: string,
339
+ ): ForgeResult<T> {
340
+ const run = runLimited(
341
+ runner,
342
+ bin,
343
+ args,
344
+ input === undefined ? { timeoutMs } : { timeoutMs, input },
345
+ true,
346
+ );
347
+ if (run.status !== 0) {
348
+ const text = redactText(`${run.stderr}\n${run.stdout}`).trim();
349
+ const reason = forgeReason(run);
350
+ if (
351
+ !run.missing &&
352
+ !run.timedOut &&
353
+ /\b409\b|head branch was modified|sha does not match|sha.*mismatch/iu.test(text)
354
+ )
355
+ return failure(
356
+ "blocked",
357
+ `head_moved: ${what} refused because the head changed (${reason})`,
358
+ "workit pr status # re-check the new head, then verify it again",
359
+ );
360
+ if (!run.missing && !run.timedOut && /\b(405|406|422)\b/u.test(text))
361
+ return failure("blocked", `${what} refused by the forge: ${reason}`);
362
+ const failed = cliFailure<T>(bin, apiHost, run, what, timeoutMs);
363
+ // A timeout, a dropped connection or a 5xx may come after the forge
364
+ // applied the write: look before writing again.
365
+ if (
366
+ !failed.ok &&
367
+ failed.code === "unavailable" &&
368
+ !run.missing &&
369
+ !isRateLimited(run) &&
370
+ !/ is not authenticated for /u.test(failed.error)
371
+ )
372
+ return failure(
373
+ "unavailable",
374
+ failed.error,
375
+ "workit pr status # the write may have gone through; check it first, then retry",
376
+ );
377
+ return failed;
378
+ }
379
+ if (!run.stdout.trim()) return { ok: true, data: {} as T };
380
+ try {
381
+ return { ok: true, data: JSON.parse(run.stdout) as T };
382
+ } catch {
383
+ return failure("failed", `${bin} api ${what} returned invalid JSON`);
384
+ }
385
+ }