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
@@ -0,0 +1,834 @@
1
+ /**
2
+ * ── ⭐⭐⭐ lib/localize.mjs — WHICH FILES, AND NOTHING ELSE ───────────────────
3
+ *
4
+ * THE MEASUREMENT THIS MODULE EXISTS FOR. File-level localization is worth a
5
+ * **15-17x improvement over a no-file baseline** — larger than any prompt,
6
+ * model or harness change measured on this codebase. Everything downstream (the
7
+ * edit, the patch, the verifier) operates on whatever files arrived; if the
8
+ * right file is not among them, none of it can succeed.
9
+ *
10
+ * ── ⚠️ FOUR RESULTS THAT CONTRADICT THE OBVIOUS DESIGN ──────────────────────
11
+ *
12
+ * 1. **LLM-selected files BEAT gold.** Model-chosen files scored **60.6%**
13
+ * using **8.5 files**; the gold buggy-files-only set scored **56.2%** using
14
+ * **1.25**. Surrounding context is not slack — it is the difference. So
15
+ * this module PADS a thin answer rather than trusting a precise one.
16
+ * 2. **The optimum is 6-10 files.** Not one, not fifty. Both bounds are
17
+ * enforced here (`padToWindow`).
18
+ * 3. **Line-level narrowing DEGRADES results.** Cutting a file down to the
19
+ * suspect lines removes the context that made the file useful and adds
20
+ * noise. ⚠️ THERE IS DELIBERATELY NO LINE-RANGE STEP IN THIS MODULE, and
21
+ * adding one would be a regression, not a feature.
22
+ * 4. **The winning approach uses NO EMBEDDINGS AT ALL.** No index, no vector
23
+ * store, no similarity. Repo tree → an explicit "which folders are
24
+ * IRRELEVANT" filter → 5-10 files ordered by importance → skeletons →
25
+ * stop. The negative filter is the step almost nobody copies and it is the
26
+ * one that makes the rest cheap.
27
+ *
28
+ * Remaining headroom for PERFECT localization is **+7.7 points**, so this is not
29
+ * a place to spend forever — it is a place to stop being blind.
30
+ *
31
+ * ── ⚠️ WHAT THIS MODULE IS NOT ─────────────────────────────────────────────
32
+ *
33
+ * It is NOT `repo-map.mjs`. That module renders a budgeted list of FILES with
34
+ * exported symbol names, and it is the right thing for round 1 of every turn.
35
+ * This one renders DIRECTORIES, because the question here is different: the
36
+ * model is being asked which folders to throw away, and a file list cannot be
37
+ * thrown away by the folder. The two are complementary and share `estimateTokens`
38
+ * and `byCodePoint` rather than re-deriving them.
39
+ *
40
+ * ⚠️⚠️ IT MAKES NO MODEL CALLS AND CANNOT. There is no import of `model.mjs`
41
+ * or `chain.mjs` anywhere in this file; every model interaction goes through an
42
+ * injected `askImpl`, and with no `askImpl` the loop refuses to run. That is not
43
+ * a testing convenience — it means a bug in this module cannot cost money,
44
+ * because there is no client in scope for it to call.
45
+ *
46
+ * ⭐ EVERY EXPORT BELOW IS PURE except `localize`, which is async only because
47
+ * `askImpl` is. No clock, no randomness, no ambient `fs`.
48
+ */
49
+
50
+ import { estimateTokens } from './repo-map.mjs';
51
+ import { byCodePoint } from './prefix-order.mjs';
52
+
53
+ /**
54
+ * ⭐ THE DEFAULT IS SMALL ON PURPOSE — 2,000, against `repo-map`'s 9,000.
55
+ *
56
+ * The tree is not the payload, it is a QUESTION: "which of these folders can we
57
+ * throw away?". Measured on the shape this module targets — 50,000 files over
58
+ * 40 packages — the whole tree renders in ~90 lines, roughly 400 tokens, so the
59
+ * budget is not what limits a real monorepo. It limits the pathological case of
60
+ * a repo with thousands of near-empty directories, which is precisely where an
61
+ * unbounded renderer would have produced tens of thousands of tokens to say
62
+ * almost nothing.
63
+ */
64
+ export const DEFAULT_TREE_BUDGET_TOKENS = 2_000;
65
+
66
+ /**
67
+ * ⭐ 6 AND 10 ARE THE MEASURED WINDOW, not a taste call. Below 6 the model is
68
+ * working without the surrounding context that beat gold; above 10 the extra
69
+ * files measure as noise. Both ends are enforced.
70
+ */
71
+ export const OPTIMAL_MIN_FILES = 6;
72
+ export const OPTIMAL_MAX_FILES = 10;
73
+
74
+ /**
75
+ * ⚠️ THE LOOP IS BOUNDED BECAUSE A MODEL CAN NAME A NEW FILE FOREVER. Three
76
+ * rounds is: name → look at skeletons → confirm. A run that has not converged
77
+ * by then is not going to, and every extra round is a whole model call.
78
+ */
79
+ export const MAX_LOCALIZE_ROUNDS = 3;
80
+
81
+ /** How many directories the omission note names before it stops. */
82
+ const MAX_GAP_LINES = 8;
83
+
84
+ /** A skeleton line longer than this is a minified file, not a signature. */
85
+ const MAX_LINE_CHARS = 200;
86
+
87
+ /** A signature spanning more lines than this is not a signature any more. */
88
+ const MAX_SIGNATURE_LINES = 8;
89
+
90
+ /** Source extensions a padding sibling may have. Prose is not a code sibling. */
91
+ const SOURCE_EXT = /\.(mjs|cjs|jsx?|tsx?|mts|cts|py|go|rs|rb|php|java|kt|swift|cs|c|h|cc|cpp|hpp|vue|svelte)$/i;
92
+
93
+ // ─────────────────────────────────────────────────────────────────────────────
94
+ // PATHS
95
+ // ─────────────────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * One spelling of a path, so two spellings of the same file cannot read as two
99
+ * files. ⚠️ THE BACKSLASH CASE IS NOT HYPOTHETICAL — this project is developed
100
+ * on Windows, and a model handed `lib\localize.mjs` in one round and
101
+ * `lib/localize.mjs` in the next would never reach a fixed point.
102
+ */
103
+ export function normalizePath(p) {
104
+ if (typeof p !== 'string') return '';
105
+ let out = p.replace(/\\/g, '/').trim();
106
+ while (out.startsWith('./')) out = out.slice(2);
107
+ while (out.startsWith('/')) out = out.slice(1);
108
+ while (out.endsWith('/')) out = out.slice(0, -1);
109
+ return out;
110
+ }
111
+
112
+ /** The directory a path sits in. `''` — not `'.'` — for a file at the root. */
113
+ function dirOf(path) {
114
+ const i = path.lastIndexOf('/');
115
+ return i < 0 ? '' : path.slice(0, i);
116
+ }
117
+
118
+ function depthOf(dir) {
119
+ if (dir === '') return 0;
120
+ return dir.split('/').length;
121
+ }
122
+
123
+ // ─────────────────────────────────────────────────────────────────────────────
124
+ // THE TREE
125
+ // ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Index every directory implied by a list of paths.
129
+ *
130
+ * ⭐ `own` AND `subtree` ARE BOTH NEEDED, and keeping only one is the bug.
131
+ * `own` alone makes a parent whose children were pruned look empty; `subtree`
132
+ * alone makes a package root look like it holds 1,250 files directly. The model
133
+ * decides whether to cut a folder on the size of what it is cutting, which is
134
+ * `subtree`, and decides whether to look inside on `own`.
135
+ *
136
+ * @param {string[]} paths
137
+ * @returns {Map<string, {dir: string, own: number, subtree: number, depth: number}>}
138
+ */
139
+ export function buildDirIndex(paths) {
140
+ const index = new Map();
141
+ const touch = (dir) => {
142
+ let row = index.get(dir);
143
+ if (!row) {
144
+ row = { dir, own: 0, subtree: 0, depth: depthOf(dir) };
145
+ index.set(dir, row);
146
+ }
147
+ return row;
148
+ };
149
+ touch('');
150
+
151
+ for (const raw of Array.isArray(paths) ? paths : []) {
152
+ const path = normalizePath(raw);
153
+ if (path === '') continue;
154
+ const dir = dirOf(path);
155
+ touch(dir).own += 1;
156
+ // Every ancestor counts it in its subtree, including the root sentinel.
157
+ let cursor = dir;
158
+ for (;;) {
159
+ touch(cursor).subtree += 1;
160
+ if (cursor === '') break;
161
+ cursor = dirOf(cursor);
162
+ }
163
+ }
164
+ return index;
165
+ }
166
+
167
+ /**
168
+ * Render the directory tree, under a hard budget.
169
+ *
170
+ * ── ⭐⭐ WHY DIRECTORIES, AND WHY THIS IS THE SIZE ANSWER ────────────────────
171
+ *
172
+ * A 50,000-file monorepo must not produce a 50,000-line prompt. It does not,
173
+ * and not because of the budget — because the UNIT is a directory. Measured on
174
+ * exactly that shape (40 packages × 1,250 files): **90 lines, and all 40
175
+ * packages visible**. The budget is the second line of defence, for a tree that
176
+ * is pathological in directories rather than files.
177
+ *
178
+ * ── ⭐ SHALLOW FIRST, AND WHY THAT IS NOT `repo-map`'s DEPTH CLIFF ──────────
179
+ *
180
+ * `repo-map.mjs` documents, with numbers, that ordering a FILE list by depth
181
+ * produced "659 files listed, drawn from 7 of 360 directories" — a cliff. The
182
+ * ordering here is depth-first-shallow anyway, and the inversion is the point:
183
+ * the model is choosing folders to DISCARD, and a folder subsumes its subtree.
184
+ * Cutting `docs/` cuts every `docs/**` whether or not `docs/deep/deeper/` was
185
+ * ever printed. So a shallow line is worth more than a deep one here in a way
186
+ * it never is in a file list, and a deep directory that was not shown is not
187
+ * invisible — its files are still counted in its parent's `subtree`.
188
+ *
189
+ * @param {string[]} paths
190
+ * @param {{budgetTokens?: number}} [opts]
191
+ */
192
+ export function renderTree(paths, opts = {}) {
193
+ const budgetTokens = opts.budgetTokens ?? DEFAULT_TREE_BUDGET_TOKENS;
194
+ const index = buildDirIndex(paths);
195
+ const filesTotal = index.get('')?.subtree ?? 0;
196
+
197
+ // Directories with nothing in their subtree cannot exist here (they are only
198
+ // created by a path), but a defensive filter costs nothing and keeps the
199
+ // renderer honest if the index is ever fed directly.
200
+ const rows = [...index.values()].filter((r) => r.subtree > 0);
201
+ rows.sort((a, b) => a.depth - b.depth || b.subtree - a.subtree || byCodePoint(a.dir, b.dir));
202
+
203
+ const lineFor = (r) => {
204
+ const name = r.dir === '' ? './' : `${r.dir}/`;
205
+ const deeper = r.subtree - r.own;
206
+ if (deeper > 0) return ` ${name} ${r.own} files (+${deeper} deeper)`;
207
+ return ` ${name} ${r.own} files`;
208
+ };
209
+
210
+ const render = (n) => {
211
+ const shown = rows.slice(0, n);
212
+ const hidden = rows.slice(n);
213
+ const parts = [
214
+ 'REPO TREE — directories only. Counts are files directly in the folder;',
215
+ '"+N deeper" is everything below it, shown or not.',
216
+ '',
217
+ ...[...shown].sort((a, b) => byCodePoint(a.dir, b.dir)).map(lineFor),
218
+ ];
219
+ if (hidden.length > 0) {
220
+ /**
221
+ * ⚠️ NAMED PARENTS, NOT A BARE COUNT. `repo-map.mjs` learned this the
222
+ * same way: "42 directories omitted" is unactionable, while "src/ 31
223
+ * directories" is a next move — and here it is more than a courtesy,
224
+ * because the model is about to declare folders irrelevant and must not
225
+ * declare one irrelevant on the strength of a listing it cannot see.
226
+ */
227
+ const byTop = new Map();
228
+ for (const r of hidden) {
229
+ const top = r.dir.includes('/') ? `${r.dir.slice(0, r.dir.indexOf('/'))}/` : `${r.dir}/`;
230
+ byTop.set(top, (byTop.get(top) ?? 0) + 1);
231
+ }
232
+ const gaps = [...byTop.entries()]
233
+ .sort((a, b) => b[1] - a[1] || byCodePoint(a[0], b[0]))
234
+ .slice(0, MAX_GAP_LINES);
235
+ parts.push('', `NOT SHOWN ${hidden.length} of ${rows.length} directories — deepest first`);
236
+ for (const [top, n2] of gaps) parts.push(` under ${top} ${n2} directories`);
237
+ }
238
+ parts.push('', `TOTALS ${rows.length} directories, ${filesTotal} files`);
239
+ return parts.join('\n');
240
+ };
241
+
242
+ // Largest prefix that fits. Monotonic in `n`, so a binary search is exact and
243
+ // cannot loop — a renderer that hangs on a big repo is the failure this whole
244
+ // module is meant to prevent.
245
+ let lo = 0;
246
+ let hi = rows.length;
247
+ if (estimateTokens(render(hi)) > budgetTokens) {
248
+ while (lo < hi) {
249
+ const mid = Math.ceil((lo + hi) / 2);
250
+ if (estimateTokens(render(mid)) <= budgetTokens) lo = mid;
251
+ else hi = mid - 1;
252
+ }
253
+ } else {
254
+ lo = rows.length;
255
+ }
256
+
257
+ const text = render(lo);
258
+ const tokens = estimateTokens(text);
259
+ return {
260
+ text,
261
+ dirsTotal: rows.length,
262
+ dirsShown: lo,
263
+ filesTotal,
264
+ truncated: lo < rows.length,
265
+ tokens,
266
+ /**
267
+ * ── ⚠️ THE BUDGET BOUNDS THE LISTING, NOT THE FRAME, AND IT SAYS SO ──────
268
+ *
269
+ * Measured while writing this: at `budgetTokens: 60` the renderer returned
270
+ * 127 tokens with ZERO directories listed, because the header, the
271
+ * NOT-SHOWN note and the totals are a fixed ~120-token frame. A budget below
272
+ * the frame cannot be met by dropping lines, and the only two honest
273
+ * options are to return a truncated frame that lies about being a tree, or
274
+ * to return the frame and FLAG the overrun.
275
+ *
276
+ * ⭐ Flagging is the one that cannot mislead. A caller that must fit a hard
277
+ * ceiling can test this and drop the tree entirely; a caller that cannot
278
+ * still gets a tree that states its own totals. Silently exceeding the
279
+ * budget while reporting nothing is the shape this repo keeps paying for.
280
+ */
281
+ budgetOverrun: tokens > budgetTokens,
282
+ };
283
+ }
284
+
285
+ // ─────────────────────────────────────────────────────────────────────────────
286
+ // THE NEGATIVE FILTER — the step almost nobody copies
287
+ // ─────────────────────────────────────────────────────────────────────────────
288
+
289
+ /**
290
+ * Apply a model-named list of IRRELEVANT folders to a path list.
291
+ *
292
+ * ── ⭐ WHY ASK WHAT TO THROW AWAY RATHER THAN WHAT TO KEEP ──────────────────
293
+ *
294
+ * A "which folders matter" question makes the model commit to a small set from
295
+ * a listing it has only skimmed, and everything it forgot is gone. A "which
296
+ * folders are irrelevant" question is answered from confidence: `docs/`,
297
+ * `vendor/`, `fixtures/` are obviously not where a login bug lives, and
298
+ * anything the model is unsure about simply survives. The failure mode of the
299
+ * first question is losing the answer; the failure mode of the second is doing
300
+ * less work than we hoped. Those are not comparable costs.
301
+ *
302
+ * ── ⚠️ TWO GUARDS, BOTH OF THEM LOAD-BEARING ───────────────────────────────
303
+ *
304
+ * · SEGMENT MATCHING. `src/app` must not delete `src/apple.js`. A bare
305
+ * `startsWith` does exactly that, silently, and the file it eats is a source
306
+ * file in the same directory as the one the model actually wanted.
307
+ * · REFUSAL. A filter that removes EVERY candidate is rejected whole. An
308
+ * empty candidate set does not read downstream as "the filter was wrong",
309
+ * it reads as "this repo has no relevant files" — an error disguised as an
310
+ * answer, which is the one shape this codebase keeps paying for.
311
+ *
312
+ * @returns {{kept: string[], removed: string[], unmatched: string[], rejected: string[], refused: boolean, reason: string}}
313
+ */
314
+ export function applyIrrelevantFilter(paths, folders) {
315
+ const all = (Array.isArray(paths) ? paths : []).map(normalizePath).filter((p) => p !== '');
316
+ const rejected = [];
317
+ const wanted = [];
318
+ for (const raw of Array.isArray(folders) ? folders : []) {
319
+ const f = normalizePath(raw);
320
+ // ⚠️ `.`, `/`, `` and `..` all mean "the whole repo" after normalization.
321
+ // They are rejected here rather than caught by the emptiness guard below,
322
+ // so the caller can tell a bad ENTRY from an over-broad but legitimate one.
323
+ if (f === '' || f === '.' || f === '..') { rejected.push(String(raw)); continue; }
324
+ wanted.push(f);
325
+ }
326
+
327
+ const hits = new Map(wanted.map((f) => [f, 0]));
328
+ const kept = [];
329
+ const removed = [];
330
+ for (const p of all) {
331
+ let cut = null;
332
+ for (const f of wanted) {
333
+ // Equality catches a folder name that is actually a file; the trailing
334
+ // slash is what makes this a SEGMENT match rather than a prefix match.
335
+ if (p === f || p.startsWith(`${f}/`)) { cut = f; break; }
336
+ }
337
+ if (cut === null) kept.push(p);
338
+ else { removed.push(p); hits.set(cut, hits.get(cut) + 1); }
339
+ }
340
+
341
+ const unmatched = [...hits.entries()].filter(([, n]) => n === 0).map(([f]) => f).sort(byCodePoint);
342
+
343
+ if (all.length > 0 && kept.length === 0) {
344
+ return {
345
+ kept: all,
346
+ removed: [],
347
+ unmatched,
348
+ rejected,
349
+ refused: true,
350
+ reason: 'the filter would have removed every candidate file, so it was not applied',
351
+ };
352
+ }
353
+
354
+ return { kept, removed, unmatched, rejected, refused: false, reason: '' };
355
+ }
356
+
357
+ // ─────────────────────────────────────────────────────────────────────────────
358
+ // THE SKELETON — signatures and declarations, never a body
359
+ // ─────────────────────────────────────────────────────────────────────────────
360
+
361
+ /**
362
+ * ⚠️ EXTENSION → LANGUAGE, and an unknown one produces NOTHING.
363
+ * `repo-map.mjs` states the rule for symbol names and it is stronger here: a
364
+ * markdown file containing the words "export function" is not a module, and a
365
+ * fabricated skeleton is worse than no skeleton because the model reads a
366
+ * skeleton as a PARSE and stops looking.
367
+ */
368
+ function languageOf(path) {
369
+ const m = /\.([A-Za-z0-9]+)$/.exec(String(path || ''));
370
+ if (!m) return null;
371
+ const ext = m[1].toLowerCase();
372
+ if (['mjs', 'cjs', 'js', 'jsx'].includes(ext)) return 'js';
373
+ if (['ts', 'tsx', 'mts', 'cts'].includes(ext)) return 'ts';
374
+ if (ext === 'py') return 'py';
375
+ return null;
376
+ }
377
+
378
+ /** Words that begin a STATEMENT, not a declaration. A body line, in short. */
379
+ const JS_CONTROL = /^(if|for|while|switch|catch|return|else|do|try|throw|await|yield|case|break|continue|new|delete|typeof|void)\b/;
380
+
381
+ const JS_DECL = [
382
+ /^export\s+default\b/,
383
+ /^export\s+(async\s+)?function\b/,
384
+ /^export\s+(abstract\s+)?class\b/,
385
+ /^export\s+(const|let|var)\b/,
386
+ /^export\s+(type|interface|enum|namespace|declare)\b/,
387
+ /^export\s*[{*]/,
388
+ /^import\b/,
389
+ /^(async\s+)?function\s*\*?\s*[A-Za-z_$]/,
390
+ /^(abstract\s+)?class\s+[A-Za-z_$]/,
391
+ /^(const|let|var)\s+[A-Za-z_$][\w$]*\s*[:=]/,
392
+ /^(type|interface|enum|namespace|declare)\s+[A-Za-z_$]/,
393
+ /^module\.exports\b/,
394
+ /^exports\.[A-Za-z_$]/,
395
+ ];
396
+
397
+ /** Inside a class body: `run(x) {`, `async run(x) {`, `get size() {`, `#priv(` … */
398
+ const JS_MEMBER = /^((public|private|protected|static|readonly|abstract|override|async|get|set)\s+)*[#*]?[A-Za-z_$][\w$]*\s*[(<]/;
399
+
400
+ const PY_DECL = [
401
+ /^(async\s+)?def\s+/,
402
+ /^class\s+/,
403
+ /^@[A-Za-z_]/,
404
+ /^(import|from)\s+/,
405
+ /^[A-Z_][A-Z0-9_]*\s*[:=]/,
406
+ ];
407
+
408
+ /** Parens/brackets still open at the end of a line — i.e. the signature runs on. */
409
+ function openDelta(line) {
410
+ let d = 0;
411
+ let str = null;
412
+ for (let i = 0; i < line.length; i++) {
413
+ const c = line[i];
414
+ if (str) {
415
+ if (c === '\\') { i += 1; continue; }
416
+ if (c === str) str = null;
417
+ continue;
418
+ }
419
+ if (c === '"' || c === "'" || c === '`') { str = c; continue; }
420
+ if (c === '(' || c === '[') d += 1;
421
+ else if (c === ')' || c === ']') d -= 1;
422
+ }
423
+ return d;
424
+ }
425
+
426
+ /**
427
+ * ⭐ THE BODY IS CUT AT THE BRACE, not at the line. `export function f(a) { return a; }`
428
+ * is a declaration AND a body on one line, and keeping it whole leaks exactly
429
+ * what the skeleton exists to drop.
430
+ */
431
+ function trimBody(line, language) {
432
+ let out = line;
433
+ if (language !== 'py') {
434
+ /**
435
+ * ⚠️⚠️ THE FIRST VERSION ATE EVERY IMPORT, AND THE TEST CAUGHT IT.
436
+ * `import { readFileSync } from 'node:fs';` rendered as the single word
437
+ * `import` — the brace-cut fired on a BINDING LIST. An import line that
438
+ * says nothing about what was imported is worse than no import line: the
439
+ * model reads "this file imports something" and learns nothing, while
440
+ * paying for the line. On an import/export the braces ARE the payload.
441
+ */
442
+ /**
443
+ * ⚠️⚠️ THE FIRST FIX FOR THAT WAS `/^(import|export)\b/` AND IT WAS TOO
444
+ * WIDE — a second test caught it. It exempted `export function f(a) {
445
+ * return SECRET; }` as well, so a one-line function body was reproduced
446
+ * verbatim in the "signatures only" output. The exemption belongs to the
447
+ * two forms where a brace is a BINDING LIST rather than a block: an import,
448
+ * and a re-export (`export { a }` / `export * from`). `export function`,
449
+ * `export class` and `export const` all open real bodies.
450
+ */
451
+ const isBindingList = /^import\b/.test(out) || /^export\s*[{*]/.test(out);
452
+ const brace = isBindingList ? -1 : out.indexOf('{');
453
+ // ⚠️ NOT WHEN THE BRACE IS THE TYPE. `function f(o: { a: number })` opens a
454
+ // brace inside the parameter list; cutting there destroys the signature.
455
+ if (brace >= 0 && openDelta(out.slice(0, brace)) <= 0) out = out.slice(0, brace).replace(/\s+$/, '');
456
+ } else {
457
+ const colon = out.lastIndexOf(':');
458
+ if (colon >= 0 && colon === out.length - 1) out = out.slice(0, colon + 1);
459
+ }
460
+ if (out.length > MAX_LINE_CHARS) out = `${out.slice(0, MAX_LINE_CHARS)}…`;
461
+ return out;
462
+ }
463
+
464
+ /**
465
+ * Signatures and declarations for one file, with line numbers, no bodies.
466
+ *
467
+ * ── ⭐ WHY THIS IS THE STEP THAT MAKES 6-10 FILES AFFORDABLE ────────────────
468
+ *
469
+ * The window is 6-10 files because that is what measures best — but ten whole
470
+ * files is tens of thousands of tokens and blows the round budget on its own.
471
+ * A skeleton is what makes the measured optimum reachable rather than
472
+ * theoretical: the model sees the shape of ten files for the price of two, and
473
+ * then spends `read_file` on the one or two it actually needs.
474
+ *
475
+ * ── ⭐ THE LINE NUMBERS ARE THE ELISION MARKER ──────────────────────────────
476
+ *
477
+ * There is no `// …` between kept lines, because the gap in the numbering says
478
+ * the same thing for free AND says how big the gap is. They are also the only
479
+ * way back: a model that wants the body of `run()` reads `read_around` at that
480
+ * number instead of re-reading the file.
481
+ *
482
+ * ⚠️ THE OUTPUT IS A REGEX GUESS AND SAYS SO. A missing member proves nothing.
483
+ *
484
+ * @returns {{ok: boolean, language: string|null, text: string, linesKept: number, linesTotal: number, bytesIn: number, bytesOut: number, reason: string}}
485
+ */
486
+ export function extractSkeleton(path, source, opts = {}) {
487
+ const language = languageOf(path);
488
+ const bytesIn = typeof source === 'string' ? source.length : 0;
489
+ const nothing = (reason) => ({
490
+ ok: false, language, text: '', linesKept: 0, linesTotal: 0, bytesIn, bytesOut: 0, reason,
491
+ });
492
+
493
+ if (typeof source !== 'string' || source === '') return nothing('the file is empty or could not be read');
494
+ if (!language) return nothing(`no skeleton for this file type — read it with read_file if you need it`);
495
+
496
+ const maxLines = opts.maxLines ?? 400;
497
+ const lines = source.split(/\r?\n/);
498
+ const kept = [];
499
+
500
+ let classIndent = -1;
501
+ let carry = 0; // parens still open from an unfinished signature
502
+
503
+ for (let i = 0; i < lines.length && kept.length < maxLines; i++) {
504
+ const raw = lines[i];
505
+ const trimmed = raw.trim();
506
+ const indent = raw.length - raw.trimStart().length;
507
+
508
+ if (carry > 0) {
509
+ // ⚠️ A SIGNATURE THAT SPANS LINES IS ONE SIGNATURE. Cutting it at the
510
+ // first line leaves `export function wide(` — a fragment that tells the
511
+ // model the function exists and lies about how it is called.
512
+ kept.push({ n: i + 1, text: trimBody(trimmed, language) });
513
+ carry += openDelta(trimmed);
514
+ if (carry < 0) carry = 0;
515
+ continue;
516
+ }
517
+
518
+ if (trimmed === '') continue;
519
+
520
+ if (language === 'py') {
521
+ if (!PY_DECL.some((rx) => rx.test(trimmed))) continue;
522
+ kept.push({ n: i + 1, text: `${' '.repeat(Math.min(indent, 8))}${trimBody(trimmed, language)}` });
523
+ const d = openDelta(trimmed);
524
+ if (d > 0) carry = Math.min(d, MAX_SIGNATURE_LINES);
525
+ continue;
526
+ }
527
+
528
+ // JS/TS. Class membership is tracked by indentation rather than by counting
529
+ // braces: a member is a line INSIDE a class that looks callable, and the
530
+ // class ends at the first line indented no further than the class itself.
531
+ if (classIndent >= 0 && indent <= classIndent && !/^[})\]]/.test(trimmed)) classIndent = -1;
532
+
533
+ /**
534
+ * ── ⚠️⚠️ INDENTATION IS WHAT SEPARATES A DECLARATION FROM A LOCAL ──────
535
+ *
536
+ * The first version matched `^(const|let|var)\s+\w+\s*[:=]` at ANY indent
537
+ * and therefore kept `const intermediate = a * SECRET_BODY_TOKEN;` — a line
538
+ * from inside a function body, which is exactly the thing a skeleton exists
539
+ * to drop. It is not a small leak either: locals are the majority of lines
540
+ * in most functions, so the "skeleton" was reproducing the file.
541
+ *
542
+ * ⭐ A skeleton is MODULE-LEVEL structure plus CLASS MEMBERS, and both of
543
+ * those have a known indentation. `import`/`export` are exempt because a
544
+ * `declare module { … }` block legitimately indents them.
545
+ */
546
+ const isDecl = indent === 0
547
+ ? JS_DECL.some((rx) => rx.test(trimmed))
548
+ : /^(export|import)\b/.test(trimmed) && JS_DECL.some((rx) => rx.test(trimmed));
549
+ const isMember = classIndent >= 0
550
+ && indent > classIndent
551
+ && !JS_CONTROL.test(trimmed)
552
+ && JS_MEMBER.test(trimmed)
553
+ // ⚠️ A CALL IS NOT A MEMBER. `doSomething(x);` matches JS_MEMBER's shape
554
+ // exactly; what separates a declaration from a call is that a declaration
555
+ // OPENS A BODY on the same line or runs on to the next.
556
+ && (trimmed.includes('{') || openDelta(trimmed) > 0 || /[)>]\s*[:;]?\s*$/.test(trimmed))
557
+ && !trimmed.endsWith(';');
558
+
559
+ if (!isDecl && !isMember) continue;
560
+
561
+ kept.push({ n: i + 1, text: `${classIndent >= 0 && isMember ? ' ' : ''}${trimBody(trimmed, language)}` });
562
+
563
+ if (/^(export\s+)?(abstract\s+)?class\s/.test(trimmed) || /^export\s+default\s+class\s/.test(trimmed)) {
564
+ classIndent = indent;
565
+ }
566
+ const d = openDelta(trimmed);
567
+ if (d > 0) carry = Math.min(d, MAX_SIGNATURE_LINES);
568
+ }
569
+
570
+ if (kept.length === 0) return nothing('no declarations found — this file may be data, or all body');
571
+
572
+ const width = String(lines.length).length;
573
+ const body = kept.map((k) => `${String(k.n).padStart(width, ' ')}: ${k.text}`).join('\n');
574
+ const text = `${path} — signatures only (a regex guess; a missing member proves nothing)\n${body}`;
575
+
576
+ return {
577
+ ok: true,
578
+ language,
579
+ text,
580
+ linesKept: kept.length,
581
+ linesTotal: lines.length,
582
+ bytesIn,
583
+ bytesOut: text.length,
584
+ reason: '',
585
+ };
586
+ }
587
+
588
+ // ─────────────────────────────────────────────────────────────────────────────
589
+ // THE WINDOW
590
+ // ─────────────────────────────────────────────────────────────────────────────
591
+
592
+ /**
593
+ * Enforce the measured 6-10 file window.
594
+ *
595
+ * ⭐ PADDING IS THE COUNTER-INTUITIVE HALF, and it is the finding this whole
596
+ * module is built on: LLM-selected files at **8.5 files scored 60.6%**, gold
597
+ * buggy-files-only at **1.25 files scored 56.2%**. A precise answer measured
598
+ * WORSE than a loose one. So a model that names two files is not rewarded for
599
+ * its confidence — its neighbours are added, because "the file next to the bug"
600
+ * is where the caller, the type and the test live.
601
+ *
602
+ * ⚠️ SIBLINGS ONLY, AND SOURCE ONLY. Padding with `docs/x.md` spends the window
603
+ * on something that cannot contain the answer; padding from an unrelated
604
+ * directory is the noise that line-level narrowing was measured to introduce.
605
+ */
606
+ export function padToWindow(chosen, allPaths, min = OPTIMAL_MIN_FILES, max = OPTIMAL_MAX_FILES) {
607
+ const all = (Array.isArray(allPaths) ? allPaths : []).map(normalizePath).filter(Boolean);
608
+ const files = [];
609
+ const seen = new Set();
610
+ for (const raw of Array.isArray(chosen) ? chosen : []) {
611
+ const p = normalizePath(raw);
612
+ if (p === '' || seen.has(p)) continue;
613
+ seen.add(p);
614
+ files.push(p);
615
+ }
616
+
617
+ const before = files.length;
618
+ if (files.length < min) {
619
+ const dirs = [...new Set(files.map(dirOf))];
620
+ const siblings = all
621
+ .filter((p) => !seen.has(p) && SOURCE_EXT.test(p) && dirs.includes(dirOf(p)))
622
+ .sort(byCodePoint);
623
+ for (const s of siblings) {
624
+ if (files.length >= min) break;
625
+ seen.add(s);
626
+ files.push(s);
627
+ }
628
+ }
629
+
630
+ const out = files.slice(0, max);
631
+ return { files: out, padded: Math.max(0, out.length - before), truncated: files.length > max };
632
+ }
633
+
634
+ // ─────────────────────────────────────────────────────────────────────────────
635
+ // THE FIXED POINT
636
+ // ─────────────────────────────────────────────────────────────────────────────
637
+
638
+ /**
639
+ * Has the model stopped asking for new files?
640
+ *
641
+ * ⚠️ "NOTHING NEW", NOT "THE SAME LIST". Context is monotone — a file that was
642
+ * put in the prompt last round is still in the prompt — so a round that drops a
643
+ * file it named before has still added nothing and the loop is done. Testing
644
+ * for an identical list would spin forever against a model that reorders, which
645
+ * they do, and each spin is a whole paid round.
646
+ */
647
+ export function convergence(inContext, named) {
648
+ const have = new Set((Array.isArray(inContext) ? inContext : []).map(normalizePath).filter(Boolean));
649
+ const added = [];
650
+ for (const raw of Array.isArray(named) ? named : []) {
651
+ const p = normalizePath(raw);
652
+ if (p === '' || have.has(p) || added.includes(p)) continue;
653
+ added.push(p);
654
+ }
655
+ return { converged: added.length === 0, added, union: [...have, ...added] };
656
+ }
657
+
658
+ // ─────────────────────────────────────────────────────────────────────────────
659
+ // THE LOOP
660
+ // ─────────────────────────────────────────────────────────────────────────────
661
+
662
+ /**
663
+ * Agentless's sequence, with every model call injected.
664
+ *
665
+ * tree → "which folders are IRRELEVANT" → 5-10 files by importance →
666
+ * skeletons → ask again → stop when nothing new is named.
667
+ *
668
+ * ⚠️ NO EMBEDDINGS, NO INDEX, NO SIMILARITY SEARCH. That is not a shortcut —
669
+ * it is the approach that measured best, and it is why this module has zero
670
+ * dependencies and needs no build step.
671
+ *
672
+ * ⚠️⚠️ AND NO LINE RANGES. Line-level narrowing measured WORSE than whole
673
+ * files. If a future round of this module grows a "which lines" step, it is
674
+ * undoing a measurement, not adding a feature.
675
+ *
676
+ * @param {object} o
677
+ * @param {string[]} o.paths every candidate path in the repo
678
+ * @param {(req: {step: string, task: string, tree?: string, candidates?: string[], skeletons?: string}) => Promise<string[]>} o.askImpl
679
+ * @param {(path: string) => string|null} [o.readImpl]
680
+ */
681
+ export async function localize({
682
+ paths = [],
683
+ task = '',
684
+ askImpl = null,
685
+ readImpl = () => null,
686
+ budgetTokens = DEFAULT_TREE_BUDGET_TOKENS,
687
+ minFiles = OPTIMAL_MIN_FILES,
688
+ maxFiles = OPTIMAL_MAX_FILES,
689
+ maxRounds = MAX_LOCALIZE_ROUNDS,
690
+ } = {}) {
691
+ const stats = { askCalls: 0, skeletonBytesIn: 0, skeletonBytesOut: 0 };
692
+ const fail = (error) => ({
693
+ ok: false, error, files: [], skeletons: [], rounds: 0, converged: false,
694
+ tree: '', irrelevant: [], unmatched: [], refused: false, stats,
695
+ });
696
+
697
+ /**
698
+ * ⚠️ THIS IS THE SPEND GUARD, AND IT IS STRUCTURAL. The module imports no
699
+ * model client, so "no askImpl" is not a missing option — it is the absence
700
+ * of any way to make a call at all. Refusing here makes that explicit rather
701
+ * than returning a plausible empty answer.
702
+ */
703
+ if (typeof askImpl !== 'function') {
704
+ return fail('localize needs an askImpl — this module makes no model calls of its own');
705
+ }
706
+
707
+ const candidates = (Array.isArray(paths) ? paths : []).map(normalizePath).filter(Boolean);
708
+ if (candidates.length === 0) return fail('no candidate paths — nothing to localize against');
709
+
710
+ try {
711
+ // ── 1. the tree, and the negative filter ────────────────────────────────
712
+ const tree = renderTree(candidates, { budgetTokens });
713
+ stats.askCalls += 1;
714
+ const folders = await askImpl({ step: 'irrelevant', task, tree: tree.text });
715
+ const filtered = applyIrrelevantFilter(candidates, folders);
716
+
717
+ // ── 2..N. files, skeletons, fixed point ────────────────────────────────
718
+ let context = [];
719
+ let skeletons = [];
720
+ let rounds = 0;
721
+ let converged = false;
722
+
723
+ for (let r = 0; r < Math.max(1, maxRounds); r++) {
724
+ rounds += 1;
725
+ stats.askCalls += 1;
726
+ const named = await askImpl({
727
+ step: 'files',
728
+ task,
729
+ tree: tree.text,
730
+ candidates: filtered.kept,
731
+ skeletons: skeletons.map((s) => s.text).join('\n\n'),
732
+ round: rounds,
733
+ });
734
+
735
+ const conv = convergence(context, named);
736
+ context = conv.union;
737
+ if (conv.converged) { converged = true; break; }
738
+
739
+ /**
740
+ * ⚠️ SKELETONS ARE BUILT ONLY FOR WHAT IS NEW. Re-reading a file the
741
+ * model already has in context spends I/O to produce bytes that are
742
+ * already in the prompt — and it would also move them, which costs the
743
+ * prompt cache the whole tail of the message.
744
+ */
745
+ for (const p of conv.added.slice(0, maxFiles)) {
746
+ let src = null;
747
+ try { src = readImpl(p); } catch { src = null; }
748
+ const sk = extractSkeleton(p, src);
749
+ stats.skeletonBytesIn += sk.bytesIn;
750
+ stats.skeletonBytesOut += sk.bytesOut;
751
+ if (sk.ok) skeletons.push({ path: p, text: sk.text });
752
+ }
753
+ }
754
+
755
+ const windowed = padToWindow(context, filtered.kept, minFiles, maxFiles);
756
+ // Only keep skeletons for files that survived the window, so the two halves
757
+ // of the answer can never disagree about which files were chosen.
758
+ skeletons = skeletons.filter((s) => windowed.files.includes(s.path));
759
+
760
+ return {
761
+ ok: true,
762
+ files: windowed.files,
763
+ skeletons,
764
+ rounds,
765
+ converged,
766
+ tree: tree.text,
767
+ irrelevant: (Array.isArray(folders) ? folders : []).map(normalizePath).filter(Boolean),
768
+ unmatched: filtered.unmatched,
769
+ refused: filtered.refused,
770
+ padded: windowed.padded,
771
+ stats,
772
+ error: '',
773
+ };
774
+ } catch (err) {
775
+ // ⚠️ NEVER THROWS. Localization is an optimisation on top of a turn, exactly
776
+ // as `repoMapForExecutor` is; a provider hiccup must degrade to "we did not
777
+ // narrow it down" and let the turn proceed, never take the turn down.
778
+ return fail(err?.message ? String(err.message) : String(err));
779
+ }
780
+ }
781
+
782
+ // ─────────────────────────────────────────────────────────────────────────────
783
+ // REACHABILITY — schema, dispatch, and the sentence that says it exists
784
+ // ─────────────────────────────────────────────────────────────────────────────
785
+
786
+ /**
787
+ * ⭐ THREE PARTS OR IT SCORES ZERO. A capability needs a SCHEMA (the model can
788
+ * name it), a DISPATCH (calling it does something), and a DESCRIPTION (the
789
+ * model knows it exists and when to reach). This repo has shipped
790
+ * built-but-unreachable four times in one day; the schema and the handler live
791
+ * here together so wiring is one import and one case rather than three edits in
792
+ * three files that can drift apart.
793
+ */
794
+ export function localizeToolSchemas() {
795
+ return [{
796
+ type: 'function',
797
+ function: {
798
+ name: 'localize_files',
799
+ description:
800
+ 'Work out WHICH FILES a task touches, before reading any of them. Renders the repo as a directory tree, '
801
+ + 'discards folders that are irrelevant, then returns 6-10 files ordered by importance with a signatures-only '
802
+ + 'skeleton of each. Use this FIRST on an unfamiliar or large repository — file-level localization measures '
803
+ + '15-17x better than working without it, and 6-10 files with surrounding context measures better than one '
804
+ + 'exact file. Do not ask it for line ranges: narrowing below the file measures WORSE.',
805
+ parameters: {
806
+ type: 'object',
807
+ properties: {
808
+ task: { type: 'string', description: 'What you are trying to change or fix, in one sentence.' },
809
+ },
810
+ required: ['task'],
811
+ },
812
+ },
813
+ }];
814
+ }
815
+
816
+ /**
817
+ * The dispatch half. `deps` carries the seam: the caller supplies `paths`,
818
+ * `askImpl` and `readImpl`, so this function is as network-free as the rest of
819
+ * the module.
820
+ */
821
+ export async function runLocalizeTool(args = {}, deps = {}) {
822
+ const task = typeof args.task === 'string' ? args.task : '';
823
+ const res = await localize({ ...deps, task });
824
+ if (!res.ok) return { ok: false, error: res.error };
825
+ const header = res.converged
826
+ ? `localized to ${res.files.length} files in ${res.rounds} round${res.rounds === 1 ? '' : 's'}`
827
+ : `localized to ${res.files.length} files — did NOT converge in ${res.rounds} rounds, so treat this as a starting point`;
828
+ return {
829
+ ok: true,
830
+ files: res.files,
831
+ converged: res.converged,
832
+ text: [header, '', ...res.files.map((f, i) => ` ${i + 1}. ${f}`), '', ...res.skeletons.map((s) => s.text)].join('\n'),
833
+ };
834
+ }