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,1101 @@
1
+ /**
2
+ * ── lib/repo-map.mjs — LET THE MODEL SEE THE WHOLE REPO, CHEAPLY ────────────
3
+ *
4
+ * THE MEASURED DEFECT THIS REPLACES. `gatherWorkspaceContext` (lib/turn.mjs)
5
+ * walks TWO directory levels and inlines whole file BODIES in ALPHABETICAL
6
+ * order, capped at 12 files / 40KB. On any real repository that shows the model
7
+ * roughly 5% of the paths, and spends thousands of tokens doing it — on
8
+ * READMEs, changelogs and build junk, because alphabetical order is not
9
+ * relevance order. The file the model actually needs is invisible.
10
+ *
11
+ * ⭐ AND AN INVISIBLE FILE IS NOT A NEUTRAL ABSENCE. A model that cannot see
12
+ * `lib/chain.mjs` does not go looking for it; it invents a plausible
13
+ * `src/chain.js` and writes there. Blindness does not read as blindness from
14
+ * the inside — it reads as "that file does not exist".
15
+ *
16
+ * ⭐ THE ECONOMICS ARE THE ENTIRE ARGUMENT. A path is a handful of tokens; a
17
+ * file is thousands. Listing two thousand paths costs less than inlining five
18
+ * files. So this module trades CONTENT for COVERAGE, and the trade is not close.
19
+ *
20
+ * ── THE FOUR PROPERTIES THAT ARE LOAD-BEARING ───────────────────────────────
21
+ *
22
+ * 1. DETERMINISM. Same tree, same bytes, byte for byte, every run. `readdir`
23
+ * makes NO order promise, so every list here is sorted by CODE POINT (not
24
+ * `localeCompare`, which is ICU-dependent and therefore machine-dependent).
25
+ * There are no timestamps and no rendered ages anywhere in the output —
26
+ * "3 minutes ago" changes every single run. A map that reshuffles destroys
27
+ * the cached prompt prefix, and prefix stability is worth 3.05x.
28
+ *
29
+ * 2. HONEST TRUNCATION. It never implies completeness it does not have, it
30
+ * states the total, and it says WHERE the gaps are rather than only how
31
+ * many — a bare count is unactionable, a named directory is a next move.
32
+ *
33
+ * 3. NO CONTENT LEAVES. It emits paths and symbol NAMES, never a file body.
34
+ * The old pre-read shipped `.env` verbatim to four upstream providers; the
35
+ * prompt is an exfiltration path and this module treats it as one. It
36
+ * reuses `refusedCommitPath` from git.mjs deliberately — that list already
37
+ * means "must never leave this machine", and a second copy is the copy
38
+ * that goes stale.
39
+ *
40
+ * 4. THE GUESS IS LABELLED. Symbols come from a regex, not a parser. A wrong
41
+ * guess is acceptable; presenting one as authoritative is not, because a
42
+ * missing name would otherwise read as proof of absence.
43
+ *
44
+ * ⚠️ EVERY IMPL IS INJECTED. No clock, no randomness, no ambient `fs` inside
45
+ * the logic — the defaults at the bottom are the only place the real
46
+ * filesystem is touched, so every property above is provable with data.
47
+ */
48
+
49
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
50
+ import { join } from 'node:path';
51
+
52
+ import { refusedCommitPath } from './secret-paths.mjs';
53
+ /**
54
+ * ⚠️ THIS COMPARATOR WAS DEFINED HERE AND THE RULE WAS WRITTEN HERE — and the
55
+ * two other modules that render into the prompt sorted with `localeCompare`
56
+ * anyway. It now lives in one place, so a module cannot follow the comment
57
+ * without also following the code. See `prefix-order.mjs`.
58
+ */
59
+ import { byCodePoint } from './prefix-order.mjs';
60
+
61
+ /**
62
+ * ⚠️ BYTE-IDENTICAL TO lib/search.mjs ON PURPOSE, AND GUARDED BY A TEST.
63
+ *
64
+ * Two ideas about which directories exist IS the bug: the map would tell the
65
+ * model a file is absent that `search_text` can find, or list one that
66
+ * `find_files` will never return. The drift guard in the test suite reads
67
+ * search.mjs's declaration and compares. If you change one, change both.
68
+ */
69
+ export const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.vercel', 'coverage', '.turbo']);
70
+
71
+ /** Hidden DIRECTORIES that are ordinary source. Same list, same reasons. */
72
+ export const HIDDEN_DIRS_ALLOWED = new Set(['.github', '.vscode', '.husky', '.circleci', '.changeset', '.storybook']);
73
+
74
+ /**
75
+ * ⚠️ HIDDEN FILES ARE WITHHELD BY DEFAULT, and this allowlist is the exception.
76
+ *
77
+ * The default has to be "withhold", because hidden files are overwhelmingly
78
+ * config and credentials — `.env`, `.netrc`, `.pgpass`, `.npmrc`. An allowlist
79
+ * is safe by construction in a way a denylist never is: a file nobody thought
80
+ * of is excluded rather than leaked.
81
+ *
82
+ * ⭐ `.gitignore` earns its place because the model is repeatedly asked to add
83
+ * a line to it, and a file it cannot see is a file it will recreate from
84
+ * scratch and clobber.
85
+ */
86
+ export const HIDDEN_FILES_ALLOWED = new Set([
87
+ '.gitignore', '.gitattributes', '.gitmodules', '.dockerignore',
88
+ '.editorconfig', '.nvmrc', '.node-version', '.prettierrc', '.eslintrc',
89
+ ]);
90
+
91
+ /** Extensions we will try to read for symbols. Everything else yields nothing. */
92
+ const SYMBOL_EXT = /\.(mjs|cjs|jsx?|tsx?|mts|cts|py|go|rs)$/i;
93
+
94
+ /** Source we would rather show than an asset when the budget is tight. */
95
+ const SOURCE_EXT = /\.(mjs|cjs|jsx?|tsx?|mts|cts|py|go|rs|rb|php|java|kt|swift|cs|c|h|cc|cpp|hpp|sql|sh|vue|svelte|css|scss|html)$/i;
96
+
97
+ /** Config and prose: worth listing, not worth crowding out a source file. */
98
+ const DOC_EXT = /\.(json|ya?ml|toml|ini|md|mdx|txt|env\.example)$/i;
99
+
100
+ /** Directories whose name answers "where are the tests". */
101
+ const TEST_DIR_NAMES = new Set(['test', 'tests', '__tests__', 'spec', 'e2e', 'testing']);
102
+
103
+ /**
104
+ * ── ⚠️⚠️⭐ THE SCRIPT LIST WAS ALPHABETICAL, AND IT HID `npm test` ───────────
105
+ *
106
+ * The old line was `Object.keys(pkg.scripts).sort(byCodePoint).slice(0, 6)`.
107
+ * Alphabetical order is not importance order — that is the argument this
108
+ * module's own header makes about FILES, and the scripts list was doing exactly
109
+ * what it condemns.
110
+ *
111
+ * ⚠️ MEASURED ON `console/`. It has 16 scripts. The first six alphabetically:
112
+ *
113
+ * bench · bench:all · bench:apps · bench:creative · bench:creative:all ·
114
+ * bench:creative:selftest
115
+ *
116
+ * Six spellings of one verb, and `test`, `build`, `lint`, `dev`, `start` and
117
+ * `type-check` were ALL cut. **A model that cannot see the project's own test
118
+ * command cannot verify its work** — it invents one, runs `npm run tests`, gets
119
+ * "missing script", and concludes the repo has no tests.
120
+ *
121
+ * ⚠️ AND THIS REPO SURVIVED BY ONE SLOT, WHICH IS WHY NOBODY SAW IT. We have
122
+ * exactly six scripts (`bundle`, `bundle:mcp`, `machine`, `machine:stop`,
123
+ * `test`, `test:raw`), so `test` was the sixth and made the cut by luck. Add one
124
+ * script sorting before it and `npm test` disappears from our own map.
125
+ *
126
+ * ⭐ SO THE LIST IS RANKED BY WHAT AN AGENT NEEDS, and the ranking is ordered by
127
+ * that need: how do I verify (`test`), how do I build it, how do I check it, how
128
+ * do I run it. `format` last because it changes files rather than reporting on
129
+ * them.
130
+ */
131
+ export const SCRIPT_VERBS = Object.freeze([
132
+ 'test', 'build', 'lint', 'typecheck', 'type-check', 'check',
133
+ 'verify', 'e2e', 'dev', 'start', 'format',
134
+ ]);
135
+
136
+ /**
137
+ * ⭐ 8, NOT 6, AND THE NUMBER IS THE SMALLER HALF OF THE FIX. Eleven ranked
138
+ * verbs cannot all fit, and that is fine — but six could not even hold the four
139
+ * that matter alongside anything project-specific. A script line is ~30
140
+ * characters, so the whole section costs ~70 tokens: it is not what the budget
141
+ * is fighting over.
142
+ */
143
+ export const MAX_SCRIPTS_SHOWN = 8;
144
+
145
+ /**
146
+ * Choose which scripts to show.
147
+ *
148
+ * ⭐ THE SECOND RULE IS WHAT ACTUALLY KILLED `console/`: after the ranked verbs,
149
+ * remaining slots take at most ONE script per `:` family. `bench:all` tells a
150
+ * model nothing it did not learn from `bench`, and six of them told it nothing
151
+ * six times while pushing out `test`. A family is the part before the first `:`.
152
+ *
153
+ * ⚠️ IT RETURNS THE OMITTED COUNT because a truncated list that does not say it
154
+ * is truncated is the same lie the FILES section refuses to tell.
155
+ */
156
+ export function rankScripts(names, max = MAX_SCRIPTS_SHOWN) {
157
+ const all = [...new Set(names)].sort(byCodePoint);
158
+ const chosen = [];
159
+ const taken = new Set();
160
+ for (const verb of SCRIPT_VERBS) {
161
+ if (chosen.length >= max) break;
162
+ if (!all.includes(verb) || taken.has(verb)) continue;
163
+ chosen.push(verb);
164
+ taken.add(verb);
165
+ }
166
+ const families = new Set(chosen.map((n) => n.split(':')[0]));
167
+ for (const name of all) {
168
+ if (chosen.length >= max) break;
169
+ if (taken.has(name)) continue;
170
+ const family = name.split(':')[0];
171
+ if (families.has(family)) continue;
172
+ chosen.push(name);
173
+ taken.add(name);
174
+ families.add(family);
175
+ }
176
+ return { chosen, omitted: all.length - chosen.length };
177
+ }
178
+
179
+ /**
180
+ * ⚠️ NEVER READ FOR SYMBOLS ABOVE THIS. Matches search.mjs's ceiling. A 900KB
181
+ * generated bundle is not a file whose export list helps anyone, and reading it
182
+ * costs real milliseconds per entry across a big tree.
183
+ */
184
+ const MAX_SYMBOL_FILE_BYTES = 512 * 1024;
185
+
186
+ /** A pathological file cannot produce a thousand-symbol line. */
187
+ const MAX_SYMBOLS_PER_FILE = 64;
188
+ /** …and the RENDERED line is shorter still, because the model pays per token. */
189
+ const MAX_SYMBOLS_SHOWN = 6;
190
+
191
+ /**
192
+ * ⚠️ THE WALK IS BOUNDED BY ENTRY COUNT, NOT BY DEPTH — that inversion is the
193
+ * whole point of this module. Depth is what made the old pre-read blind; a
194
+ * count is what actually protects against a pathological tree.
195
+ */
196
+ export const DEFAULT_MAX_ENTRIES = 12_000;
197
+
198
+ /** A depth cap exists only so a symlink cycle cannot hang the process. */
199
+ const MAX_DEPTH = 24;
200
+
201
+ /** Symbols are extracted for the highest-priority files only; reads are not free. */
202
+ const MAX_SYMBOL_READS = 800;
203
+
204
+ /** How many directories the omission report names before it stops. */
205
+ const MAX_GAP_LINES = 10;
206
+
207
+ /**
208
+ * ── ⭐ THE DEFAULT IS A MEASUREMENT, NOT A ROUND NUMBER ─────────────────────
209
+ *
210
+ * Measured against `gatherWorkspaceContext` on two real trees:
211
+ *
212
+ * this repo (144 files) — old: 9,627 tokens for 12 file BODIES and a
213
+ * two-level tree
214
+ * new: 2,554 tokens for ALL 144 paths + symbols
215
+ * console/ (2,246 files) — old: 10,889 tokens, still 12 bodies, ~5% of paths
216
+ * new at 6,000: 638 paths, 44 symbol lists
217
+ *
218
+ * So 6,000 is CHEAPER than what the old pre-read actually spent on both, and
219
+ * buys an order of magnitude more coverage.
220
+ *
221
+ * ⭐ AND IT IS CHEAPER STILL ON EVERY ROUND AFTER THE FIRST. The map is
222
+ * byte-identical run to run by construction, so it sits inside the cached
223
+ * prompt prefix — 3.05x on DeepSeek. A stable 6,000 tokens costs about what an
224
+ * unstable 2,000 does, which is exactly why determinism was worth building.
225
+ *
226
+ * ── ⚠️ RAISED 6,000 → 9,000 (2026-08-16). THE REASONING ABOVE IS ALL STILL
227
+ * TRUE; IT WAS JUST ANSWERING A DIFFERENT QUESTION ─────────────────────────
228
+ *
229
+ * Every number above is a comparison against the OLD pre-read, and against that
230
+ * baseline 6,000 wins easily. It was never checked against the only question
231
+ * that matters on a big repo — how much of the tree actually arrives. Measured
232
+ * on `console/` (2,406 files) at 6,000: **659 files listed, and they came from
233
+ * 7 of the 360 directories.** Nothing below depth 1 was visible at all.
234
+ *
235
+ * ⭐ THE ORDERING WAS THE BULK OF THAT (see `orderForBudget`) and its fix is
236
+ * free. The raise is the smaller, second lever, and it is defensible on the same
237
+ * ground the original number was chosen on: **9,000 is still below what the
238
+ * pre-read this module replaced actually spent on BOTH trees** (9,627 and
239
+ * 10,889). We have not made round 1 more expensive than the thing we deleted.
240
+ *
241
+ * ⚠️⚠️ AND IT IS A CEILING, NOT A SPEND — which is the whole reason the raise
242
+ * is cheap. Measured: this repo's own 344 files render in 5,701 tokens, so they
243
+ * fit under the OLD 6,000 with room to spare and cost exactly the same after the
244
+ * raise as before it. Nothing changes for any repo that already fitted. The
245
+ * extra tokens are spent only where the map was blind, which is precisely the
246
+ * case that was paying 6,000 tokens to see 1.9% of the directories.
247
+ */
248
+ export const DEFAULT_BUDGET_TOKENS = 9_000;
249
+
250
+ /**
251
+ * ── THE TOKEN ESTIMATE ──────────────────────────────────────────────────────
252
+ *
253
+ * Deliberately crude, deliberately PESSIMISTIC. ~3.5 chars per token rather
254
+ * than the usual 4, because paths tokenize worse than prose: every `/`, `-`
255
+ * and `.` is a boundary. Under-estimating means the real prompt overruns the
256
+ * budget the caller set, which is the failure that matters here.
257
+ */
258
+ export function estimateTokens(text) {
259
+ if (!text) return 0;
260
+ return Math.ceil(String(text).length / 3.5);
261
+ }
262
+
263
+
264
+ // ─────────────────────────────────────────────────────────────────────────────
265
+ // .gitignore
266
+ //
267
+ // ⚠️ THE OLD PRE-READ IGNORED .gitignore ENTIRELY, and therefore shipped the
268
+ // CONTENTS of gitignored files to the model provider. Those files are
269
+ // gitignored for a reason and the reason is frequently "it has a secret in it".
270
+ // ─────────────────────────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Parse a `.gitignore` into ordered rules. Blank lines and comments vanish;
274
+ * ORDER SURVIVES, because in git the LAST matching rule wins and a negation
275
+ * that arrives before its pattern means nothing.
276
+ */
277
+ export function parseGitignore(text) {
278
+ const rules = [];
279
+ if (typeof text !== 'string') return rules;
280
+ for (const raw of text.split(/\r?\n/)) {
281
+ let line = raw.replace(/\s+$/, '');
282
+ if (line === '') continue;
283
+ if (line.startsWith('#')) continue;
284
+ let negate = false;
285
+ if (line.startsWith('!')) {
286
+ negate = true;
287
+ line = line.slice(1);
288
+ } else if (line.startsWith('\\#') || line.startsWith('\\!')) {
289
+ // An escaped leading `#` or `!` is a LITERAL first character, not syntax.
290
+ line = line.slice(1);
291
+ }
292
+ if (line === '') continue;
293
+ rules.push({ pattern: line, negate, dirOnly: line.endsWith('/') });
294
+ }
295
+ return rules;
296
+ }
297
+
298
+ /**
299
+ * Glob → regex source, with the one reading everyone else uses: `*` does not
300
+ * cross a slash, and `**` spans ZERO OR MORE directories.
301
+ *
302
+ * ⚠️ THE ZERO CASE IS THE BUG search.mjs ALREADY FIXED ONCE. `docs/**"/"draft.md`
303
+ * must match `docs/draft.md`. Reading `**` as "one or more" makes the pattern
304
+ * silently miss the commonest case.
305
+ */
306
+ function globSource(glob) {
307
+ let out = '';
308
+ for (let i = 0; i < glob.length; i++) {
309
+ const c = glob[i];
310
+ if (c === '*') {
311
+ if (glob[i + 1] === '*') {
312
+ const prevIsSlash = i === 0 || glob[i - 1] === '/';
313
+ const nextIsSlash = glob[i + 2] === '/';
314
+ if (prevIsSlash && nextIsSlash) {
315
+ out += '(?:.*/)?';
316
+ i += 2; // consume the second `*` and the `/` the group already covers
317
+ continue;
318
+ }
319
+ out += '.*';
320
+ i += 1;
321
+ continue;
322
+ }
323
+ out += '[^/]*';
324
+ continue;
325
+ }
326
+ if (c === '?') { out += '[^/]'; continue; }
327
+ out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
328
+ }
329
+ return out;
330
+ }
331
+
332
+ function compileRule(rule) {
333
+ let p = rule.pattern;
334
+ const dirOnly = p.endsWith('/');
335
+ if (dirOnly) p = p.slice(0, -1);
336
+ // A leading slash anchors to the ignore file's own directory. So does an
337
+ // interior slash — that is git's rule, not an approximation of it.
338
+ let anchored = p.startsWith('/');
339
+ if (anchored) p = p.slice(1);
340
+ if (p.includes('/')) anchored = true;
341
+ return { rx: new RegExp(`^${globSource(p)}$`), anchored, dirOnly, negate: rule.negate };
342
+ }
343
+
344
+ /**
345
+ * Build a matcher: `(relPath, isDir) => boolean`.
346
+ *
347
+ * ⭐ ANCESTORS ARE CHECKED SEPARATELY, and that is not an optimisation — it is
348
+ * the semantics. `src/generated/` ignores `src/generated/x.js`, and the walk is
349
+ * not the only caller, so the matcher cannot rely on "we never descended".
350
+ */
351
+ export function makeIgnoreMatcher(rules) {
352
+ const compiled = rules.map(compileRule);
353
+ if (compiled.length === 0) return () => false;
354
+
355
+ /** @returns {boolean | undefined} the last matching rule's verdict, or none. */
356
+ const verdict = (rel, isDir) => {
357
+ let out;
358
+ for (const r of compiled) {
359
+ if (r.dirOnly && !isDir) continue;
360
+ if (r.anchored) {
361
+ if (r.rx.test(rel)) out = !r.negate;
362
+ continue;
363
+ }
364
+ // Unanchored: match the whole path or any trailing segment sequence.
365
+ if (r.rx.test(rel)) { out = !r.negate; continue; }
366
+ let hit = false;
367
+ for (let i = 0; i < rel.length; i++) {
368
+ if (rel[i] !== '/') continue;
369
+ if (r.rx.test(rel.slice(i + 1))) { hit = true; break; }
370
+ }
371
+ if (hit) out = !r.negate;
372
+ }
373
+ return out;
374
+ };
375
+
376
+ return (rel, isDir = false) => {
377
+ const own = verdict(rel, isDir);
378
+ if (own !== undefined) return own;
379
+ // No rule spoke about this path. An ignored ANCESTOR still buries it.
380
+ const parts = rel.split('/');
381
+ for (let i = 1; i < parts.length; i++) {
382
+ const ancestor = parts.slice(0, i).join('/');
383
+ if (verdict(ancestor, true) === true) return true;
384
+ }
385
+ return false;
386
+ };
387
+ }
388
+
389
+ // ─────────────────────────────────────────────────────────────────────────────
390
+ // SYMBOLS — a regex guess, labelled as one
391
+ // ─────────────────────────────────────────────────────────────────────────────
392
+
393
+ const IDENT = /^[A-Za-z_$][\w$]*$/;
394
+ /** Words a naive regex catches that are syntax, not names. */
395
+ const NOT_A_NAME = new Set(['default', 'from', 'as', 'function', 'class', 'const', 'let', 'var', 'async', 'type', 'interface', 'enum']);
396
+
397
+ const JS_PATTERNS = [
398
+ /^\s*export\s+default\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/gm,
399
+ /^\s*export\s+(?:async\s+)?function\s*\*?\s*([A-Za-z_$][\w$]*)/gm,
400
+ /^\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/gm,
401
+ /^\s*export\s+(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/gm,
402
+ /^\s*export\s+(?:type|interface|enum)\s+([A-Za-z_$][\w$]*)/gm,
403
+ /^\s*exports\.([A-Za-z_$][\w$]*)\s*=/gm,
404
+ ];
405
+ /** `export { a, b as c }` and `module.exports = { a, b }` — a LIST, not a name. */
406
+ const JS_LIST_PATTERNS = [
407
+ /^\s*export\s*\{([^}]*)\}/gm,
408
+ /^\s*module\.exports\s*=\s*\{([^}]*)\}/gm,
409
+ ];
410
+
411
+ const PY_PATTERNS = [/^(?:async\s+)?def\s+([A-Za-z_]\w*)/gm, /^class\s+([A-Za-z_]\w*)/gm];
412
+ const GO_PATTERNS = [/^func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)/gm, /^type\s+([A-Za-z_]\w*)/gm];
413
+ const RS_PATTERNS = [
414
+ /^\s*pub\s+(?:async\s+)?fn\s+([A-Za-z_]\w*)/gm,
415
+ /^\s*pub\s+(?:struct|enum|trait|mod|type|const|static)\s+([A-Za-z_]\w*)/gm,
416
+ ];
417
+
418
+ /**
419
+ * Exported symbol names for one file, by regex.
420
+ *
421
+ * ⚠️ IT RETURNS NOTHING RATHER THAN GARBAGE for a file it does not understand.
422
+ * A markdown file containing the words "export function" is not a module, and
423
+ * emitting `fake` from it would be worse than emitting nothing — the model
424
+ * would go looking for a symbol that never existed.
425
+ */
426
+ export function extractExports(path, source) {
427
+ if (typeof source !== 'string' || source === '') return [];
428
+ if (!SYMBOL_EXT.test(path)) return [];
429
+ const ext = path.slice(path.lastIndexOf('.') + 1).toLowerCase();
430
+
431
+ const found = new Set();
432
+ const add = (name) => {
433
+ const n = String(name).trim();
434
+ if (!IDENT.test(n)) return;
435
+ if (NOT_A_NAME.has(n)) return;
436
+ found.add(n);
437
+ };
438
+
439
+ const run = (patterns) => {
440
+ for (const rx of patterns) {
441
+ rx.lastIndex = 0;
442
+ let m;
443
+ while ((m = rx.exec(source)) !== null) add(m[1]);
444
+ }
445
+ };
446
+
447
+ if (ext === 'py') run(PY_PATTERNS);
448
+ else if (ext === 'go') run(GO_PATTERNS);
449
+ else if (ext === 'rs') run(RS_PATTERNS);
450
+ else {
451
+ run(JS_PATTERNS);
452
+ for (const rx of JS_LIST_PATTERNS) {
453
+ rx.lastIndex = 0;
454
+ let m;
455
+ while ((m = rx.exec(source)) !== null) {
456
+ for (const piece of m[1].split(',')) {
457
+ const parts = piece.trim().split(/\s+as\s+/);
458
+ add(parts[parts.length - 1]);
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ return [...found].sort(byCodePoint).slice(0, MAX_SYMBOLS_PER_FILE);
465
+ }
466
+
467
+ // ─────────────────────────────────────────────────────────────────────────────
468
+ // PRIORITY — which paths survive a tight budget
469
+ // ─────────────────────────────────────────────────────────────────────────────
470
+
471
+ /**
472
+ * ⭐ THE ORDER IS THE PRODUCT WHEN THE BUDGET BITES. Alphabetical is what the
473
+ * old pre-read used, and alphabetical is why two hundred `assets/img000.png`
474
+ * crowded out the one `src/target.ts` the task was about.
475
+ *
476
+ * Lower is better. Source beats prose beats assets.
477
+ */
478
+ function fileCategory(file, entryTargets) {
479
+ if (file.path === 'package.json' || entryTargets.has(file.path)) return 0;
480
+ if (SOURCE_EXT.test(file.path)) return 1;
481
+ if (DOC_EXT.test(file.path)) return 2;
482
+ return 3;
483
+ }
484
+
485
+ /** The directory a path sits in. `''` for a file at the root. */
486
+ function dirOf(path) {
487
+ const i = path.lastIndexOf('/');
488
+ return i < 0 ? '' : path.slice(0, i);
489
+ }
490
+
491
+ /**
492
+ * ── ⚠️⚠️⭐ THE ORDER USED TO CONTAIN A DEPTH TERM, AND IT REBUILT THE EXACT
493
+ * BLINDNESS THIS MODULE WAS WRITTEN TO DELETE ────────────────────────────
494
+ *
495
+ * The old comparator was `category * 1_000_000 + min(depth, 40) * 10_000`, and
496
+ * its stated reason was right in spirit: *"shallow beats deep, so the SHAPE of
497
+ * the project survives even when most of it does not."* Wanting the shape to
498
+ * survive is correct. Expressing it as a STRICT tiebreak ahead of the path is
499
+ * what broke, because a strict ordering on depth is not a bias toward the
500
+ * shape — it is a breadth-first cut, and a breadth-first cut is a DEPTH CLIFF.
501
+ * Every file at depth N is listed before any file at depth N+1, so the budget
502
+ * runs out inside one depth band and everything below it vanishes together.
503
+ *
504
+ * ⚠️⚠️ MEASURED ON `console/` (2,406 files, 360 directories) BEFORE THIS FIX:
505
+ *
506
+ * depth 0 : 6 of 39 listed
507
+ * depth 1 : 653 of 1281 listed
508
+ * depth 2 : 0 of 482 ← the cliff
509
+ * depth 3 : 0 of 285
510
+ * depth 4+: 0 of 319
511
+ *
512
+ * 659 files listed, drawn from **7 of the 360 directories**. 1,610 of the
513
+ * 1,747 omitted files were SOURCE — 1,017 `.ts` and 386 `.tsx`.
514
+ *
515
+ * ⭐ THIS IS THE SAME DEFECT THE HEADER OF THIS FILE ATTACKS BY NAME. It opens
516
+ * by indicting `gatherWorkspaceContext` for walking "TWO directory levels", and
517
+ * the replacement WALKED the whole tree and then threw everything past two
518
+ * levels away at render time, for 6,000 tokens instead of 12 file bodies. The
519
+ * walk was fixed and the ordering quietly undid it. That is worth stating
520
+ * plainly, because "we already fixed that" is why nobody looked.
521
+ *
522
+ * ⭐⭐ AND IT MATTERS MORE THAN THE FILE COUNT, because of the header's own
523
+ * argument: *"an invisible file is not a neutral absence … it reads as 'that
524
+ * file does not exist'."* A model that can see 659 files from 7 directories does
525
+ * not know 353 other directories exist. A model that can see ONE file in every
526
+ * directory knows where everything lives and can `read_file` the rest. Reach
527
+ * across the tree is the product; the count is a proxy that stopped tracking it.
528
+ *
529
+ * ── ⭐ SO THE CUT IS A BREADTH SAMPLE, NOT A PREFIX ─────────────────────────
530
+ *
531
+ * Files are dealt out one per directory per pass: every directory's first
532
+ * source file, then every directory's second, and so on. The budget then runs
533
+ * out at "the 7th file of the big directories" instead of "every directory
534
+ * below depth 1". Measured at the same 6,000 tokens the cliff was measured at:
535
+ * **453 files but 347 of 347 source directories, reaching depth 7.**
536
+ *
537
+ * ⚠️ DEPTH IS STILL HERE AND STILL DOES ITS JOB — it just ranks WITHIN a pass
538
+ * rather than above one. Among all the directories' first files, the shallow
539
+ * ones are listed first, so the shape still survives a budget too tight for a
540
+ * full pass. That is what the original comment wanted; this is the ordering
541
+ * that delivers it without the cliff.
542
+ *
543
+ * ⚠️ CATEGORY STILL OUTRANKS EVERYTHING, so `src/target.ts` still beats two
544
+ * hundred `assets/img000.png` — the sample is taken within a category, so an
545
+ * asset-only directory is not represented until every source file has been.
546
+ *
547
+ * ⚠️ DETERMINISM SURVIVES: the pass index is assigned after a CODE POINT sort,
548
+ * so which file is a directory's "first" cannot depend on `readdir` order.
549
+ */
550
+ export function orderForBudget(files, entryTargets = new Set()) {
551
+ const byPath = [...files].sort((a, b) => byCodePoint(a.path, b.path));
552
+ const seen = new Map();
553
+ const rows = byPath.map((file) => {
554
+ const category = fileCategory(file, entryTargets);
555
+ // The pass is counted per (directory, category) rather than per directory:
556
+ // a directory's README must not consume the slot its `index.ts` needs.
557
+ const key = `${dirOf(file.path)}\u0000${category}`;
558
+ const pass = seen.get(key) ?? 0;
559
+ seen.set(key, pass + 1);
560
+ return { file, category, pass, depth: Math.min(file.depth, 40) };
561
+ });
562
+ rows.sort((a, b) => a.category - b.category
563
+ || a.pass - b.pass
564
+ || a.depth - b.depth
565
+ || byCodePoint(a.file.path, b.file.path));
566
+ return rows.map((r) => r.file);
567
+ }
568
+
569
+ // ─────────────────────────────────────────────────────────────────────────────
570
+ // THE WALK
571
+ // ─────────────────────────────────────────────────────────────────────────────
572
+
573
+ function defaultImpls(root) {
574
+ return {
575
+ existsImpl: (rel) => existsSync(rel === '' ? root : join(root, rel)),
576
+ readdirImpl: (rel) => readdirSync(rel === '' ? root : join(root, rel), { withFileTypes: true })
577
+ .map((d) => ({ name: d.name, type: d.isDirectory() ? 'dir' : d.isFile() ? 'file' : 'other' })),
578
+ statImpl: (rel) => {
579
+ const st = statSync(join(root, rel), { throwIfNoEntry: false });
580
+ if (!st) return null;
581
+ return { size: st.size, mtimeMs: st.mtimeMs, dir: st.isDirectory() };
582
+ },
583
+ readFileImpl: (rel) => {
584
+ try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; }
585
+ },
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Build the map.
591
+ *
592
+ * @param {string} root absolute path, used only by the default impls
593
+ * @param {object} [impls] existsImpl / readdirImpl / statImpl / readFileImpl
594
+ * @param {object} [opts] { budgetTokens, maxEntries }
595
+ * @returns {{ ok: boolean, text: string, files: object[], truncated: boolean, stats: object, error?: string }}
596
+ */
597
+ export function buildRepoMap(root, impls = {}, opts = {}) {
598
+ const io = { ...defaultImpls(root), ...(impls || {}) };
599
+ // Tolerate the options being folded into the second argument — a caller that
600
+ // writes `buildRepoMap(root, { budgetTokens: 800 })` means something obvious
601
+ // and refusing it would fail correct work.
602
+ const budgetTokens = opts.budgetTokens ?? impls?.budgetTokens ?? DEFAULT_BUDGET_TOKENS;
603
+ const maxEntries = opts.maxEntries ?? impls?.maxEntries ?? DEFAULT_MAX_ENTRIES;
604
+
605
+ const stats = {
606
+ totalFiles: 0,
607
+ listedFiles: 0,
608
+ omittedFiles: 0,
609
+ skippedDirs: 0,
610
+ skippedDirNames: [],
611
+ hidden: 0,
612
+ withheld: 0,
613
+ gitignored: 0,
614
+ gitignoreUsed: false,
615
+ unreadableDirs: 0,
616
+ walkCapped: false,
617
+ maxDepthReached: 0,
618
+ entryPoints: [],
619
+ budgetTokens,
620
+ tokensEstimated: 0,
621
+ /**
622
+ * ⭐ DIRECTORY REACH, BECAUSE THE FILE COUNT STOPPED TRACKING THE PRODUCT.
623
+ * See `orderForBudget`: 659 of 2,406 files reads as 27% coverage and was
624
+ * actually 7 of 360 directories. A count of files cannot tell those two
625
+ * apart; this pair can, and it is what the regression test binds to.
626
+ */
627
+ dirsTotal: 0,
628
+ dirsListed: 0,
629
+ };
630
+
631
+ const fail = (error) => ({ ok: false, text: '', files: [], truncated: false, stats, error });
632
+
633
+ let rootOk = false;
634
+ try { rootOk = io.existsImpl('') !== false; } catch { rootOk = false; }
635
+ if (!rootOk) return fail(`could not read the workspace root — it does not exist, or permission was denied (EACCES)`);
636
+
637
+ const readdir = (rel) => {
638
+ try {
639
+ const out = io.readdirImpl(rel);
640
+ return Array.isArray(out) ? out : null;
641
+ } catch (err) {
642
+ return { error: err };
643
+ }
644
+ };
645
+
646
+ const rootEntries = readdir('');
647
+ if (rootEntries === null || (rootEntries && rootEntries.error)) {
648
+ const code = rootEntries?.error?.code ?? rootEntries?.error?.message ?? 'unknown';
649
+ return fail(`could not read the workspace root: ${code}`);
650
+ }
651
+
652
+ /** @type {{path:string, depth:number, size:number, mtimeMs:number}[]} */
653
+ const candidates = [];
654
+ const skippedNames = new Set();
655
+ let entriesSeen = 0;
656
+
657
+ /**
658
+ * ⚠️ AN EXPLICIT STACK, NOT RECURSION. A deep tree is exactly the case this
659
+ * module exists to handle, and blowing the JS stack on it would be a comic
660
+ * failure mode.
661
+ */
662
+ const stack = [{ rel: '', depth: 0, ignore: [] }];
663
+
664
+ while (stack.length > 0) {
665
+ if (entriesSeen >= maxEntries) { stats.walkCapped = true; break; }
666
+ const dir = stack.pop();
667
+ if (dir.depth > stats.maxDepthReached) stats.maxDepthReached = dir.depth;
668
+
669
+ const listed = dir.rel === '' ? rootEntries : readdir(dir.rel);
670
+ if (listed === null || (listed && listed.error)) {
671
+ stats.unreadableDirs += 1;
672
+ continue;
673
+ }
674
+
675
+ // ⚠️ SORTED HERE, ONCE. Everything downstream inherits a stable order, so
676
+ // no property of the output can depend on what readdir felt like doing.
677
+ const entries = [...listed]
678
+ .filter((e) => e && typeof e.name === 'string')
679
+ .sort((a, b) => byCodePoint(a.name, b.name));
680
+
681
+ // A nested .gitignore governs its own subtree and nothing above it.
682
+ let ignoreChain = dir.ignore;
683
+ const gitignoreEntry = entries.find((e) => e.name === '.gitignore' && e.type === 'file');
684
+ if (gitignoreEntry) {
685
+ stats.gitignoreUsed = true;
686
+ let text = null;
687
+ try { text = io.readFileImpl(dir.rel === '' ? '.gitignore' : `${dir.rel}/.gitignore`); } catch { text = null; }
688
+ const rules = parseGitignore(text);
689
+ if (rules.length > 0) {
690
+ ignoreChain = [...dir.ignore, { base: dir.rel, match: makeIgnoreMatcher(rules) }];
691
+ }
692
+ }
693
+
694
+ const ignored = (rel, isDir) => {
695
+ for (const layer of ignoreChain) {
696
+ const scoped = layer.base === '' ? rel : rel.slice(layer.base.length + 1);
697
+ if (layer.match(scoped, isDir)) return true;
698
+ }
699
+ return false;
700
+ };
701
+
702
+ const childDirs = [];
703
+ for (const entry of entries) {
704
+ if (entriesSeen >= maxEntries) { stats.walkCapped = true; break; }
705
+ entriesSeen += 1;
706
+ const name = entry.name;
707
+ const rel = dir.rel === '' ? name : `${dir.rel}/${name}`;
708
+
709
+ if (entry.type === 'dir') {
710
+ if (SKIP_DIRS.has(name)) { stats.skippedDirs += 1; skippedNames.add(name); continue; }
711
+ if (name.startsWith('.') && !HIDDEN_DIRS_ALLOWED.has(name)) { stats.hidden += 1; continue; }
712
+ if (refusedCommitPath(`${rel}/`)) { stats.withheld += 1; continue; }
713
+ if (ignored(rel, true)) { stats.gitignored += 1; continue; }
714
+ if (dir.depth + 1 > MAX_DEPTH) continue;
715
+ childDirs.push({ rel, depth: dir.depth + 1, ignore: ignoreChain });
716
+ continue;
717
+ }
718
+ // ⚠️ A symlink, socket or fifo is neither. Skipping every non-file,
719
+ // non-dir entry is what makes a cycle structurally impossible.
720
+ if (entry.type !== 'file') continue;
721
+
722
+ if (name.startsWith('.') && !HIDDEN_FILES_ALLOWED.has(name)) { stats.hidden += 1; continue; }
723
+ if (refusedCommitPath(rel)) { stats.withheld += 1; continue; }
724
+ if (ignored(rel, false)) { stats.gitignored += 1; continue; }
725
+
726
+ let st = null;
727
+ try { st = io.statImpl(rel); } catch { st = null; }
728
+ candidates.push({
729
+ path: rel,
730
+ depth: dir.depth,
731
+ size: typeof st?.size === 'number' ? st.size : 0,
732
+ mtimeMs: typeof st?.mtimeMs === 'number' ? st.mtimeMs : 0,
733
+ });
734
+ }
735
+
736
+ // Pushed in reverse so the stack pops them in sorted order. Purely
737
+ // cosmetic for correctness, load-bearing for reading a debug dump.
738
+ for (let i = childDirs.length - 1; i >= 0; i--) stack.push(childDirs[i]);
739
+ }
740
+
741
+ stats.skippedDirNames = [...skippedNames].sort(byCodePoint);
742
+ stats.totalFiles = candidates.length;
743
+
744
+ // ── package.json: entry points, scripts ───────────────────────────────────
745
+ const pkg = readPackageJson(io, candidates);
746
+ stats.entryPoints = pkg.entryPoints;
747
+ const entryTargets = new Set(pkg.entryPoints.map((e) => e.target.replace(/^\.\//, '')));
748
+
749
+ // ── priority order ────────────────────────────────────────────────────────
750
+ const ordered = orderForBudget(candidates, entryTargets);
751
+
752
+ // ── symbols, for the files most likely to be shown ────────────────────────
753
+ for (const f of ordered.slice(0, MAX_SYMBOL_READS)) {
754
+ if (!SYMBOL_EXT.test(f.path)) continue;
755
+ if (f.size > MAX_SYMBOL_FILE_BYTES) continue;
756
+ let src = null;
757
+ try { src = io.readFileImpl(f.path); } catch { src = null; }
758
+ if (typeof src !== 'string') continue;
759
+ const names = extractExports(f.path, src);
760
+ // ⚠️ AN EMPTY LIST IS LEFT UNDEFINED, NOT STORED AS []. `[]` renders as
761
+ // "this file exports nothing", which is a claim a regex cannot make.
762
+ if (names.length > 0) f.symbols = names;
763
+ }
764
+
765
+ if (candidates.length === 0) {
766
+ const text = 'REPO MAP — the workspace is empty (no files this agent may list)';
767
+ stats.tokensEstimated = estimateTokens(text);
768
+ return { ok: true, text, files: [], truncated: false, stats };
769
+ }
770
+
771
+ /**
772
+ * ── ⭐ TWO LEVERS, AND THE ORDER BETWEEN THEM IS THE WHOLE DESIGN ──────────
773
+ *
774
+ * MEASURED on a real 2,246-file Next.js repo: an annotated line averages 93
775
+ * characters, a bare path 28. So symbols cost 3.3x, and at a 3,000-token
776
+ * budget they were buying 77 symbol lists at the price of 230 PATHS.
777
+ *
778
+ * ⚠️ THAT TRADE IS BACKWARDS AND IT INVERTS THE MODULE'S OWN THESIS. Coverage
779
+ * is the product — the thing that turns "I cannot find it, I will invent a
780
+ * plausible file" into "I can see it, let me open it". A symbol list is a
781
+ * convenience on top; the model can always call `read_file`. So symbols are
782
+ * surrendered FIRST and paths LAST, never the other way round.
783
+ *
784
+ * Lever A: how many of the highest-priority files carry symbols (all → none).
785
+ * Lever B: how many files are listed at all (all → one).
786
+ */
787
+ const render = (n, symCount) => {
788
+ const chosen = ordered.slice(0, n);
789
+ const omitted = ordered.slice(n);
790
+ const symAllowed = new Set(ordered.slice(0, symCount).filter((f) => f.symbols).map((f) => f.path));
791
+ const lineFor = (f) => {
792
+ if (!f.symbols || !symAllowed.has(f.path)) return ` ${f.path}`;
793
+ const shown = f.symbols.slice(0, MAX_SYMBOLS_SHOWN);
794
+ const extra = f.symbols.length - shown.length;
795
+ return ` ${f.path} [${shown.join(', ')}${extra > 0 ? ` +${extra}` : ''}]`;
796
+ };
797
+ return { text: renderMap({ chosen, omitted, ordered, stats, pkg, lineFor, symAllowed }), chosen, omitted };
798
+ };
799
+
800
+ /** Largest symbol count that still fits, for a fixed file count. Monotonic. */
801
+ const fitSymbols = (n) => {
802
+ let lo = 0;
803
+ let hi = n;
804
+ while (lo < hi) {
805
+ const mid = Math.ceil((lo + hi) / 2);
806
+ if (estimateTokens(render(n, mid).text) <= budgetTokens) lo = mid;
807
+ else hi = mid - 1;
808
+ }
809
+ return lo;
810
+ };
811
+
812
+ let take = ordered.length;
813
+ let symCount = ordered.length;
814
+ let out = render(take, symCount);
815
+
816
+ if (estimateTokens(out.text) > budgetTokens) {
817
+ symCount = fitSymbols(take);
818
+ out = render(take, symCount);
819
+ // Still over with zero symbols? Only then does a PATH get dropped, from the
820
+ // lowest-priority end, in batches. Bounded: a runaway here is a hang.
821
+ for (let guard = 0; guard < 64 && take > 1; guard++) {
822
+ const used = estimateTokens(out.text);
823
+ if (used <= budgetTokens) break;
824
+ const excess = used - budgetTokens;
825
+ const perLine = Math.max(1, Math.ceil(used / Math.max(1, take)));
826
+ take = Math.max(1, take - Math.max(1, Math.ceil(excess / perLine)));
827
+ out = render(take, symCount);
828
+ }
829
+ // Dropping paths freed room; hand it back to symbols rather than waste it.
830
+ // One pass, after `take` has settled, so this can never oscillate.
831
+ const regained = fitSymbols(take);
832
+ if (regained > symCount) {
833
+ symCount = regained;
834
+ out = render(take, symCount);
835
+ }
836
+ }
837
+ /**
838
+ * ⚠️ `files` CARRIES EVERY SYMBOL LIST THAT WAS EXTRACTED; `text` carries
839
+ * only the ones the budget paid for. That is a deliberate asymmetry — a
840
+ * programmatic caller should not lose data to a rendering decision — and
841
+ * `symbolsShown` is the number that reconciles the two. Do not read a bare
842
+ * path in `text` as "this file exports nothing".
843
+ */
844
+ stats.symbolsShown = ordered.slice(0, Math.min(symCount, take)).filter((f) => f.symbols).length;
845
+
846
+ const listed = [...out.chosen].sort((a, b) => byCodePoint(a.path, b.path));
847
+ stats.listedFiles = out.chosen.length;
848
+ stats.omittedFiles = out.omitted.length;
849
+ stats.dirsTotal = new Set(ordered.map((f) => dirOf(f.path))).size;
850
+ stats.dirsListed = new Set(out.chosen.map((f) => dirOf(f.path))).size;
851
+ stats.tokensEstimated = estimateTokens(out.text);
852
+
853
+ return {
854
+ ok: true,
855
+ text: out.text,
856
+ files: listed.map((f) => ({ path: f.path, bytes: f.size, ...(f.symbols ? { symbols: f.symbols } : {}) })),
857
+ truncated: out.omitted.length > 0 || stats.walkCapped,
858
+ stats,
859
+ };
860
+ }
861
+
862
+ // ─────────────────────────────────────────────────────────────────────────────
863
+ // RENDERING
864
+ // ─────────────────────────────────────────────────────────────────────────────
865
+
866
+ function readPackageJson(io, candidates) {
867
+ const empty = { entryPoints: [], scripts: [], scriptsOmitted: 0 };
868
+ if (!candidates.some((f) => f.path === 'package.json')) return empty;
869
+ let raw = null;
870
+ try { raw = io.readFileImpl('package.json'); } catch { return empty; }
871
+ if (typeof raw !== 'string') return empty;
872
+ let pkg;
873
+ // ⚠️ A REPO MID-EDIT STILL GETS A MAP. A half-typed package.json is a normal
874
+ // state of a working tree, and it is not a reason to blind the model.
875
+ try { pkg = JSON.parse(raw); } catch { return empty; }
876
+ if (!pkg || typeof pkg !== 'object') return empty;
877
+
878
+ const entryPoints = [];
879
+ if (typeof pkg.main === 'string') entryPoints.push({ kind: 'main', name: 'main', target: pkg.main });
880
+ if (typeof pkg.bin === 'string') entryPoints.push({ kind: 'bin', name: pkg.name ?? 'bin', target: pkg.bin });
881
+ else if (pkg.bin && typeof pkg.bin === 'object') {
882
+ for (const name of Object.keys(pkg.bin).sort(byCodePoint)) {
883
+ if (typeof pkg.bin[name] === 'string') entryPoints.push({ kind: 'bin', name, target: pkg.bin[name] });
884
+ }
885
+ }
886
+ const scripts = [];
887
+ let scriptsOmitted = 0;
888
+ if (pkg.scripts && typeof pkg.scripts === 'object') {
889
+ // ⚠️ RANKED, NOT ALPHABETICAL — see `rankScripts`; alphabetical hid `npm test`.
890
+ const runnable = Object.keys(pkg.scripts).filter((n) => typeof pkg.scripts[n] === 'string');
891
+ const picked = rankScripts(runnable);
892
+ scriptsOmitted = picked.omitted;
893
+ for (const name of picked.chosen) {
894
+ const cmd = pkg.scripts[name];
895
+ scripts.push({ name, cmd: cmd.length > 70 ? `${cmd.slice(0, 70)}…` : cmd });
896
+ }
897
+ }
898
+ return { entryPoints, scripts, scriptsOmitted };
899
+ }
900
+
901
+ function renderMap({ chosen, omitted, ordered, stats, pkg, lineFor, symAllowed }) {
902
+ const total = ordered.length;
903
+ const truncated = omitted.length > 0 || stats.walkCapped;
904
+ const parts = [];
905
+
906
+ /**
907
+ * ── ⭐⭐ THE COUNT WAS AT BYTE 0, AND IT IS THE THING THAT CHANGES MOST ──────
908
+ *
909
+ * ⚠️⚠️ MEASURED 2026-08-16 on this repo (346 files, a 19,950-byte / ~5,000-token
910
+ * map), by building the map, creating ONE file, and building it again:
911
+ *
912
+ * agent creates one new source file 13 of 19,975 bytes survive 0.1%
913
+ * agent creates one new test file 13 of 19,987 bytes survive 0.1%
914
+ * agent EDITS an existing file 19,760 of 19,952 bytes survive 99.0%
915
+ *
916
+ * The edit case is 99% because the fix below this function already moved
917
+ * LARGEST and RECENTLY CHANGED behind FILES. The CREATE case was 0.1% because
918
+ * of THIS LINE: `346 files` became `347 files` at byte 13, and a prefix cache
919
+ * is worth nothing past its first differing byte, so the entire map — every
920
+ * section, including the ones that did not change — was re-paid at full price.
921
+ *
922
+ * ⚠️ AND CREATING A FILE IS NOT AN EDGE CASE. It is what a coding agent does;
923
+ * the CLI is then invoked again in the same repo, which is the whole shape of
924
+ * the cache floor the pricing is sized on.
925
+ *
926
+ * ⭐ SAME INFORMATION, MOVED, NOT DROPPED. The counts render in `TOTALS` at the
927
+ * very end, beside `NOT LISTED`, which is where the omission breakdown already
928
+ * lives and is the more useful place to read them anyway. What stays here is
929
+ * COMPLETE / INCOMPLETE — the one fact the model must have BEFORE it reads the
930
+ * listing ("is this everything?"), and the one that does NOT move when a file
931
+ * appears: it flips only when the repo crosses the token budget.
932
+ *
933
+ * ⚠️ THE HEADER MUST NAME WHERE THE NUMBERS WENT. A map that says INCOMPLETE
934
+ * and never says how incomplete is unactionable, which is the defect the
935
+ * original line was written to close — the fix is placement, not deletion.
936
+ *
937
+ * ⚠️⚠️ AND IT MUST BE SHORTER THAN WHAT IT REPLACED, NOT LONGER. The first
938
+ * draft spent an explanatory sentence here and turned the depth-cliff test
939
+ * red — measured, and the mechanism is exact: the map renders against a TOKEN
940
+ * BUDGET, so every fixed byte of preamble is a file line the budget can no
941
+ * longer afford. That fixture needed 401 estimated tokens to reach its 33rd
942
+ * file and had 400; 61 bytes of new prose bought the deepest directory band
943
+ * back out of the map. A caching change that quietly shrinks coverage is not a
944
+ * win, so both lines below are SHORTER than the counted versions they replace.
945
+ */
946
+ parts.push(truncated
947
+ ? 'REPO MAP — INCOMPLETE (counts at the end)'
948
+ : 'REPO MAP — COMPLETE (counts at the end)');
949
+
950
+ // ⭐ THE LABEL ONLY APPEARS WHEN THERE IS A GUESS TO LABEL. Printing it over
951
+ // a map with no symbols spends tokens warning about nothing.
952
+ if (chosen.some((f) => f.symbols && symAllowed.has(f.path))) {
953
+ parts.push('symbol names are a regex guess, not a parse — a missing name proves nothing');
954
+ }
955
+
956
+ if (pkg.entryPoints.length > 0 || pkg.scripts.length > 0) {
957
+ const lines = ['', 'ENTRY POINTS'];
958
+ for (const e of pkg.entryPoints) lines.push(` ${e.kind} ${e.name} ${e.target}`);
959
+ for (const s of pkg.scripts) lines.push(` script ${s.name} ${s.cmd}`);
960
+ // ⚠️ SAID OUT LOUD, for the same reason the FILES section states its total:
961
+ // a list that is silently short reads as the complete set, and the model
962
+ // then believes a script it cannot see does not exist.
963
+ if (pkg.scriptsOmitted > 0) {
964
+ lines.push(` ${pkg.scriptsOmitted} further script${pkg.scriptsOmitted === 1 ? '' : 's'} not shown — read package.json for the rest`);
965
+ }
966
+ parts.push(lines.join('\n'));
967
+ }
968
+
969
+ const testDirs = new Map();
970
+ for (const f of chosen) {
971
+ const top = f.path.includes('/') ? f.path.slice(0, f.path.indexOf('/')) : '';
972
+ if (top && TEST_DIR_NAMES.has(top)) testDirs.set(top, (testDirs.get(top) ?? 0) + 1);
973
+ }
974
+ /**
975
+ * ⚠️ NAMES HERE, COUNTS IN `TOTALS` — for the same measured reason as the
976
+ * header. `test/ 190 files` becomes `191 files` the moment the agent writes
977
+ * one test, and this section sits AHEAD of the FILES listing, so with the
978
+ * header fixed this line would simply become the new byte-13. The question
979
+ * this section answers is "where are the tests", and a directory name answers
980
+ * it whole; the size of the suite is a total, and totals now live together.
981
+ */
982
+ const testDirNames = [...testDirs.keys()].sort(byCodePoint);
983
+ if (testDirs.size > 0) {
984
+ parts.push(['', 'TESTS', ...testDirNames.map((name) => ` ${name}/`)].join('\n'));
985
+ }
986
+
987
+ const listed = [...chosen].sort((a, b) => byCodePoint(a.path, b.path));
988
+ parts.push(['', 'FILES', ...listed.map(lineFor)].join('\n'));
989
+
990
+ /**
991
+ * ── ⭐⭐ THE TWO VOLATILE SECTIONS COME LAST, AND THAT IS THE WHOLE POINT ──
992
+ *
993
+ * ⚠️⚠️ THEY USED TO COME BEFORE `FILES`, AND IT COST ~60% OF THE MAP. Both
994
+ * are ordered by something the AGENT ITSELF CHANGES — byte size and mtime — so
995
+ * writing ONE file reshuffles them. Sitting ahead of `FILES` (hundreds of lines
996
+ * on any real repo, against ~10 here) that meant a single write diverged the
997
+ * prompt at roughly a third of the way in; from down here it diverges at ~95%.
998
+ * The map is the bulk of round 1's user message, so this decides how much of a
999
+ * fresh run can be served out of the previous run's cache — the common case for
1000
+ * a CLI, which is invoked over and over in the same repo.
1001
+ *
1002
+ * ⚠️ DETERMINISM AND STABILITY ARE DIFFERENT PROPERTIES, and only the first
1003
+ * was designed for. The header rule ("deterministic, never a rendered age") is
1004
+ * about `readdir` order and clocks; it says nothing about the agent's own
1005
+ * edits. Both are needed, and the fix for the second is placement, not sorting.
1006
+ *
1007
+ * ⚠️ SAME INFORMATION, SAME MAP — nothing is dropped, and no line changes.
1008
+ * A model reading top to bottom now meets the stable inventory first and the two
1009
+ * ranked hints after it, which is also the better reading order.
1010
+ */
1011
+ // ⚠️ TWO IS THE THRESHOLD, NOT THREE. A "largest files" list of one entry is
1012
+ // noise, but a two-file repo still has a biggest file and a newest one.
1013
+ if (chosen.length >= 2) {
1014
+ const largest = [...chosen]
1015
+ .sort((a, b) => (b.size - a.size) || byCodePoint(a.path, b.path))
1016
+ .slice(0, 5);
1017
+ parts.push(['', 'LARGEST', ...largest.map((f) => ` ${f.path} ${f.size} bytes`)].join('\n'));
1018
+
1019
+ /**
1020
+ * ⭐ AN ORDER, NEVER A TIMESTAMP. "modified 4 minutes ago" changes on every
1021
+ * single run, which changes the prompt prefix, which throws away the 3.05x
1022
+ * cache discount for a fact nobody reads. The rank carries the whole signal.
1023
+ */
1024
+ const recent = [...chosen]
1025
+ .sort((a, b) => (b.mtimeMs - a.mtimeMs) || byCodePoint(a.path, b.path))
1026
+ .slice(0, 5);
1027
+ parts.push(['', 'RECENTLY CHANGED — newest first', ...recent.map((f) => ` ${f.path}`)].join('\n'));
1028
+ }
1029
+
1030
+ const notes = [];
1031
+ if (omitted.length > 0) {
1032
+ notes.push(` omitted for budget ${omitted.length} files — use find_files or search_text to reach them`);
1033
+ const byTop = new Map();
1034
+ for (const f of omitted) {
1035
+ const top = f.path.includes('/') ? `${f.path.slice(0, f.path.indexOf('/'))}/` : './';
1036
+ byTop.set(top, (byTop.get(top) ?? 0) + 1);
1037
+ }
1038
+ const gaps = [...byTop.entries()]
1039
+ .sort((a, b) => (b[1] - a[1]) || byCodePoint(a[0], b[0]))
1040
+ .slice(0, MAX_GAP_LINES);
1041
+ for (const [dir, n] of gaps) notes.push(` ${dir} ${n} files`);
1042
+ }
1043
+ if (stats.walkCapped) notes.push(' walk capped the tree exceeded the entry limit and was cut short');
1044
+ if (stats.skippedDirNames.length > 0) notes.push(` not walked ${stats.skippedDirNames.join(', ')}`);
1045
+ if (stats.gitignored > 0) notes.push(` gitignored ${stats.gitignored} entries`);
1046
+ if (stats.hidden > 0) notes.push(` hidden ${stats.hidden} entries`);
1047
+ if (stats.withheld > 0) notes.push(` withheld ${stats.withheld} credential-shaped files`);
1048
+ if (stats.unreadableDirs > 0) notes.push(` unreadable ${stats.unreadableDirs} directories`);
1049
+ if (notes.length > 0) parts.push(['', 'NOT LISTED', ...notes].join('\n'));
1050
+
1051
+ /**
1052
+ * ── ⭐⭐ EVERY NUMBER THAT MOVES WHEN A FILE APPEARS, IN ONE PLACE, LAST ─────
1053
+ *
1054
+ * ⚠️ THIS IS NOT A NEW FACT, IT IS A RELOCATED ONE. `${total} files found,
1055
+ * ${chosen.length} listed` used to be the first thirteen bytes of the map and
1056
+ * `test/ N files` the ~350th; both changed on any file creation and both sat
1057
+ * ahead of the FILES listing, which is the bulk of the map. Down here they
1058
+ * cost the tail instead of the whole thing.
1059
+ *
1060
+ * ⚠️ IT IS UNCONDITIONAL, unlike `NOT LISTED`. A map with nothing omitted
1061
+ * still has a total, and "how big is this project" is a question the model
1062
+ * answers wrongly by guessing if nothing states it.
1063
+ *
1064
+ * ⚠️ AND IT GOES AFTER `NOT LISTED`, NOT BEFORE. Both are volatile, so the
1065
+ * order between them costs nothing — but the omission breakdown is what a
1066
+ * reader wants immediately after "INCOMPLETE", and the totals are the summary
1067
+ * it adds up to.
1068
+ */
1069
+ const totals = [
1070
+ '',
1071
+ truncated ? `TOTALS ${total} files found, ${chosen.length} listed` : `TOTALS ${total} files, all listed`,
1072
+ ...testDirNames.map((name) => ` ${name}/ ${testDirs.get(name)} files`),
1073
+ ];
1074
+ parts.push(totals.join('\n'));
1075
+
1076
+ return parts.join('\n');
1077
+ }
1078
+
1079
+ // ─────────────────────────────────────────────────────────────────────────────
1080
+ // THE WIRING SEAM
1081
+ // ─────────────────────────────────────────────────────────────────────────────
1082
+
1083
+ /**
1084
+ * The one call `turn.mjs` needs.
1085
+ *
1086
+ * ⚠️ IT RETURNS A STRING AND NEVER THROWS. A pre-read is an optimisation, not
1087
+ * a precondition: an unreadable workspace must degrade to "no map" and let the
1088
+ * turn proceed, never take the turn down with it. That is why every failure
1089
+ * here is an empty string rather than an exception or an apology in the prompt.
1090
+ */
1091
+ export function repoMapForExecutor(executor, opts = {}) {
1092
+ try {
1093
+ const root = executor?.root;
1094
+ if (!root || typeof root !== 'string') return '';
1095
+ if (!existsSync(root)) return '';
1096
+ const map = buildRepoMap(root, {}, opts);
1097
+ return map.ok ? map.text : '';
1098
+ } catch {
1099
+ return '';
1100
+ }
1101
+ }