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/search.mjs ADDED
@@ -0,0 +1,659 @@
1
+ /**
2
+ * ── ⭐ SEARCH — THE DIFFERENCE BETWEEN WRITING FILES AND WORKING IN A REPO ───
3
+ *
4
+ * Until now Acuvo Code could only touch files it was HANDED: the deterministic
5
+ * gather reads the tree and a few small files, and everything else is invisible.
6
+ * That is fine for "create src/thing.js" and useless for "rename this function
7
+ * everywhere" or "why does the login page 404". Asked to change something it
8
+ * cannot find, the model does the only thing left to it — invents a plausible
9
+ * file and writes over the wrong one.
10
+ *
11
+ * ⭐ So this is the highest-value tool the CLI was missing, and it is the one
12
+ * every real coding agent leans on hardest. Two verbs, deliberately:
13
+ * · `find_files` — locate by NAME (glob)
14
+ * · `search_text` — locate by CONTENT (regex, with line numbers)
15
+ *
16
+ * ── ⚠️ WHY IT IS IMPLEMENTED IN NODE AND NOT BY SHELLING TO ripgrep ─────────
17
+ * `command.mjs` exists to keep a shell away from model-authored strings, and its
18
+ * allowlist deliberately excludes every binary but four. Adding `rg` would mean
19
+ * either a new binary in that list (with a pattern argument the model controls —
20
+ * the exact shape the allowlist refuses) or `shell: true`, which hands a shell
21
+ * the string the module exists to keep from one. A directory walk in Node costs
22
+ * a few hundred lines and gives up nothing that matters at repo scale.
23
+ *
24
+ * ── ⚠️⚠️ AND THE THING THIS MODULE GOT WRONG FOR ITS FIRST LIFE ────────────
25
+ * A search tool's output is not a list, it is a CLAIM: "I looked, and here is
26
+ * what is there". `searchText` used to make that claim while quietly declining
27
+ * to open files — too big, NUL byte, unreadable — and returning
28
+ * `{ matches: [], truncated: false, scanned: N }`, three fields that together
29
+ * say "everything was looked at and the string is not there". The model then
30
+ * stops looking, which is the whole cost: a wrong answer it has no reason to
31
+ * doubt. The rule the module already applied to truncation ("a model told the
32
+ * first will stop looking") applies identically to skips, so every file the
33
+ * walk reached and did not read now comes back named, with a reason.
34
+ */
35
+
36
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
37
+ import { join, relative, sep } from 'node:path';
38
+
39
+ /**
40
+ * ⭐ IMPORTED, NEVER RE-IMPLEMENTED. `git.mjs` already owns the one list of
41
+ * "files that must never leave this machine"; `turn.mjs` reuses it for the same
42
+ * reason. A second copy here would be the copy that goes stale the day someone
43
+ * adds a filename to the first one.
44
+ */
45
+ import { refusedCommitPath } from './secret-paths.mjs';
46
+ import { rankMatches } from './search-rank.mjs';
47
+
48
+ /**
49
+ * ⚠️ NEVER DESCENDED. Walking `node_modules` on a real project is tens of
50
+ * thousands of files and would blow both the time budget and the model's
51
+ * context with matches from other people's code. `.git` is worse than useless —
52
+ * it is binary objects that happen to contain fragments of your source.
53
+ */
54
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.vercel', 'coverage', '.turbo']);
55
+
56
+ /**
57
+ * ── ⭐ HIDDEN DIRECTORIES THAT ARE ORDINARY SOURCE ───────────────────────────
58
+ *
59
+ * DIRECTORIES ONLY, and deliberately short. Every entry here is a place teams
60
+ * keep code they routinely ask an agent to change — CI workflows above all —
61
+ * and none of them is a conventional home for a credential. `.env` is a FILE,
62
+ * so nothing in this set can expose it; the hidden-file rule is untouched.
63
+ *
64
+ * ⚠️ THE BAR FOR ADDING ONE: it must be a directory whose contents a stranger
65
+ * would expect to appear in a code search, and which no common tool uses to
66
+ * store secrets. `.aws`, `.ssh`, `.docker` and `.gnupg` fail that test and must
67
+ * never appear here.
68
+ */
69
+ const HIDDEN_DIRS_ALLOWED = new Set(['.github', '.vscode', '.husky', '.circleci', '.changeset', '.storybook']);
70
+
71
+ /**
72
+ * ── ⭐⭐ THE AGENT COULD NOT FIND ITS OWN WORK ────────────────────────────────
73
+ *
74
+ * `.acuvo/` is where THIS package writes the plan, the board, the checkpoint
75
+ * journal, the policy, the acceptance record and the audit log. All of it is
76
+ * hidden, so all of it was invisible to `find_files` and `search_text` — an
77
+ * agent resuming a job could not answer "what did I already do?" about files it
78
+ * had written itself, and got the `matches: [] / skippedCount: 0` triple this
79
+ * module documents as meaning NOT THERE.
80
+ *
81
+ * ⚠️ IT IS DELIBERATELY NOT IN `HIDDEN_DIRS_ALLOWED` ABOVE. That set's stated
82
+ * bar is "no common tool uses it to store secrets", and `.acuvo` FAILS it —
83
+ * `credentials.json` lives in a directory of exactly this name. Folding it in
84
+ * would quietly widen a set whose comment promises the opposite.
85
+ *
86
+ * ⭐ WHAT KEEPS IT SAFE IS NOT THIS LINE. `searchText` already refuses the
87
+ * CONTENTS of anything `refusedCommitPath` names, and that pattern list matches
88
+ * `credentials.json` wherever it sits. The containment is at the FILE level,
89
+ * which is the level that survives someone running the agent with the workspace
90
+ * set to their home directory. The test that matters asserts the token never
91
+ * comes back, not that this constant has a particular value.
92
+ */
93
+ const ACUVO_DIR = '.acuvo';
94
+
95
+ /**
96
+ * ⚠️ SESSIONS ARE THE AGENT'S OWN TRANSCRIPT, AND SEARCHING THEM IS AN ECHO.
97
+ * A session file is every word the model wrote, so a search for a symbol finds
98
+ * the agent's own earlier SPECULATION about that symbol alongside the source —
99
+ * indistinguishable in a result list, and the older guess reads as evidence.
100
+ * One session measured here is 43KB of prose. `acuvo --sessions` lists them
101
+ * properly; this walk stays out.
102
+ */
103
+ const ACUVO_SUBDIRS_SKIPPED = new Set(['sessions']);
104
+
105
+ /** Files we will never read as text. Reading them yields replacement chars. */
106
+ const BINARY_EXT = /\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|mp4|mov|mp3|wav|woff2?|ttf|eot|so|dll|exe|node)$/i;
107
+
108
+ /**
109
+ * Bounds. Every one of these exists because the failure it prevents is silent:
110
+ * a search that walks forever looks like a hung terminal, and one that returns
111
+ * 4,000 matches costs more in context than the answer is worth.
112
+ */
113
+ export const MAX_FILES_SCANNED = 4_000;
114
+ export const MAX_MATCHES = 60;
115
+ export const MAX_FILE_BYTES = 512 * 1024;
116
+ export const MAX_LINE_CHARS = 200;
117
+ /**
118
+ * ── ⭐ HOW FAR PAST THE CURRENT PAGE WE KEEP COUNTING ───────────────────────
119
+ *
120
+ * `total` is the field that turns "truncated" from a dead end into a decision:
121
+ * 61 hits and 6,000 hits are the same word today, and they call for opposite
122
+ * next moves. Counting is far cheaper than RETURNING — a counted match costs an
123
+ * integer, a returned one costs a line of context — so we keep counting after
124
+ * the page is full.
125
+ *
126
+ * ⚠️ BUT NOT FOREVER. `searchText` has to READ a file to count matches in it,
127
+ * and the old code's early break is what made a search for a common token
128
+ * finish after three files. So counting stops this far past the requested page,
129
+ * and when it does the reply says `countCapped: true` and `totalExact: false`
130
+ * rather than passing a floor off as a census.
131
+ */
132
+ export const MAX_COUNTED = 1_000;
133
+ /**
134
+ * How many skipped paths are NAMED in a reply. The count is always exact; this
135
+ * caps only the list, because a repo with 400 images must not spend the model's
136
+ * whole context enumerating them.
137
+ */
138
+ export const MAX_SKIPPED_LISTED = 20;
139
+
140
+ /** Depth-first walk, honouring the skip list and the file cap. */
141
+ function* walk(root, dir, budget, inAcuvo = false) {
142
+ let entries;
143
+ try {
144
+ entries = readdirSync(dir, { withFileTypes: true });
145
+ } catch {
146
+ return; // an unreadable directory is skipped, never fatal
147
+ }
148
+ // Sorted so two runs over an unchanged tree return the same order — a search
149
+ // whose results shuffle makes a diff between runs unreadable.
150
+ entries.sort((a, b) => a.name.localeCompare(b.name));
151
+ for (const e of entries) {
152
+ if (budget.scanned >= MAX_FILES_SCANNED) return;
153
+ if (e.name.startsWith('.') && e.name !== '.env.example') {
154
+ /**
155
+ * ── ⚠️ HIDDEN ENTRIES: REFUSE THE CREDENTIALS, NOT THE SOURCE ──────────
156
+ *
157
+ * The original rule skipped EVERY dotted entry and recorded nothing. Its
158
+ * reason is right and stays: a search that surfaces `.env` into a model's
159
+ * context is an exfiltration channel with good intentions, and hidden
160
+ * FILES are overwhelmingly config and credentials.
161
+ *
162
+ * ⚠️ BUT IT ALSO MADE ORDINARY SOURCE UNREACHABLE AND SAID NOTHING.
163
+ * `.github/workflows`, `.vscode`, `.husky` and `.circleci` are code people
164
+ * ask an agent to change constantly — "fix the CI workflow" is a top task
165
+ * — and the reply was the exact `matches: [] / truncated: false /
166
+ * skippedCount: 0` triple this module documents as meaning "not there".
167
+ * Being unable to see a file is survivable. Reporting it as ABSENT is not,
168
+ * because the model stops looking and then invents.
169
+ *
170
+ * ⭐ So: walk a small allowlist of hidden directories that are plainly
171
+ * source, skip everything else hidden as before, and RECORD every skip so
172
+ * the caller can name it. Hidden FILES are still never walked — the
173
+ * allowlist is directories only, so `.env` is untouched by this.
174
+ */
175
+ if (e.isDirectory() && HIDDEN_DIRS_ALLOWED.has(e.name)) {
176
+ yield* walk(root, join(dir, e.name), budget);
177
+ continue;
178
+ }
179
+ if (e.isDirectory() && e.name === ACUVO_DIR) {
180
+ yield* walk(root, join(dir, e.name), budget, true);
181
+ continue;
182
+ }
183
+ if (budget.hiddenSkipped && budget.hiddenSkipped.length < MAX_SKIPPED_LISTED) {
184
+ budget.hiddenSkipped.push({
185
+ path: relative(root, join(dir, e.name)).split(sep).join('/'),
186
+ reason: e.isDirectory() ? 'hidden directory, not searched' : 'hidden file, not read',
187
+ });
188
+ }
189
+ budget.hiddenSkippedCount = (budget.hiddenSkippedCount ?? 0) + 1;
190
+ continue;
191
+ }
192
+ if (e.isDirectory()) {
193
+ if (inAcuvo && ACUVO_SUBDIRS_SKIPPED.has(e.name)) {
194
+ if (budget.hiddenSkipped && budget.hiddenSkipped.length < MAX_SKIPPED_LISTED) {
195
+ budget.hiddenSkipped.push({
196
+ path: relative(root, join(dir, e.name)).split(sep).join('/'),
197
+ reason: 'the agent\'s own session transcripts — list them with `acuvo --sessions`',
198
+ });
199
+ }
200
+ budget.hiddenSkippedCount = (budget.hiddenSkippedCount ?? 0) + 1;
201
+ continue;
202
+ }
203
+ if (SKIP_DIRS.has(e.name)) {
204
+ if (budget.hiddenSkipped && budget.hiddenSkipped.length < MAX_SKIPPED_LISTED) {
205
+ budget.hiddenSkipped.push({
206
+ path: relative(root, join(dir, e.name)).split(sep).join('/'),
207
+ reason: 'skipped directory (build output or dependencies)',
208
+ });
209
+ }
210
+ budget.hiddenSkippedCount = (budget.hiddenSkippedCount ?? 0) + 1;
211
+ continue;
212
+ }
213
+ yield* walk(root, join(dir, e.name), budget);
214
+ } else if (e.isFile()) {
215
+ budget.scanned += 1;
216
+ yield join(dir, e.name);
217
+ }
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Placeholders used while expanding a glob. They stand in for an expansion that
223
+ * itself contains glob characters, so the later passes cannot chew up the regex
224
+ * an earlier pass just wrote. Control characters because a path may not contain
225
+ * one, so no real pattern can collide with them.
226
+ */
227
+ const TOK_STARSTAR = '\x00'; // `**` not followed by a slash
228
+ const TOK_GLOBSTAR_SEG = '\x01'; // `**/` — a whole directory span, possibly empty
229
+
230
+ /**
231
+ * A tiny glob: `*` (not across `/`), `**` (across `/`), `?`. Anchored whole.
232
+ *
233
+ * ⚠️ BUILT RATHER THAN REGEX-FROM-USER-INPUT. Handing a model's string straight
234
+ * to `new RegExp` is a catastrophic-backtracking DoS in one call — `(a+)+$` on a
235
+ * long path hangs the process. Escaping everything and expanding only the three
236
+ * glob tokens means the pattern can only ever describe a path shape.
237
+ *
238
+ * ── ⚠️ `**` + `/` MATCHES ZERO DIRECTORIES, AND USED NOT TO ────────────────
239
+ * `**` became `.*` and the `/` after it stayed a literal, so the pattern
240
+ * `**` `/` `*.json` compiled to `^.*\/[^/]*\.json$` — with a MANDATORY slash.
241
+ * Effect: it found `src/a.json` and missed `package.json`, which means the
242
+ * BROADER pattern returned strictly FEWER files than the narrower `*.json`.
243
+ * That is the opposite of what a glob promises, and it fails silently: the
244
+ * model asks for "every json in the repo", gets a list with the root one
245
+ * absent, and concludes the file does not exist. Bash globstar, minimatch and
246
+ * ripgrep all read a `**` segment as ZERO or more directories, so the honest
247
+ * expansion is `(?:.*\/)?` and never `.*\/`.
248
+ *
249
+ * ⚠️ It has to go behind its own placeholder because the replacement contains
250
+ * both `?` and `*`; written any earlier, the two token passes below would
251
+ * rewrite it into nonsense.
252
+ */
253
+ export function globToRegExp(pattern) {
254
+ const escaped = String(pattern)
255
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&')
256
+ .replace(/\*\*\//g, TOK_GLOBSTAR_SEG) // a directory span, zero or more deep
257
+ .replace(/\*\*/g, TOK_STARSTAR) // placeholder so * does not eat it
258
+ .replace(/\*/g, '[^/]*')
259
+ .replace(/\?/g, '[^/]')
260
+ .replace(new RegExp(TOK_STARSTAR, 'g'), '.*')
261
+ .replace(new RegExp(TOK_GLOBSTAR_SEG, 'g'), '(?:.*/)?');
262
+ return new RegExp(`^${escaped}$`, 'i');
263
+ }
264
+
265
+ /**
266
+ * ── ⚠️ AN OFFSET IS EITHER A WHOLE COUNT OR A MISTAKE ───────────────────────
267
+ *
268
+ * Coercing `-1` or `"later"` to 0 would hand back page one while the model
269
+ * believes it is reading page two — a silent wrong answer of exactly the class
270
+ * this module exists to stop, and one the model cannot detect. A refusal it can
271
+ * read is strictly better.
272
+ *
273
+ * ⚠️ The shapes a real tool-call carries are still accepted: absent, `0`, and a
274
+ * NUMERIC STRING, because JSON emitted by a model routinely types numbers as
275
+ * strings and refusing that would fail correct work.
276
+ */
277
+ function normaliseOffset(offset, tool) {
278
+ if (offset === undefined || offset === null || offset === '') return { ok: true, value: 0 };
279
+ const n = typeof offset === 'number' ? offset : Number(String(offset).trim());
280
+ if (!Number.isInteger(n) || n < 0) {
281
+ return {
282
+ ok: false,
283
+ error: `${tool}: offset must be a whole number ≥ 0 — the number of results already seen, e.g. ${MAX_MATCHES} for the second page. Got ${JSON.stringify(offset)}.`,
284
+ };
285
+ }
286
+ return { ok: true, value: n };
287
+ }
288
+
289
+ /**
290
+ * Locate files by name. Returns repo-relative POSIX paths.
291
+ *
292
+ * ⭐ `offset` is how many matches to SKIP, so a model told `truncated: true` has
293
+ * somewhere to go. The walk is not repeated to find page two — it never was
294
+ * capable of stopping early in a way that mattered here, because matching a
295
+ * NAME costs a regex test, not a file read. So the whole tree is matched once,
296
+ * every hit is counted into `total`, and only the requested window is recorded.
297
+ */
298
+ export function findFiles(root, pattern, { offset = 0 } = {}) {
299
+ if (!pattern || typeof pattern !== 'string') return { ok: false, error: 'a glob pattern is required' };
300
+ const off = normaliseOffset(offset, 'find_files');
301
+ if (!off.ok) return off;
302
+ const start = off.value;
303
+ let rx;
304
+ try {
305
+ rx = globToRegExp(pattern);
306
+ } catch {
307
+ return { ok: false, error: `not a usable pattern: ${pattern}` };
308
+ }
309
+ const budget = { scanned: 0, hiddenSkipped: [], hiddenSkippedCount: 0 };
310
+ const hits = [];
311
+ let total = 0;
312
+ for (const abs of walk(root, root, budget)) {
313
+ const rel = relative(root, abs).split(sep).join('/');
314
+ if (!rx.test(rel) && !rx.test(rel.split('/').pop())) continue;
315
+ total += 1;
316
+ // Counted always; recorded only inside the requested window. The old code
317
+ // broke out of the walk here, which is why it could say "there are more"
318
+ // and never say how many, nor let anyone ask for them.
319
+ if (total > start && hits.length < MAX_MATCHES) hits.push(rel);
320
+ }
321
+ const scanCapped = budget.scanned >= MAX_FILES_SCANNED;
322
+ const unseen = start + hits.length < total;
323
+ return {
324
+ ok: true,
325
+ pattern,
326
+ files: hits,
327
+ /** How many matches were skipped to build this page. Echoed so a caller that
328
+ * asked for page two and got page one can SEE that its offset was dropped. */
329
+ offset: start,
330
+ /** Every match the walk found, not just the ones returned. */
331
+ total,
332
+ /**
333
+ * ⚠️ `total` IS A FLOOR, NOT A CENSUS, WHEN THE WALK WAS CAPPED. Saying
334
+ * "47 files" about a tree we only walked a third of is the same lie as
335
+ * `truncated: false`, wearing a number.
336
+ */
337
+ totalExact: !scanCapped,
338
+ /** Where to resume. `null` means there is nothing after this page. */
339
+ nextOffset: unseen ? start + hits.length : null,
340
+ /**
341
+ * ⚠️ TRUNCATION IS REPORTED, NEVER SILENT. "12 files" and "the first 60 of
342
+ * many" are different answers, and a model told the first will stop looking.
343
+ *
344
+ * ⚠️⚠️ THIS COMMENT WAS TRUE AND THE CODE UNDER IT WAS NOT. It read
345
+ * `hits.length >= MAX_MATCHES` — the MATCH cap only — while `walk()` also
346
+ * stops dead at MAX_FILES_SCANNED. A walk cut short after 4,000 of 12,000
347
+ * files, finding 3 matches, answered `truncated: false`: a claim of
348
+ * completeness over a third of the tree, printed directly beneath a comment
349
+ * promising the opposite.
350
+ *
351
+ * ⭐ `searchText` HAD ALREADY FIXED EXACTLY THIS (see its `scanCapped`, and
352
+ * the note above it recording the same bug). Its sibling never got the fix.
353
+ * Both causes mean "there may be more", so both set the flag and
354
+ * `scanCapped` says which.
355
+ *
356
+ * ⭐ NOW EXACT RATHER THAN PESSIMISTIC. `hits.length >= MAX_MATCHES` was a
357
+ * proxy for "there are probably more"; with `total` counted we know. A full
358
+ * page that happens to be the last page honestly says `truncated: false`.
359
+ */
360
+ truncated: unseen || scanCapped,
361
+ scanned: budget.scanned,
362
+ scanCapped,
363
+ // Places the walk declined to enter, so "not found" is never confused with
364
+ // "not looked at". The count is exact even when the list is capped.
365
+ skipped: budget.hiddenSkipped,
366
+ skippedCount: budget.hiddenSkippedCount,
367
+ };
368
+ }
369
+
370
+ /**
371
+ * Search file CONTENT.
372
+ *
373
+ * ⚠️ The pattern is a regex because that is what makes search useful, so it is
374
+ * compiled inside a try and every match is bounded — see MAX_* above. A literal
375
+ * search is available by escaping, which the description tells the model.
376
+ *
377
+ * Returns, on success:
378
+ * matches the hits, capped at MAX_MATCHES
379
+ * truncated true when there may be more — capped matches OR a capped walk
380
+ * scanned files the walk reached
381
+ * skipped up to MAX_SKIPPED_LISTED `{ path, reason }` of files NOT read
382
+ * skippedCount the exact number not read, even when the list above is capped
383
+ * withheld how many of those were withheld as credential files
384
+ */
385
+ export function searchText(root, pattern, { glob = null, offset = 0 } = {}) {
386
+ if (!pattern || typeof pattern !== 'string') return { ok: false, error: 'a search pattern is required' };
387
+ const off = normaliseOffset(offset, 'search_text');
388
+ if (!off.ok) return off;
389
+ const start = off.value;
390
+ let rx;
391
+ try {
392
+ rx = new RegExp(pattern, 'i');
393
+ } catch (err) {
394
+ return { ok: false, error: `not a valid regular expression: ${String(err?.message || err)}` };
395
+ }
396
+ const nameFilter = glob ? globToRegExp(glob) : null;
397
+ /**
398
+ * ⭐ The walk records the directories it declined to ENTER, which the
399
+ * per-file `note()` below could never see — it only ever hears about files
400
+ * the walk actually yielded. A directory skipped at the walk level was
401
+ * previously invisible to both, which is how "not searched" came back as
402
+ * "not there".
403
+ */
404
+ const budget = { scanned: 0, hiddenSkipped: [], hiddenSkippedCount: 0 };
405
+ const matches = [];
406
+
407
+ /**
408
+ * ⚠️ EVERY `continue` IN THE LOOP BELOW GOES THROUGH HERE. That is the whole
409
+ * fix: the old code had four bare `continue`s, and four bare `continue`s in a
410
+ * function that reports `matches: []` is a function that says "not there"
411
+ * when it means "not opened". If you add a fifth reason to skip a file, it
412
+ * gets a `note()` too — a skip nobody can see is the bug returning.
413
+ */
414
+ const skipped = [];
415
+ let skippedCount = 0;
416
+ let withheld = 0;
417
+ const note = (path, reason) => {
418
+ skippedCount += 1;
419
+ if (skipped.length < MAX_SKIPPED_LISTED) skipped.push({ path, reason });
420
+ };
421
+
422
+ /**
423
+ * How far past the requested page we keep counting before admitting the count
424
+ * is a floor. See MAX_COUNTED — counting costs a file read here, which is why
425
+ * this is bounded at all.
426
+ */
427
+ const countLimit = start + MAX_MATCHES + MAX_COUNTED;
428
+ let total = 0;
429
+ let countCapped = false;
430
+
431
+ scan:
432
+ for (const abs of walk(root, root, budget)) {
433
+ const rel = relative(root, abs).split(sep).join('/');
434
+ // A caller's own `glob` narrowing is not a skip — they asked for it, and
435
+ // reporting it back as "not looked at" would bury the real skips in noise.
436
+ if (nameFilter && !nameFilter.test(rel) && !nameFilter.test(rel.split('/').pop())) continue;
437
+
438
+ /**
439
+ * ── ⚠️⚠️ THE SECOND DOOR INTO THE WORST BUG THIS PACKAGE HAS HAD ────────
440
+ *
441
+ * `turn.mjs:112` documents the first: the deterministic pre-load read every
442
+ * small file in the tree into the prompt, `.env` and `id_rsa` included, and
443
+ * the provider chain then fanned them across up to four upstreams. It was
444
+ * fixed there, and the fix stopped exactly one caller.
445
+ *
446
+ * This tool is the other way in, and a worse one, because the pre-load only
447
+ * reads what happens to be lying around while this reads what the model
448
+ * ASKS for — in round 1, before any plan is reviewed, through the same
449
+ * prompt and the same chain. Verified on a fixture 2026-08-10: searching
450
+ * `CANARY` returned `password: …` from `config/credentials.yml`, the body
451
+ * of `id_rsa`, and `{"aws_secret":"…"}` from `secrets.json`.
452
+ *
453
+ * ⭐ The hidden-file rule in `walk` was never cover for this. It stops
454
+ * `.env`, and stops nothing else: `id_rsa`, `secrets.json`, `server.key`
455
+ * and `config/credentials.yml` are all ordinary visible files.
456
+ *
457
+ * ⭐ WITHHELD, NOT DROPPED. Silently omitting them would be the very lie
458
+ * this function was just fixed for — the model would search for a config
459
+ * key, find nothing, and go write a duplicate. It is told the file exists
460
+ * and that the contents are not coming.
461
+ */
462
+ if (refusedCommitPath(rel)) {
463
+ withheld += 1;
464
+ note(rel, 'credential file — the path is shown, the contents are never returned');
465
+ continue;
466
+ }
467
+
468
+ if (BINARY_EXT.test(rel)) { note(rel, 'binary file extension — not read as text'); continue; }
469
+
470
+ let stat;
471
+ try { stat = statSync(abs); } catch { note(rel, 'could not be stat-ed — it may have been deleted mid-walk'); continue; }
472
+ if (stat.size > MAX_FILE_BYTES) {
473
+ note(rel, `over ${MAX_FILE_BYTES / 1024}KB — read it directly with read_file if you need this one`);
474
+ continue;
475
+ }
476
+
477
+ let text;
478
+ try { text = readFileSync(abs, 'utf8'); } catch { note(rel, 'could not be read — check the file permissions'); continue; }
479
+ // A NUL in the first block means binary regardless of extension — the same
480
+ // heuristic readFile uses, so search and read agree about what is text.
481
+ // ⚠️ UTF-16 source lands here too, so this is not only "real" binaries.
482
+ if (text.includes('\x00')) { note(rel, 'binary (contains a NUL byte) — UTF-16 text looks like this too'); continue; }
483
+
484
+ /**
485
+ * ── ⚠️ A UTF-8 BOM DEFEATS A `^` ANCHOR, SILENTLY ──────────────────────
486
+ * `readFileSync(..., 'utf8')` hands back the BOM as a real U+FEFF at index
487
+ * 0, so line 1 of a BOM file is `"import …"` and `/^import/` does not
488
+ * match it. The reply is `matches: []` — the exact triple this module
489
+ * documents as meaning "it is not there" — for a line that plainly is.
490
+ * The BOM is an encoding marker, not content, so it comes off before
491
+ * anything looks at the text, which also keeps it out of the returned line.
492
+ */
493
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
494
+
495
+ const lines = text.split('\n');
496
+ for (let i = 0; i < lines.length; i++) {
497
+ const raw = lines[i];
498
+ /**
499
+ * ── ⚠️ CRLF: THE OTHER HALF OF A REAL WINDOWS TREE ────────────────────
500
+ * Splitting on `\n` leaves the `\r` sitting on the end of every line, so
501
+ * `$` can never match anything but the carriage return, and a
502
+ * `$`-anchored search over a CRLF file returns NOTHING. Same silent
503
+ * "it is not there". `\r` is half of the line TERMINATOR, not content.
504
+ */
505
+ const line = raw.charCodeAt(raw.length - 1) === 13 ? raw.slice(0, -1) : raw;
506
+ if (!rx.test(line)) continue;
507
+
508
+ total += 1;
509
+ if (total > start && matches.length < MAX_MATCHES) {
510
+ /**
511
+ * ── ⭐⭐ THE ONE-LINE FIX THIS FUNCTION EXISTED TO GET WRONG ────────
512
+ *
513
+ * This used to be `lines[i].trim()`, and `trim()` takes the LEADING
514
+ * whitespace off. Search feeds edit: the model builds an `edit_file`
515
+ * old_string out of this exact string, and a left-aligned copy of an
516
+ * indented line CANNOT MATCH the file. It is refused, guesses the
517
+ * indentation, is refused again, and the round budget is gone —
518
+ * `read-window.mjs:34` records the session that died this way, six
519
+ * spaces guessed where `lib/git.mjs:282` has two.
520
+ *
521
+ * ⭐ TRAILING whitespace only. That direction is safe in the way the
522
+ * other is not: the result stays a byte-exact SUBSTRING of the file,
523
+ * which is the only property `edit_file` actually tests.
524
+ */
525
+ const kept = line.trimEnd();
526
+ const clipped = kept.length > MAX_LINE_CHARS;
527
+ const m = { path: rel, line: i + 1, text: kept.slice(0, MAX_LINE_CHARS) };
528
+ /**
529
+ * ⚠️ SAID OUT LOUD when it happens. A clipped line is a PREFIX, so an
530
+ * old_string built from it is refused — and being told why beats
531
+ * guessing. An 8,000-character minified line would otherwise swamp the
532
+ * whole reply with one useless match, so the cap stays.
533
+ */
534
+ if (clipped) m.clipped = true;
535
+ matches.push(m);
536
+ }
537
+ if (total >= countLimit) { countCapped = true; break scan; }
538
+ }
539
+ }
540
+
541
+ /**
542
+ * ⚠️ THE WALK CAP IS A TRUNCATION TOO. `walk` stops dead at
543
+ * MAX_FILES_SCANNED, and the old code still answered `truncated: false` — the
544
+ * same claim of completeness, made by a different mechanism. Both causes mean
545
+ * "there may be more", so both set the flag, and `scanCapped` says which.
546
+ */
547
+ const scanCapped = budget.scanned >= MAX_FILES_SCANNED;
548
+ const unseen = start + matches.length < total;
549
+
550
+ return {
551
+ ok: true,
552
+ pattern,
553
+ /**
554
+ * ⭐ RANKED, NOT WALK-ORDERED. Matches were pushed in raw filesystem
555
+ * traversal order and capped at MAX_MATCHES, so a model hunting a symbol
556
+ * read whatever the directory walk reached first and the DEFINITION being
557
+ * near the top was luck. `rankMatches` puts definitions and exports above
558
+ * mentions.
559
+ *
560
+ * ⚠️ IT REORDERS THE PAGE AND CHANGES NOTHING ELSE. `total`, `unseen` and
561
+ * `nextOffset` are computed above from the unranked window and are
562
+ * untouched — re-ranking ACROSS pages would require collecting every match
563
+ * before returning any, which breaks the bounded-scan contract that keeps a
564
+ * search on a large repo from reading the whole thing.
565
+ */
566
+ matches: rankMatches(matches, pattern),
567
+ /** Matches skipped to build this page — echoed so a dropped offset is visible. */
568
+ offset: start,
569
+ /** Every match found, not just the ones returned. 61 and 6,000 are different answers. */
570
+ total,
571
+ /** ⚠️ `total` is a FLOOR when the walk or the count was cut short. */
572
+ totalExact: !scanCapped && !countCapped,
573
+ /** Where to resume; `null` when nothing follows this page. */
574
+ nextOffset: unseen || countCapped ? start + matches.length : null,
575
+ countCapped,
576
+ truncated: unseen || countCapped || scanCapped,
577
+ scanned: budget.scanned,
578
+ scanCapped,
579
+ /**
580
+ * ⭐ Two sources, one answer. `skipped` holds files the walk YIELDED and
581
+ * this function then declined to read; `budget.hiddenSkipped` holds
582
+ * directories and hidden files the walk never entered at all. Reporting only
583
+ * the first is what let a whole `.github/` tree vanish behind
584
+ * `skippedCount: 0`. The list is capped for context; the count is exact.
585
+ */
586
+ skipped: [...skipped, ...budget.hiddenSkipped].slice(0, MAX_SKIPPED_LISTED),
587
+ skippedCount: skippedCount + budget.hiddenSkippedCount,
588
+ withheld,
589
+ };
590
+ }
591
+
592
+ export function searchToolSchemas() {
593
+ return [
594
+ {
595
+ type: 'function',
596
+ function: {
597
+ name: 'find_files',
598
+ description: [
599
+ 'Find files by NAME anywhere in the workspace, using a glob.',
600
+ 'Examples: "*.test.ts", "src/**/*.tsx", "package.json".',
601
+ 'A `**/` segment matches zero or more directories, so "**/*.json" includes a top-level package.json.',
602
+ 'Use this before assuming where something lives — never guess a path.',
603
+ 'node_modules, .git, dist and build are never searched.',
604
+ 'Your own `.acuvo/` IS searched — the plan, board, checkpoints, policy and audit log are all findable — except `.acuvo/sessions`, which you list with `acuvo --sessions` instead.',
605
+ // ⚠️ A cap the model cannot step past is a dead end, and a cap with no
606
+ // size attached makes 61 files and 6,000 files read identically.
607
+ `Results are capped at ${MAX_MATCHES} per call: \`total\` is how many matched in all, and \`offset\` skips that many to read the next page (use the \`nextOffset\` the reply gives you). \`totalExact: false\` means even \`total\` is a floor.`,
608
+ ].join(' '),
609
+ parameters: {
610
+ type: 'object',
611
+ properties: {
612
+ pattern: { type: 'string', description: 'A glob, e.g. "src/**/*.ts".' },
613
+ offset: {
614
+ type: 'integer',
615
+ minimum: 0,
616
+ description: `How many matches to skip. Defaults to 0. Pass ${MAX_MATCHES} for the second page, or just use the reply's nextOffset.`,
617
+ },
618
+ },
619
+ required: ['pattern'],
620
+ },
621
+ },
622
+ },
623
+ {
624
+ type: 'function',
625
+ function: {
626
+ name: 'search_text',
627
+ description: [
628
+ 'Search file CONTENTS with a regular expression and get back path, line number and the line.',
629
+ 'This is how you find where a function is defined, who calls it, or where a string comes from.',
630
+ 'Optionally narrow by filename with `glob`. Escape regex characters for a literal search.',
631
+ // ⭐ THE HANDOFF, STATED. The model has to know the string is safe to
632
+ // paste into an edit, or it will "helpfully" re-indent it and fail.
633
+ 'Each hit\'s `text` is the line VERBATIM — leading indentation and all, byte-exact except for trailing whitespace — so you can copy it straight into an edit_file old_string. (`clipped: true` means the line was longer than the display cap and the text is only a prefix; read_around it instead.)',
634
+ `Results are capped at ${MAX_MATCHES} per call: \`total\` is how many lines matched in all, and \`offset\` skips that many to read the next page (use the \`nextOffset\` the reply gives you). \`totalExact: false\` means even \`total\` is a floor.`,
635
+ // ⚠️ SAID OUT LOUD, because a field the model is not told about is a
636
+ // field it does not read, and then the honest reply is as misleading
637
+ // as the dishonest one was.
638
+ 'An empty `matches` means NOT FOUND only if `skippedCount` is 0: `skipped` lists files that could not be read (too large, binary) and `withheld` counts credential files whose contents are never returned. If what you want may be in one of those, open it with read_file instead.',
639
+ ].join(' '),
640
+ parameters: {
641
+ type: 'object',
642
+ properties: {
643
+ pattern: {
644
+ type: 'string',
645
+ description: 'A JavaScript regular expression, case-insensitive. `^` and `$` anchor to the line, and work on CRLF files and files with a UTF-8 BOM.',
646
+ },
647
+ glob: { type: 'string', description: 'Optional filename filter, e.g. "*.ts".' },
648
+ offset: {
649
+ type: 'integer',
650
+ minimum: 0,
651
+ description: `How many matches to skip. Defaults to 0. Pass ${MAX_MATCHES} for the second page, or just use the reply's nextOffset.`,
652
+ },
653
+ },
654
+ required: ['pattern'],
655
+ },
656
+ },
657
+ },
658
+ ];
659
+ }