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,749 @@
1
+ /**
2
+ * ── ⭐⭐ SUBAGENTS — DELEGATION, AND THE CHEAPEST CONTEXT THERE IS ───────────
3
+ *
4
+ * The main loop can now say "go and find where this is defined across 400
5
+ * files" and get back two hundred tokens instead of fifty thousand.
6
+ *
7
+ * ⚠️ THIS IS NOT `--parallel`. That runs N unrelated tasks a HUMAN typed, in
8
+ * separate workspaces, with collision detection between them. This is one task
9
+ * asking for help mid-thought, and the help happens in its own head.
10
+ *
11
+ * ── ⭐ WHY IT IS A MARGIN FEATURE AS WELL AS A CONTEXT ONE ──────────────────
12
+ * Measured 2026-08-11: DeepSeek caches prompt prefixes automatically and a hit
13
+ * costs up to 50x less — but ONLY while the prefix repeats byte-for-byte from
14
+ * token 0. Every noisy search a parent performs itself lands in the parent's
15
+ * message array and pushes the useful part further from the cached head. A
16
+ * subagent's exploration never touches that array at all, so the expensive
17
+ * cached prefix survives work that would otherwise pollute it.
18
+ *
19
+ * ⭐ Context hygiene and cost are the same lever, and almost nobody treats them
20
+ * that way.
21
+ *
22
+ * ── ⚠️ THE FOUR RULES, AND THE FAILURE EACH ONE PREVENTS ───────────────────
23
+ *
24
+ * 1. ⚠️⚠️ READ-ONLY **BY DEFAULT**, AND LOCKED TWICE. A subagent that writes
25
+ * can collide with the parent editing the same file, and whoever finishes
26
+ * second wins silently. `parallel.mjs` exists to catch exactly that between
27
+ * processes and is blind to it here, because these run INSIDE one session.
28
+ * So the offer excludes every mutating verb AND `allowRun: false` is passed,
29
+ * because a model can emit a call for a tool it was never shown — the same
30
+ * belt-and-braces `check_acceptance` already uses.
31
+ *
32
+ * ── ⭐⭐ AND THAT BLOCKER IS NOW SOLVED — `write: true` ───────────────────
33
+ * Read the rule again: the reason is TECHNICAL ("blind to it here"), not
34
+ * moral. `lib/handoff.mjs` removes the blindness — a writing helper works in
35
+ * an isolated COPY of the workspace (so it cannot race anybody), and its
36
+ * changes are re-applied THROUGH THE PARENT'S EXECUTOR with a per-file
37
+ * SHA-256 collision check, so a file that moved underneath us is refused BY
38
+ * NAME instead of silently overwritten. The parent's own guards — leases,
39
+ * `--dry-run`, the `.acuvo/` leash, path containment — all still apply,
40
+ * because the apply path is the same `executor.writeFile` every other tool
41
+ * uses. ⚠️ It is OPT-IN per call: a helper asked a question still cannot
42
+ * write a byte.
43
+ *
44
+ * 2. ⚠️ NO RECURSION. A subagent that can delegate is an unbounded fork bomb
45
+ * billed to the user. `delegate` is absent from `SUBAGENT_TOOL_NAMES`, and
46
+ * depth is checked before a model is called, so both the offer and the
47
+ * dispatcher refuse.
48
+ *
49
+ * 3. ⚠️ DISTILLED, NEVER FORWARDED. Returning the transcript would defeat the
50
+ * whole point: the parent would pay for precisely the context it delegated
51
+ * to avoid. The parent gets a sentence, a cost, and a list of files touched.
52
+ *
53
+ * 4. ⚠️ A FAILURE IS DATA. A dead subagent returns a reason the parent can act
54
+ * on. Throwing into the parent's loop would take down a session that was
55
+ * otherwise fine, to report that a helper was not.
56
+ */
57
+
58
+ /**
59
+ * ⚠️ STATIC, AND SAFE TO BE. `turn.mjs` is still imported LAZILY below because
60
+ * it imports `tools.mjs`, which imports THIS file — a cycle. `handoff.mjs` and
61
+ * `workspace.mjs` sit underneath both and import neither, so there is no cycle
62
+ * to dodge here, and `summariseForParent` is synchronous and could not await
63
+ * one anyway.
64
+ */
65
+ import { describeHandoff, runInIsolatedCopy } from './handoff.mjs';
66
+ import { createLocalExecutor } from './workspace.mjs';
67
+
68
+ /**
69
+ * What a researcher may hold. Read, search, navigate — nothing that changes
70
+ * anything.
71
+ *
72
+ * ⚠️ SPELLED OUT RATHER THAN FILTERED FROM THE REGISTRY. A denylist would
73
+ * silently hand a subagent every future tool anyone adds; this way a new
74
+ * capability reaches subagents only when somebody decides it should.
75
+ */
76
+ export const SUBAGENT_TOOL_NAMES = Object.freeze([
77
+ 'read_file',
78
+ 'read_lines',
79
+ 'read_around',
80
+ 'list_dir',
81
+ 'find_files',
82
+ 'search_text',
83
+ 'find_definition',
84
+ 'find_references',
85
+ 'list_symbols',
86
+ 'git_status',
87
+ 'git_diff',
88
+ 'git_log',
89
+ ]);
90
+
91
+ /**
92
+ * ── ⭐⭐ WHAT A *BUILDER* MAY HOLD — the read verbs plus three write verbs ────
93
+ *
94
+ * ⚠️ SPELLED OUT, LIKE THE LIST ABOVE, and for the same reason: a denylist
95
+ * would hand every future tool to a writing helper the day it is registered.
96
+ *
97
+ * ⚠️⚠️ THE THREE GIT VERBS ARE DELIBERATELY ABSENT, AND THIS IS THE TRAP THAT
98
+ * WOULD HAVE SHIPPED. A builder runs inside the isolated copy, and the copy
99
+ * skips `.git` (`best-of.mjs:42 COPY_SKIP_DIRS` — copying a repo's object
100
+ * store per delegation is enormous and the helper cannot commit anything
101
+ * anyway). So `git_status`, `git_diff` and `git_log` could only ever return
102
+ * "not a git repository" there. ⭐ That is precisely the dead button
103
+ * `tools.mjs` refuses to ship everywhere else — a tool the model is offered,
104
+ * tries, and learns to apologise for. Offering them would cost ~750 tokens a
105
+ * round to teach the helper to fail.
106
+ *
107
+ * ⚠️ AND STILL NOTHING THAT EXECUTES. `allowRun: false` is passed for a builder
108
+ * exactly as for a researcher — not from timidity, but because the copy has no
109
+ * `node_modules` either (same skip set), so `npm test` inside it would fail for
110
+ * a reason that has nothing to do with the code. A verification that fails for
111
+ * the wrong reason is worse than no verification: the parent would act on it.
112
+ * ⭐ The parent verifies, after the changes land in the real workspace where
113
+ * the dependencies actually are.
114
+ */
115
+ /**
116
+ * ── ⭐⭐ AND WHAT A *VERIFYING* BUILDER MAY HOLD — the write verbs plus ONE ────
117
+ *
118
+ * ⚠️ THE COMMENT ON `SUBAGENT_WRITE_TOOL_NAMES` BELOW IS STILL TRUE AND IS
119
+ * DELIBERATELY LEFT STANDING. It argues that a builder must execute nothing
120
+ * because the isolated copy has no `node_modules`, so a verification run in
121
+ * there would fail for a reason that has nothing to do with the code, and "a
122
+ * verification that fails for the wrong reason is worse than no verification:
123
+ * the parent would act on it." Every word of that was correct — and it is an
124
+ * argument about the COPY, not about the helper.
125
+ *
126
+ * ⭐ SO THE COPY CHANGED. `runInIsolatedCopy({ linkNodeModules: true })` links
127
+ * the real dependency tree into the copy (a junction on Windows, measured safe
128
+ * under the recursive delete — see its comment). With the dependencies present,
129
+ * `npm test` inside the copy means what it says, and the old objection no longer
130
+ * applies to a run that asked for this.
131
+ *
132
+ * ⚠️ A SEPARATE LIST, NOT A MUTATION OF THE ONE BELOW. `run_command` must not
133
+ * appear in the default builder's offer: `subagent-write.test.mjs` pins it as a
134
+ * dead button there and it is RIGHT to, because a plain `write:true` helper
135
+ * still gets an unlinked copy and `allowRun: false`. Two capabilities, two
136
+ * lists, and neither can leak into the other by accident.
137
+ *
138
+ * ⚠️ ONE RUN VERB, NOT FIVE. `check_types`, `check_acceptance`, `evaluate` and
139
+ * `run_program` would each cost tokens in the offer on every round of every
140
+ * delegated build; `run_command` reaches all of them through the same
141
+ * allowlist (`node`, `npm`, `npx`, `tsc`) that governs the parent. Breadth in
142
+ * the offer is not breadth in capability here.
143
+ */
144
+ /* Declared just below `SUBAGENT_WRITE_TOOL_NAMES`, because it is that list
145
+ * plus one verb and retyping thirteen names would be two documents to keep in
146
+ * agreement — the drift this file already records three times. */
147
+
148
+ export const SUBAGENT_WRITE_TOOL_NAMES = Object.freeze([
149
+ 'read_file',
150
+ 'read_lines',
151
+ 'read_around',
152
+ 'list_dir',
153
+ 'find_files',
154
+ 'search_text',
155
+ 'find_definition',
156
+ 'find_references',
157
+ 'list_symbols',
158
+ /**
159
+ * ⭐ `edit_file` BEFORE `write_file`, because the order in the offer is a
160
+ * hint the model reads — `tools.mjs:520` makes the same point: for a file
161
+ * that already exists, `write_file` is a destructive operation wearing the
162
+ * costume of an edit.
163
+ */
164
+ 'edit_file',
165
+ 'write_file',
166
+ /**
167
+ * ⭐ THE BULK WRITE EARNS ITS ~300 TOKENS HERE MORE THAN ANYWHERE. A builder
168
+ * is capped at 6 rounds; "create these four files" as four rounds is a
169
+ * helper that runs out of budget mid-feature, and `handoff.mjs:changedPaths`
170
+ * already reads `result.written[]` so every one of them is applied.
171
+ */
172
+ 'write_files',
173
+ 'delete_file',
174
+ ]);
175
+
176
+ export const SUBAGENT_VERIFY_TOOL_NAMES = Object.freeze([...SUBAGENT_WRITE_TOOL_NAMES, 'run_command']);
177
+
178
+ /**
179
+ * ⚠️ ONE LEVEL. A parent may delegate; a subagent may not. Two levels sounds
180
+ * harmless and is how a five-round task becomes a hundred model calls nobody
181
+ * authorised.
182
+ */
183
+ export const MAX_SUBAGENT_DEPTH = 1;
184
+
185
+ /**
186
+ * ⚠️ A SUBAGENT MUST NOT OUTSPEND ITS PARENT. A researcher that needs more than
187
+ * a handful of rounds is being asked the wrong question, and the honest answer
188
+ * is a worse summary rather than a bigger bill.
189
+ */
190
+ export const MAX_SUBAGENT_ROUNDS = 6;
191
+ const DEFAULT_SUBAGENT_ROUNDS = 4;
192
+
193
+ const MAX_SUMMARY_CHARS = 900;
194
+ const MAX_FILES_LISTED = 12;
195
+
196
+ /**
197
+ * ── ⭐⭐ THE CONTEXT CHANNEL — THE HELPER USED TO GET A TASK STRING AND NOTHING ─
198
+ *
199
+ * Rule 3 at the top of this file says the RESULT is distilled and never
200
+ * forwarded, and it is right. Nobody ever wrote the rule for the other
201
+ * direction, so the input was a single sentence by default rather than by
202
+ * decision — and a helper that does not know what the parent has already
203
+ * established re-derives it. Measured cost of that: a builder capped at 6 rounds
204
+ * spending two of them opening files the parent read a minute earlier, and
205
+ * arriving at a different opinion about them, which the parent then has to
206
+ * reconcile against its own.
207
+ *
208
+ * ⭐ IT IS A BRIEF, NOT A TRANSCRIPT. Handing over the message array would
209
+ * reproduce the exact cost the whole feature exists to avoid — the parent would
210
+ * pay to send the context it delegated in order not to send. What travels is
211
+ * what the PARENT chose to write down, which is also the only part it can
212
+ * vouch for.
213
+ *
214
+ * ⚠️ AND IT IS CAPPED, OUT LOUD. Without a ceiling the cheapest way for a model
215
+ * to "give context" is to paste a file, and delegation would quietly become the
216
+ * most expensive tool in the package instead of the cheapest. 4,000 characters
217
+ * is ~1,000 estimated tokens — about a quarter of `read_file`'s own budget, and
218
+ * enough for a paragraph of findings and a list of constraints. Over that it is
219
+ * TRIMMED and the trim is announced inside the prompt, so the helper knows it
220
+ * was handed a truncated brief rather than a complete one.
221
+ */
222
+ export const MAX_CONTEXT_CHARS = 4_000;
223
+
224
+ /**
225
+ * Fold the parent's brief into the task the helper is given.
226
+ *
227
+ * ⚠️ PURE, AND SEPARATE FROM `runSubagent`, because it decides what a paid model
228
+ * call is asked — reviewable on its own rather than buried in an options object.
229
+ *
230
+ * ⚠️ THE CONTEXT GOES AFTER THE TASK, NOT BEFORE IT. The first thing the helper
231
+ * reads has to be the thing it was asked to do; a brief on top buries the
232
+ * question under its own background, and `prompt.mjs` makes the same call for
233
+ * the same reason.
234
+ *
235
+ * @param {string} task
236
+ * @param {unknown} context
237
+ * @returns {string}
238
+ */
239
+ export function briefFor(task, context) {
240
+ const extra = typeof context === 'string' ? context.trim() : '';
241
+ if (!extra) return task;
242
+ const trimmed = extra.length > MAX_CONTEXT_CHARS;
243
+ const body = trimmed ? extra.slice(0, MAX_CONTEXT_CHARS) : extra;
244
+ return `${task}\n\n`
245
+ + '--- WHAT THE AGENT THAT ASKED YOU HAS ALREADY ESTABLISHED ---\n'
246
+ + 'Treat this as known. Do not spend a round re-deriving it, and do not contradict it without saying so.\n'
247
+ + `${body}\n`
248
+ + (trimmed
249
+ ? `[the brief was longer than ${MAX_CONTEXT_CHARS} characters and was cut here — ask for anything you are missing in your summary]\n`
250
+ : '')
251
+ + '--- end of brief ---';
252
+ }
253
+
254
+ /** Every path a set of tool records looked at, in first-seen order. */
255
+ function filesTouched(executed) {
256
+ const seen = [];
257
+ for (const rec of executed ?? []) {
258
+ const p = rec?.args?.path ?? rec?.result?.path;
259
+ if (typeof p === 'string' && p && !seen.includes(p)) seen.push(p);
260
+ }
261
+ return seen;
262
+ }
263
+
264
+ /**
265
+ * Turn a finished session into the few hundred characters the parent actually
266
+ * needs.
267
+ *
268
+ * ⭐ THE MODEL'S OWN CLOSING NOTE IS THE ANSWER. It is the one place the
269
+ * subagent states what it concluded, in its own words, having seen everything.
270
+ * Re-deriving that from tool results here would be a second, worse summariser.
271
+ *
272
+ * ⚠️ AND WHEN THERE IS NO NOTE, SAY SO OUT LOUD. An empty string tells the
273
+ * parent nothing and reads as success; "found nothing" is a fact it can act on.
274
+ */
275
+ /**
276
+ * ── ⭐⭐ WHAT THE HELPER PROVED, AS A FACT RATHER THAN AS A CLAIM ─────────────
277
+ *
278
+ * ⚠️ IT IS APPENDED SEPARATELY AND NEVER FOLDED INTO THE NOTE, for the same
279
+ * reason `describeHandoff` is: the note is what the helper MEANT, capped at
280
+ * `MAX_SUMMARY_CHARS`, and a verdict squeezed out by a chatty model is a verdict
281
+ * the parent never hears. `outcome.verification` is what a process actually did.
282
+ *
283
+ * ⚠️ AND SILENCE IS NOT SUCCESS. A helper that ran nothing says so in words,
284
+ * because "the tests pass" and "no test was run" look identical to a parent
285
+ * reading a summary that mentions neither — and the second one is the state the
286
+ * parent must not report to the user as the first.
287
+ *
288
+ * @param {{ ran?: boolean, passed?: boolean|null, command?: string|null, exitCode?: number|null }|null} v
289
+ */
290
+ export function describeVerification(v, { offered = false } = {}) {
291
+ if (!offered) return '';
292
+ if (!v || v.ran !== true) {
293
+ return 'VERIFIED: nothing. It was allowed to run commands and did not — treat the change as unproven.';
294
+ }
295
+ const cmd = v.command ? `\`${v.command}\`` : 'a command';
296
+ return v.passed === true
297
+ ? `VERIFIED: it ran ${cmd} in its isolated copy and it passed (exit ${v.exitCode ?? 0}).`
298
+ : `⚠ NOT VERIFIED: it ran ${cmd} in its isolated copy and it FAILED (exit ${v.exitCode ?? '?'}). `
299
+ + 'The files below were still applied — re-run it yourself before saying the work is done.';
300
+ }
301
+
302
+ export function summariseForParent(outcome, handoff = null, { verifyOffered = false } = {}) {
303
+ if (!outcome || outcome.ok !== true) {
304
+ return `the helper did not finish: ${outcome?.error ?? 'no reason given'}`;
305
+ }
306
+
307
+ const note = typeof outcome.note === 'string' ? outcome.note.trim() : '';
308
+ const files = filesTouched(outcome.executed);
309
+ const proof = describeVerification(outcome.verification, { offered: verifyOffered });
310
+
311
+ /**
312
+ * ── ⚠️⚠️ WHAT LANDED IS A FACT, AND IT OUTRANKS THE MODEL'S OPINION ───────
313
+ *
314
+ * A builder's note says what it MEANT to do. `handoff` says what is on disk.
315
+ * When those disagree — a collision refused one file, the leash refused
316
+ * another — the parent must be told by the second, or it will report the
317
+ * first to the user as if it were the second. So this paragraph is appended
318
+ * unconditionally in write mode, including when the helper wrote nothing.
319
+ */
320
+ const landed = handoff ? describeHandoff(handoff) : '';
321
+
322
+ /**
323
+ * ⚠️ THE PROOF SITS WITH `landed`, ABOVE THE FILE LIST AND BELOW THE NOTE —
324
+ * both are facts about what happened rather than about what was intended, and
325
+ * keeping them adjacent is what stops a reader taking the note's confidence
326
+ * and the handoff's file list as one statement with no verdict between them.
327
+ */
328
+ const tail = [landed, proof].filter(Boolean).join('\n');
329
+
330
+ if (!note) {
331
+ const nothing = files.length
332
+ ? `Nothing conclusive. It looked at ${files.slice(0, MAX_FILES_LISTED).join(', ')} and did not report a finding.`
333
+ : 'Nothing conclusive — it did not report a finding, and opened no files.';
334
+ return tail ? `${nothing}\n${tail}` : nothing;
335
+ }
336
+
337
+ const body = note.length > MAX_SUMMARY_CHARS
338
+ ? `${note.slice(0, MAX_SUMMARY_CHARS)}…`
339
+ : note;
340
+
341
+ const looked = files.length === 0
342
+ ? ''
343
+ : `\n(looked at: ${files.slice(0, MAX_FILES_LISTED).join(', ')}${files.length > MAX_FILES_LISTED ? ` +${files.length - MAX_FILES_LISTED} more` : ''})`;
344
+
345
+ return tail ? `${body}${looked}\n${tail}` : `${body}${looked}`;
346
+ }
347
+
348
+ /**
349
+ * Run one scoped helper and return what it concluded — and, when it was asked
350
+ * to build, what it changed.
351
+ *
352
+ * @param {{ task: string, executor: any, config: any, depth?: number, maxRounds?: number,
353
+ * maxTokens?: number, timeoutMs?: number, write?: boolean, onEvent?: (e:any)=>void }} args
354
+ * @param {{ sessionImpl?: Function, isolateImpl?: Function, makeExecutor?: Function }} [deps]
355
+ */
356
+ export async function runSubagent(args = {}, {
357
+ sessionImpl = null,
358
+ /**
359
+ * ⚠️ INJECTED, NOT IMPORTED AT THE CALL SITE, for the reason every outward
360
+ * call in this package is injected: without it the ONLY way to exercise the
361
+ * write path is a real model call against a real temp directory, which means
362
+ * in practice it is exercised once by hand and never again. The two things
363
+ * being pinned here — a collision is refused, and `--dry-run` still holds —
364
+ * are the two that must never regress quietly.
365
+ */
366
+ isolateImpl = null,
367
+ makeExecutor = null,
368
+ } = {}) {
369
+ const task = typeof args.task === 'string' ? args.task.trim() : '';
370
+ if (!task) {
371
+ // ⚠️ Refused BEFORE a model is called. An empty task still costs a round.
372
+ return { ok: false, error: 'a delegated task needs a specific question — an empty one would spend a round to learn nothing' };
373
+ }
374
+
375
+ const depth = Number.isFinite(args.depth) ? args.depth : 0;
376
+ if (depth >= MAX_SUBAGENT_DEPTH) {
377
+ return {
378
+ ok: false,
379
+ error: `a helper cannot delegate again (depth ${depth}) — ask it a narrower question, or do the remaining step yourself`,
380
+ };
381
+ }
382
+
383
+ const run = sessionImpl ?? (await import('./turn.mjs')).runSession;
384
+
385
+ /**
386
+ * ── ⭐⭐ RESEARCHER OR BUILDER — ONE FLAG, TWO OFFERS ──────────────────────
387
+ *
388
+ * ⚠️ `=== true` RATHER THAN TRUTHINESS. This decides whether a helper may
389
+ * change files on someone's disk; the string "false", which is what a model
390
+ * emits about once in fifty when a schema says boolean, is not a yes.
391
+ */
392
+ const wantsWrite = args.write === true;
393
+
394
+ /**
395
+ * ── ⭐⭐ AND MAY IT PROVE ITS OWN WORK? ────────────────────────────────────
396
+ *
397
+ * ⚠️ ONLY WITH `write`, AND THAT IS NOT TIDINESS. A RESEARCHER runs against
398
+ * the PARENT'S OWN EXECUTOR — the real workspace, not a copy — so letting one
399
+ * execute commands would put an unsupervised process in the user's actual
400
+ * repository to answer a question. A builder already works in an isolated
401
+ * copy, which is the only place a command can be both meaningful and contained.
402
+ *
403
+ * ⚠️ `=== true` again, for the reason above: this decides whether a process
404
+ * starts, and the string "false" is not a yes.
405
+ */
406
+ const wantsVerify = wantsWrite && args.verify === true;
407
+
408
+ /**
409
+ * ⚠️ THE DEFAULT MOVES WITH THE JOB, AND THE CEILING DOES NOT. Four rounds is
410
+ * right for "read these files and tell me X". A helper that must WRITE, RUN,
411
+ * READ THE FAILURE and FIX IT has spent four before it has looked at anything,
412
+ * so the default for a verifying build is the whole allowance —
413
+ * `MAX_SUBAGENT_ROUNDS`, which is unchanged, so nothing about the worst case
414
+ * moves. An explicit `maxRounds` from the model still wins, and is still
415
+ * clamped by the same ceiling.
416
+ */
417
+ const maxRounds = Math.min(
418
+ Math.max(1, Number.isFinite(args.maxRounds)
419
+ ? Math.floor(args.maxRounds)
420
+ : (wantsVerify ? MAX_SUBAGENT_ROUNDS : DEFAULT_SUBAGENT_ROUNDS)),
421
+ MAX_SUBAGENT_ROUNDS,
422
+ );
423
+
424
+ /**
425
+ * ⚠️ A BUILDER NEEDS A REAL WORKSPACE TO COPY. `runSession` is given an
426
+ * executor by embedders that have no disk at all (`memory-workspace.mjs`
427
+ * exists precisely so the browser builder can run this loop), and
428
+ * `runInIsolatedCopy` would `cpSync` from `undefined`. Refusing here names
429
+ * the cause; failing inside the copier would name a path that never existed.
430
+ */
431
+ if (wantsWrite && typeof args.executor?.root !== 'string') {
432
+ return { ok: false, error: 'a building helper needs a real workspace on disk, and this run has none — do this part yourself' };
433
+ }
434
+
435
+ const build = wantsWrite ? (isolateImpl ?? runInIsolatedCopy) : null;
436
+ const buildExecutor = wantsWrite ? (makeExecutor ?? createLocalExecutor) : null;
437
+
438
+ /** Everything both modes pass, so the two call sites cannot drift apart. */
439
+ const sessionArgs = {
440
+ /**
441
+ * ⭐ THE BRIEF, NOT THE BARE TASK. `briefFor` returns `task` unchanged
442
+ * when no context was supplied, so a caller that never heard of the
443
+ * channel sends byte-for-byte what it sent before — which matters more
444
+ * than usual here, because the task text is the head of the helper's
445
+ * cacheable prefix.
446
+ */
447
+ task: briefFor(task, args.context),
448
+ executor: args.executor,
449
+ config: args.config,
450
+ maxRounds,
451
+ maxTokens: args.maxTokens,
452
+ timeoutMs: args.timeoutMs,
453
+ /**
454
+ * ── ⚠️⚠️ `allowRun` WAS false IN BOTH MODES, AND THE REASON WAS THE COPY ─
455
+ *
456
+ * The reasoning it shipped with, kept verbatim because it was CORRECT:
457
+ * *"a builder still executes nothing: the isolated copy has no
458
+ * `node_modules` and no `.git` (`COPY_SKIP_DIRS`), so a verification
459
+ * command run in there would fail for a reason that has nothing to do with
460
+ * the code, and the parent would act on that answer."*
461
+ *
462
+ * ⭐ THAT IS AN ARGUMENT ABOUT THE COPY. It said the helper cannot verify
463
+ * because the copy cannot support a verification — not that a helper
464
+ * should not. So the copy changed: with `verify: true`,
465
+ * `runInIsolatedCopy` links the real `node_modules` in (see its comment,
466
+ * and the measurement proving the recursive delete does not follow it),
467
+ * and `npm test` in there means what it says.
468
+ *
469
+ * ⚠️ `.git` IS STILL ABSENT AND THE GIT VERBS ARE STILL NOT OFFERED. This
470
+ * change buys exactly one thing — running the project's own checks — and
471
+ * nothing about committing, which the helper still cannot do.
472
+ *
473
+ * ⚠️ AND IT IS STILL false BY DEFAULT. A `write: true` helper that did not
474
+ * ask for this gets the unlinked copy and the old lock, so the sentence
475
+ * above remains true of it word for word.
476
+ */
477
+ allowRun: wantsVerify,
478
+ toolNames: wantsVerify
479
+ ? [...SUBAGENT_VERIFY_TOOL_NAMES]
480
+ : (wantsWrite ? [...SUBAGENT_WRITE_TOOL_NAMES] : [...SUBAGENT_TOOL_NAMES]),
481
+ /**
482
+ * ⚠️ THE HELPER'S OWN DISPATCHER MUST KNOW IT IS A HELPER. The offer
483
+ * already omits `delegate`, but a model can emit a call for a tool it was
484
+ * never shown — so the depth travels with the session and the dispatcher
485
+ * refuses independently. Two locks, because one of them is a list.
486
+ */
487
+ depth: depth + 1,
488
+ /**
489
+ * ── ⚠️⚠️ THE HELPER USED TO SPEND OUTSIDE EVERY CEILING ────────────────
490
+ *
491
+ * `runSession` defaults `budgetUsd` to null, and nothing here passed one.
492
+ * So the moment the model called `delegate`, the one differentiator this
493
+ * package actually claims — *tell me the price before it runs and stop at
494
+ * the number you gave me* — became false: the parent stopped at its
495
+ * ceiling, and the helper it spawned had none at all. The cost came back
496
+ * in `costUsd` and was reported, so the money was MEASURED and simply not
497
+ * BOUNDED, which is the most misleading of the three possible states.
498
+ *
499
+ * ⭐ THE HELPER'S CEILING IS THE PARENT'S REMAINDER, not a fraction of it.
500
+ * A fraction would be an invented constant to defend; the remainder makes
501
+ * the arithmetic self-evident — the helper cannot spend money the run does
502
+ * not have, so the TOTAL is still the number the user typed, which is the
503
+ * only number that was ever promised. `null` (a run with `--budget none`)
504
+ * passes through as unbounded, exactly as before.
505
+ */
506
+ budgetUsd: Number.isFinite(args.budgetUsd) ? args.budgetUsd : null,
507
+ /**
508
+ * ⭐ And the fleet ceiling travels too, or seven terminals could each
509
+ * delegate their way around the workspace-wide cap.
510
+ */
511
+ fleetGate: args.fleetGate ?? null,
512
+ onEvent: args.onEvent,
513
+ };
514
+
515
+ let outcome;
516
+ /**
517
+ * ⚠️ DECLARED OUT HERE, NOT INSIDE THE BRANCH. The parent's dispatcher needs
518
+ * these to mark its own record `mutated` — and a record that is silent about
519
+ * the files a helper changed is exactly the half-connected defect this
520
+ * package keeps shipping: the run summary would say "0 files written" over
521
+ * work that is on disk.
522
+ */
523
+ let handoff = null;
524
+ /** ⚠️ Only ever true when `verify` was asked for AND the link actually took. */
525
+ let dependenciesLinked = false;
526
+ try {
527
+ if (build) {
528
+ const isolated = await build({
529
+ root: args.executor.root,
530
+ executor: args.executor,
531
+ /**
532
+ * ⭐ THE DEPENDENCIES TRAVEL ONLY WHEN THERE IS SOMETHING TO RUN. A
533
+ * `write: true` helper that did not ask to verify gets exactly the copy
534
+ * it always got, so the one thing this flag can change is the one thing
535
+ * it is for.
536
+ */
537
+ linkNodeModules: wantsVerify,
538
+ /**
539
+ * ── ⭐⭐ THE HELPER'S EXECUTOR IS **NEVER** A DRY RUN, AND I HAD THIS
540
+ * BACKWARDS ─────────────────────────────────────────────────────────
541
+ *
542
+ * My first version inherited `args.executor.dryRun` into the copy,
543
+ * reasoning that a `--dry-run` parent should produce a previewing
544
+ * helper. A mutation test SURVIVED, which is how I found out the test
545
+ * was passing for the wrong reason, and the probe that followed showed
546
+ * the inheritance was not merely untested but WRONG. Measured, with a
547
+ * `--dry-run` parent and a helper that wrote two files:
548
+ *
549
+ * applied : ["existing.txt"] ← the ORIGINAL bytes, not the
550
+ * helper's — a preview of
551
+ * nothing
552
+ * ⚠ brand-new.txt: no such file — nothing was deleted
553
+ * ← a file the helper CREATED,
554
+ * reported as a failed DELETE
555
+ *
556
+ * ⭐ Because a dry-run write is a no-op, the file never appeared in the
557
+ * copy, so `applyHandoff` saw an absent source and correctly concluded
558
+ * "the helper deleted this" — the right inference from a lie.
559
+ *
560
+ * ⭐⭐ THE COPY IS SCRATCH SPACE. There is nothing in it to protect, and
561
+ * the whole point of copying was that writes there are free. The
562
+ * `--dry-run` PROMISE is about the USER'S files, and it is kept where
563
+ * it belongs: at the apply boundary, by `executor.writeFile` on the
564
+ * PARENT (`workspace.mjs:653`), which validates everything and then
565
+ * declines to touch the disk. So a dry run now previews the real work
566
+ * instead of previewing an empty directory.
567
+ */
568
+ run: (copyRoot) => run({
569
+ ...sessionArgs,
570
+ executor: buildExecutor(copyRoot),
571
+ }),
572
+ });
573
+ if (!isolated.ok) return { ok: false, error: isolated.error, costUsd: 0, tokens: 0, roundsUsed: 0, files: [] };
574
+ outcome = isolated.outcome;
575
+ /**
576
+ * ⚠️⚠️ A VERIFICATION THAT COULD NOT HAVE WORKED MUST NOT BE REPORTED AS
577
+ * ONE. If the link failed — no `node_modules` to link, a filesystem that
578
+ * refuses links — every command in the copy failed with "Cannot find
579
+ * module", which is exactly the wrong-reason failure the old `allowRun:
580
+ * false` existed to avoid. Carrying the flag out here is what lets the
581
+ * summary say so instead of handing the parent a red verdict about the
582
+ * environment dressed as a red verdict about the code.
583
+ */
584
+ dependenciesLinked = isolated.dependenciesLinked === true;
585
+ handoff = { written: isolated.written, refused: isolated.refused, problems: isolated.problems };
586
+ } else {
587
+ outcome = await run(sessionArgs);
588
+ }
589
+ } catch (err) {
590
+ // ⚠️ A helper's death must not take the parent's session with it.
591
+ return { ok: false, error: `the helper crashed: ${err?.message ?? String(err)}` };
592
+ }
593
+
594
+ const usage = outcome?.usage ?? null;
595
+ const shared = {
596
+ costUsd: Number.isFinite(usage?.cost) ? usage.cost : 0,
597
+ tokens: Number.isFinite(usage?.total_tokens) ? usage.total_tokens : 0,
598
+ roundsUsed: Number.isFinite(outcome?.roundsUsed) ? outcome.roundsUsed : 0,
599
+ files: filesTouched(outcome?.executed),
600
+ /**
601
+ * ⚠️⚠️ REPORTED ON THE FAILURE PATH TOO, and this is not tidiness. A
602
+ * builder that wrote three files and then ran out of rounds has ALREADY
603
+ * had those three files applied to the real workspace — the apply happens
604
+ * the moment the session returns, whatever the session's verdict. A caller
605
+ * told "it failed" and given an empty change list would leave real edits
606
+ * unmentioned on someone's disk, which is the worst of the three states.
607
+ */
608
+ written: handoff?.written ?? [],
609
+ refused: handoff?.refused ?? [],
610
+ };
611
+
612
+ if (!outcome || outcome.ok !== true) {
613
+ return { ok: false, error: outcome?.error ?? 'the helper returned no result', ...shared };
614
+ }
615
+
616
+ /**
617
+ * ⚠️ THE HONEST DEGRADATION, AND IT IS ONE SENTENCE. `verify: true` with no
618
+ * dependencies to link is a helper that COULD run commands and whose every
619
+ * import would have failed. Saying nothing would let the parent read a red
620
+ * `npm test` as a broken change; saying this makes it a broken environment,
621
+ * which is a completely different next move.
622
+ */
623
+ const degraded = wantsVerify && !dependenciesLinked
624
+ ? '\n⚠ Its isolated copy had no dependencies linked, so anything it ran there could not resolve imports — '
625
+ + 'treat any command result from it as inconclusive and verify in the real workspace yourself.'
626
+ : '';
627
+
628
+ return {
629
+ ok: true,
630
+ summary: `${summariseForParent(outcome, handoff, { verifyOffered: wantsVerify })}${degraded}`,
631
+ verified: wantsVerify && dependenciesLinked ? (outcome?.verification ?? null) : null,
632
+ ...shared,
633
+ };
634
+ }
635
+
636
+ /**
637
+ * ⭐ THE DESCRIPTION IS WHERE THE JUDGEMENT LIVES. A model that does not know it
638
+ * gets a SUMMARY back will delegate and then ask for the same files anyway,
639
+ * paying twice. So the contract is stated in the first sentence, and the good
640
+ * use — a broad search whose answer is small — is named explicitly.
641
+ */
642
+ export function subagentToolSchemas() {
643
+ return [
644
+ {
645
+ type: 'function',
646
+ function: {
647
+ name: 'delegate',
648
+ description:
649
+ 'Hand a self-contained piece of work to a helper with its own fresh context; you get back a short '
650
+ + 'SUMMARY, not everything it read. Default it only READS — best when answering would mean opening '
651
+ + 'many files to produce a small answer ("where is X defined", "which files call Y"). With '
652
+ + 'write:true it also BUILDS a piece you have already specified ("write the tests for parser.mjs"), and '
653
+ + 'write+verify runs the project\'s own check inside its copy. Pass `context` with anything you have '
654
+ + 'already worked out — it starts with a blank head. It cannot delegate further.',
655
+ parameters: {
656
+ type: 'object',
657
+ additionalProperties: false,
658
+ required: ['task'],
659
+ properties: {
660
+ task: {
661
+ type: 'string',
662
+ description: 'One specific question, with enough detail to answer without asking you anything back.',
663
+ },
664
+ /**
665
+ * ── ⭐⭐ THE INPUT HALF OF THE CONTRACT, WHICH NEVER EXISTED ───────
666
+ *
667
+ * Rule 3 of this file governs what comes BACK (distilled, never the
668
+ * transcript). Nothing ever governed what goes IN, so the answer was
669
+ * "one sentence" by default rather than by decision — and a helper
670
+ * that does not know what the parent has established re-derives it,
671
+ * out of an allowance of four rounds.
672
+ *
673
+ * ⚠️ THE DESCRIPTION HAS TO SAY WHAT *NOT* TO PUT HERE. The cheapest
674
+ * way for a model to "give context" is to paste a file, which would
675
+ * turn the cheapest tool in the package into the most expensive —
676
+ * the exact cost `delegate` exists to avoid. So it names findings
677
+ * and constraints, and rules out file contents explicitly.
678
+ */
679
+ context: {
680
+ type: 'string',
681
+ description:
682
+ 'What you already know that it would otherwise spend rounds rediscovering: findings so far, '
683
+ + 'constraints, decisions already made, names of the files that matter. NOT file contents — it '
684
+ + 'can read those itself, and pasting them here costs you the saving you delegated for. '
685
+ + `Trimmed at ${MAX_CONTEXT_CHARS} characters.`,
686
+ },
687
+ /**
688
+ * ── ⭐ A BOOLEAN ON AN EXISTING TOOL, NOT A SECOND TOOL ──────────
689
+ *
690
+ * ⚠️ AND HERE IS THE MEASURED PRICE, because my first version of
691
+ * this comment guessed "roughly 60 tokens" and the guess was out by
692
+ * more than 4x. MEASURED with `JSON.stringify` on the real schema
693
+ * (~3.6 chars/token): the `delegate` schema was **771 chars / ~214
694
+ * tokens** and is now **1,239 chars / ~344 tokens** — a delta of
695
+ * **468 chars, ~130 tokens on every round of every run**. The first
696
+ * draft of this text measured ~264 tokens; re-measuring it is what
697
+ * prompted the trim, which is the entire argument for measuring
698
+ * rather than asserting.
699
+ *
700
+ * ⭐ IT IS STILL THE CHEAPER SHAPE. A separate `delegate_build`
701
+ * tool would cost ~250-300 tokens ON TOP of the 214 this one
702
+ * already costs — call it ~500 against ~344 — and it would leave
703
+ * two schemas sharing `task`, `maxRounds`, the depth rule and the
704
+ * budget rule, which is two documents to keep in agreement. This
705
+ * package has three recorded cases of exactly that drifting.
706
+ *
707
+ * ⭐ AND IT SITS IN THE CACHED PREFIX. Tool schemas are identical
708
+ * byte-for-byte on every round, so the delta is paid at full price
709
+ * once and at cache rates (up to 50x less) thereafter.
710
+ *
711
+ * ⚠️ THE DESCRIPTION MUST SAY WHEN *NOT* TO USE IT. A model that
712
+ * delegates the whole task builds nothing itself and cannot verify
713
+ * what came back, because the helper never ran a command.
714
+ */
715
+ write: {
716
+ type: 'boolean',
717
+ description:
718
+ 'Default false. True lets the helper CHANGE FILES — it works in an isolated copy and its '
719
+ + 'changes are applied back for you. Only for a bounded, fully-specified piece: never the '
720
+ + 'whole task, and never while you are still deciding what to build. A file that changed '
721
+ + 'underneath it is refused, not overwritten, and the summary names it. Nothing is verified '
722
+ + 'unless you also pass verify.',
723
+ },
724
+ /**
725
+ * ⚠️ THE DESCRIPTION SAYS THE COPY IS WHERE IT RUNS, AND IT MUST.
726
+ * A model told only "it can verify" would ask for a command whose
727
+ * effects it expects to see in the real workspace — a server it
728
+ * started, a file its script wrote outside the change set. Only the
729
+ * change set comes back, and the sentence has to say so or the
730
+ * capability quietly lies about its own scope.
731
+ */
732
+ verify: {
733
+ type: 'boolean',
734
+ description:
735
+ 'Default false, and only meaningful with write. True lets the helper RUN the project\'s own '
736
+ + 'check (e.g. `npm test`) inside its isolated copy, with the real dependencies linked in, and '
737
+ + 'fix what it broke before handing back. The verdict comes back in the summary. Only the files '
738
+ + 'it changed are applied — anything else the command did happens in the copy and is discarded.',
739
+ },
740
+ maxRounds: {
741
+ type: 'integer',
742
+ description: `How many rounds it may spend, 1-${MAX_SUBAGENT_ROUNDS} (default ${DEFAULT_SUBAGENT_ROUNDS}, or ${MAX_SUBAGENT_ROUNDS} when verifying).`,
743
+ },
744
+ },
745
+ },
746
+ },
747
+ },
748
+ ];
749
+ }