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/git.mjs ADDED
@@ -0,0 +1,1341 @@
1
+ /**
2
+ * ── ⭐ GIT — THE AGENT CAN FINALLY SEE WHAT IT CHANGED ───────────────────────
3
+ *
4
+ * Until now the CLI wrote files and had no idea what it had done to the repo.
5
+ * That gap is bigger than it sounds, because it removes the one check a human
6
+ * developer performs constantly and for free: `git diff` before committing.
7
+ * Without it the model's only model of the working tree is its own memory of
8
+ * the files it wrote this session — which is wrong the moment anything existed
9
+ * before the session started.
10
+ *
11
+ * ── ⚠️ WHY GIT IS NOT A `run_command` BINARY ────────────────────────────────
12
+ * The obvious implementation is to add `git` to `ALLOWED_BINARIES`. It is the
13
+ * wrong one, twice over, and both reasons are structural rather than fussy:
14
+ *
15
+ * 1. **The character whitelist would make commit impossible.** `command.mjs`
16
+ * refuses quotes, so a commit message could never contain an apostrophe, a
17
+ * comma, a newline or a colon — i.e. could never be a commit message. The
18
+ * only way to allow one through a command STRING is to weaken the
19
+ * whitelist that protects every other command, for the sake of one verb.
20
+ * 2. **Git's surface is enormous and mostly destructive.** `reset --hard`,
21
+ * `checkout .`, `clean -fdx`, `push --force`, `filter-branch` — an agent
22
+ * with a git command string is one hallucination away from deleting work
23
+ * that was never its own. A flag denylist for a program with a thousand
24
+ * flags is a promise nobody can keep.
25
+ *
26
+ * ⭐ So git is exposed as STRUCTURED VERBS. The model supplies parameters, this
27
+ * file builds the exact `argv`, and there is no path from a model-authored
28
+ * string to a git subcommand it was not given. `push`, `reset`, `checkout`,
29
+ * `clean`, `rebase` and `remote` are not refused by a check — they are simply
30
+ * not expressible. That is the whitelist doctrine the rest of the package uses,
31
+ * applied where it matters most.
32
+ *
33
+ * ── ⚠️ THE SUBDIRECTORY TRAP, WHICH IS THE REAL BUG IN HERE ─────────────────
34
+ * `git` walks UP from its cwd to find a repository. Point this CLI at
35
+ * `~/work/monorepo/packages/thing` and every command silently operates on the
36
+ * whole monorepo: `git status` reports other people's work in progress, and a
37
+ * commit lands in a repository the user never pointed us at. Nothing errors.
38
+ *
39
+ * So every verb first resolves `--show-toplevel` and REFUSES unless it equals
40
+ * the workspace root. A workspace inside someone else's repo is a legitimate
41
+ * place to write code and never a legitimate place to commit from.
42
+ *
43
+ * ── ⚠️⚠️ AND THE SECOND ESCAPE, WHICH THAT ONE DOES NOT CATCH (fixed 08-11) ──
44
+ * Proving the REPO is the workspace says nothing about whether a PATH inside it
45
+ * really is. `gitCommit` validated its paths with `normalizeRelativePath` — the
46
+ * pure, LEXICAL half of the workspace guard, which by design never touches the
47
+ * disk and therefore cannot see a link. Measured in a scratch repo: a directory
48
+ * junction (`mklink /J`, no elevation) pointing out of the workspace let
49
+ * `paths: ['link/secret.txt']` commit an outside file, `ok: true`, and
50
+ * `git show HEAD:link/secret.txt` printed its contents.
51
+ *
52
+ * ⭐ So the containment every file verb enforces — `resolveInWorkspace`, i.e.
53
+ * realpath the deepest EXISTING ancestor and compare against the realpath'd root
54
+ * — is now applied in `gitCommit` too, and by the same means rather than a
55
+ * second invention. The full argument, including why it is not too tight, sits
56
+ * at the check itself; the bidirectional proof is
57
+ * `test/git-commit-containment.test.mjs`.
58
+ */
59
+
60
+ import { realpathSync, statSync } from 'node:fs';
61
+ import { isAbsolute, relative, resolve, sep } from 'node:path';
62
+
63
+ import { clampOutput, scrubEnvironment, spawnBounded } from './command.mjs';
64
+ import { normalizeRelativePath, resolveInWorkspace } from './workspace.mjs';
65
+
66
+ /**
67
+ * ⚠️ THE SHAPES ARE DECLARED, NOT INFERRED — the same rule `workspace.mjs` and
68
+ * `command.mjs` follow, and for the same reason. Left to inference, `ok` widens
69
+ * to `boolean` instead of the literals, so a caller that has ALREADY checked
70
+ * `if (!r.ok)` still cannot reach `r.error` without a cast. A discriminated
71
+ * union is what makes the refusal path type-safe at every call site.
72
+ *
73
+ * @typedef {{ ok: false, error: string }} GitRefused
74
+ * @typedef {{ ok: true, exitCode: number | null, stdout: string, stderr: string, timedOut: boolean }} GitRan
75
+ * @typedef {(file: string, args: string[], opts: object) => any} SpawnImpl
76
+ * @typedef {{ spawnImpl?: SpawnImpl }} GitOpts
77
+ */
78
+
79
+ /** Git is fast or wedged; there is no slow-but-fine case worth waiting on. */
80
+ export const GIT_TIMEOUT_MS = 20_000;
81
+ /** A diff is the output most likely to be enormous, and the model pays per token. */
82
+ export const MAX_DIFF_CHARS = 12_000;
83
+ export const MAX_COMMIT_MESSAGE_CHARS = 4_000;
84
+ export const MAX_COMMIT_PATHS = 50;
85
+ /** More than this and the answer is "read the log yourself". */
86
+ export const MAX_LOG_COUNT = 50;
87
+
88
+ /**
89
+ * ⚠️ NEVER STAGED, WHATEVER THE MODEL ASKS OR `.gitignore` SAYS.
90
+ *
91
+ * The failure this prevents: the agent writes a `.env` so the app it just built
92
+ * can run, then helpfully commits "all the project files". The secret is now in
93
+ * history — a place from which deleting it does not remove it — and the repo may
94
+ * be pushed by a human later who has no idea it is in there.
95
+ *
96
+ * Git already refuses ignored files without `-f` (which is never passed), so
97
+ * this only fires when the file is NOT ignored, which is exactly the dangerous
98
+ * case: the repository has no protection and nobody noticed.
99
+ */
100
+ /**
101
+ * ⚠️⚠️ THIS IS THE ONE LIST. `read-window.mjs` used to keep a second one
102
+ * (`CREDENTIAL_BASENAME`) for `read_lines` and `read_around`, written
103
+ * separately, and the two DISAGREED. Measured 2026-08-13 through the real
104
+ * dispatcher:
105
+ *
106
+ * read_lines LEAKED : vault.pfx · keys.jks · secrets.json · credentials.yml
107
+ * · service-account.json
108
+ * read_file LEAKED : .git-credentials
109
+ *
110
+ * Each list covered holes the other left, so which of a user's secrets were
111
+ * protected depended on which verb the model happened to pick. Both consumers
112
+ * now call `refusedCommitPath`, and `.git-credentials` — which only the other
113
+ * list had — is folded in below. Add the next pattern HERE and every consumer
114
+ * gains it at once.
115
+ */
116
+ /**
117
+ * ⚠️ THE LIST MOVED TO `secret-paths.mjs`, A LEAF MODULE, and this file now
118
+ * IMPORTS it like everyone else. `workspace.mjs` needs it for `move_file` — a
119
+ * rename is the one verb that can relabel `.env` into something committable —
120
+ * and importing it from here closed a cycle the bundler cannot order:
121
+ * command.mjs → workspace.mjs → git.mjs → command.mjs
122
+ *
123
+ * ⚠️ AND IT IS NOT RE-EXPORTED FROM HERE. `export { x } from` is a form
124
+ * `scripts/bundle.mjs` refuses outright ("a bundler that half-supports a form
125
+ * is how a bundle ends up one byte wrong"), so every consumer imports the leaf
126
+ * directly. Caught by the bundle test, not by the suite — Node runs a cycle
127
+ * and a re-export happily, so nothing looked wrong until ship time.
128
+ */
129
+ import { refusedCommitPath } from './secret-paths.mjs';
130
+
131
+ /**
132
+ * ⚠️ THE GIT ENVIRONMENT, AND WHY IT IS MORE LOCKED DOWN THAN THE COMMAND ONE.
133
+ *
134
+ * `scrubEnvironment` already removes conventionally-named secrets. On top of
135
+ * that:
136
+ *
137
+ * · `GIT_TERMINAL_PROMPT=0` — git asking for a username on a machine with no
138
+ * terminal attached is a process that hangs until the timeout kills it. Every
139
+ * credential prompt becomes an immediate, legible error instead.
140
+ * · `GIT_PAGER=cat` + `--no-pager` — a pager waits for a keypress that will
141
+ * never come. Same hang, different cause, so both are closed.
142
+ * · `GIT_OPTIONAL_LOCKS=0` — a plain `status` should not take the index lock
143
+ * and fight the editor the user has open in the same repo.
144
+ *
145
+ * ⚠️⚠️ THIS PARAGRAPH USED TO SAY GIT HERE HAD "NOWHERE TO SEND ANYTHING".
146
+ * That stopped being true on 2026-08-14 when `git_push` landed. It is still
147
+ * true of THIS function: `gitEnvironment` is the environment for every LOCAL
148
+ * verb, and a local verb has no business holding a credential. The one verb
149
+ * that leaves the machine builds its own environment in `pushEnvironment`
150
+ * below, restores exactly one variable, and says why.
151
+ */
152
+ export function gitEnvironment(env = process.env) {
153
+ const out = scrubEnvironment(env);
154
+ out.GIT_TERMINAL_PROMPT = '0';
155
+ out.GIT_PAGER = 'cat';
156
+ out.GIT_OPTIONAL_LOCKS = '0';
157
+ return out;
158
+ }
159
+
160
+ /**
161
+ * ── ⚠️⚠️ THE SCRUB DELETES `SSH_AUTH_SOCK`, AND PUSH IS THE VERB THAT NEEDS IT ──
162
+ *
163
+ * `SECRET_NAME` in command.mjs is `/(KEY|TOKEN|SECRET|…|AUTH|…)/i` — read it at
164
+ * `command.mjs:814`. `SSH_AUTH_SOCK` contains "AUTH", so `scrubEnvironment`
165
+ * drops it, and every other verb in this file is happier for that. A push over
166
+ * `git@github.com:` with no agent socket does not hang (GIT_TERMINAL_PROMPT=0)
167
+ * — it fails with "Permission denied (publickey)", which reads like a broken
168
+ * key rather than an environment we emptied ourselves.
169
+ *
170
+ * ⭐ SO PUSH GETS THE SOCKET BACK AND NOTHING ELSE. The value is a path to a
171
+ * unix socket / named pipe, not a secret; the agent behind it will only ever
172
+ * sign a challenge for a process that can reach it, and the argv reaching it
173
+ * was built by this file. HTTPS remotes need nothing here — git's credential
174
+ * helper is a separate program driven by config, not by our environment — and
175
+ * `GITHUB_TOKEN` stays deleted, because git does not read it and `github.mjs`
176
+ * fetches its own copy for the API call.
177
+ *
178
+ * ⚠️ HONEST LIMIT: this is REASONED, not measured. The push proof in
179
+ * `test/git-deliver.test.mjs` pushes to a LOCAL BARE REPOSITORY, which needs no
180
+ * credential at all, so it does not exercise SSH. What IS measured is the shape
181
+ * of the environment — `test/git-deliver.test.mjs` asserts the socket survives
182
+ * and `OPENROUTER_API_KEY` does not.
183
+ */
184
+ export const PUSH_ENV_KEEP = Object.freeze(['SSH_AUTH_SOCK', 'SSH_AGENT_PID']);
185
+
186
+ export function pushEnvironment(env = process.env) {
187
+ const out = gitEnvironment(env);
188
+ for (const name of PUSH_ENV_KEEP) {
189
+ const value = env[name];
190
+ if (typeof value === 'string' && value !== '') out[name] = value;
191
+ }
192
+ return out;
193
+ }
194
+
195
+ /**
196
+ * Run git with an argv this file constructed. `args` never contains anything
197
+ * derived from a model string except as a SEPARATE ARRAY ELEMENT, which is
198
+ * where the safety comes from: an element is one argument no matter what is in
199
+ * it, because no shell exists to re-split it.
200
+ */
201
+ /** @returns {Promise<GitRefused | GitRan>} */
202
+ async function git(root, args, { spawnImpl, timeoutMs = GIT_TIMEOUT_MS, env = null } = {}) {
203
+ const run = await spawnBounded({
204
+ file: 'git',
205
+ args: ['--no-pager', ...args],
206
+ cwd: root,
207
+ timeoutMs,
208
+ spawnImpl,
209
+ // ⚠️ `env` IS AN OVERRIDE, NOT A REPLACEMENT OF THE RULE. Every local verb
210
+ // gets `gitEnvironment()`; only `gitPush` passes anything, and what it
211
+ // passes is `pushEnvironment()` — which is `gitEnvironment()` plus the ssh
212
+ // agent socket. There is no path here to an unscrubbed environment.
213
+ env: env ?? gitEnvironment(),
214
+ });
215
+ if (!run.ok) {
216
+ /**
217
+ * ⚠️ ENOENT HERE MEANS GIT IS NOT INSTALLED, and the raw spawn error says
218
+ * "spawn git ENOENT", which reads like a bug in this CLI rather than a
219
+ * missing program. Translate it once, here.
220
+ */
221
+ const missing = /ENOENT/.test(run.error ?? '');
222
+ return { ok: false, error: missing ? 'git is not installed, or not on PATH for this process' : run.error };
223
+ }
224
+ return {
225
+ ok: true,
226
+ exitCode: run.exitCode,
227
+ stdout: run.stdout,
228
+ stderr: run.stderr,
229
+ timedOut: run.timedOut,
230
+ };
231
+ }
232
+
233
+ /**
234
+ * ── ⚠️⚠️ `realpath` DOES NOT EXPAND AN 8.3 SHORT NAME, AND GIT RETURNS THE LONG ONE ──
235
+ *
236
+ * Measured on Windows 2026-08-13, and it is not a corner case:
237
+ *
238
+ * C:\...\Temp\ACUVOR~1 (the 8.3 alias, what the caller had)
239
+ * realpathSync(...) -> C:\...\Temp\ACUVOR~1 ← unchanged
240
+ * git rev-parse -> C:/.../Temp/acuvorunneradminprobe
241
+ *
242
+ * Two spellings of ONE directory, and the string compare above — case-folded,
243
+ * realpath'd, and written precisely to avoid being "too literal" — still calls
244
+ * them different. Every git verb is then refused with "this workspace is INSIDE
245
+ * a git repository rooted at …", naming a path that IS the workspace.
246
+ *
247
+ * ⭐ WHO HITS IT: anyone reached through an 8.3 alias, which Windows generates
248
+ * for any name over eight characters. `C:\Users\<name>` longer than eight,
249
+ * `C:\PROGRA~1`, and — the reason this surfaced at all — GitHub's own Windows
250
+ * runner, where `runneradmin` is `RUNNER~1`. It broke SEVEN tests the moment
251
+ * the Windows matrix first executed.
252
+ *
253
+ * ⭐ THE FIX IS TO STOP COMPARING SPELLINGS. The question was never "are these
254
+ * strings equal", it is "are these the same directory", and the filesystem
255
+ * answers that directly: device + inode. Node exposes both on Windows (the
256
+ * inode is the NTFS file index), so this needs no dependency and no platform
257
+ * branch. `bigint: true` because a 64-bit file index does not survive a double.
258
+ *
259
+ * ⚠️ It can only ever turn a refusal INTO an allow, and only when the two paths
260
+ * are provably one directory — a genuine outer repository is a different
261
+ * directory with a different index, so the guard this backs up is untouched.
262
+ * Anything unreadable, or a filesystem that reports no inode at all (0 on some
263
+ * network and FAT volumes), falls back to the string compare rather than
264
+ * guessing: unknown identity must not read as "same".
265
+ */
266
+ function sameDirectoryOnDisk(a, b) {
267
+ try {
268
+ const sa = statSync(a, { bigint: true });
269
+ const sb = statSync(b, { bigint: true });
270
+ if (sa.ino === 0n || sb.ino === 0n) return false;
271
+ return sa.dev === sb.dev && sa.ino === sb.ino;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Resolve the repository and prove it is THIS workspace — see the subdirectory
279
+ * trap in the header. Every verb goes through here first.
280
+ *
281
+ * ── ⚠️⚠️ THE GUARD WAS APPLIED TO THE READS TOO, AND THAT COST A REAL USER
282
+ * THEIR WHOLE GIT SURFACE (fixed 2026-08-14) ────────────────────────
283
+ *
284
+ * MEASURED, in this very checkout, before the fix — `acuvo-code/` is a
285
+ * subdirectory of the outer repository:
286
+ *
287
+ * gitStatus(root) -> ok:false "this workspace is INSIDE a git repository…"
288
+ * gitDiff(root) -> ok:false "this workspace is INSIDE a git repository…"
289
+ * gitLog(root) -> ok:false
290
+ * gitCommit(root) -> ok:false
291
+ *
292
+ * Four dark verbs, for a workspace layout that is not exotic — it is what every
293
+ * monorepo package looks like. And the two that hurt most are the READS: an
294
+ * agent that just edited three files could not look at its own diff.
295
+ *
296
+ * ⭐ THE GUARD'S ARGUMENT ONLY EVER APPLIED TO WRITING. Re-read the header: the
297
+ * danger is "a commit lands in a repository the user never pointed us at" and
298
+ * "status reports other people's work in progress". The first is about writing
299
+ * history. The second is not an argument for refusing — it is an argument for
300
+ * SCOPING, which git does natively and for free: run from the workspace with a
301
+ * `.` pathspec and the answer is about the workspace and nothing else.
302
+ *
303
+ * So: `allowSubdirectory: true` is passed by `gitStatus`, `gitDiff` and
304
+ * `gitLog`, which then scope every question to the workspace directory and
305
+ * report paths relative to it. The write verbs — `gitCommit`, `gitBranch`,
306
+ * `gitPush` — leave it false and still refuse outright, because a commit, a
307
+ * ref and a push are repository-wide acts that cannot be scoped to a
308
+ * subdirectory even in principle.
309
+ *
310
+ * ⚠️ WHY THE READS ARE NOT A LEAK EITHER. Scoped to `.`, they can only report
311
+ * files inside the workspace — the same files `read_file` would happily open.
312
+ * The refusal was not protecting anything the file tools were not already
313
+ * handing over.
314
+ */
315
+ /**
316
+ * @param {string} root
317
+ * @param {GitOpts & { allowSubdirectory?: boolean }} [opts]
318
+ * @returns {Promise<GitRefused | { ok: true, root: string, subdirectory: string }>}
319
+ */
320
+ export async function resolveRepo(root, { spawnImpl, allowSubdirectory = false } = {}) {
321
+ const top = await git(root, ['rev-parse', '--show-toplevel'], { spawnImpl });
322
+ if (!top.ok) return top;
323
+ if (top.exitCode !== 0) {
324
+ return { ok: false, error: 'this workspace is not a git repository, so there is nothing to inspect or commit' };
325
+ }
326
+ const reported = top.stdout.trim();
327
+ if (!reported) return { ok: false, error: 'git did not report a repository root' };
328
+
329
+ /**
330
+ * ⚠️ COMPARED THROUGH `realpath`, NOT AS STRINGS. Git prints forward slashes
331
+ * on Windows and resolves symlinks; the workspace root may be `C:\...` and a
332
+ * symlinked path besides. A string compare would report a MISMATCH for the
333
+ * ordinary case and refuse to work at all — the failure mode of a safety
334
+ * check that is too literal is that it gets deleted.
335
+ */
336
+ let a; let b;
337
+ try {
338
+ a = realpathSync(resolve(reported));
339
+ b = realpathSync(resolve(root));
340
+ } catch (err) {
341
+ return { ok: false, error: `could not resolve the repository path: ${err instanceof Error ? err.message : String(err)}` };
342
+ }
343
+ const same = (process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b)
344
+ || sameDirectoryOnDisk(a, b);
345
+ if (!same && allowSubdirectory) {
346
+ /**
347
+ * ⭐ THE READ PATH. The workspace is somewhere inside the repository, which
348
+ * is the ordinary monorepo-package shape. Work out where, so the caller can
349
+ * scope its question to `.` and strip the prefix off what git reports.
350
+ *
351
+ * ⚠️ `relative()` IS ASKED IN THE REALPATH'D WORLD, both sides, so a repo
352
+ * under a junctioned home directory does not read as an escape — the same
353
+ * reason the compare above is realpath'd.
354
+ *
355
+ * ⚠️ AND IT CAN STILL SAY NO. If the workspace does not resolve INSIDE the
356
+ * reported repository root, this is not a subdirectory at all: git found a
357
+ * repository by walking up from a path that realpath moved elsewhere. That
358
+ * is the one shape where scoping would be a guess, so it keeps the refusal.
359
+ */
360
+ const rel = relative(a, b);
361
+ if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
362
+ return { ok: true, root: a, subdirectory: rel.split(sep).join('/') };
363
+ }
364
+ }
365
+ if (!same) {
366
+ /**
367
+ * ⚠️ IT NAMES BOTH SIDES, AND THAT IS NOT DECORATION.
368
+ *
369
+ * This refusal used to print only the repository root. When it fired on a
370
+ * Windows CI runner against a repo that WAS its own root, the message said
371
+ * "you are inside a repository rooted at X" where X was, to the eye, the
372
+ * workspace itself — an accusation with no way to check it, about the one
373
+ * mechanism that gates every git verb. The comparison is between two
374
+ * realpath'd strings, so the only useful thing to print is BOTH of them.
375
+ *
376
+ * ⭐ A safety check that is too literal gets deleted, and the step before
377
+ * deletion is a user who cannot tell whether it is right.
378
+ */
379
+ return {
380
+ ok: false,
381
+ error:
382
+ `this workspace is INSIDE a git repository rooted at ${reported}, not at the workspace root. `
383
+ + 'Writing verbs are refused here: a commit, a branch or a push is repository-wide, and would '
384
+ + 'act on the whole outer repository, which nobody pointed this agent at. '
385
+ + 'git_status, git_diff and git_log still work — they are scoped to this directory. '
386
+ + `(repository root resolves to "${a}"; the workspace root resolves to "${b}")`,
387
+ };
388
+ }
389
+ return { ok: true, root: b, subdirectory: '' };
390
+ }
391
+
392
+ /** Parse `status --porcelain=v1` into something a model can reason about. */
393
+ export function parseStatus(porcelain) {
394
+ const files = [];
395
+ for (const raw of String(porcelain).split('\n')) {
396
+ if (raw.length < 4) continue;
397
+ const x = raw[0];
398
+ const y = raw[1];
399
+ let path = raw.slice(3);
400
+ // A rename is printed as `old -> new`; the new name is the useful one.
401
+ const arrow = path.indexOf(' -> ');
402
+ if (arrow !== -1) path = path.slice(arrow + 4);
403
+ files.push({
404
+ path: path.replace(/^"|"$/g, ''),
405
+ staged: x !== ' ' && x !== '?',
406
+ // `??` is untracked — new work, which is the common case for this agent
407
+ // and the one a model most often forgets it has to `add`.
408
+ untracked: x === '?' && y === '?',
409
+ code: `${x}${y}`,
410
+ });
411
+ }
412
+ return files;
413
+ }
414
+
415
+ /**
416
+ * @param {string} root
417
+ * @param {GitOpts} [opts]
418
+ * @returns {Promise<GitRefused | { ok: true, branch: string, files: {path:string,staged:boolean,untracked:boolean,code:string}[], clean: boolean }>}
419
+ */
420
+ export async function gitStatus(root, { spawnImpl } = {}) {
421
+ const repo = await resolveRepo(root, { spawnImpl, allowSubdirectory: true });
422
+ if (!repo.ok) return repo;
423
+
424
+ /**
425
+ * ⭐ `-- .` IS THE WHOLE CONTAINMENT STORY FOR A READ. Pathspecs are resolved
426
+ * relative to the process cwd, and cwd is the workspace, so this asks about
427
+ * the workspace directory and nothing else. At the repository root the
428
+ * pathspec is omitted entirely, so the ordinary case is byte-identical to
429
+ * what this function ran before.
430
+ */
431
+ const statusArgs = ['status', '--porcelain=v1'];
432
+ if (repo.subdirectory) statusArgs.push('--', '.');
433
+
434
+ const [status, branch] = await Promise.all([
435
+ git(root, statusArgs, { spawnImpl }),
436
+ git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { spawnImpl }),
437
+ ]);
438
+ if (!status.ok) return status;
439
+ if (status.exitCode !== 0) return { ok: false, error: status.stderr.trim() || 'git status failed' };
440
+
441
+ /**
442
+ * ⚠️ PORCELAIN PATHS ARE REPOSITORY-RELATIVE EVEN WITH A PATHSPEC, and there
443
+ * is no `--relative` for `status` (the config knob only touches the long
444
+ * format). So a workspace at `packages/thing` would be handed
445
+ * `packages/thing/src/a.js` — a path `read_file` refuses, because from the
446
+ * workspace the file is `src/a.js`. Stripping the prefix is what makes the
447
+ * result usable by the very next tool call, which is the only reason a status
448
+ * is worth paying for.
449
+ */
450
+ const prefix = repo.subdirectory ? `${repo.subdirectory}/` : '';
451
+ const files = parseStatus(status.stdout).map((f) => (
452
+ prefix && f.path.startsWith(prefix) ? { ...f, path: f.path.slice(prefix.length) } : f
453
+ ));
454
+ return {
455
+ ok: true,
456
+ // ⭐ REPORTED, not hidden. The model is being told about a slice of a bigger
457
+ // repository, and a slice presented as the whole is how a wrong conclusion
458
+ // gets drawn confidently.
459
+ subdirectory: repo.subdirectory || null,
460
+ // ⚠️ A repository with no commits reports a branch name but `rev-parse`
461
+ // fails; an empty string here is honest and the caller renders "(no
462
+ // commits yet)" rather than printing an error for a normal new repo.
463
+ branch: branch.ok && branch.exitCode === 0 ? branch.stdout.trim() : '',
464
+ files,
465
+ clean: files.length === 0,
466
+ };
467
+ }
468
+
469
+ /**
470
+ * @param {string} root
471
+ * @param {{ path?: string | null, staged?: boolean } & GitOpts} [opts]
472
+ * @returns {Promise<GitRefused | { ok: true, staged: boolean, path: string | null, diff: string, truncated: boolean, empty: boolean }>}
473
+ */
474
+ export async function gitDiff(root, { path = null, staged = false, spawnImpl } = {}) {
475
+ const repo = await resolveRepo(root, { spawnImpl, allowSubdirectory: true });
476
+ if (!repo.ok) return repo;
477
+
478
+ const args = ['diff', '--no-color'];
479
+ if (staged) args.push('--cached');
480
+ /**
481
+ * ── ⭐ `--relative` DOES BOTH JOBS, AND I ONLY BELIEVED THAT AFTER MEASURING ─
482
+ *
483
+ * A diff's paths live inside the PATCH TEXT (`--- a/packages/thing/a.js`), so
484
+ * unlike the status case they cannot be fixed up afterwards — rewriting patch
485
+ * text with a string replace is how a diff stops applying.
486
+ *
487
+ * ⚠️ I FIRST WROTE THIS AS `--relative` PLUS A `-- .` PATHSPEC, on the theory
488
+ * that `--relative` only renames headers and something else had to do the
489
+ * containment. Measured, cwd = `packages/thing`, git 2.50.1, with a change in
490
+ * BOTH `outer.md` and `packages/thing/a.js`:
491
+ *
492
+ * git diff -> both files, repo-relative headers
493
+ * git diff --relative -> a.js only, header `a/a.js` ← both jobs
494
+ * git diff -- . -> a.js only, header `a/packages/thing/a.js`
495
+ * git diff --relative -- . -> identical to `--relative` alone
496
+ *
497
+ * ⭐ So the pathspec was a second mechanism with NO observable effect — and a
498
+ * mutation test proved it: deleting it left all 21 tests green, which is the
499
+ * definition of a line nothing can hold to account. It is gone. `--relative`
500
+ * excludes changes outside the current directory by itself, which is exactly
501
+ * the containment we wanted, and the one line is falsifiable.
502
+ *
503
+ * ⚠️ Only when we are actually in a subdirectory. At the root it is a no-op,
504
+ * and a no-op flag added "for consistency" is a behaviour change waiting to
505
+ * be discovered by somebody else.
506
+ */
507
+ if (repo.subdirectory) args.push('--relative');
508
+ // ⚠️ "." IS HOW A MODEL SAYS "EVERYTHING", and normalizeRelativePath refuses
509
+ // it ("resolves to the workspace root itself") — correctly, for a file tool.
510
+ // Here it means the default, so it is translated rather than rejected with a
511
+ // message the model would try to work around.
512
+ if (path !== null && path !== undefined && path !== '' && path !== '.' && path !== './') {
513
+ const norm = normalizeRelativePath(path);
514
+ if (!norm.ok) return { ok: false, error: `"${path}" is not a usable path: ${norm.reason}` };
515
+ // ⚠️ `--` FIRST. Without it a path that begins with a dash, or that happens
516
+ // to equal a branch name, is parsed as a revision — and `git diff main`
517
+ // means something entirely different from `git diff -- main`.
518
+ args.push('--', norm.path);
519
+ }
520
+ const run = await git(root, args, { spawnImpl });
521
+ if (!run.ok) return run;
522
+ if (run.exitCode !== 0) return { ok: false, error: run.stderr.trim() || 'git diff failed' };
523
+
524
+ const clamped = clampOutput(run.stdout, MAX_DIFF_CHARS);
525
+ return {
526
+ ok: true,
527
+ staged,
528
+ path: path ?? null,
529
+ subdirectory: repo.subdirectory || null,
530
+ diff: clamped.text,
531
+ truncated: clamped.truncated,
532
+ // ⚠️ An empty diff is a RESULT, not a failure — and specifically it is the
533
+ // answer to "did my edit apply?", so it must be reported as a fact rather
534
+ // than as an absence the model has to infer from a blank string.
535
+ empty: run.stdout.trim() === '',
536
+ };
537
+ }
538
+
539
+ /**
540
+ * @param {string} root
541
+ * @param {{ count?: number, path?: string | null } & GitOpts} [opts]
542
+ * @returns {Promise<GitRefused | { ok: true, commits: {hash:string,author:string,when:string,subject:string}[], empty: boolean }>}
543
+ */
544
+ export async function gitLog(root, { count = 10, path = null, spawnImpl } = {}) {
545
+ const repo = await resolveRepo(root, { spawnImpl, allowSubdirectory: true });
546
+ if (!repo.ok) return repo;
547
+
548
+ const n = Math.min(Math.max(1, Number.isFinite(count) ? Math.floor(count) : 10), MAX_LOG_COUNT);
549
+ // Unit-separator delimited rather than a pretty format with spaces: a subject
550
+ // line contains anything, including whatever character seemed safe to split on.
551
+ const args = ['log', `-n${n}`, '--no-color', '--pretty=format:%h\u001f%an\u001f%ar\u001f%s'];
552
+ if (path) {
553
+ const norm = normalizeRelativePath(path);
554
+ if (!norm.ok) return { ok: false, error: `"${path}" is not a usable path: ${norm.reason}` };
555
+ args.push('--', norm.path);
556
+ } else if (repo.subdirectory) {
557
+ // ⭐ "recent commits" in a monorepo package means recent commits TO THIS
558
+ // PACKAGE. The whole-repo log is somebody else's history and would teach the
559
+ // model the wrong commit-message convention for the directory it is in.
560
+ args.push('--', '.');
561
+ }
562
+ const run = await git(root, args, { spawnImpl });
563
+ if (!run.ok) return run;
564
+ if (run.exitCode !== 0) {
565
+ const err = run.stderr.trim();
566
+ // A brand-new repo has no HEAD; that is not an error worth alarming about.
567
+ if (/does not have any commits yet|unknown revision/i.test(err)) {
568
+ return { ok: true, commits: [], empty: true, subdirectory: repo.subdirectory || null };
569
+ }
570
+ return { ok: false, error: err || 'git log failed' };
571
+ }
572
+ const commits = run.stdout
573
+ .split('\n')
574
+ .filter(Boolean)
575
+ .map((line) => {
576
+ const [hash, author, when, ...subject] = line.split('\u001f');
577
+ return { hash, author, when, subject: subject.join('\u001f') };
578
+ });
579
+ return { ok: true, commits, empty: commits.length === 0, subdirectory: repo.subdirectory || null };
580
+ }
581
+
582
+ /**
583
+ * Validate a model-authored commit message.
584
+ *
585
+ * Pure, and stricter than git is: git accepts almost anything, but a message
586
+ * that starts with `-` becomes a flag at some future call site, and control
587
+ * characters make a log unreadable in ways nobody debugs.
588
+ */
589
+ /**
590
+ * @param {unknown} raw
591
+ * @returns {{ ok: true, message: string } | GitRefused}
592
+ */
593
+ export function validateCommitMessage(raw) {
594
+ if (typeof raw !== 'string') return { ok: false, error: 'a commit message is required' };
595
+ const message = raw.trim();
596
+ if (!message) return { ok: false, error: 'the commit message is empty — say what changed and why' };
597
+ if (message.length > MAX_COMMIT_MESSAGE_CHARS) {
598
+ return { ok: false, error: `the commit message is ${message.length} characters, over the ${MAX_COMMIT_MESSAGE_CHARS} limit` };
599
+ }
600
+ if (message.startsWith('-')) {
601
+ return { ok: false, error: 'a commit message may not start with "-" — it would be read as a flag' };
602
+ }
603
+ // eslint-disable-next-line no-control-regex
604
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/.test(message)) {
605
+ return { ok: false, error: 'the commit message contains control characters (newlines and tabs are fine, nothing else)' };
606
+ }
607
+ return { ok: true, message };
608
+ }
609
+
610
+ /**
611
+ * Stage the named paths and commit them.
612
+ *
613
+ * ── ⚠️ WHY `paths` IS REQUIRED AND THERE IS NO "COMMIT EVERYTHING" ──────────
614
+ * `git add -A` is one keystroke for a human who can see the file list, and a
615
+ * loaded gun for an agent that cannot. It sweeps up the scratch file, the
616
+ * accidental 40MB fixture, the other lane's half-finished work in the same
617
+ * checkout, and the `.env` written two tool calls ago. Requiring the paths
618
+ * forces the model to have looked — which is the behaviour we want anyway, and
619
+ * `git_status` is right there.
620
+ */
621
+ /**
622
+ * @param {string} root
623
+ * @param {{ message?: unknown, paths?: unknown, dryRun?: boolean } & GitOpts} [opts]
624
+ * @returns {Promise<GitRefused | { ok: true, hash: string, message: string, files: string[], fileCount: number }>}
625
+ */
626
+ export async function gitCommit(root, { message, paths, spawnImpl, dryRun = false } = {}) {
627
+ const repo = await resolveRepo(root, { spawnImpl });
628
+ if (!repo.ok) return repo;
629
+
630
+ const valid = validateCommitMessage(message);
631
+ if (!valid.ok) return valid;
632
+
633
+ if (!Array.isArray(paths) || paths.length === 0) {
634
+ return { ok: false, error: 'paths is required — name the files to commit. Call git_status first; there is no "commit everything".' };
635
+ }
636
+ if (paths.length > MAX_COMMIT_PATHS) {
637
+ return { ok: false, error: `${paths.length} paths is over the ${MAX_COMMIT_PATHS} limit for one commit` };
638
+ }
639
+
640
+ const clean = [];
641
+ for (const p of paths) {
642
+ const norm = normalizeRelativePath(p);
643
+ if (!norm.ok) return { ok: false, error: `"${p}" is not a usable path: ${norm.reason}` };
644
+ const refused = refusedCommitPath(norm.path);
645
+ if (refused) return { ok: false, error: refused };
646
+ /**
647
+ * ── ⚠️⚠️ THE CONTAINMENT CHECK, AND WHY IT CANNOT BE THE LEXICAL ONE ─────
648
+ *
649
+ * `normalizeRelativePath` above is PURE — it never touches the disk, so it
650
+ * refuses `../x` and `C:\x` and cannot possibly see that `link/` is a
651
+ * junction to somewhere else. For months that was the only guard on the one
652
+ * verb in this package that writes PERMANENT HISTORY, while `write_file`,
653
+ * `read_file` and `delete_file` all went through `resolveInWorkspace`.
654
+ *
655
+ * ⭐ Measured on 2026-08-11, `mklink /J` needing no elevation:
656
+ * repo/link -> ../outside ; gitCommit(paths:['link/secret.txt'])
657
+ * -> ok:true, and `git show HEAD:link/secret.txt` printed the outside
658
+ * file. Deleting it afterwards does not remove it from history.
659
+ *
660
+ * ⚠️ ON POSIX `git add` REFUSES "beyond a symbolic link" ITSELF, which is
661
+ * exactly why this went unnoticed: git covered for the missing guard on the
662
+ * platform nobody here runs on. A Windows directory junction is not a
663
+ * symlink to git — it walks through it as an ordinary directory.
664
+ *
665
+ * So the answer is the SAME mechanism the file tools use, not a second
666
+ * invention: realpath the deepest EXISTING ancestor and compare against the
667
+ * realpath'd root. That is also what keeps it from being too tight —
668
+ * · the root is realpath'd too, so a repo under a junctioned/symlinked home
669
+ * directory is normal rather than an escape;
670
+ * · a path that does not exist is contained by its parent, so staging a
671
+ * DELETION (the file is gone — that IS the commit) still works;
672
+ * · `isInside` folds case, so a drive letter in the other case is not a
673
+ * mismatch;
674
+ * · a link pointing back INSIDE the workspace resolves inside and is allowed.
675
+ *
676
+ * ⚠️ Intent is 'read', deliberately. 'write' would additionally refuse
677
+ * `node_modules/`, `.next/` and `.vercel/` with "it executes code on the
678
+ * owner's next command" — true of writing a file there and NOT true of
679
+ * recording one in history, and a repository that deliberately vendors its
680
+ * dependencies is somebody's real, legitimate commit. This check is about
681
+ * containment and nothing else.
682
+ *
683
+ * ⚠️ And it runs in the validation loop, BEFORE `git add`. Refusing after
684
+ * staging would leave the good paths of a mixed commit sitting in the index
685
+ * for the next commit to sweep up silently.
686
+ */
687
+ const contained = resolveInWorkspace(repo.root, norm.path, 'read');
688
+ if (!contained.ok) {
689
+ return {
690
+ ok: false,
691
+ error:
692
+ `"${p}" cannot be committed: ${contained.reason}. It resolves outside the workspace `
693
+ + `(${repo.root}) once links are followed, and committing it would put a file nobody `
694
+ + 'pointed this agent at into permanent history. Commit only paths that live inside the workspace.',
695
+ };
696
+ }
697
+ clean.push(norm.path);
698
+ }
699
+
700
+ if (dryRun) {
701
+ return { ok: false, error: 'this is a --dry-run, so nothing is staged or committed' };
702
+ }
703
+
704
+ // `--` again: a path called `main` must not be read as a revision.
705
+ const add = await git(root, ['add', '--', ...clean], { spawnImpl });
706
+ if (!add.ok) return add;
707
+ if (add.exitCode !== 0) {
708
+ const err = add.stderr.trim();
709
+ /**
710
+ * ⚠️ THE IGNORED-FILE REFUSAL IS A FEATURE AND MUST READ LIKE ONE. Git's own
711
+ * message suggests `-f`, which this agent will never pass; left unrewritten
712
+ * the model reads the hint, tries to obey it, and burns a round discovering
713
+ * the flag does not exist here.
714
+ */
715
+ if (/ignored by one of your \.gitignore|is ignored/i.test(err)) {
716
+ return { ok: false, error: `${err}\n\nThat file is gitignored and this agent never force-adds — leave it out of the commit.` };
717
+ }
718
+ return { ok: false, error: err || 'git add failed' };
719
+ }
720
+
721
+ const staged = await git(root, ['diff', '--cached', '--name-only'], { spawnImpl });
722
+ const stagedFiles = staged.ok ? staged.stdout.split('\n').map((s) => s.trim()).filter(Boolean) : [];
723
+ if (stagedFiles.length === 0) {
724
+ /**
725
+ * ⚠️ CAUGHT BEFORE COMMITTING, because `git commit` with nothing staged
726
+ * exits non-zero with a wall of advice, and the model reads a failed commit
727
+ * as "something broke" when the truth is "those files are already
728
+ * committed, unchanged" — a completely different next action.
729
+ */
730
+ return { ok: false, error: 'nothing to commit: those paths have no changes staged (they may already be committed, or identical to HEAD)' };
731
+ }
732
+
733
+ const commit = await git(root, ['commit', '-m', valid.message], { spawnImpl });
734
+ if (!commit.ok) return commit;
735
+ if (commit.exitCode !== 0) {
736
+ const err = `${commit.stdout}\n${commit.stderr}`.trim();
737
+ if (/Please tell me who you are|unable to auto-detect email|empty ident name/i.test(err)) {
738
+ return {
739
+ ok: false,
740
+ error: 'git has no author identity configured on this machine, so it cannot record a commit. Set user.name and user.email in your git config and ask again.',
741
+ };
742
+ }
743
+ return { ok: false, error: err || 'git commit failed' };
744
+ }
745
+
746
+ const head = await git(root, ['rev-parse', '--short', 'HEAD'], { spawnImpl });
747
+ return {
748
+ ok: true,
749
+ hash: head.ok && head.exitCode === 0 ? head.stdout.trim() : '',
750
+ message: valid.message,
751
+ files: stagedFiles,
752
+ // ⭐ Reported because it is the number a human checks first, and because it
753
+ // differs from `paths.length` whenever a named path was already clean.
754
+ fileCount: stagedFiles.length,
755
+ };
756
+ }
757
+
758
+ // ───────────────────────────────────────────────────────────────────────────
759
+ // ⭐⭐ THE DELIVERY HALF — BRANCH, PUSH, PULL REQUEST
760
+ //
761
+ // ── THE MEASUREMENT THAT MOTIVATED IT (2026-08-14) ─────────────────────────
762
+ // node -e "import('./lib/git.mjs').then(m =>
763
+ // console.log(m.gitToolSchemas().map(t => t.function.name).join(', ')))"
764
+ // -> git_status, git_diff, git_log, git_commit
765
+ //
766
+ // The agent could commit and then could not deliver. "Finish the work and open
767
+ // a PR" is the single most common end-of-task instruction a coding agent gets,
768
+ // and the only way to obey it was `--shell`, i.e. handing over the entire
769
+ // machine to get one `git push`. A capability gate that is escaped by granting
770
+ // a strictly larger one is not a gate, it is a nuisance.
771
+ //
772
+ // ── ⚠️ AND THE THREE VERBS ARE NOT THE SAME KIND OF THING ──────────────────
773
+ // · `git_branch` is nearly free: it touches no remote, destroys nothing, and
774
+ // the worst outcome is a branch nobody wanted, deleted in one command. It
775
+ // rides with `allowRun`, like commit, and needs nothing else.
776
+ // · `git_push` LEAVES THE MACHINE. It is visible to colleagues, hard to undo,
777
+ // and it is the only verb in this file that can leak — a repository is
778
+ // exactly the kind of thing you do not want sent somewhere by accident. So it
779
+ // is off unless an operator turns it on BY NAME, refuses protected branches
780
+ // with no override reachable by the model, and can only ever reach a remote
781
+ // the repository already has configured.
782
+ // · The pull request needs a network call and a credential, and both already
783
+ // existed in `github.mjs` — unreached, which is this package's signature
784
+ // defect. It is folded into `git_push` rather than given its own schema: a
785
+ // PR without a push is meaningless, the gate is identical, and one fewer
786
+ // schema is ~250 tokens saved on every round of every run.
787
+ // ───────────────────────────────────────────────────────────────────────────
788
+
789
+ /**
790
+ * ⚠️ A BRANCH NAME IS A REF, AND GIT'S RULES FOR ONE ARE NOT OBVIOUS. This is
791
+ * `git check-ref-format --branch` reimplemented as a pure function, because the
792
+ * alternative — hand the string to git and read the error — means a model
793
+ * spends a paid round learning that `feature/../..` is not a name.
794
+ *
795
+ * ⭐ The load-bearing ones are the first two. A leading `-` becomes a FLAG at
796
+ * some call site, and `..`/`@{` are revision syntax: `git switch a..b` is not a
797
+ * branch operation at all. Everything after that is git's own list.
798
+ */
799
+ export const MAX_BRANCH_NAME_CHARS = 200;
800
+
801
+ /**
802
+ * @param {unknown} raw
803
+ * @returns {{ ok: true, name: string } | GitRefused}
804
+ */
805
+ export function validateBranchName(raw) {
806
+ if (typeof raw !== 'string') return { ok: false, error: 'a branch name is required' };
807
+ const name = raw.trim();
808
+ if (!name) return { ok: false, error: 'the branch name is empty — say what the branch is for, e.g. "fix/login-timeout"' };
809
+ if (name.length > MAX_BRANCH_NAME_CHARS) {
810
+ return { ok: false, error: `the branch name is ${name.length} characters, over the ${MAX_BRANCH_NAME_CHARS} limit` };
811
+ }
812
+ if (name.startsWith('-')) return { ok: false, error: 'a branch name may not start with "-" — it would be read as a flag' };
813
+ if (/\s/.test(name)) return { ok: false, error: 'a branch name may not contain whitespace — use "-" or "/" instead' };
814
+ // eslint-disable-next-line no-control-regex
815
+ if (/[\u0000-\u001f\u007f]/.test(name)) return { ok: false, error: 'the branch name contains control characters' };
816
+ if (/[~^:?*[\\]/.test(name)) return { ok: false, error: 'a branch name may not contain any of ~ ^ : ? * [ \\ — git refuses them' };
817
+ if (name.includes('..')) return { ok: false, error: 'a branch name may not contain ".." — that is git\'s range syntax, not a name' };
818
+ if (name.includes('@{')) return { ok: false, error: 'a branch name may not contain "@{" — that is git\'s reflog syntax' };
819
+ if (name === '@') return { ok: false, error: '"@" is not a branch name — it is shorthand for HEAD' };
820
+ if (name === 'HEAD') return { ok: false, error: '"HEAD" is not a branch name — it is the pointer to whichever branch you are on' };
821
+ if (name.startsWith('refs/')) return { ok: false, error: 'give the branch name only, without the "refs/heads/" prefix' };
822
+ if (name.startsWith('/') || name.endsWith('/')) return { ok: false, error: 'a branch name may not begin or end with "/"' };
823
+ for (const segment of name.split('/')) {
824
+ if (segment === '') return { ok: false, error: 'a branch name may not contain an empty path segment ("//")' };
825
+ if (segment.startsWith('.')) return { ok: false, error: 'no part of a branch name may begin with "." — git refuses it' };
826
+ if (segment.endsWith('.')) return { ok: false, error: 'no part of a branch name may end with "." — git refuses it' };
827
+ if (segment.endsWith('.lock')) return { ok: false, error: 'no part of a branch name may end with ".lock" — that is git\'s own lockfile suffix' };
828
+ }
829
+ return { ok: true, name };
830
+ }
831
+
832
+ /**
833
+ * Create a branch and switch to it, or switch to one that already exists.
834
+ *
835
+ * ── ⚠️ WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────
836
+ * No delete, no rename, no `-B`, no `--force`, no `checkout <path>`. Every one
837
+ * of those can destroy work the agent did not create, and the whole argument
838
+ * for structured verbs (see the header) is that a verb you did not write cannot
839
+ * be reached by a model that guesses well.
840
+ *
841
+ * ⭐ REUSED, NOT CLOBBERED, when the branch exists — `github.mjs:createBranch`
842
+ * makes the same choice and states the reason: re-running after a failed
843
+ * attempt is completely normal, and `-B` would silently throw away whatever the
844
+ * last attempt left there.
845
+ *
846
+ * ⚠️ AND THE EXISTENCE PROBE ASKS FOR `refs/heads/<name>` RATHER THAN `<name>`.
847
+ * `git rev-parse --verify <name>` resolves ANY object — a tag, a remote-tracking
848
+ * branch, or a plain abbreviated SHA. A branch called `beef` would come back
849
+ * "exists" because an object starting `beef` does, and we would `switch` to a
850
+ * detached HEAD instead of creating the branch. (`github.mjs:230` still has the
851
+ * bare form; noted there, not fixed from here.)
852
+ */
853
+ /**
854
+ * @param {string} root
855
+ * @param {{ name?: unknown, dryRun?: boolean } & GitOpts} [opts]
856
+ * @returns {Promise<GitRefused | { ok: true, branch: string, created: boolean, previous: string, switched: boolean }>}
857
+ */
858
+ export async function gitBranch(root, { name, spawnImpl, dryRun = false } = {}) {
859
+ // ⚠️ STRICT: a ref is repository-wide. There is no such thing as creating a
860
+ // branch "for this subdirectory only", so the subdirectory allowance that the
861
+ // read verbs get would be a lie here.
862
+ const repo = await resolveRepo(root, { spawnImpl });
863
+ if (!repo.ok) return repo;
864
+
865
+ const valid = validateBranchName(name);
866
+ if (!valid.ok) return valid;
867
+
868
+ const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { spawnImpl });
869
+ const previous = head.ok && head.exitCode === 0 ? head.stdout.trim() : '';
870
+
871
+ if (previous === valid.name) {
872
+ // ⚠️ A RESULT, NOT A FAILURE. The model asked to be on that branch and it
873
+ // is on that branch; reporting an error would send it hunting for a problem
874
+ // that does not exist, which is the same mistake `gitDiff` avoids for an
875
+ // empty diff.
876
+ return { ok: true, branch: valid.name, created: false, switched: false, previous };
877
+ }
878
+
879
+ if (dryRun) return { ok: false, error: 'this is a --dry-run, so no branch is created and nothing is switched' };
880
+
881
+ const exists = await git(root, ['rev-parse', '--verify', '--quiet', `refs/heads/${valid.name}`], { spawnImpl });
882
+ if (!exists.ok) return exists;
883
+ const existed = exists.exitCode === 0;
884
+
885
+ const sw = await git(root, existed ? ['switch', valid.name] : ['switch', '-c', valid.name], { spawnImpl });
886
+ if (!sw.ok) return sw;
887
+ if (sw.exitCode !== 0) {
888
+ const err = `${sw.stdout}\n${sw.stderr}`.trim();
889
+ /**
890
+ * ⚠️ GIT'S OWN ADVICE HERE NAMES `git stash`, WHICH THIS AGENT DOES NOT
891
+ * HAVE. Left unrewritten the model reads the hint, tries to obey it, and
892
+ * burns a round discovering the verb does not exist — the same failure the
893
+ * gitignore branch of `gitCommit` was written to prevent.
894
+ */
895
+ if (/would be overwritten|commit your changes or stash/i.test(err)) {
896
+ return {
897
+ ok: false,
898
+ error: `${err}\n\nThis agent has no stash and never discards changes. Commit the files first with git_commit, then branch.`,
899
+ };
900
+ }
901
+ return { ok: false, error: err || `could not switch to ${valid.name}` };
902
+ }
903
+ return { ok: true, branch: valid.name, created: !existed, switched: true, previous };
904
+ }
905
+
906
+ /**
907
+ * ── ⚠️⚠️ THE PROTECTED BRANCHES, AND WHY THE MODEL CANNOT UNSET THEM ────────
908
+ *
909
+ * There was no branch-protection policy anywhere in this package before today
910
+ * — checked: `grep -rn "protected" lib/*.mjs` returned only compaction and
911
+ * lease matches. So this is a new policy, and a new policy in the one verb that
912
+ * leaves the machine should be conservative and boring.
913
+ *
914
+ * ⭐ THE LIST IS ADDITIVE ONLY. `ACUVO_PROTECTED_BRANCHES` can ADD names; there
915
+ * is no variable, flag or tool argument that removes one. That asymmetry is the
916
+ * point: a safety list a caller can empty is a safety list the model will
917
+ * eventually be told to empty. A human who genuinely means to push `main`
918
+ * types eleven characters in their own terminal.
919
+ *
920
+ * ⭐ AND THE REMOTE'S OWN DEFAULT BRANCH COUNTS, whatever it is called. That is
921
+ * the real protection this repository already has rather than one I invented:
922
+ * `origin/HEAD` is what the hosting provider says the trunk is, so a repo whose
923
+ * trunk is `acuvo` or `closer-local` is covered without anyone configuring
924
+ * anything. ⚠️ It is frequently ABSENT in a clone (git only writes it on
925
+ * `clone`, not on `remote add`), which is exactly why it supplements the static
926
+ * list instead of replacing it.
927
+ */
928
+ export const PROTECTED_BRANCHES = Object.freeze([
929
+ 'main', 'master', 'develop', 'development', 'trunk', 'release', 'prod', 'production',
930
+ ]);
931
+ export const PROTECTED_BRANCHES_ENV = 'ACUVO_PROTECTED_BRANCHES';
932
+
933
+ export function protectedBranches(env = process.env) {
934
+ const extra = String(env?.[PROTECTED_BRANCHES_ENV] ?? '')
935
+ .split(',')
936
+ .map((s) => s.trim().toLowerCase())
937
+ .filter(Boolean);
938
+ return [...new Set([...PROTECTED_BRANCHES, ...extra])];
939
+ }
940
+
941
+ /** @returns {string | null} the refusal, or null if this branch may be pushed. */
942
+ export function protectedBranchRefusal(branch, { env = process.env, defaultBranch = null } = {}) {
943
+ const name = String(branch ?? '').trim();
944
+ if (!name) return 'could not work out which branch is checked out, so this push is refused';
945
+ const lower = name.toLowerCase();
946
+ const list = protectedBranches(env);
947
+ const isDefault = defaultBranch && lower === String(defaultBranch).trim().toLowerCase();
948
+ if (!list.includes(lower) && !isDefault) return null;
949
+ const why = isDefault && !list.includes(lower)
950
+ ? `"${name}" is the default branch of the remote`
951
+ : `"${name}" is a protected branch`;
952
+ return [
953
+ `${why}, and this agent never pushes to one.`,
954
+ 'Make a branch for the work and push that instead:',
955
+ ` git_branch { "name": "fix/what-you-did" } then git_push`,
956
+ `(protected here: ${list.join(', ')}${defaultBranch ? `, and the remote default "${defaultBranch}"` : ''}.`,
957
+ `Add more with ${PROTECTED_BRANCHES_ENV}=a,b — there is no way to remove one.)`,
958
+ ].join(' ');
959
+ }
960
+
961
+ /** The operator's switch. ⚠️ Absent means OFF — a push must be asked for. */
962
+ export const ALLOW_PUSH_ENV = 'ACUVO_ALLOW_PUSH';
963
+
964
+ export function pushEnabled(env = process.env) {
965
+ const raw = String(env?.[ALLOW_PUSH_ENV] ?? '').trim().toLowerCase();
966
+ return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
967
+ }
968
+
969
+ /**
970
+ * ⭐ THE OFFER FOLLOWS `mediaToolNames`: a tool whose configuration is absent is
971
+ * never MENTIONED to the model. That is not only safety — it is the cost
972
+ * argument. A schema the model is never shown costs zero tokens, so the default
973
+ * install pays nothing at all for a verb it has not enabled, and the ~170
974
+ * tokens per round are spent only by someone who asked for them.
975
+ */
976
+ export function gitPushToolNames(env = process.env, { allowRun = true } = {}) {
977
+ return allowRun && pushEnabled(env) ? ['git_push'] : [];
978
+ }
979
+
980
+ /** A push can be slow on a big repository in a way `status` never is. */
981
+ export const PUSH_TIMEOUT_MS = 120_000;
982
+
983
+ /** What the remote calls its trunk, or null when the clone never recorded it. */
984
+ async function remoteDefaultBranch(root, remote, { spawnImpl } = {}) {
985
+ const r = await git(root, ['symbolic-ref', '--quiet', '--short', `refs/remotes/${remote}/HEAD`], { spawnImpl });
986
+ if (!r.ok || r.exitCode !== 0) return null;
987
+ const value = r.stdout.trim();
988
+ if (!value) return null;
989
+ return value.startsWith(`${remote}/`) ? value.slice(remote.length + 1) : value;
990
+ }
991
+
992
+ /**
993
+ * Push the current branch, and optionally open a pull request for it.
994
+ *
995
+ * ── ⚠️ THE ARGV IS FIXED: `push --set-upstream <remote> <branch>` ───────────
996
+ * No `--force`, no `--force-with-lease`, no `--delete`, no `--mirror`, no
997
+ * `--tags`, no refspec. The model supplies a remote NAME that must already be
998
+ * configured and nothing else — which is what stops the obvious attack, since
999
+ * `git push https://somewhere-else/x HEAD` would post the entire repository to
1000
+ * an address of the model's choosing and looks exactly like a normal push in a
1001
+ * transcript.
1002
+ *
1003
+ * ⭐ `--set-upstream` is unconditional and idempotent: the FIRST push of a new
1004
+ * branch without it leaves the branch with no upstream, and the next `git pull`
1005
+ * a human runs in that clone fails with a paragraph of advice.
1006
+ */
1007
+ /**
1008
+ * @param {string} root
1009
+ * @returns {Promise<GitRefused | { ok: true, remote: string, branch: string, output: string, pullRequest: object | null, nextSteps: string[] }>}
1010
+ */
1011
+ export async function gitPush(root, {
1012
+ remote = 'origin',
1013
+ openPullRequest = false,
1014
+ pullRequestTitle = null,
1015
+ pullRequestBody = null,
1016
+ pullRequestBase = null,
1017
+ spawnImpl,
1018
+ dryRun = false,
1019
+ env = process.env,
1020
+ fetchImpl = fetch,
1021
+ detectImpl = null,
1022
+ tokenImpl = null,
1023
+ } = {}) {
1024
+ /**
1025
+ * ⚠️ THE GATE IS CHECKED HERE AS WELL AS AT THE OFFER, and that is not
1026
+ * belt-and-braces for its own sake: `tools.mjs` already learned this the hard
1027
+ * way with `repl` and `start_process` (see
1028
+ * `test/no-run-holds-at-dispatcher.test.mjs`) — a model can emit a call for a
1029
+ * tool it was never shown, from a resumed session or a provider echoing a
1030
+ * stale tool list, and a gate that lives only in the offer does not hold.
1031
+ */
1032
+ if (!pushEnabled(env)) {
1033
+ return {
1034
+ ok: false,
1035
+ error: `pushing is turned off. This agent only pushes when the operator asks for it by name: set ${ALLOW_PUSH_ENV}=1 in the environment. Until then, commit the work and hand the branch over.`,
1036
+ };
1037
+ }
1038
+
1039
+ const repo = await resolveRepo(root, { spawnImpl });
1040
+ if (!repo.ok) return repo;
1041
+
1042
+ /**
1043
+ * ⚠️ VALIDATED BEFORE ANYTHING LEAVES THE MACHINE. Discovering that the PR
1044
+ * has no title AFTER the push has already happened means reporting a
1045
+ * half-done job for a reason that was knowable for free.
1046
+ */
1047
+ if (openPullRequest) {
1048
+ const t = String(pullRequestTitle ?? '').trim();
1049
+ if (!t) return { ok: false, error: 'openPullRequest needs a pullRequestTitle — say what the pull request is for' };
1050
+ if (t.startsWith('-')) return { ok: false, error: 'a pull request title may not start with "-"' };
1051
+ }
1052
+
1053
+ const remoteName = String(remote ?? 'origin').trim() || 'origin';
1054
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(remoteName)) {
1055
+ return { ok: false, error: `"${remoteName}" is not a remote name. Pass the NAME of a configured remote (usually "origin") — a URL is not accepted here, because pushing to an arbitrary address would send the whole repository somewhere nobody chose.` };
1056
+ }
1057
+ const remotes = await git(root, ['remote'], { spawnImpl });
1058
+ if (!remotes.ok) return remotes;
1059
+ const configured = remotes.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
1060
+ if (!configured.includes(remoteName)) {
1061
+ return {
1062
+ ok: false,
1063
+ error: configured.length
1064
+ ? `this repository has no remote called "${remoteName}". It has: ${configured.join(', ')}`
1065
+ : 'this repository has no remotes configured, so there is nowhere to push to',
1066
+ };
1067
+ }
1068
+
1069
+ const head = await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], { spawnImpl });
1070
+ if (!head.ok) return head;
1071
+ const branch = head.exitCode === 0 ? head.stdout.trim() : '';
1072
+ if (!branch || branch === 'HEAD') {
1073
+ // ⚠️ DETACHED HEAD. `push origin HEAD` from here creates a ref named after
1074
+ // whatever git guesses, on a remote, from a state the user probably did not
1075
+ // intend to be in.
1076
+ return { ok: false, error: 'HEAD is detached (not on a branch), so there is no branch to push. Use git_branch to make one first.' };
1077
+ }
1078
+
1079
+ const defaultBranch = await remoteDefaultBranch(root, remoteName, { spawnImpl });
1080
+ const refusal = protectedBranchRefusal(branch, { env, defaultBranch });
1081
+ if (refusal) return { ok: false, error: refusal };
1082
+
1083
+ if (dryRun) return { ok: false, error: 'this is a --dry-run, so nothing is pushed' };
1084
+
1085
+ const run = await git(root, ['push', '--set-upstream', remoteName, branch], {
1086
+ spawnImpl,
1087
+ timeoutMs: PUSH_TIMEOUT_MS,
1088
+ env: pushEnvironment(env),
1089
+ });
1090
+ if (!run.ok) return run;
1091
+ if (run.exitCode !== 0) {
1092
+ const err = `${run.stdout}\n${run.stderr}`.trim();
1093
+ if (/could not read Username|terminal prompts disabled|Authentication failed/i.test(err)) {
1094
+ return {
1095
+ ok: false,
1096
+ error: `${err}\n\nGit has no usable credential for ${remoteName} in this environment. Run the push yourself, or configure a credential helper / ssh agent for this machine.`,
1097
+ };
1098
+ }
1099
+ if (/non-fast-forward|rejected|fetch first/i.test(err)) {
1100
+ return {
1101
+ ok: false,
1102
+ error: `${err}\n\nThe remote has commits this branch does not. This agent never force-pushes and cannot pull — a human needs to reconcile the two.`,
1103
+ };
1104
+ }
1105
+ return { ok: false, error: err || 'git push failed' };
1106
+ }
1107
+
1108
+ const pushed = {
1109
+ ok: true,
1110
+ remote: remoteName,
1111
+ branch,
1112
+ // ⚠️ Git writes the interesting part of a push to STDERR ("* [new branch]",
1113
+ // the remote's own PR hint). Reporting only stdout would report nothing.
1114
+ output: clampOutput(`${run.stdout}\n${run.stderr}`.trim(), 2_000).text,
1115
+ pullRequest: null,
1116
+ nextSteps: [],
1117
+ };
1118
+ if (!openPullRequest) return pushed;
1119
+
1120
+ pushed.pullRequest = await openPr(root, {
1121
+ branch, base: pullRequestBase || defaultBranch, title: String(pullRequestTitle).trim(),
1122
+ body: pullRequestBody == null ? '' : String(pullRequestBody), env, fetchImpl, detectImpl, tokenImpl,
1123
+ });
1124
+ /**
1125
+ * ⭐ A FAILED PR DOES NOT UNDO A SUCCESSFUL PUSH, so it must not be reported
1126
+ * as a failed push. The commits ARE on the remote; the model needs to know
1127
+ * that and stop, not retry the push. The manual command rides along so the
1128
+ * human can finish it in one paste.
1129
+ */
1130
+ if (!pushed.pullRequest.ok) {
1131
+ pushed.nextSteps = [`gh pr create --head ${branch}${pullRequestBase ? ` --base ${pullRequestBase}` : ''} --fill`];
1132
+ }
1133
+ return pushed;
1134
+ }
1135
+
1136
+ /**
1137
+ * The pull request itself.
1138
+ *
1139
+ * ⭐ EVERY PIECE OF THIS ALREADY EXISTED IN `github.mjs` AND NOTHING CALLED IT
1140
+ * — `detectRepo` (which asks git rather than guessing from the folder name, and
1141
+ * reads `git config` rather than `remote get-url` so a URL rewrite does not
1142
+ * make a GitHub repo look like it is not on GitHub) and `findToken` (which
1143
+ * reuses `gh auth token`, resolved to an ABSOLUTE path after a `gh.exe` in the
1144
+ * working directory was measured beating the real one). Reusing them is the
1145
+ * whole point: the unreached-capability defect is fixed by CALLING the code,
1146
+ * not by writing a second copy of it.
1147
+ */
1148
+ async function openPr(root, { branch, base, title, body, env, fetchImpl, detectImpl, tokenImpl }) {
1149
+ const { detectRepo, findToken } = await import('./github.mjs');
1150
+ const detect = detectImpl ?? detectRepo;
1151
+ const token = tokenImpl ?? findToken;
1152
+
1153
+ const where = detect(root);
1154
+ if (!where.ok) return { ok: false, error: `cannot open a pull request: ${where.error}` };
1155
+ const cred = token({ env });
1156
+ if (!cred.ok) return { ok: false, error: cred.error };
1157
+
1158
+ const payload = { title, head: branch, base: base || 'main', body: body.slice(0, 60_000) };
1159
+ let res;
1160
+ try {
1161
+ res = await fetchImpl(`https://api.github.com/repos/${where.owner}/${where.repo}/pulls`, {
1162
+ method: 'POST',
1163
+ headers: {
1164
+ accept: 'application/vnd.github+json',
1165
+ authorization: `Bearer ${cred.token}`,
1166
+ 'content-type': 'application/json',
1167
+ 'user-agent': 'acuvo-code',
1168
+ },
1169
+ body: JSON.stringify(payload),
1170
+ signal: AbortSignal.timeout(20_000),
1171
+ });
1172
+ } catch (err) {
1173
+ return { ok: false, error: `could not reach GitHub: ${err?.cause?.code ?? err?.name ?? err}` };
1174
+ }
1175
+ const json = await res.json().catch(() => null);
1176
+ if (res.status === 201 && json) {
1177
+ return { ok: true, number: json.number, url: json.html_url, base: payload.base };
1178
+ }
1179
+ /**
1180
+ * ⚠️ 422 IS THE ONE THAT MATTERS AND IT IS AMBIGUOUS. GitHub returns it for
1181
+ * "a pull request already exists for this branch" AND for "the base branch
1182
+ * does not exist" AND for "no commits between the two". Its own `message`
1183
+ * distinguishes them, so it is passed through rather than replaced by a guess.
1184
+ */
1185
+ const detail = json?.errors?.map?.((e) => e?.message).filter(Boolean).join('; ') || json?.message || '';
1186
+ return { ok: false, error: `GitHub refused the pull request (HTTP ${res.status})${detail ? `: ${detail}` : ''}` };
1187
+ }
1188
+
1189
+ /** Render for the model — compact, and leading with the fact that drives the next move. */
1190
+ export function formatStatusForModel(result) {
1191
+ if (!result.ok) return `git status: ${result.error}`;
1192
+ /**
1193
+ * ⭐ THE SCOPE IS SAID OUT LOUD. "working tree clean" about a workspace that
1194
+ * is one package of a monorepo is a true sentence a model will read as a
1195
+ * bigger claim than it is — and the next thing it does with that belief is
1196
+ * decide the repository has nothing to commit.
1197
+ */
1198
+ const scope = result.subdirectory ? ` (scoped to ${result.subdirectory}/ inside a larger repository)` : '';
1199
+ if (result.clean) return `branch ${result.branch || '(no commits yet)'}${scope} — working tree clean, nothing to commit`;
1200
+ const lines = [`branch ${result.branch || '(no commits yet)'}${scope} — ${result.files.length} changed:`];
1201
+ for (const f of result.files.slice(0, 60)) {
1202
+ const tag = f.untracked ? 'untracked' : f.staged ? 'staged' : 'modified';
1203
+ lines.push(` ${tag.padEnd(9)} ${f.path}`);
1204
+ }
1205
+ if (result.files.length > 60) lines.push(` … and ${result.files.length - 60} more`);
1206
+ return lines.join('\n');
1207
+ }
1208
+
1209
+ export function gitToolSchemas() {
1210
+ return [
1211
+ {
1212
+ type: 'function',
1213
+ function: {
1214
+ name: 'git_status',
1215
+ description: [
1216
+ 'List what has changed in the workspace repository: branch, and every modified, staged or',
1217
+ 'untracked file. Call this BEFORE git_commit — commit requires you to name the files, and',
1218
+ 'this is how you know what they are.',
1219
+ ].join(' '),
1220
+ parameters: { type: 'object', properties: {}, required: [] },
1221
+ },
1222
+ },
1223
+ {
1224
+ type: 'function',
1225
+ function: {
1226
+ name: 'git_diff',
1227
+ description: [
1228
+ 'Show the actual line-by-line changes in the working tree. This is how you CHECK your own',
1229
+ 'edit did what you meant before committing it, and how you see changes that existed before',
1230
+ 'this session. Optionally limit to one path, or pass staged=true for what is already staged.',
1231
+ ].join(' '),
1232
+ parameters: {
1233
+ type: 'object',
1234
+ properties: {
1235
+ path: { type: 'string', description: 'Optional workspace-relative file or directory.' },
1236
+ staged: { type: 'boolean', description: 'true to diff what is staged rather than the working tree.' },
1237
+ },
1238
+ required: [],
1239
+ },
1240
+ },
1241
+ },
1242
+ {
1243
+ type: 'function',
1244
+ function: {
1245
+ name: 'git_log',
1246
+ description: [
1247
+ 'Recent commits: hash, author, relative date and subject. Use it to learn a repository\'s',
1248
+ 'commit-message conventions before writing one, or to see what recently changed in a file.',
1249
+ ].join(' '),
1250
+ parameters: {
1251
+ type: 'object',
1252
+ properties: {
1253
+ count: { type: 'number', description: `How many commits, 1–${MAX_LOG_COUNT} (default 10).` },
1254
+ path: { type: 'string', description: 'Optional path — only commits touching it.' },
1255
+ },
1256
+ required: [],
1257
+ },
1258
+ },
1259
+ },
1260
+ {
1261
+ type: 'function',
1262
+ function: {
1263
+ name: 'git_commit',
1264
+ description: [
1265
+ 'Stage the named files and commit them. You MUST list the paths — there is no "commit',
1266
+ 'everything", because sweeping up files you have not looked at is how scratch files and',
1267
+ 'secrets get committed. Call git_status and git_diff first, then commit deliberately.',
1268
+ 'Match the repository\'s existing message style (git_log shows it).',
1269
+ 'This agent cannot reset, merge, rebase or discard anything — commit, branch and push are the only write verbs.',
1270
+ ].join(' '),
1271
+ parameters: {
1272
+ type: 'object',
1273
+ properties: {
1274
+ message: { type: 'string', description: 'The commit message. Multi-line is fine.' },
1275
+ paths: {
1276
+ type: 'array',
1277
+ items: { type: 'string' },
1278
+ description: 'Workspace-relative paths to stage and commit. Required, and non-empty.',
1279
+ },
1280
+ },
1281
+ required: ['message', 'paths'],
1282
+ },
1283
+ },
1284
+ },
1285
+ /**
1286
+ * ⭐ ~95 TOKENS, AND IT EARNS THEM ON THE FIRST TASK THAT SAYS "don't commit
1287
+ * to main". Without it the agent's only correct move was to refuse the work
1288
+ * or commit to whatever branch it happened to start on.
1289
+ */
1290
+ {
1291
+ type: 'function',
1292
+ function: {
1293
+ name: 'git_branch',
1294
+ description: [
1295
+ 'Create a branch and switch to it — or switch to one that already exists. Do this BEFORE',
1296
+ 'committing work that should not land on the branch you started on. It never force-creates,',
1297
+ 'never deletes a branch, and never discards commits.',
1298
+ ].join(' '),
1299
+ parameters: {
1300
+ type: 'object',
1301
+ properties: {
1302
+ name: { type: 'string', description: 'The branch name, e.g. "fix/login-timeout". No spaces.' },
1303
+ },
1304
+ required: ['name'],
1305
+ },
1306
+ },
1307
+ },
1308
+ /**
1309
+ * ⚠️ THIS SCHEMA IS ~180 TOKENS AND IS SHOWN TO NOBODY BY DEFAULT — see
1310
+ * `gitPushToolNames`. It is declared here anyway so the registry, the
1311
+ * doctor and the policy validator can all SEE it; what varies per machine
1312
+ * is the offer, which is the convention `tools.mjs` states for the media
1313
+ * tools and follows here.
1314
+ */
1315
+ {
1316
+ type: 'function',
1317
+ function: {
1318
+ name: 'git_push',
1319
+ description: [
1320
+ 'Push the current branch to a remote, and optionally open a pull request for it.',
1321
+ 'This is the one action that LEAVES THIS MACHINE and that other people will see, so use it',
1322
+ 'when the task asked you to deliver the work — not as a reflex after every commit.',
1323
+ 'It refuses to push a protected branch (main, master, develop, trunk, release, prod,',
1324
+ 'production, and whatever the remote calls its default): branch with git_branch first.',
1325
+ 'It never force-pushes and never deletes a remote branch.',
1326
+ ].join(' '),
1327
+ parameters: {
1328
+ type: 'object',
1329
+ properties: {
1330
+ remote: { type: 'string', description: 'Name of a configured remote — default "origin". A URL is refused.' },
1331
+ openPullRequest: { type: 'boolean', description: 'true to open a GitHub pull request after the push succeeds.' },
1332
+ pullRequestTitle: { type: 'string', description: 'Required when openPullRequest is true.' },
1333
+ pullRequestBody: { type: 'string', description: 'The PR description. Say what changed and how you verified it.' },
1334
+ pullRequestBase: { type: 'string', description: 'Branch to merge into. Defaults to the remote\'s default branch.' },
1335
+ },
1336
+ required: [],
1337
+ },
1338
+ },
1339
+ },
1340
+ ];
1341
+ }