acuvo-code 0.2.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 (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/gh.mjs ADDED
@@ -0,0 +1,1536 @@
1
+ /**
2
+ * ── ⭐⭐ THE AGENT COULD START FROM AN ISSUE AND COULD NOT PARTICIPATE ────────
3
+ *
4
+ * MEASURED 2026-08-15, on this machine: `gh` v2.90.0 is installed at
5
+ * `C:\Program Files\GitHub CLI\gh.EXE` and authenticated (keyring, account
6
+ * `xxiautomate-star`, scopes `repo, workflow, read:org, gist, user`). And the
7
+ * MODEL had no GitHub verb at all. `github.mjs` fetches ONE issue over REST at
8
+ * startup, turns it into the task, and that is the entire surface: mid-run the
9
+ * agent cannot list the other issues, cannot read the pull request it is being
10
+ * asked to address, cannot read a single review comment, and cannot see why CI
11
+ * went red. "Fix the review feedback" and "the build is failing, fix it" — the
12
+ * two most common follow-ups a coding agent gets — were both unanswerable.
13
+ *
14
+ * ── ⚠️ WHY THIS IS NOT `gh` ADDED TO `ALLOWED_BINARIES` ─────────────────────
15
+ * `git.mjs` already made this argument and it applies here with less mercy, so
16
+ * this file follows its shape rather than inventing a second one:
17
+ *
18
+ * 1. **The surface is enormous and mostly WRITE.** `gh pr merge`, `gh issue
19
+ * close`, `gh release create`, `gh secret set`, `gh repo delete`, `gh
20
+ * workflow run` — and `gh api -X POST <anything>`, which is a universal
21
+ * write to every endpoint GitHub has. A flag denylist for a program whose
22
+ * last subcommand is "run an arbitrary HTTP method against an arbitrary
23
+ * path" is not a promise anybody can keep.
24
+ * 2. **Every write is PUBLIC and IRREVERSIBLE-ISH.** A wrong file write is
25
+ * undone by writing the file again. A wrong comment on someone's issue is
26
+ * an email to everyone watching the repository, and deleting it does not
27
+ * un-send that. `github.mjs`'s header already refused to push or open a PR
28
+ * for exactly this reason; this file is the same policy, enforced instead
29
+ * of merely intended.
30
+ *
31
+ * ⭐ SO `gh` IS EXPOSED AS STRUCTURED VERBS, and the argv for each one is a
32
+ * LITERAL IN THIS FILE. The model supplies parameters — a number, a limit, a
33
+ * label — and `planGh` builds the exact argument vector. `pr merge`, `issue
34
+ * close`, `api`, `secret set` are not refused by a check that could be
35
+ * mis-written: THEY ARE NOT EXPRESSIBLE. There is no path from a model-authored
36
+ * string to a subcommand it was not given, because the subcommand words never
37
+ * come from the model at all.
38
+ *
39
+ * ── ⭐ THE SURFACE, AND THE ARGUMENT FOR EACH ONE ───────────────────────────
40
+ * · `issue list` — the "what is there to work on" question. Without it the
41
+ * only way in is a human typing a number.
42
+ * · `issue view` — ⚠️ arguably duplicates `github.mjs:fetchIssue`. Kept:
43
+ * `fetchIssue` is reachable only from `--issue N` at startup,
44
+ * returns a fixed field set, and refuses a PR outright. This
45
+ * is the mid-run "what does the linked issue actually say".
46
+ * · `pr list` — cheap, and the only way to find "the PR for this branch".
47
+ * · `pr view` — ⭐ THE HIGHEST-VALUE READ IN THE FILE. `reviews`,
48
+ * `latestReviews` and `comments` are what "address the review
49
+ * feedback" means. Nothing else in this package can see them.
50
+ * · `pr diff` — reviewing a change that is not ours. ⚠️ The one output that
51
+ * is routinely enormous; see the cap below.
52
+ * · `pr checks` — one call for "is CI green", against `run list` + `run view`
53
+ * which is two calls and a join.
54
+ * · `run list` — which CI runs exist and which are red.
55
+ * · `run view` — the run and its jobs.
56
+ * · `run view --log-failed` — ⭐ the reason "fix the failing build" works at
57
+ * all. ⚠️ `--log-failed`, never `--log`: a full CI log is
58
+ * megabytes of setup noise, and the model pays per token.
59
+ *
60
+ * ── ⚠️ AND WHAT WAS ARGUED FOR AND LEFT OUT ─────────────────────────────────
61
+ * · `gh api` — REFUSED, permanently. It is read-only only until the model adds
62
+ * `-X POST`, and no validator can enumerate what an arbitrary API path does.
63
+ * The whole point of structured verbs is that the dangerous form is absent.
64
+ * · `gh search issues/prs/code` — genuinely read-only and genuinely useful, but
65
+ * it is a fourth noun with its own query language, and this package's
66
+ * signature defect is capability that is built and never reached. Three nouns
67
+ * proven end to end beat four half-tested ones. `--search` on list covers most
68
+ * of it already.
69
+ * · `gh release view`, `gh repo view` — read-only, low value per schema token.
70
+ * Left out to hold the offer at three schemas.
71
+ * · `gh pr checkout` — reads like a read. It is not: it mutates the working
72
+ * tree, and `git.mjs` deliberately has no checkout for that exact reason.
73
+ */
74
+
75
+ import { MAX_CAPTURED_CHARS, clampOutput, scrubEnvironment, spawnBounded } from './command.mjs';
76
+ import { resolveOnPath } from './lsp.mjs';
77
+ import { validateBranchName } from './git.mjs';
78
+
79
+ /**
80
+ * @typedef {{ ok: false, error: string }} GhRefused
81
+ * @typedef {{ ok: true, verb: string, args: string[], json: boolean, maxChars: number }} GhPlan
82
+ */
83
+
84
+ /** The API behind gh is fast or broken; a long wait here teaches nothing. */
85
+ export const GH_TIMEOUT_MS = 30_000;
86
+ /**
87
+ * ── ⚠️⚠️ THE REAL CEILING IS `spawnBounded`'s, NOT OURS, AND I SHIPPED THE
88
+ * WRONG NUMBERS FOR IT UNTIL A TEST SAID SO ─────────────────────────
89
+ *
90
+ * `spawnBounded` clamps each stream to `MAX_CAPTURED_CHARS` (8,000, at
91
+ * `command.mjs:80`) BEFORE it ever returns. So this file's first draft, with a
92
+ * 16,000-character "generous cap" for a diff, was writing a number that can
93
+ * never be reached: a 200KB pull-request diff arrived already cut to 8,000, this
94
+ * module's own cap then found nothing to trim, and the result went back with
95
+ * `truncated: false`. A confident "here is the diff" about 4% of the diff.
96
+ *
97
+ * ⭐ TWO FIXES, AND BOTH MATTER. The text caps are now DERIVED from the capture
98
+ * buffer, so they cannot silently become fiction again if that number changes.
99
+ * And `runGh` reads `stdoutOmitted`/`stdoutProduced` back out of `spawnBounded`
100
+ * and folds them into `truncated`/`omitted` — because a cut made upstream is
101
+ * still a cut, and this package treats silent truncation as the worst class of
102
+ * bug it can ship.
103
+ *
104
+ * ⚠️ JSON IS DIFFERENT AND IS *NOT* CAPPED AT 8,000. `capJsonPayload` re-emits
105
+ * the parsed value with two-space indentation, and gh prints compact JSON — so
106
+ * 8,000 captured characters routinely become 15,000+ pretty-printed ones. That
107
+ * cap is reachable and is doing real work.
108
+ */
109
+ export const MAX_GH_OUTPUT_CHARS = 12_000;
110
+ /**
111
+ * ⚠️ A PR DIFF CANNOT BE SHOWN WHOLE BY ANY CAP. A 40-file refactor is hundreds
112
+ * of kilobytes; the capture buffer is 8,000 characters. So the cap's job is not
113
+ * to be generous — it cannot be — it is to be HONEST and to name the way out.
114
+ * See `capNote`, and `capTextPayload`'s two different sentences.
115
+ */
116
+ export const MAX_GH_DIFF_CHARS = MAX_CAPTURED_CHARS;
117
+ /** A failed-step log is the one output where the TAIL is the answer. */
118
+ export const MAX_GH_LOG_CHARS = MAX_CAPTURED_CHARS;
119
+ /**
120
+ * ── ⚠️ THE LIMITS ARE MEASURED, AND THE FIRST DRAFT'S WERE FANTASY ──────────
121
+ *
122
+ * Draft: default 30, maximum 100. Measured against cli/cli on 2026-08-15, at
123
+ * the default of 30 and with these exact field sets:
124
+ *
125
+ * gh issue list --limit 30 -> 16,393 chars (546 per issue)
126
+ * gh pr list --limit 30 -> 16,851 chars (562 per PR)
127
+ * gh run list --limit 30 -> 8,933 chars (298 per run)
128
+ * capture ceiling 8,000
129
+ *
130
+ * ⭐ So the DEFAULT overflowed on every noun, and the advertised maximum of 100
131
+ * was a number that could never be delivered — roughly 56,000 characters into an
132
+ * 8,000-character pipe. Offering it is not generosity, it is a promise the tool
133
+ * breaks silently.
134
+ *
135
+ * ⚠️ The per-item cost is mostly `labels` (223/item — GitHub returns id, name,
136
+ * description AND colour per label) and `author` (89/item, a nested object).
137
+ * Both are kept: "which issue should I pick up" is unanswerable without them.
138
+ * The LIMIT is what moves instead. 10 × 562 ≈ 5,600, which fits with headroom.
139
+ *
140
+ * ⭐ Above ~13 items the result is still returned, now truncated and loudly
141
+ * labelled rather than lost — so the maximum is a soft, honest 30 rather than a
142
+ * refusal, and someone who explicitly asks for 30 is told what they got.
143
+ */
144
+ export const MAX_GH_LIMIT = 30;
145
+ export const DEFAULT_GH_LIMIT = 10;
146
+ export const MAX_GH_SEARCH_CHARS = 200;
147
+ export const MAX_GH_LABELS = 10;
148
+
149
+ /**
150
+ * ── ⚠️ THE FIELD LISTS ARE MEASURED, NOT REMEMBERED ─────────────────────────
151
+ *
152
+ * Every name below was read out of `gh <cmd> --json zzz` on gh 2.90.0, which
153
+ * prints the available fields when the requested one does not exist. That
154
+ * matters because a single wrong field name makes the WHOLE call fail with
155
+ * "Unknown JSON field" — not degrade, fail — so a remembered field list is a
156
+ * verb that is dark on every machine until someone runs it.
157
+ *
158
+ * ⭐ AND `--json` AT ALL RATHER THAN SCRAPING gh's PROSE. gh's human output is
159
+ * a table tuned for a terminal: it truncates titles to the window width, prints
160
+ * relative dates ("about 2 hours ago") that cannot be compared, and drops the
161
+ * body entirely. Structured output survives all three, and it is the difference
162
+ * between the model guessing at a review comment and reading it.
163
+ */
164
+ /**
165
+ * ── ⚠️⚠️ THE FIELD LISTS ARE SPLIT, AND ONLY THE REAL RUN SHOWED WHY ────────
166
+ *
167
+ * The first draft asked `view` for everything at once — metadata, body, reviews
168
+ * AND comments. Every unit test passed. Then the first end-to-end call against a
169
+ * real public repository (cli/cli #9000, 2026-08-15):
170
+ *
171
+ * gh issue view 9000 --json <the whole draft list> -> 12,320 chars
172
+ * gh pr view 9000 --json <the whole draft list> -> 17,403 chars
173
+ * capture ceiling (MAX_CAPTURED_CHARS) -> 8,000 chars
174
+ *
175
+ * ⭐ So the two most valuable verbs in the file failed on an ORDINARY issue —
176
+ * not a pathological one — and the honest "ask for less" error I had written was
177
+ * advice with no way to follow it: there is no unit smaller than one issue.
178
+ * Built, tested, green, and DARK on every real repository. That is this
179
+ * package's signature defect and no unit test was ever going to find it.
180
+ *
181
+ * ⭐ MEASURED PER FIELD, which is what made the fix obvious rather than a guess:
182
+ * body 6,949 · comments 4,971 · reviews 2,609 · latestReviews 2,096
183
+ * … and ALL the metadata together (number, title, state, labels, author,
184
+ * refs, mergeable, counts, url) ≈ 500.
185
+ *
186
+ * Three fields are the entire problem. So the heavy content moves to its own
187
+ * action — `comments` — and `view` keeps the body plus the cheap metadata. Two
188
+ * calls that work beat one call that cannot.
189
+ *
190
+ * ⚠️ `latestReviews` IS DROPPED ENTIRELY: it is a subset of `reviews` (the most
191
+ * recent per reviewer), and 2,096 characters to repeat information already in
192
+ * the response is the difference between `pr comments` fitting and not.
193
+ */
194
+ export const ISSUE_LIST_FIELDS = 'number,title,state,labels,author,assignees,createdAt,updatedAt,url';
195
+ export const ISSUE_VIEW_FIELDS = 'number,title,state,stateReason,body,labels,author,assignees,milestone,createdAt,url';
196
+ /** ⭐ The discussion, on its own budget. An issue's answer is usually in here. */
197
+ export const ISSUE_COMMENTS_FIELDS = 'number,title,comments';
198
+ export const PR_LIST_FIELDS = 'number,title,state,isDraft,author,headRefName,baseRefName,labels,createdAt,updatedAt,url';
199
+ export const PR_VIEW_FIELDS = 'number,title,state,isDraft,body,author,headRefName,baseRefName,mergeable,mergeStateStatus,reviewDecision,files,additions,deletions,changedFiles,url';
200
+ /** ⭐ THE HIGHEST-VALUE READ IN THE FILE — "address the review feedback". */
201
+ export const PR_COMMENTS_FIELDS = 'number,title,state,reviewDecision,reviews,comments';
202
+ export const PR_CHECKS_FIELDS = 'name,state,bucket,workflow,event,link,description,startedAt,completedAt';
203
+ export const RUN_LIST_FIELDS = 'databaseId,number,name,workflowName,status,conclusion,headBranch,event,createdAt,url';
204
+ export const RUN_VIEW_FIELDS = 'databaseId,number,name,workflowName,status,conclusion,headBranch,headSha,event,createdAt,jobs,url';
205
+
206
+ /** gh's own `--status` vocabulary for `run list`, copied from `gh run list --help`. */
207
+ export const RUN_STATUSES = Object.freeze([
208
+ 'queued', 'completed', 'in_progress', 'requested', 'waiting', 'pending', 'action_required',
209
+ 'cancelled', 'failure', 'neutral', 'skipped', 'stale', 'startup_failure', 'success', 'timed_out',
210
+ ]);
211
+
212
+ // ───────────────────────────────────────────────────────────────────────────
213
+ // THE VALIDATORS — pure, and every refusal names the way out
214
+ // ───────────────────────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * ── ⚠️⚠️ `--repo` ACCEPTS `[HOST/]OWNER/REPO`, AND THE HOST IS THE HOLE ─────
218
+ *
219
+ * Straight from `gh pr list --help`:
220
+ * -R, --repo [HOST/]OWNER/REPO Select another repository
221
+ *
222
+ * So `repo: "evil.example.com/owner/name"` is a valid gh argument that points
223
+ * the CLI — carrying the user's credential — at a host nobody chose. It is not
224
+ * a shell escape, it is a documented feature, which is exactly the kind of thing
225
+ * an argument validator is for and a character whitelist would sail past.
226
+ *
227
+ * ⭐ So: exactly ONE slash, and both halves are GitHub name characters. A host,
228
+ * a URL, a `..`, an `@` and a leading `-` are all inexpressible.
229
+ */
230
+ export function validateRepo(raw) {
231
+ if (raw === null || raw === undefined || raw === '') return { ok: true, repo: null };
232
+ if (typeof raw !== 'string') return { ok: false, error: 'repo must be a string like "owner/name"' };
233
+ const repo = raw.trim();
234
+ if (!repo) return { ok: true, repo: null };
235
+ if (repo.includes('://') || repo.includes('@')) {
236
+ return { ok: false, error: `"${repo}" looks like a URL. Pass just "owner/name" — the host is always github.com, because sending your credential to a host the model chose is not something this agent does.` };
237
+ }
238
+ const parts = repo.split('/');
239
+ if (parts.length !== 2) {
240
+ return {
241
+ ok: false,
242
+ error: parts.length > 2
243
+ ? `"${repo}" has ${parts.length - 1} slashes. gh would read the first part as a HOST and talk to it with your credential, so only "owner/name" is accepted.`
244
+ : `"${repo}" is not a repository. Give "owner/name", or leave repo out entirely to use the repository this workspace is in.`,
245
+ };
246
+ }
247
+ for (const part of parts) {
248
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(part)) {
249
+ return { ok: false, error: `"${part}" is not a GitHub owner or repository name — letters, digits, ".", "_" and "-", not starting with a dash.` };
250
+ }
251
+ // ⚠️ `..` is path traversal in gh's REST path building, and a repository
252
+ // name cannot legitimately contain it.
253
+ if (part.includes('..')) return { ok: false, error: `"${part}" contains "..", which is never part of a GitHub name.` };
254
+ }
255
+ return { ok: true, repo };
256
+ }
257
+
258
+ /**
259
+ * ⚠️ AN ISSUE/PR NUMBER, AND NOT A URL. gh accepts `gh pr view <url>` and
260
+ * `gh pr view <branch>` as well as a number — so a model that pasted
261
+ * `https://github.com/someone-else/private/pull/1` would read a DIFFERENT
262
+ * repository through the `repo` guard above without ever touching `--repo`.
263
+ * A positive integer makes that shape inexpressible.
264
+ */
265
+ /**
266
+ * ── ⚠️⚠️ THE CEILING IS PER-NOUN, AND ONE CEILING BROKE THE CI VERBS ────────
267
+ *
268
+ * The first draft capped every number at 9,999,999, on the reasoning that no
269
+ * repository has ten million issues. True — and irrelevant to the other thing
270
+ * this validator guards. MEASURED on the first end-to-end run, 2026-08-15:
271
+ *
272
+ * gh run list --repo cli/cli -> databaseId 31882889895 (11 digits)
273
+ * validateNumber(31882889895) -> REFUSED
274
+ *
275
+ * ⭐ So `run view` and `run view --log-failed` — the entire "why did CI fail"
276
+ * story, and the best argument for this whole file — were unreachable against
277
+ * every real repository, and the refusal read "is not a real issue or pull
278
+ * request number" about a RUN ID. A guard that fails correct work is worse than
279
+ * no guard, and one that misnames the thing it rejected is worse still: it sends
280
+ * the reader looking for a bug in the wrong noun.
281
+ *
282
+ * ⚠️ A run id is a global, monotonically increasing counter across all of
283
+ * GitHub, so it has no small bound and never will. `MAX_RUN_ID` is set well
284
+ * above today's ~3.2e10 while still refusing the absurd — the check is against
285
+ * typos and overflow, not against GitHub's own numbering.
286
+ */
287
+ export const MAX_ITEM_NUMBER = 9_999_999;
288
+ export const MAX_RUN_ID = 1e15;
289
+
290
+ export function validateNumber(raw, what = 'number', max = MAX_ITEM_NUMBER) {
291
+ if (typeof raw === 'string' && /^\d+$/.test(raw.trim())) raw = Number(raw.trim());
292
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw) || raw < 1) {
293
+ return { ok: false, error: `${what} must be a positive whole number (e.g. 42). A URL or a branch name is not accepted here — use "repo" to point at another repository.` };
294
+ }
295
+ // ⭐ Names the parameter it rejected, not a noun it guessed at.
296
+ if (raw > max) return { ok: false, error: `${what} ${raw} is too large to be a real ${what} (the limit is ${max}).` };
297
+ return { ok: true, value: raw };
298
+ }
299
+
300
+ /** Clamped rather than refused: "give me 500" is a reasonable ask with an unreasonable answer. */
301
+ export function validateLimit(raw) {
302
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: DEFAULT_GH_LIMIT };
303
+ const n = typeof raw === 'string' ? Number(raw.trim()) : raw;
304
+ if (typeof n !== 'number' || !Number.isFinite(n)) {
305
+ return { ok: false, error: `limit must be a number between 1 and ${MAX_GH_LIMIT}` };
306
+ }
307
+ return { ok: true, value: Math.min(Math.max(1, Math.floor(n)), MAX_GH_LIMIT) };
308
+ }
309
+
310
+ /**
311
+ * A GitHub login, or `@me`.
312
+ *
313
+ * ⭐ `@me` is kept deliberately: "the pull requests assigned to me" is the
314
+ * question a user actually asks, and gh resolves it from the authenticated
315
+ * account rather than from anything the model knows.
316
+ */
317
+ export function validateLogin(raw, what = 'author') {
318
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: null };
319
+ if (typeof raw !== 'string') return { ok: false, error: `${what} must be a GitHub username, or "@me"` };
320
+ const v = raw.trim();
321
+ if (!v) return { ok: true, value: null };
322
+ if (v === '@me') return { ok: true, value: v };
323
+ if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(v)) {
324
+ return { ok: false, error: `"${v}" is not a GitHub username. Give a login like "octocat", or "@me" for the account gh is signed in as.` };
325
+ }
326
+ return { ok: true, value: v };
327
+ }
328
+
329
+ /** One label. Repeatable — gh's `--label` is a `strings` flag. */
330
+ export function validateLabel(raw) {
331
+ if (typeof raw !== 'string') return { ok: false, error: 'each label must be a string' };
332
+ const v = raw.trim();
333
+ if (!v) return { ok: false, error: 'a label may not be empty' };
334
+ if (v.length > 60) return { ok: false, error: `the label is ${v.length} characters, over the 60 limit` };
335
+ if (v.startsWith('-')) return { ok: false, error: `a label may not start with "-" — it would be read as a flag. If the label really is "${v}", filter with search instead.` };
336
+ // eslint-disable-next-line no-control-regex
337
+ if (/[\u0000-\u001f\u007f]/.test(v)) return { ok: false, error: 'the label contains control characters' };
338
+ return { ok: true, value: v };
339
+ }
340
+
341
+ /**
342
+ * A GitHub search query — free text, on purpose.
343
+ *
344
+ * ⚠️ THIS IS THE ONE PARAMETER THAT MUST ALLOW SPACES, COLONS AND QUOTES, since
345
+ * `is:open label:"needs triage" sort:updated` is what a search query looks like.
346
+ * That is safe for exactly one reason and it is worth stating: it becomes ONE
347
+ * ARGV ELEMENT, and an element is one argument no matter what is in it, because
348
+ * `spawnBounded` sets `shell: false` and no shell exists to re-split it. The
349
+ * character whitelist that `command.mjs` needs is a defence against a SHELL; it
350
+ * is not needed, and would be actively wrong, here.
351
+ */
352
+ export function validateSearch(raw) {
353
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: null };
354
+ if (typeof raw !== 'string') return { ok: false, error: 'search must be a string' };
355
+ const v = raw.trim();
356
+ if (!v) return { ok: true, value: null };
357
+ if (v.length > MAX_GH_SEARCH_CHARS) {
358
+ return { ok: false, error: `the search query is ${v.length} characters, over the ${MAX_GH_SEARCH_CHARS} limit — narrow it with a qualifier like "label:bug" instead of prose` };
359
+ }
360
+ // eslint-disable-next-line no-control-regex
361
+ if (/[\u0000-\u001f\u007f]/.test(v)) return { ok: false, error: 'the search query contains control characters' };
362
+ return { ok: true, value: v };
363
+ }
364
+
365
+ export function validateEnum(raw, allowed, what) {
366
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: null };
367
+ if (typeof raw !== 'string') return { ok: false, error: `${what} must be one of: ${allowed.join(', ')}` };
368
+ const v = raw.trim().toLowerCase();
369
+ if (!v) return { ok: true, value: null };
370
+ if (!allowed.includes(v)) {
371
+ return { ok: false, error: `"${raw}" is not a valid ${what}. Use one of: ${allowed.join(', ')}.` };
372
+ }
373
+ return { ok: true, value: v };
374
+ }
375
+
376
+ /**
377
+ * ⭐ REUSED, NOT REWRITTEN. `git.mjs:validateBranchName` is already git's
378
+ * `check-ref-format --branch` as a pure function, and a branch is a branch
379
+ * whether git or gh is about to be handed it. A second copy is how the two
380
+ * drift and one of them starts accepting `--force` as a branch name.
381
+ */
382
+ export function validateBranchArg(raw, what) {
383
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: null };
384
+ const r = validateBranchName(raw);
385
+ if (!r.ok) return { ok: false, error: `${what}: ${r.error}` };
386
+ return { ok: true, value: r.name };
387
+ }
388
+
389
+ /** A workflow file name or display name, e.g. "ci.yml". */
390
+ export function validateWorkflow(raw) {
391
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: null };
392
+ if (typeof raw !== 'string') return { ok: false, error: 'workflow must be a string like "ci.yml"' };
393
+ const v = raw.trim();
394
+ if (!v) return { ok: true, value: null };
395
+ if (v.length > 120) return { ok: false, error: `the workflow name is ${v.length} characters, over the 120 limit` };
396
+ if (v.startsWith('-')) return { ok: false, error: 'a workflow name may not start with "-" — it would be read as a flag' };
397
+ // eslint-disable-next-line no-control-regex
398
+ if (/[\u0000-\u001f\u007f]/.test(v)) return { ok: false, error: 'the workflow name contains control characters' };
399
+ return { ok: true, value: v };
400
+ }
401
+
402
+ // ───────────────────────────────────────────────────────────────────────────
403
+ // THE VERB TABLE — the argv words live HERE and nowhere else
404
+ // ───────────────────────────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * ⚠️ `--flag=value` RATHER THAN `--flag`, `value`, AND IT IS NOT STYLE.
408
+ *
409
+ * gh is a Go program using pflag, and pflag consumes the NEXT ARGUMENT as the
410
+ * value of a flag that takes one — including an argument that begins with a
411
+ * dash. So `['--label', '--json']` would set the label to the string
412
+ * `--json`… or, depending on the flag's type, be re-read as a flag. The `=`
413
+ * form has exactly one parse: everything after the first `=` is the value, and
414
+ * it cannot be re-read as anything.
415
+ *
416
+ * ⭐ The validators refuse a leading `-` as well. Two independent mechanisms for
417
+ * the same hole is deliberate here, because this is the boundary where a
418
+ * model-authored string meets an argument parser we do not own.
419
+ */
420
+ const flag = (name, value) => `--${name}=${value}`;
421
+
422
+ /**
423
+ * ⚠️ EVERY ENTRY IS A `build` FUNCTION AND A CAP. The subcommand words —
424
+ * 'issue', 'list', 'pr', 'view', 'run' — are string literals inside these
425
+ * builders. Nothing the model sends is ever concatenated into a subcommand
426
+ * position, which is what makes `pr merge` unreachable rather than refused.
427
+ */
428
+ const VERBS = Object.freeze({
429
+ 'issue.list': {
430
+ json: true,
431
+ maxChars: MAX_GH_OUTPUT_CHARS,
432
+ build(p) {
433
+ const args = ['issue', 'list', flag('json', ISSUE_LIST_FIELDS)];
434
+ const state = validateEnum(p.state, ['open', 'closed', 'all'], 'issue state');
435
+ if (!state.ok) return state;
436
+ if (state.value) args.push(flag('state', state.value));
437
+ const limit = validateLimit(p.limit);
438
+ if (!limit.ok) return limit;
439
+ args.push(flag('limit', String(limit.value)));
440
+ const labels = collectLabels(p.labels);
441
+ if (!labels.ok) return labels;
442
+ for (const l of labels.value) args.push(flag('label', l));
443
+ const assignee = validateLogin(p.assignee, 'assignee');
444
+ if (!assignee.ok) return assignee;
445
+ if (assignee.value) args.push(flag('assignee', assignee.value));
446
+ const author = validateLogin(p.author, 'author');
447
+ if (!author.ok) return author;
448
+ if (author.value) args.push(flag('author', author.value));
449
+ const search = validateSearch(p.search);
450
+ if (!search.ok) return search;
451
+ if (search.value) args.push(flag('search', search.value));
452
+ return { ok: true, args };
453
+ },
454
+ },
455
+ 'issue.view': {
456
+ json: true,
457
+ maxChars: MAX_GH_OUTPUT_CHARS,
458
+ build(p) {
459
+ const n = validateNumber(p.number, 'number');
460
+ if (!n.ok) return n;
461
+ return { ok: true, args: ['issue', 'view', String(n.value), flag('json', ISSUE_VIEW_FIELDS)] };
462
+ },
463
+ },
464
+ /**
465
+ * ── ⚠️ WHY `--body=` AND NOT `'--body', value` ──────────────────────────────
466
+ *
467
+ * `flag()` produces ONE argv element, `--body=<whatever>`, and `gh` reads
468
+ * everything after the first `=` as the value. So a body of `--force` stays a
469
+ * body. Pushed as two elements it would be indistinguishable from a real flag
470
+ * the moment a model wrote one, and the model writes the body. Do not "tidy"
471
+ * this into two pushes; `gh-write.test.mjs` fails if anyone does.
472
+ */
473
+ 'issue.comment': {
474
+ json: false,
475
+ maxChars: MAX_GH_OUTPUT_CHARS,
476
+ build(p) {
477
+ const n = validateNumber(p.number, 'number');
478
+ if (!n.ok) return n;
479
+ const body = validateBody(p.body);
480
+ if (!body.ok) return body;
481
+ return { ok: true, args: ['issue', 'comment', String(n.value), flag('body', body.value)] };
482
+ },
483
+ },
484
+ 'pr.comment': {
485
+ json: false,
486
+ maxChars: MAX_GH_OUTPUT_CHARS,
487
+ build(p) {
488
+ const n = validateNumber(p.number, 'number');
489
+ if (!n.ok) return n;
490
+ const body = validateBody(p.body);
491
+ if (!body.ok) return body;
492
+ return { ok: true, args: ['pr', 'comment', String(n.value), flag('body', body.value)] };
493
+ },
494
+ },
495
+ /**
496
+ * ⭐ NO `--head`. `gh` infers the head branch from the checkout, and letting a
497
+ * model name it would let it open a pull request from a branch it never
498
+ * touched. `--base` is optional and validated; omitted, GitHub uses the
499
+ * repository's default, which is the correct answer almost every time.
500
+ */
501
+ 'pr.create': {
502
+ json: false,
503
+ maxChars: MAX_GH_OUTPUT_CHARS,
504
+ build(p) {
505
+ const title = validateTitle(p.title);
506
+ if (!title.ok) return title;
507
+ const body = validateBody(p.body);
508
+ if (!body.ok) return body;
509
+ const args = ['pr', 'create', flag('title', title.value), flag('body', body.value)];
510
+ const base = validateBranchArg(p.base, 'base');
511
+ if (!base.ok) return base;
512
+ if (base.value) args.push(flag('base', base.value));
513
+ /**
514
+ * ⚠️ DRAFT IS AVAILABLE AND NOT THE DEFAULT. A draft PR is the polite
515
+ * option, but making it the default would mean an agent that "opened a
516
+ * PR" produced something nobody is asked to review — finishing the task in
517
+ * form and not in fact.
518
+ */
519
+ if (p.draft === true) args.push('--draft');
520
+ return { ok: true, args };
521
+ },
522
+ },
523
+ 'issue.comments': {
524
+ json: true,
525
+ maxChars: MAX_GH_OUTPUT_CHARS,
526
+ build(p) {
527
+ const n = validateNumber(p.number, 'number');
528
+ if (!n.ok) return n;
529
+ return { ok: true, args: ['issue', 'view', String(n.value), flag('json', ISSUE_COMMENTS_FIELDS)] };
530
+ },
531
+ },
532
+ 'pr.list': {
533
+ json: true,
534
+ maxChars: MAX_GH_OUTPUT_CHARS,
535
+ build(p) {
536
+ const args = ['pr', 'list', flag('json', PR_LIST_FIELDS)];
537
+ const state = validateEnum(p.state, ['open', 'closed', 'merged', 'all'], 'pull request state');
538
+ if (!state.ok) return state;
539
+ if (state.value) args.push(flag('state', state.value));
540
+ const limit = validateLimit(p.limit);
541
+ if (!limit.ok) return limit;
542
+ args.push(flag('limit', String(limit.value)));
543
+ const head = validateBranchArg(p.head, 'head');
544
+ if (!head.ok) return head;
545
+ if (head.value) args.push(flag('head', head.value));
546
+ const base = validateBranchArg(p.base, 'base');
547
+ if (!base.ok) return base;
548
+ if (base.value) args.push(flag('base', base.value));
549
+ const author = validateLogin(p.author, 'author');
550
+ if (!author.ok) return author;
551
+ if (author.value) args.push(flag('author', author.value));
552
+ const labels = collectLabels(p.labels);
553
+ if (!labels.ok) return labels;
554
+ for (const l of labels.value) args.push(flag('label', l));
555
+ const search = validateSearch(p.search);
556
+ if (!search.ok) return search;
557
+ if (search.value) args.push(flag('search', search.value));
558
+ return { ok: true, args };
559
+ },
560
+ },
561
+ 'pr.view': {
562
+ json: true,
563
+ maxChars: MAX_GH_OUTPUT_CHARS,
564
+ build(p) {
565
+ const n = validateNumber(p.number, 'number');
566
+ if (!n.ok) return n;
567
+ return { ok: true, args: ['pr', 'view', String(n.value), flag('json', PR_VIEW_FIELDS)] };
568
+ },
569
+ },
570
+ 'pr.comments': {
571
+ json: true,
572
+ maxChars: MAX_GH_OUTPUT_CHARS,
573
+ build(p) {
574
+ const n = validateNumber(p.number, 'number');
575
+ if (!n.ok) return n;
576
+ return { ok: true, args: ['pr', 'view', String(n.value), flag('json', PR_COMMENTS_FIELDS)] };
577
+ },
578
+ },
579
+ 'pr.diff': {
580
+ json: false,
581
+ maxChars: MAX_GH_DIFF_CHARS,
582
+ build(p) {
583
+ const n = validateNumber(p.number, 'number');
584
+ if (!n.ok) return n;
585
+ /**
586
+ * ⚠️ `--color=never` EXPLICITLY, not by trusting `NO_COLOR`. gh's own flag
587
+ * defaults to `auto`, and "auto" has been known to mean "yes" under a CI
588
+ * harness that fakes a TTY. ANSI escapes inside a diff are tokens the
589
+ * model pays for and cannot use, and they break any attempt to apply it.
590
+ */
591
+ return { ok: true, args: ['pr', 'diff', String(n.value), flag('color', 'never')] };
592
+ },
593
+ },
594
+ 'pr.checks': {
595
+ json: true,
596
+ maxChars: MAX_GH_OUTPUT_CHARS,
597
+ /**
598
+ * ⚠️⚠️ THIS VERB EXITS NON-ZERO WHEN IT WORKS. `gh pr checks` returns 8 for
599
+ * pending checks and non-zero when a check has failed — i.e. the exact case
600
+ * the model asked about. Treating that as a failure would mean "why is CI
601
+ * red" reports "the tool broke", which is the single most misleading thing
602
+ * this file could do. See `nonZeroIsAResult`.
603
+ */
604
+ nonZeroIsAResult: true,
605
+ build(p) {
606
+ const n = validateNumber(p.number, 'number');
607
+ if (!n.ok) return n;
608
+ return { ok: true, args: ['pr', 'checks', String(n.value), flag('json', PR_CHECKS_FIELDS)] };
609
+ },
610
+ },
611
+ 'run.list': {
612
+ json: true,
613
+ maxChars: MAX_GH_OUTPUT_CHARS,
614
+ build(p) {
615
+ const args = ['run', 'list', flag('json', RUN_LIST_FIELDS)];
616
+ const limit = validateLimit(p.limit);
617
+ if (!limit.ok) return limit;
618
+ args.push(flag('limit', String(limit.value)));
619
+ const branch = validateBranchArg(p.branch, 'branch');
620
+ if (!branch.ok) return branch;
621
+ if (branch.value) args.push(flag('branch', branch.value));
622
+ const workflow = validateWorkflow(p.workflow);
623
+ if (!workflow.ok) return workflow;
624
+ if (workflow.value) args.push(flag('workflow', workflow.value));
625
+ const status = validateEnum(p.status, RUN_STATUSES, 'run status');
626
+ if (!status.ok) return status;
627
+ if (status.value) args.push(flag('status', status.value));
628
+ return { ok: true, args };
629
+ },
630
+ },
631
+ 'run.view': {
632
+ json: true,
633
+ maxChars: MAX_GH_OUTPUT_CHARS,
634
+ build(p) {
635
+ const n = validateNumber(p.runId, 'runId', MAX_RUN_ID);
636
+ if (!n.ok) return n;
637
+ return { ok: true, args: ['run', 'view', String(n.value), flag('json', RUN_VIEW_FIELDS)] };
638
+ },
639
+ },
640
+ 'run.failed': {
641
+ /**
642
+ * ⚠️ NO `--json` HERE, AND THAT IS gh's RULE NOT A CHOICE. `--log-failed`
643
+ * and `--json` are mutually exclusive — asking for both is an error, so the
644
+ * schema must never let the model believe it can have structure here.
645
+ * Measured: `gh run view 1 --log-failed --json status` fails outright.
646
+ */
647
+ json: false,
648
+ maxChars: MAX_GH_LOG_CHARS,
649
+ /** An entirely green run prints nothing and exits 0. That is an ANSWER. */
650
+ emptyIsAResult: 'no failed steps in this run — nothing was logged because nothing failed',
651
+ build(p) {
652
+ const n = validateNumber(p.runId, 'runId', MAX_RUN_ID);
653
+ if (!n.ok) return n;
654
+ const args = ['run', 'view', String(n.value), '--log-failed'];
655
+ if (p.job !== null && p.job !== undefined && p.job !== '') {
656
+ const j = validateNumber(p.job, 'job', MAX_RUN_ID);
657
+ if (!j.ok) return j;
658
+ args.push(flag('job', String(j.value)));
659
+ }
660
+ return { ok: true, args };
661
+ },
662
+ },
663
+ });
664
+
665
+ function collectLabels(raw) {
666
+ if (raw === null || raw === undefined || raw === '') return { ok: true, value: [] };
667
+ const list = Array.isArray(raw) ? raw : [raw];
668
+ if (list.length > MAX_GH_LABELS) {
669
+ return { ok: false, error: `${list.length} labels is over the ${MAX_GH_LABELS} limit — filter on fewer, or use search with a query like "label:a label:b"` };
670
+ }
671
+ const out = [];
672
+ for (const l of list) {
673
+ const v = validateLabel(l);
674
+ if (!v.ok) return v;
675
+ out.push(v.value);
676
+ }
677
+ return { ok: true, value: out };
678
+ }
679
+
680
+ /** Which actions exist under each noun. ⭐ Derived from VERBS so it cannot drift. */
681
+ export const GH_NOUNS = Object.freeze({
682
+ issue: ['list', 'view', 'comments'],
683
+ pr: ['list', 'view', 'diff', 'checks', 'comments'],
684
+ run: ['list', 'view', 'failed'],
685
+ });
686
+
687
+ /**
688
+ * ── ⭐⭐⭐ THE LAST INCH OF EVERY TASK ────────────────────────────────────────
689
+ *
690
+ * Measured 2026-08-20: `gh` was read-only in every noun, so a run that read a PR
691
+ * review, fixed the code and committed it **ended at a local branch**. Nobody
692
+ * else could see the work. That is this CLI's biggest "cannot finish the job" —
693
+ * bigger than any missing tool, because it is the last inch of every task.
694
+ *
695
+ * ⚠️ THE OLD REFUSAL WAS RIGHT ABOUT THE RISK, and its wording is the reason
696
+ * this list is three items and not a noun: *"a comment, a close, a merge or a
697
+ * re-run is visible to everyone watching the repository and cannot be undone by
698
+ * trying again."* All true. So:
699
+ *
700
+ * · `pr create` and `*.comment` ADD something, attributed, on work the agent
701
+ * just did. A bad one is embarrassing and editable by a human in ten seconds.
702
+ * · `merge`, `approve`, `close`, `delete`, `rerun` CHANGE OR END something
703
+ * other people are relying on. They stay refused, with the same message.
704
+ *
705
+ * ⚠️ AND IT IS OFF BY DEFAULT. Absent means off, exactly like `ACUVO_ALLOW_PUSH`
706
+ * — a write to a shared repository must be asked for, never inherited from a
707
+ * default nobody chose.
708
+ */
709
+ export const GH_WRITE_ENV = 'ACUVO_GH_WRITE';
710
+
711
+ export function ghWriteEnabled(env = process.env) {
712
+ const raw = String(env?.[GH_WRITE_ENV] ?? '').trim().toLowerCase();
713
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
714
+ }
715
+
716
+ /**
717
+ * ⚠️ `run` DELIBERATELY GAINS NOTHING. Re-running someone else's CI spends their
718
+ * minutes and republishes a status other people are reading; there is no
719
+ * additive write on that noun.
720
+ */
721
+ const GH_WRITE_ACTIONS = Object.freeze({
722
+ issue: ['comment'],
723
+ pr: ['create', 'comment'],
724
+ run: [],
725
+ });
726
+
727
+ /** The actions available RIGHT NOW, which depends on the operator's switch. */
728
+ export function ghNouns(env = process.env) {
729
+ if (!ghWriteEnabled(env)) return GH_NOUNS;
730
+ return Object.freeze({
731
+ issue: [...GH_NOUNS.issue, ...GH_WRITE_ACTIONS.issue],
732
+ pr: [...GH_NOUNS.pr, ...GH_WRITE_ACTIONS.pr],
733
+ run: [...GH_NOUNS.run],
734
+ });
735
+ }
736
+
737
+ /**
738
+ * ⚠️ GitHub's own hard limit is 65,536 characters. This sits under it so the
739
+ * refusal comes from us, with a sentence the model can act on, rather than from
740
+ * the API as a 422 after the command has already run.
741
+ *
742
+ * ⭐ AND REFUSED, NEVER TRUNCATED. A tool result can be trimmed after the fact;
743
+ * a comment cannot. Everyone watching the repository is emailed the version that
744
+ * was posted, so half a diff is worse than an error the model can fix.
745
+ */
746
+ export const MAX_GH_BODY_CHARS = 60_000;
747
+ export const MAX_GH_TITLE_CHARS = 256;
748
+
749
+ export function validateBody(raw, what = 'body') {
750
+ if (typeof raw !== 'string' || !raw.trim()) {
751
+ return { ok: false, error: `gh needs a non-empty ${what}.` };
752
+ }
753
+ if (raw.length > MAX_GH_BODY_CHARS) {
754
+ return { ok: false, error: `that ${what} is ${raw.length} characters and the limit is ${MAX_GH_BODY_CHARS}. Post the summary and link to the detail — a comment nobody can read is worse than a short one.` };
755
+ }
756
+ return { ok: true, value: raw };
757
+ }
758
+
759
+ export function validateTitle(raw) {
760
+ if (typeof raw !== 'string' || !raw.trim()) {
761
+ /**
762
+ * ⚠️⚠️ NOT TIDINESS — A HANG. `gh pr create` with no title opens an editor,
763
+ * and this CLI runs with no TTY, so the prompt is not a question: it is a
764
+ * process that never returns and a timeout the model reads as a broken tool.
765
+ */
766
+ return { ok: false, error: 'gh pr create needs a title. Without one it opens an editor and hangs, because there is no terminal attached.' };
767
+ }
768
+ if (raw.length > MAX_GH_TITLE_CHARS) {
769
+ return { ok: false, error: `that title is ${raw.length} characters and the limit is ${MAX_GH_TITLE_CHARS}.` };
770
+ }
771
+ return { ok: true, value: raw.trim() };
772
+ }
773
+
774
+ /**
775
+ * ⚠️ THE NAMES A MODEL WILL REACH FOR, AND WHY EACH ONE IS A DEAD END.
776
+ *
777
+ * A refusal that only says "unknown action" is an obstacle: the model tries a
778
+ * synonym, then another, and burns three paid rounds discovering a policy. Every
779
+ * one of these is a write, and naming them individually lets the refusal say WHY
780
+ * and hand the work back to the human in the same sentence.
781
+ */
782
+ const KNOWN_WRITE_ACTIONS = Object.freeze({
783
+ create: 'gh issue create / gh pr create',
784
+ close: 'gh issue close',
785
+ reopen: 'gh issue reopen',
786
+ comment: 'gh issue comment / gh pr comment',
787
+ edit: 'gh issue edit / gh pr edit',
788
+ delete: 'gh issue delete',
789
+ transfer: 'gh issue transfer',
790
+ pin: 'gh issue pin',
791
+ lock: 'gh issue lock',
792
+ unlock: 'gh issue unlock',
793
+ merge: 'gh pr merge',
794
+ ready: 'gh pr ready',
795
+ review: 'gh pr review',
796
+ approve: 'gh pr review --approve',
797
+ close_pr: 'gh pr close',
798
+ checkout: 'gh pr checkout',
799
+ rerun: 'gh run rerun',
800
+ cancel: 'gh run cancel',
801
+ download: 'gh run download',
802
+ watch: 'gh run watch',
803
+ api: 'gh api',
804
+ release: 'gh release create',
805
+ secret: 'gh secret set',
806
+ });
807
+
808
+ /**
809
+ * Turn a noun + a parameter bag into an exact argv. PURE — no spawn, no
810
+ * network, no filesystem, no environment except `--repo`, which is a parameter.
811
+ *
812
+ * @param {string} noun 'issue' | 'pr' | 'run'
813
+ * @param {object} params
814
+ * @returns {GhPlan | GhRefused}
815
+ */
816
+ export function planGh(noun, params = {}, env = process.env) {
817
+ /**
818
+ * ⭐ THE AVAILABLE ACTIONS ARE READ ONCE, HERE, and every message below uses
819
+ * the same object. A refusal that lists a different set from the one the
820
+ * schema advertised is how a model learns to distrust its own tools.
821
+ */
822
+ const NOUNS = ghNouns(env);
823
+ const n = typeof noun === 'string' ? noun.trim().toLowerCase() : '';
824
+ if (!Object.prototype.hasOwnProperty.call(NOUNS, n)) {
825
+ return { ok: false, error: `"${noun}" is not a GitHub noun this agent uses. Use one of: ${Object.keys(NOUNS).join(', ')}.` };
826
+ }
827
+ const p = params && typeof params === 'object' ? params : {};
828
+ const rawAction = typeof p.action === 'string' ? p.action.trim().toLowerCase() : '';
829
+ if (!rawAction) {
830
+ return { ok: false, error: `gh_${n} needs an action. Available: ${NOUNS[n].join(', ')}.` };
831
+ }
832
+ if (!NOUNS[n].includes(rawAction)) {
833
+ const write = KNOWN_WRITE_ACTIONS[rawAction];
834
+ if (write) {
835
+ /**
836
+ * ⚠️ THE REASON DEPENDS ON WHICH WRITE IT IS, and saying the wrong one
837
+ * teaches the model something false. With the switch OFF, `comment` is
838
+ * refused because writes are disabled — telling it "this agent never
839
+ * writes" would be a lie it could not correct by asking an operator. With
840
+ * the switch ON, `merge` is refused because merging is not additive.
841
+ */
842
+ const disabled = !ghWriteEnabled(env);
843
+ return {
844
+ ok: false,
845
+ error:
846
+ `gh_${n} has no "${rawAction}" action here. `
847
+ + (disabled
848
+ ? `Writing to GitHub is switched off in this workspace (${GH_WRITE_ENV} is not set). `
849
+ : 'That action changes or ends something other people are relying on, so it is not available to the agent: only additive writes are. ')
850
+ + 'A close, a merge or a re-run is visible to everyone watching the repository and cannot be undone by trying again. '
851
+ + `Run it yourself when you are ready: \`${write}\`. `
852
+ + `What you can do here: ${NOUNS[n].join(', ')}.`,
853
+ };
854
+ }
855
+ return { ok: false, error: `gh_${n} has no "${rawAction}" action. Available: ${NOUNS[n].join(', ')}.` };
856
+ }
857
+
858
+ const key = `${n}.${rawAction}`;
859
+ const spec = VERBS[key];
860
+ /* c8 ignore next */
861
+ if (!spec) return { ok: false, error: `gh_${n} "${rawAction}" is not wired up` };
862
+
863
+ const built = spec.build(p);
864
+ if (!built.ok) return built;
865
+
866
+ const repo = validateRepo(p.repo);
867
+ if (!repo.ok) return repo;
868
+ const args = repo.repo ? [...built.args, flag('repo', repo.repo)] : built.args;
869
+
870
+ return {
871
+ ok: true,
872
+ verb: key,
873
+ args,
874
+ json: spec.json,
875
+ maxChars: spec.maxChars,
876
+ nonZeroIsAResult: spec.nonZeroIsAResult === true,
877
+ emptyIsAResult: spec.emptyIsAResult ?? null,
878
+ };
879
+ }
880
+
881
+ // ───────────────────────────────────────────────────────────────────────────
882
+ // FINDING gh, AND THE TWO DIFFERENT WAYS IT IS UNAVAILABLE
883
+ // ───────────────────────────────────────────────────────────────────────────
884
+
885
+ /**
886
+ * ⚠️ AN ABSOLUTE PATH, FOR THE REASON `github.mjs:findToken` DOCUMENTS AT
887
+ * LENGTH: on Windows a `gh.exe` sitting in the current directory beats
888
+ * `C:\Program Files\GitHub CLI\gh.exe`, because libuv's own path search consults
889
+ * cwd before PATH — and `shell: false` does NOT close that. `cd` into a cloned
890
+ * repository and the attacker's binary is what runs. `resolveOnPath` walks PATH
891
+ * with PATHEXT and never looks at cwd, which is the only thing that fixes it.
892
+ */
893
+ export function resolveGh(env = process.env, { resolveImpl = resolveOnPath } = {}) {
894
+ const found = resolveImpl('gh', env);
895
+ if (!found) {
896
+ return {
897
+ ok: false,
898
+ reason: 'not-installed',
899
+ error: [
900
+ 'The GitHub CLI (gh) is not installed, or is not on PATH for this process, so nothing was read.',
901
+ 'Install it — `winget install GitHub.cli` on Windows, `brew install gh` on macOS,',
902
+ 'https://cli.github.com otherwise — then run `gh auth login` once.',
903
+ 'Until then the repository is still readable with git_log and git_diff, and `acuvo --issue N`',
904
+ 'still works if GITHUB_TOKEN is set, because that path uses the REST API rather than gh.',
905
+ ].join(' '),
906
+ };
907
+ }
908
+ if (found.ok !== true) {
909
+ /**
910
+ * ── ⚠️ AN HONEST LIMIT, STATED RATHER THAN WORKED AROUND ─────────────────
911
+ *
912
+ * `resolveOnPath` reports a `.cmd`/`.bat` shim separately (scoop and npm
913
+ * installs produce one). `spawnBounded` hard-codes `shell: false` and the
914
+ * whole package depends on that — a batch shim can only be run by handing
915
+ * the argv to `cmd.exe`, which re-parses it, which is precisely the thing
916
+ * every argument guard in this file exists to prevent.
917
+ *
918
+ * ⭐ So this refuses rather than quietly opening a shell, and names two ways
919
+ * out that both work. Measured on the author's machine, `gh` resolves to
920
+ * `C:\Program Files\GitHub CLI\gh.EXE` — a real executable — so this branch
921
+ * is the minority case, not the normal one.
922
+ */
923
+ return {
924
+ ok: false,
925
+ reason: 'shim',
926
+ error:
927
+ `gh on this machine is a script shim (${found.shim}), and running one needs a shell, which this agent never opens — `
928
+ + 'a shell would re-parse the arguments that every guard here exists to control. '
929
+ + 'Point PATH at the real executable (the winget/MSI install puts gh.exe in "C:\\Program Files\\GitHub CLI"), '
930
+ + 'or set GITHUB_TOKEN and use `acuvo --issue N`, which talks to the REST API and needs no gh at all.',
931
+ };
932
+ }
933
+ return { ok: true, file: found.file };
934
+ }
935
+
936
+ /**
937
+ * ── ⚠️⚠️ WHAT ENVIRONMENT A gh CHILD GETS, AND WHY IT IS NOT THE PUSH ONE ────
938
+ *
939
+ * `git.mjs:pushEnvironment` restores `SSH_AUTH_SOCK` because `scrubEnvironment`
940
+ * deletes it (the SECRET_NAME regex at `command.mjs:1342` matches "AUTH") and
941
+ * git genuinely cannot authenticate without the agent socket. gh needs no
942
+ * socket: it speaks HTTPS to api.github.com with a token it stores itself.
943
+ * Measured here — `gh auth status` reports `Logged in to github.com account
944
+ * xxiautomate-star (keyring)` — so on this machine gh authenticates from the OS
945
+ * keyring and a fully scrubbed environment costs nothing at all.
946
+ *
947
+ * ⚠️ BUT NOT EVERYWHERE, AND THIS IS THE REGRESSION A NAIVE SCRUB WOULD SHIP.
948
+ * `GH_TOKEN` and `GITHUB_TOKEN` both match SECRET_NAME, so a plain
949
+ * `scrubEnvironment` leaves gh with NO credential in exactly the environment
950
+ * that only ever has one: CI. GitHub Actions injects `GITHUB_TOKEN` and nothing
951
+ * else. Every verb here would fail with "not authenticated", on the one machine
952
+ * where the user did everything right.
953
+ *
954
+ * ⭐ SO: a keep-list of exactly four names, and the argument for it is
955
+ * DIRECTIONALITY. `github.mjs:findToken` already reads `GITHUB_TOKEN`/`GH_TOKEN`
956
+ * and puts them in an `Authorization:` header to api.github.com — so these
957
+ * tokens already, by design, travel to GitHub. Handing the same token to a gh
958
+ * child sends it to the same destination, and the child is an absolute path off
959
+ * PATH running an argv this file built.
960
+ *
961
+ * ⚠️ AND NOTHING ELSE COMES BACK. `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`,
962
+ * `AWS_SECRET_ACCESS_KEY`, `DATABASE_URL` — the scrub deletes them and this
963
+ * function does not restore them. That is the whole "do not let a token leak
964
+ * into an unrelated child" rule: the GitHub token reaches the GitHub child, and
965
+ * a model-provider key reaches neither.
966
+ *
967
+ * ── ⚠️⚠️ THE PARAGRAPH THAT USED TO BE HERE WAS THE VULNERABILITY ───────────
968
+ *
969
+ * It read: *"`GH_HOST` and `GH_CONFIG_DIR` are deliberately NOT in the
970
+ * keep-list. They contain no SECRET_NAME word, so the scrub never removed them;
971
+ * listing them would be a line no test could hold to account."*
972
+ *
973
+ * ⭐ EVERY CLAUSE OF THAT IS TRUE, AND THE CONCLUSION IS BACKWARDS. "The scrub
974
+ * never removed them" is not a reason to leave them alone — it is the finding.
975
+ * Not being in the keep-list meant they were never RE-ADDED; it never meant
976
+ * they were absent. **`GH_HOST` passes straight through from the parent
977
+ * environment**, and this file then hands the child `GH_ENTERPRISE_TOKEN` on
978
+ * purpose.
979
+ *
980
+ * ⚠️⚠️ SO THE DIRECTIONALITY ARGUMENT ABOVE IS FALSE, and it is false in the
981
+ * one way that matters: *"these tokens already, by design, travel to GitHub"*
982
+ * holds only while something guarantees which host GitHub IS. `GH_HOST` is that
983
+ * something, and until now anything that could set an environment variable
984
+ * chose it. A cloned repository with `GH_HOST=attacker.example` in its `.env`
985
+ * gets our enterprise token posted to `attacker.example`. The guard was
986
+ * reasoning about the token and never about the destination.
987
+ *
988
+ * ⭐ THE FIX IS A DROP-LIST, NOT A LONGER KEEP-LIST. Both are deleted from the
989
+ * child environment unconditionally, so the parent's value cannot reach gh at
990
+ * all — the same shape as the workspace-env rule: a layer below the operator
991
+ * may only ever REMOVE permission, never add it.
992
+ *
993
+ * ⚠️ AND THE ENTERPRISE TOKENS GO WITH THEM BY DEFAULT. An enterprise token is
994
+ * only meaningful against an enterprise host; with `GH_HOST` gone it is a
995
+ * credential with no destination, and shipping it to github.com is worse than
996
+ * useless. GitHub Enterprise stays reachable through the explicit operator
997
+ * opt-in below.
998
+ */
999
+ export const GH_ENV_KEEP = Object.freeze(['GH_TOKEN', 'GITHUB_TOKEN']);
1000
+
1001
+ /**
1002
+ * ⚠️ REMOVED FROM THE CHILD NO MATTER WHAT THE PARENT SAYS. `GH_CONFIG_DIR` is
1003
+ * here for the same reason as `GH_HOST`: it points gh at a config file that can
1004
+ * itself name a host and carry credentials, so passing it through re-opens the
1005
+ * hole by another door.
1006
+ */
1007
+ export const GH_ENV_DROP = Object.freeze([
1008
+ 'GH_HOST', 'GH_CONFIG_DIR', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN',
1009
+ ]);
1010
+
1011
+ /**
1012
+ * ⭐ THE OPERATOR'S OWN OPT-IN, and the only way a non-github.com host is ever
1013
+ * reached. A GitHub Enterprise user sets `ACUVO_GH_HOST=github.mycorp.com` in
1014
+ * their real environment; a repository cannot, because `env-file.mjs` refuses
1015
+ * to let a workspace `.env` introduce an `ACUVO_*` variable at all.
1016
+ *
1017
+ * ⭐ THE INDIRECTION IS THE ENTIRE SECURITY PROPERTY: the name gh reads
1018
+ * (`GH_HOST`) is one a repo can set and is therefore always dropped; the name
1019
+ * WE read is one a repo cannot set. Same capability, different trust root.
1020
+ */
1021
+ export const GH_HOST_OPT_IN = 'ACUVO_GH_HOST';
1022
+
1023
+ export function ghEnvironment(env = process.env) {
1024
+ const out = scrubEnvironment(env);
1025
+ for (const name of GH_ENV_KEEP) {
1026
+ const value = env?.[name];
1027
+ if (typeof value === 'string' && value !== '') out[name] = value;
1028
+ }
1029
+ /**
1030
+ * ⚠️ DELETED AFTER the keep-loop and after the scrub, so no earlier branch can
1031
+ * reintroduce them. Order matters here: this must be the last word on these
1032
+ * four names.
1033
+ */
1034
+ for (const name of GH_ENV_DROP) delete out[name];
1035
+
1036
+ /**
1037
+ * The enterprise path, re-opened only by a variable the workspace cannot set.
1038
+ * ⚠️ The enterprise TOKEN comes back only alongside an explicit host — a
1039
+ * credential without a destination is never worth forwarding.
1040
+ */
1041
+ const optIn = String(env?.[GH_HOST_OPT_IN] ?? '').trim();
1042
+ if (optIn) {
1043
+ out.GH_HOST = optIn;
1044
+ for (const name of ['GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) {
1045
+ const value = env?.[name];
1046
+ if (typeof value === 'string' && value !== '') out[name] = value;
1047
+ }
1048
+ }
1049
+ /**
1050
+ * ⚠️ EVERY ONE OF THESE PREVENTS A HANG OR A WASTED TOKEN, and they are the
1051
+ * same three failures `gitEnvironment` closes for git:
1052
+ * · `GH_PAGER`/`PAGER=cat` — gh pipes long output through a pager that waits
1053
+ * for a keypress that will never come. That is a tool call that burns the
1054
+ * whole timeout and reports nothing.
1055
+ * · `GH_PROMPT_DISABLED` — gh asking "Which repository?" on a process with no
1056
+ * terminal is the same hang with a different cause.
1057
+ * · `NO_COLOR` / `CLICOLOR=0` — ANSI escapes are tokens the model pays for and
1058
+ * cannot use.
1059
+ * · `GH_NO_UPDATE_NOTIFIER` — the "a new release of gh is available" banner is
1060
+ * noise that has broken more than one output parser.
1061
+ * ⚠️ `GH_FORCE_TTY` is DELETED rather than set: if an operator has it in their
1062
+ * environment, gh formats for a terminal width and truncates titles, which
1063
+ * silently corrupts the structured output we asked for.
1064
+ */
1065
+ out.GH_PAGER = 'cat';
1066
+ out.PAGER = 'cat';
1067
+ out.NO_COLOR = '1';
1068
+ out.CLICOLOR = '0';
1069
+ out.GH_PROMPT_DISABLED = '1';
1070
+ out.GH_NO_UPDATE_NOTIFIER = '1';
1071
+ delete out.GH_FORCE_TTY;
1072
+ return out;
1073
+ }
1074
+
1075
+ /**
1076
+ * Translate a gh failure into one sentence a model can act on.
1077
+ *
1078
+ * ── ⚠️⚠️ THE ORDER IS LOAD-BEARING, AND I ONLY FOUND OUT BY RUNNING IT ──────
1079
+ *
1080
+ * Measured, gh 2.90.0, in a directory whose origin is not a GitHub remote:
1081
+ *
1082
+ * failed to determine base repo: none of the git remotes configured for this
1083
+ * repository point to a known GitHub host. To tell gh about a new GitHub
1084
+ * host, please use `gh auth login`
1085
+ *
1086
+ * ⭐ gh's OWN error text tells you to run `gh auth login` for a problem that has
1087
+ * nothing to do with authentication. Any auth check written as "does the message
1088
+ * mention gh auth login" classifies this as "not signed in" — and the model then
1089
+ * spends its next round telling a signed-in user to sign in, while the actual
1090
+ * fix (`repo: "owner/name"`) goes unmentioned. This is `an error string is an
1091
+ * instruction` in its purest form, and the only defence is to test the specific
1092
+ * cause BEFORE the generic one.
1093
+ */
1094
+ export function classifyGhFailure(text) {
1095
+ const s = String(text ?? '');
1096
+ if (/failed to determine base repo|none of the git remotes/i.test(s)) {
1097
+ return {
1098
+ reason: 'no-github-repo',
1099
+ error:
1100
+ 'gh could not work out which GitHub repository this is: the workspace has no remote pointing at github.com. '
1101
+ + '(gh\'s own message suggests `gh auth login` here, which is a red herring — this is not an authentication problem.) '
1102
+ + 'Pass repo: "owner/name" to say which repository you mean, or run this from a clone that has a GitHub origin.',
1103
+ };
1104
+ }
1105
+ if (/gh auth login|not logged in|no such host|authentication token|requires authentication|HTTP 401|Bad credentials/i.test(s)) {
1106
+ return {
1107
+ reason: 'not-authenticated',
1108
+ error:
1109
+ 'gh is installed but has no usable credential for github.com, so nothing was read. '
1110
+ + 'Run `gh auth login` once in your own terminal, or set GH_TOKEN in the environment — this agent will pick up either. '
1111
+ + 'It cannot sign in on your behalf: that is an interactive, account-level act.',
1112
+ };
1113
+ }
1114
+ if (/HTTP 404|Could not resolve to a|not found/i.test(s)) {
1115
+ return {
1116
+ reason: 'not-found',
1117
+ error:
1118
+ 'GitHub returned "not found". On GitHub that is ambiguous on purpose: it means either no such issue, pull request or run — '
1119
+ + 'or that it exists in a private repository your token cannot see. Check the number, then check the token\'s scopes with `gh auth status`.',
1120
+ };
1121
+ }
1122
+ if (/HTTP 403|rate limit|API rate limit exceeded/i.test(s)) {
1123
+ return {
1124
+ reason: 'forbidden',
1125
+ error:
1126
+ 'GitHub refused the request (403). Either the token lacks the scope for this — a workflow run needs the `workflow` scope, '
1127
+ + 'a private repository needs `repo` — or you are rate limited. `gh auth status` shows the scopes; a rate limit clears on its own.',
1128
+ };
1129
+ }
1130
+ return null;
1131
+ }
1132
+
1133
+ // ───────────────────────────────────────────────────────────────────────────
1134
+ // THE CAP — and saying so out loud
1135
+ // ───────────────────────────────────────────────────────────────────────────
1136
+
1137
+ /**
1138
+ * ── ⭐ A CAPPED ANSWER MUST STILL BE A TRUE ANSWER ──────────────────────────
1139
+ *
1140
+ * Two different shapes need two different cuts, and conflating them is how a
1141
+ * model gets handed a lie:
1142
+ *
1143
+ * · AN ARRAY (a list of issues, PRs, runs, checks) is cut by DROPPING ITEMS. The
1144
+ * text stays valid JSON, `capNote` says how many of how many are shown, and
1145
+ * the way out is a real one — a narrower filter or a smaller limit.
1146
+ * · AN OBJECT (one PR with its reviews and its body) cannot be cut that way, so
1147
+ * the SERIALISED TEXT is clamped head-and-tail by `clampOutput` and the result
1148
+ * is deliberately no longer parseable. ⚠️ That is why `capNote` is not
1149
+ * optional here: a model handed truncated JSON with no warning will read the
1150
+ * surviving fields as the whole record.
1151
+ *
1152
+ * ⭐ `json` always carries the FULL parsed value regardless, so the caller that
1153
+ * wants to summarise rather than paste has something to summarise from.
1154
+ */
1155
+ export function capJsonPayload(value, maxChars = MAX_GH_OUTPUT_CHARS) {
1156
+ const full = JSON.stringify(value, null, 2);
1157
+ if (full.length <= maxChars) {
1158
+ return { text: full, json: value, truncated: false, omitted: 0, capNote: null };
1159
+ }
1160
+ if (Array.isArray(value)) {
1161
+ const kept = [];
1162
+ let size = 2; // the brackets
1163
+ for (const item of value) {
1164
+ const piece = JSON.stringify(item, null, 2);
1165
+ if (size + piece.length + 2 > maxChars) break;
1166
+ size += piece.length + 2;
1167
+ kept.push(item);
1168
+ }
1169
+ /**
1170
+ * ⚠️ AT LEAST ONE ITEM, ALWAYS. A single review comment can be longer than
1171
+ * the whole budget, and returning `[]` would say "there are none" — a
1172
+ * confident falsehood about the exact thing that was asked for. One
1173
+ * over-budget item plus a note that says so is honest; zero items is not.
1174
+ */
1175
+ if (kept.length === 0 && value.length > 0) kept.push(value[0]);
1176
+ /**
1177
+ * ⚠️ RE-MEASURED AGAINST THE REAL STRING. The loop above sizes each item on
1178
+ * its own, but nesting it inside an array adds two spaces of indent to every
1179
+ * line — so a budget computed from the pieces is systematically UNDER the
1180
+ * size of the whole. A cap that is "roughly" a cap is a cap that occasionally
1181
+ * is not one, which is the class of bug this package treats as the worst it
1182
+ * can ship. Drop items until the actual text fits, never below one.
1183
+ */
1184
+ let text = JSON.stringify(kept, null, 2);
1185
+ while (text.length > maxChars && kept.length > 1) {
1186
+ kept.pop();
1187
+ text = JSON.stringify(kept, null, 2);
1188
+ }
1189
+ return {
1190
+ text,
1191
+ json: value,
1192
+ truncated: true,
1193
+ omitted: value.length - kept.length,
1194
+ capNote: `⚠️ Showing ${kept.length} of ${value.length} — the rest was cut to fit. Narrow it: a smaller limit, a state, a label, or a search query.`,
1195
+ };
1196
+ }
1197
+ const clamped = clampOutput(full, maxChars);
1198
+ return {
1199
+ text: clamped.text,
1200
+ json: value,
1201
+ truncated: true,
1202
+ omitted: clamped.omitted,
1203
+ capNote: `⚠️ ${clamped.omitted} characters were cut from the middle, so this JSON is TRUNCATED and no longer valid JSON — do not assume a field is absent because you cannot see it. Ask for one thing at a time (a single number rather than a list) if you need the whole record.`,
1204
+ };
1205
+ }
1206
+
1207
+ /** The same honesty for plain text — a diff, a failed-step log. */
1208
+ export function capTextPayload(text, maxChars, kind) {
1209
+ const clamped = clampOutput(String(text ?? ''), maxChars);
1210
+ if (!clamped.truncated) return { text: clamped.text, truncated: false, omitted: 0, capNote: null };
1211
+ const way = kind === 'diff'
1212
+ ? 'Use gh_pr { action: "view" } to list the changed files, then read_file the ones that matter — a whole diff never fits.'
1213
+ : 'Read the tail: it is kept above, and a failed step\'s error is almost always in the last lines. For more, open the run in the browser at the url from gh_run view.';
1214
+ return {
1215
+ text: clamped.text,
1216
+ truncated: true,
1217
+ omitted: clamped.omitted,
1218
+ capNote: `⚠️ ${clamped.omitted} characters were cut from the MIDDLE (the head and the tail are both kept). ${way}`,
1219
+ };
1220
+ }
1221
+
1222
+ // ───────────────────────────────────────────────────────────────────────────
1223
+ // RUNNING IT
1224
+ // ───────────────────────────────────────────────────────────────────────────
1225
+
1226
+ /**
1227
+ * Execute a plan. The only impure function in the file, and everything it
1228
+ * touches — the spawn, the environment, the gh lookup — is injectable, so every
1229
+ * branch below is reachable in a test with no network, no spawn and no gh.
1230
+ *
1231
+ * @param {string} root cwd for the child: gh resolves the repository from it.
1232
+ * @returns {Promise<GhRefused | object>}
1233
+ */
1234
+ export async function runGh(root, plan, {
1235
+ env = process.env,
1236
+ spawnImpl,
1237
+ resolveImpl = resolveOnPath,
1238
+ timeoutMs = GH_TIMEOUT_MS,
1239
+ } = {}) {
1240
+ if (!plan || plan.ok !== true) return plan ?? { ok: false, error: 'no plan to run' };
1241
+
1242
+ const gh = resolveGh(env, { resolveImpl });
1243
+ if (!gh.ok) return { ok: false, error: gh.error, reason: gh.reason };
1244
+
1245
+ const run = await spawnBounded({
1246
+ file: gh.file,
1247
+ args: plan.args,
1248
+ cwd: root,
1249
+ timeoutMs,
1250
+ spawnImpl,
1251
+ env: ghEnvironment(env),
1252
+ });
1253
+ if (!run.ok) return { ok: false, error: run.error };
1254
+ if (run.timedOut) {
1255
+ return { ok: false, reason: 'timeout', error: `gh did not finish within ${Math.round(timeoutMs / 1000)}s and was stopped. GitHub may be slow or unreachable; try again, or ask for less (a smaller limit, one item instead of a list).` };
1256
+ }
1257
+
1258
+ const combined = `${run.stdout}\n${run.stderr}`;
1259
+ const failed = run.exitCode !== 0 && !(plan.nonZeroIsAResult && run.stdout.trim() !== '');
1260
+ if (failed) {
1261
+ const known = classifyGhFailure(combined);
1262
+ if (known) return { ok: false, reason: known.reason, error: known.error };
1263
+ const raw = combined.trim();
1264
+ return { ok: false, reason: 'gh-error', error: raw ? clampOutput(raw, 2_000).text : `gh exited ${run.exitCode} without saying why` };
1265
+ }
1266
+
1267
+ /**
1268
+ * ⚠️⚠️ WHAT `spawnBounded` ALREADY THREW AWAY, BEFORE THIS FILE SAW ANYTHING.
1269
+ * It caps each stream at `MAX_CAPTURED_CHARS` and reports the true loss in
1270
+ * `stdoutOmitted` / `stdoutProduced`. Ignoring those (which the first draft
1271
+ * did) means every result computes its `truncated` flag against an
1272
+ * already-truncated string and answers "no". Read them once, here, and let
1273
+ * both branches below fold them in.
1274
+ */
1275
+ const lostUpstream = Number(run.stdoutOmitted ?? 0);
1276
+
1277
+ if (plan.json) {
1278
+ const body = run.stdout.trim();
1279
+ /**
1280
+ * ── ⭐ A CLIPPED STREAM IS NOT "gh RETURNED SOMETHING ELSE" ──────────────
1281
+ *
1282
+ * This case used to fall through to the JSON.parse failure below, whose
1283
+ * message is "gh was asked for JSON and returned something else". That is
1284
+ * false and it is expensively false: gh returned perfectly good JSON, WE cut
1285
+ * the middle out of it, and the model would go on to doubt gh, re-run the
1286
+ * same call, and get the same answer. Diagnose it where it happened and name
1287
+ * the way out, which is genuinely "ask for less".
1288
+ */
1289
+ if (lostUpstream > 0) {
1290
+ /**
1291
+ * ⚠️⚠️ AND IT IS NOT A REFUSAL EITHER, WHICH IS THE SECOND HALF OF THE
1292
+ * LESSON. My first fix returned `ok: false` here with an honest
1293
+ * explanation and the advice "ask for less". Measured against cli/cli
1294
+ * #9000, that turned the two best verbs into a dead end: there is no unit
1295
+ * smaller than one issue, so the advice could not be followed.
1296
+ *
1297
+ * ⭐ `clampOutput` keeps the HEAD AND THE TAIL, so what survives is the
1298
+ * beginning of the record — number, title, state, the start of the body —
1299
+ * which is most of what the model needed. Handing that back with a loud,
1300
+ * unmissable warning beats handing back nothing. `json` is null rather
1301
+ * than a half-parsed object, because a partial object with missing fields
1302
+ * is precisely the lie this whole section exists to prevent.
1303
+ */
1304
+ const narrower = plan.verb.endsWith('.view')
1305
+ ? ` For the discussion instead of the body, use action "comments".`
1306
+ : ' Ask for less: a smaller limit, or a state/label filter.';
1307
+ return {
1308
+ ok: true,
1309
+ verb: plan.verb,
1310
+ exitCode: run.exitCode,
1311
+ json: null,
1312
+ text: run.stdout,
1313
+ truncated: true,
1314
+ omitted: lostUpstream,
1315
+ capNote:
1316
+ `⚠️ THIS IS NOT VALID JSON AND FIELDS ARE MISSING. gh returned ${run.stdoutProduced} characters; only ${MAX_CAPTURED_CHARS} can be captured, `
1317
+ + `so ${lostUpstream} were cut from the MIDDLE (the head and tail are kept). Do not conclude a field is empty or absent because you cannot see it.${narrower}`,
1318
+ checksFailing: false,
1319
+ };
1320
+ }
1321
+ if (!body) {
1322
+ return { ok: false, reason: 'empty', error: 'gh returned nothing where JSON was expected. That usually means the command was answered by a prompt this agent cannot see — check `gh auth status`.' };
1323
+ }
1324
+ let parsed;
1325
+ try {
1326
+ parsed = JSON.parse(body);
1327
+ } catch {
1328
+ /**
1329
+ * ⚠️ THE RAW TEXT RIDES ALONG. A JSON parse failure here is nearly always
1330
+ * gh printing a banner or a warning onto stdout, and the ONLY way anyone
1331
+ * diagnoses that is by seeing what it actually printed.
1332
+ */
1333
+ return { ok: false, reason: 'bad-json', error: `gh was asked for JSON and returned something else:\n${clampOutput(body, 1_000).text}` };
1334
+ }
1335
+ const capped = capJsonPayload(parsed, plan.maxChars);
1336
+ return {
1337
+ ok: true,
1338
+ verb: plan.verb,
1339
+ exitCode: run.exitCode,
1340
+ json: capped.json,
1341
+ text: capped.text,
1342
+ truncated: capped.truncated,
1343
+ omitted: capped.omitted,
1344
+ capNote: capped.capNote,
1345
+ // ⭐ Said out loud rather than inferred from the exit code. `pr checks`
1346
+ // exits non-zero precisely when the answer is interesting.
1347
+ checksFailing: plan.nonZeroIsAResult && run.exitCode !== 0,
1348
+ };
1349
+ }
1350
+
1351
+ const body = run.stdout.trim();
1352
+ if (!body && plan.emptyIsAResult) {
1353
+ // ⚠️ AN ANSWER, NOT AN ABSENCE — the `gitDiff` empty-diff argument. "No
1354
+ // failed steps" is the thing the model most wants to hear and would
1355
+ // otherwise have to infer from a blank string.
1356
+ return { ok: true, verb: plan.verb, exitCode: run.exitCode, text: '', empty: true, note: plan.emptyIsAResult, truncated: false, omitted: 0, capNote: null };
1357
+ }
1358
+ const capped = capTextPayload(run.stdout, plan.maxChars, plan.verb === 'pr.diff' ? 'diff' : 'log');
1359
+ /**
1360
+ * ⭐ THE TWO CUTS ARE ADDED TOGETHER AND REPORTED AS ONE NUMBER. A caller that
1361
+ * sees `truncated: true, omitted: 214_337` can reason about it; one that sees
1362
+ * `truncated: false` because our own cap happened not to fire cannot.
1363
+ */
1364
+ const truncated = capped.truncated || lostUpstream > 0;
1365
+ /**
1366
+ * ⚠️ ONE COHERENT NUMBER, AND THE FIRST VERSION OF THIS WAS NONSENSE.
1367
+ * Measured on a real `run --log-failed`: it printed "31 characters were cut
1368
+ * from the MIDDLE … (1039 of those characters were dropped while capturing)".
1369
+ * 1,039 of 31. Two independent cuts were being narrated as one, and a reader
1370
+ * checking the arithmetic would conclude the whole report was untrustworthy —
1371
+ * which, on a message whose entire job is to be believed about missing data,
1372
+ * is the worst possible failure. State the TOTAL, then break it down.
1373
+ */
1374
+ const totalOmitted = capped.omitted + lostUpstream;
1375
+ const way = capped.capNote?.replace(/^⚠️ \d+ characters[^.]*\.\s*/, '') ?? '';
1376
+ const note = lostUpstream > 0
1377
+ ? `⚠️ ${totalOmitted} characters were cut from the MIDDLE of this output (the head and the tail are kept): `
1378
+ + `${lostUpstream} because gh produced ${run.stdoutProduced} characters and only ${MAX_CAPTURED_CHARS} can be captured`
1379
+ + `${capped.omitted > 0 ? `, and ${capped.omitted} more to fit the tool result` : ''}. ${way}`
1380
+ : capped.capNote;
1381
+ return {
1382
+ ok: true,
1383
+ verb: plan.verb,
1384
+ exitCode: run.exitCode,
1385
+ text: capped.text,
1386
+ empty: body === '',
1387
+ truncated,
1388
+ omitted: capped.omitted + lostUpstream,
1389
+ capNote: truncated ? note : null,
1390
+ };
1391
+ }
1392
+
1393
+ /** Plan then run — the single entry point the dispatcher calls. */
1394
+ export async function executeGh(root, noun, params = {}, opts = {}) {
1395
+ const plan = planGh(noun, params);
1396
+ if (!plan.ok) return plan;
1397
+ return runGh(root, plan, opts);
1398
+ }
1399
+
1400
+ // ───────────────────────────────────────────────────────────────────────────
1401
+ // THE OFFER
1402
+ // ───────────────────────────────────────────────────────────────────────────
1403
+
1404
+ /**
1405
+ * ⭐ THREE SCHEMAS, NOT NINE — grouped by NOUN with an enumerated action.
1406
+ *
1407
+ * git.mjs gives each verb its own schema because its verbs take genuinely
1408
+ * different parameters and one of them (push) has to be gated separately. These
1409
+ * nine share a parameter bag almost entirely, and nine schemas is roughly 900
1410
+ * tokens on EVERY round of every run. The enum is still a closed set: it is
1411
+ * rendered from `GH_NOUNS`, which is the same table `planGh` dispatches on, so
1412
+ * the offer and the dispatcher cannot drift apart.
1413
+ *
1414
+ * ⚠️ AND THE ENUM IS NOT THE SECURITY BOUNDARY. A provider that echoes a stale
1415
+ * tool list, or a resumed session, can put any string in `action` — which is why
1416
+ * `planGh` refuses an unknown action itself rather than trusting the schema.
1417
+ * `git.mjs:gitPush` makes the same argument about `pushEnabled` being checked at
1418
+ * the dispatcher as well as at the offer, and cites the test that proves it.
1419
+ *
1420
+ * ⭐ FOLLOWS `gitPushToolNames`: if gh is not installed these schemas are never
1421
+ * MENTIONED, so a machine without gh pays zero tokens for verbs it cannot run.
1422
+ */
1423
+ export function ghToolNames(env = process.env, { resolveImpl = resolveOnPath } = {}) {
1424
+ return resolveGh(env, { resolveImpl }).ok ? ['gh_issue', 'gh_pr', 'gh_run'] : [];
1425
+ }
1426
+
1427
+ const READ_ONLY_LINE = 'This agent only READS GitHub — there is no create, comment, close, merge, approve or re-run, by construction rather than by refusal.';
1428
+
1429
+ /**
1430
+ * ⚠️ THE SCHEMA MUST DESCRIBE THE TOOL THE MODEL ACTUALLY HAS. Advertising an
1431
+ * action that is switched off burns a paid round on a refusal; hiding one that
1432
+ * is switched on is capability nobody can reach — this package has shipped both,
1433
+ * most recently three tools declared, executable, advertised and never offered.
1434
+ * So the enum comes from `ghNouns(env)`, the same function `planGh` validates
1435
+ * against, and the two cannot disagree.
1436
+ */
1437
+ export function ghToolSchemas(env = process.env) {
1438
+ const NOUNS = ghNouns(env);
1439
+ const writes = ghWriteEnabled(env);
1440
+ const policyLine = writes
1441
+ ? 'This agent can ADD to GitHub — open a pull request, comment on an issue or a PR — but never close, merge, approve, delete or re-run: those change or end something other people rely on.'
1442
+ : READ_ONLY_LINE;
1443
+ const writeProps = writes
1444
+ ? {
1445
+ title: { type: 'string', description: 'pr create only — the PR title. REQUIRED for create; without it gh opens an editor and hangs.' },
1446
+ body: { type: 'string', description: 'pr create / comment — the markdown body. REQUIRED. Say what changed and why, and reference the commit.' },
1447
+ draft: { type: 'boolean', description: 'pr create only — open it as a draft. Default false.' },
1448
+ }
1449
+ : {};
1450
+ return [
1451
+ {
1452
+ type: 'function',
1453
+ function: {
1454
+ name: 'gh_issue',
1455
+ description: [
1456
+ 'Read GitHub issues: list them, read one\'s body with "view", or read its discussion with',
1457
+ '"comments". ⚠️ view and comments are SEPARATE calls on purpose — a real issue\'s body and',
1458
+ 'its comment thread together exceed what one tool result can carry.',
1459
+ policyLine,
1460
+ ].join(' '),
1461
+ parameters: {
1462
+ type: 'object',
1463
+ properties: {
1464
+ action: { type: 'string', enum: NOUNS.issue, description: '"list" for many, "view" for one issue\'s body, "comments" for its discussion. view and comments both need number.' },
1465
+ number: { type: 'number', description: 'Issue number. Required for view.' },
1466
+ state: { type: 'string', enum: ['open', 'closed', 'all'], description: 'Default open.' },
1467
+ limit: { type: 'number', description: `How many, 1–${MAX_GH_LIMIT} (default ${DEFAULT_GH_LIMIT}).` },
1468
+ labels: { type: 'array', items: { type: 'string' }, description: 'Only issues carrying all of these labels.' },
1469
+ assignee: { type: 'string', description: 'A GitHub login, or "@me".' },
1470
+ author: { type: 'string', description: 'A GitHub login, or "@me".' },
1471
+ search: { type: 'string', description: 'A GitHub search query, e.g. "sort:updated label:bug".' },
1472
+ repo: { type: 'string', description: 'Another repository as "owner/name". Omit for the one this workspace is in.' },
1473
+ ...(writes ? { body: writeProps.body } : {}),
1474
+ },
1475
+ required: ['action'],
1476
+ },
1477
+ },
1478
+ },
1479
+ {
1480
+ type: 'function',
1481
+ function: {
1482
+ name: 'gh_pr',
1483
+ description: [
1484
+ 'Read GitHub pull requests. ⭐ "comments" is the one you want when asked to address review',
1485
+ 'feedback: it returns the REVIEWS and the review COMMENTS. "view" is the body, the branches',
1486
+ 'and the changed files; "diff" is the patch; "checks" is the fastest look at whether CI is',
1487
+ 'green. ⚠️ view and comments are separate calls because together they do not fit.',
1488
+ policyLine,
1489
+ ].join(' '),
1490
+ parameters: {
1491
+ type: 'object',
1492
+ properties: {
1493
+ action: { type: 'string', enum: NOUNS.pr, description: '"list", "view" (body + files), "comments" (reviews + review comments), "diff" (the patch), "checks" (CI status).' },
1494
+ number: { type: 'number', description: 'Pull request number. Required for view, comments, diff and checks.' },
1495
+ state: { type: 'string', enum: ['open', 'closed', 'merged', 'all'], description: 'Default open. list only.' },
1496
+ limit: { type: 'number', description: `How many, 1–${MAX_GH_LIMIT} (default ${DEFAULT_GH_LIMIT}).` },
1497
+ head: { type: 'string', description: 'list only — the branch the PR is FROM. Use it to find the PR for a branch.' },
1498
+ base: { type: 'string', description: 'list only — the branch the PR merges INTO.' },
1499
+ author: { type: 'string', description: 'A GitHub login, or "@me".' },
1500
+ labels: { type: 'array', items: { type: 'string' }, description: 'Only pull requests carrying all of these labels.' },
1501
+ search: { type: 'string', description: 'A GitHub search query.' },
1502
+ repo: { type: 'string', description: 'Another repository as "owner/name".' },
1503
+ ...writeProps,
1504
+ },
1505
+ required: ['action'],
1506
+ },
1507
+ },
1508
+ },
1509
+ {
1510
+ type: 'function',
1511
+ function: {
1512
+ name: 'gh_run',
1513
+ description: [
1514
+ 'Read GitHub Actions runs: list recent runs, view one with its jobs, or — the useful one —',
1515
+ 'action "failed" to read the log of ONLY the steps that failed. That is how you find out why',
1516
+ 'CI went red without pulling megabytes of successful setup output.',
1517
+ policyLine,
1518
+ ].join(' '),
1519
+ parameters: {
1520
+ type: 'object',
1521
+ properties: {
1522
+ action: { type: 'string', enum: NOUNS.run, description: '"list", "view" (run + jobs), "failed" (log of the failed steps only).' },
1523
+ runId: { type: 'number', description: 'The run id (databaseId from list). Required for view and failed.' },
1524
+ job: { type: 'number', description: 'failed only — narrow to one job id from view.' },
1525
+ limit: { type: 'number', description: `How many, 1–${MAX_GH_LIMIT} (default ${DEFAULT_GH_LIMIT}).` },
1526
+ branch: { type: 'string', description: 'list only — runs for this branch.' },
1527
+ workflow: { type: 'string', description: 'list only — e.g. "ci.yml".' },
1528
+ status: { type: 'string', enum: [...RUN_STATUSES], description: 'list only — e.g. "failure" for just the red ones.' },
1529
+ repo: { type: 'string', description: 'Another repository as "owner/name".' },
1530
+ },
1531
+ required: ['action'],
1532
+ },
1533
+ },
1534
+ },
1535
+ ];
1536
+ }