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,322 @@
1
+ /**
2
+ * ── ⭐⭐ AN IMAGE GENERATOR WITH TASTE ───────────────────────────────────────
3
+ *
4
+ * A generator without a critic is a slot machine. You pull it, you get what you
5
+ * get, and the agent — which cannot see — writes `<img src="hero.jpg">` and
6
+ * calls the page finished. Today's own coffee site did exactly that: a hero with
7
+ * an illegible smeared label, referenced confidently in four pages.
8
+ *
9
+ * ⭐ SO: GENERATE → LOOK → RE-DIRECT. The same render→look→fix loop that makes
10
+ * the code half work, applied to the creative half. Measured on the real thing:
11
+ * qwen3.7-flash judged that hero in **6.5 seconds for $0.00004** and its verdict
12
+ * was *"garbled and illegible text on the product label is the primary
13
+ * disqualifier"* — score 2/10, not usable — which is precisely what a human
14
+ * noticed and the agent could not.
15
+ *
16
+ * ⚠️ THE CRITIC IS THE CHEAP PART. Four thousandths of a cent against seconds of
17
+ * GPU. There is no economic argument for shipping an unlooked-at image, and
18
+ * there never was — we simply had no eyes.
19
+ *
20
+ * ── ⚠️ WHY EVERY PROMPT IS REWRITTEN, NOT PASSED THROUGH ────────────────────
21
+ * A coding model asked for "an image of a coffee bag" writes exactly that, and
22
+ * gets the flat, grey, stock-photo mush that makes generated pages look
23
+ * generated. Image models respond to DIRECTION — lens, light, composition,
24
+ * grade. Supplying it is not decoration, it is the difference between an asset
25
+ * and a placeholder.
26
+ */
27
+
28
+ /**
29
+ * ⚠️ THE SINGLE MOST VALUABLE RULE HERE, AND IT CAME FROM THE CRITIC.
30
+ * Diffusion models cannot spell. Asking for a label, a sign, a logo or a UI
31
+ * guarantees garbled glyphs, and garbled text is the one defect a viewer
32
+ * notices instantly and reads as "made by a machine". So requests for text are
33
+ * actively REMOVED and replaced with a composition that does not need any.
34
+ *
35
+ * A human art director does this without being asked. It is the whole job.
36
+ */
37
+ const TEXT_REQUESTS = /\b(text|label(?:led|led)?|logo|sign|signage|words?|writing|typography|caption|title|lettering|banner|poster|menu|headline)\b/gi;
38
+
39
+ /** Direction that reads as cinema rather than stock photography. */
40
+ const CINEMATIC = [
41
+ 'cinematic still',
42
+ 'shallow depth of field',
43
+ 'volumetric directional light',
44
+ 'rich colour grade with lifted shadows',
45
+ 'shot on 35mm, subtle film grain',
46
+ 'composed off-centre with negative space',
47
+ ];
48
+
49
+ /**
50
+ * Turn a plain request into a directed one.
51
+ *
52
+ * ⚠️ THE USER'S SUBJECT IS NEVER DISCARDED — direction is appended, not
53
+ * substituted. A "cinematic" rewrite that loses what was asked for is a worse
54
+ * failure than a flat image, because the page ends up showing the wrong thing
55
+ * beautifully.
56
+ */
57
+ export function cinematicPrompt(prompt, { mode = 'cinematic', avoidText = true } = {}) {
58
+ const subject = String(prompt ?? '').trim();
59
+ if (!subject) return { prompt: '', strippedText: false };
60
+
61
+ let base = subject;
62
+ let strippedText = false;
63
+ if (avoidText && TEXT_REQUESTS.test(subject)) {
64
+ TEXT_REQUESTS.lastIndex = 0;
65
+ // Rather than delete the noun and leave a hole, neutralise it: the object
66
+ // stays, its unrenderable surface detail goes.
67
+ base = subject.replace(TEXT_REQUESTS, '').replace(/\s{2,}/g, ' ').replace(/\s+([,.])/g, '$1').trim();
68
+ strippedText = true;
69
+ }
70
+
71
+ /**
72
+ * ── ⚠️⭐ MEASURED: A NEGATIVE PROMPT DOES NOT WORK, SO RE-COMPOSE INSTEAD ──
73
+ *
74
+ * Run live against the fallback provider: "no text, no lettering, no logos"
75
+ * was in the prompt all three times and every shot came back with a garbled
76
+ * label. The critic's verdict, three times identically: *"garbled and
77
+ * completely illegible text on the label"*, score 2/10.
78
+ *
79
+ * ⚠️ Re-prompting cannot rescue a subject that CONTAINS text. A coffee bag has
80
+ * a label; asking politely for it to be blank does not remove it from the
81
+ * model's idea of a coffee bag, and most endpoints ignore negative phrasing
82
+ * entirely.
83
+ *
84
+ * ⭐ WHAT A REAL ART DIRECTOR DOES: if the label cannot be rendered, do not
85
+ * shoot the label. Turn it away, crop past it, or shoot the product out of its
86
+ * packaging. That is a COMPOSITION instruction, which models obey, instead of
87
+ * a prohibition, which they do not.
88
+ */
89
+ const packaged = /\b(bag|packet|pouch|box|bottle|can|jar|tin|carton|book|magazine|poster|shopfront|storefront|screen|monitor|laptop|phone)\b/i.test(base);
90
+ const composition = avoidText && (strippedText || packaged)
91
+ ? 'framed so no printed surface faces the camera, label turned away or cropped out of frame, focus on material and texture'
92
+ : '';
93
+
94
+ const direction = mode === 'flat'
95
+ ? ['clean product photography', 'even soft light', 'plain background']
96
+ : CINEMATIC;
97
+
98
+ const negative = avoidText
99
+ // ⚠️ Stated positively as well as negatively: many endpoints ignore a
100
+ // negative-prompt convention entirely, so the instruction has to survive in
101
+ // the positive prompt too.
102
+ ? 'no text, no lettering, no logos, no watermarks, clean unmarked surfaces'
103
+ : '';
104
+
105
+ return {
106
+ // Composition goes BEFORE the grade: it is an instruction about what is in
107
+ // the frame, and the grade only describes how the frame looks.
108
+ prompt: [base, composition, ...direction, negative].filter(Boolean).join(', '),
109
+ strippedText,
110
+ recomposed: Boolean(composition),
111
+ };
112
+ }
113
+
114
+ /**
115
+ * ⭐ CHOSEN ON MEASURED COST, NOT REPUTATION. qwen3.7-flash judged a real image
116
+ * in 6.5s for $0.00004; qwen2.5-vl-72b gave a near-identical verdict for 6x the
117
+ * price. When two models agree, the cheap one is the right one.
118
+ */
119
+ export const DEFAULT_CRITIC_MODEL = 'qwen/qwen3.7-flash';
120
+
121
+ const CRITIC_TIMEOUT_MS = 60_000;
122
+
123
+ const CRITIC_BRIEF = [
124
+ 'You are an art director reviewing a generated image before it goes on a client website.',
125
+ 'Reply with ONLY a JSON object, no prose, no code fence:',
126
+ '{"usable":true|false,"score":0-10,"problems":["..."],"betterPrompt":"..."}',
127
+ 'Be harsh. Any of these makes it NOT usable: garbled or misspelled text, malformed hands or',
128
+ 'faces, duplicated limbs, mushy or melted detail, a watermark, or a subject that is not what',
129
+ 'was asked for. "betterPrompt" must be a complete replacement prompt that would fix the',
130
+ 'problems — and it must not ask for any text, because the generator cannot spell.',
131
+ ].join(' ');
132
+
133
+ /**
134
+ * Look at an image and say whether it is good enough.
135
+ *
136
+ * ⚠️ RETURNS ok:false RATHER THAN A GUESS WHEN IT CANNOT TELL. An art director
137
+ * who bluffs is worse than none: a fabricated "looks great" would launder a bad
138
+ * asset through a process that exists to catch it. Every failure path here
139
+ * abstains loudly.
140
+ */
141
+ export async function critiqueImage(imageBytes, intent, {
142
+ apiKey = process.env.OPENROUTER_API_KEY,
143
+ model = DEFAULT_CRITIC_MODEL,
144
+ mimeType = 'image/jpeg',
145
+ fetchImpl = fetch,
146
+ } = {}) {
147
+ if (!apiKey) return { ok: false, error: 'no OPENROUTER_API_KEY, so the image cannot be looked at' };
148
+ if (!imageBytes || imageBytes.length < 1000) return { ok: false, error: 'there is no image here to look at' };
149
+
150
+ const dataUrl = `data:${mimeType};base64,${Buffer.from(imageBytes).toString('base64')}`;
151
+ let res;
152
+ try {
153
+ res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
154
+ method: 'POST',
155
+ headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
156
+ body: JSON.stringify({
157
+ model,
158
+ max_tokens: 500,
159
+ // ⚠️ Reasoning OFF. On a reasoning model the thinking budget is charged
160
+ // against max_tokens and can consume all of it, returning zero content —
161
+ // measured on this exact account today, 15,999 reasoning tokens and an
162
+ // empty reply.
163
+ reasoning: { enabled: false },
164
+ messages: [{
165
+ role: 'user',
166
+ content: [
167
+ { type: 'text', text: `${CRITIC_BRIEF}\n\nThe image was meant to be: ${String(intent).slice(0, 400)}` },
168
+ { type: 'image_url', image_url: { url: dataUrl } },
169
+ ],
170
+ }],
171
+ }),
172
+ signal: AbortSignal.timeout(CRITIC_TIMEOUT_MS),
173
+ });
174
+ } catch (err) {
175
+ return { ok: false, error: `could not reach the critic: ${err?.name ?? err}` };
176
+ }
177
+ if (!res.ok) return { ok: false, error: `the critic returned HTTP ${res.status}` };
178
+
179
+ let text;
180
+ try {
181
+ const j = await res.json();
182
+ text = j?.choices?.[0]?.message?.content ?? '';
183
+ } catch {
184
+ return { ok: false, error: 'the critic returned a body that was not JSON' };
185
+ }
186
+
187
+ const parsed = parseVerdict(text);
188
+ if (!parsed) return { ok: false, error: 'the critic did not answer in the required shape' };
189
+ return { ok: true, ...parsed };
190
+ }
191
+
192
+ /**
193
+ * ⚠️ MODELS FENCE THEIR JSON EVEN WHEN TOLD NOT TO, and they add a sentence
194
+ * before it. Refusing to parse that would abstain on a perfectly good verdict,
195
+ * so the object is extracted rather than the whole string trusted.
196
+ */
197
+ export function parseVerdict(text) {
198
+ if (!text) return null;
199
+ const body = String(text).replace(/```(?:json)?/gi, '');
200
+ const start = body.indexOf('{');
201
+ const end = body.lastIndexOf('}');
202
+ if (start < 0 || end <= start) return null;
203
+ let obj;
204
+ try { obj = JSON.parse(body.slice(start, end + 1)); } catch { return null; }
205
+ if (typeof obj !== 'object' || obj === null) return null;
206
+
207
+ const score = Number(obj.score);
208
+ return {
209
+ // ⚠️ A missing verdict is treated as NOT usable. The whole point is to catch
210
+ // bad assets, so an unparseable opinion must not read as approval.
211
+ usable: obj.usable === true,
212
+ score: Number.isFinite(score) ? Math.max(0, Math.min(10, score)) : 0,
213
+ problems: Array.isArray(obj.problems) ? obj.problems.map(String).slice(0, 6) : [],
214
+ betterPrompt: typeof obj.betterPrompt === 'string' && obj.betterPrompt.trim() ? obj.betterPrompt.trim() : null,
215
+ };
216
+ }
217
+
218
+ /** Below this, re-shoot. 7 is "a client would accept it". */
219
+ export const ACCEPT_SCORE = 7;
220
+
221
+ /**
222
+ * ── ⭐ THE DIRECTOR: SHOOT, LOOK, RE-SHOOT ──────────────────────────────────
223
+ *
224
+ * @param generate async ({ prompt }) => { ok, path, bytes, absolutePath, mimeType }
225
+ *
226
+ * ⚠️ BOUNDED AT TWO RE-SHOOTS AND THAT IS A COST DECISION, not a quality one. A
227
+ * loop that keeps going until it is happy is exactly the unmetered loop that
228
+ * makes this product lose money per call, and image models plateau fast: if the
229
+ * third attempt is still wrong, the fourth will be too.
230
+ *
231
+ * ⚠️ AND IT ALWAYS RETURNS THE BEST ATTEMPT, never nothing. A page with a
232
+ * mediocre hero is a page; a page with no hero is a bug. What changes is that
233
+ * the caller is TOLD the score, so it can mention it rather than pretend.
234
+ */
235
+ export async function directImage({
236
+ prompt,
237
+ generate,
238
+ readBytes,
239
+ mode = 'cinematic',
240
+ maxAttempts = 3,
241
+ acceptScore = ACCEPT_SCORE,
242
+ apiKey = process.env.OPENROUTER_API_KEY,
243
+ criticModel = DEFAULT_CRITIC_MODEL,
244
+ fetchImpl = fetch,
245
+ }) {
246
+ const directed = cinematicPrompt(prompt, { mode });
247
+ let current = directed.prompt;
248
+ const attempts = [];
249
+ let best = null;
250
+
251
+ for (let i = 0; i < Math.max(1, maxAttempts); i += 1) {
252
+ const shot = await generate({ prompt: current });
253
+ if (!shot?.ok) {
254
+ attempts.push({ attempt: i + 1, error: shot?.error ?? 'generation failed' });
255
+ // ⚠️ A generation failure is not a critique failure — stop, do not burn
256
+ // the remaining attempts on a provider that is down. The breaker upstream
257
+ // already decided this endpoint is not answering.
258
+ break;
259
+ }
260
+
261
+ let verdict = null;
262
+ if (apiKey) {
263
+ const bytes = readBytes ? readBytes(shot) : null;
264
+ if (bytes) {
265
+ verdict = await critiqueImage(bytes, prompt, {
266
+ apiKey, model: criticModel, fetchImpl,
267
+ mimeType: shot.mimeType ?? (String(shot.path).endsWith('.png') ? 'image/png' : 'image/jpeg'),
268
+ });
269
+ }
270
+ }
271
+
272
+ const score = verdict?.ok ? verdict.score : null;
273
+ attempts.push({ attempt: i + 1, path: shot.path, score, problems: verdict?.ok ? verdict.problems : [], criticError: verdict?.ok ? null : verdict?.error ?? 'not looked at' });
274
+
275
+ /**
276
+ * ⚠️ AN UNSCORED SHOT WINS ONLY IF NOTHING IS SCORED. Treating "could not
277
+ * look" as a passing grade would silently disable the whole mechanism the
278
+ * moment the critic has a bad day — the exact shape of failure that let a
279
+ * blind audit issue all-clears for eighteen commits.
280
+ */
281
+ if (!best || (score ?? -1) > (best.score ?? -1)) best = { ...shot, score, problems: verdict?.ok ? verdict.problems : [] };
282
+
283
+ if (score === null) break; // no eyes available; one shot is all we can justify
284
+ if (score >= acceptScore) break; // good enough — stop spending
285
+
286
+ /**
287
+ * ── ⚠️⭐ PLATEAU: A RE-SHOOT THAT DID NOT IMPROVE WILL NOT IMPROVE ───────
288
+ *
289
+ * Measured live: three attempts at the same subject scored 2, 2, 2 — the
290
+ * critic's rewritten prompt each time, and 100 seconds of wall clock to
291
+ * arrive back where it started. The defect was structural (a coffee bag has
292
+ * a label and the model cannot spell), and no amount of re-prompting fixes a
293
+ * structural defect.
294
+ *
295
+ * ⭐ So the loop stops when a shot fails to BEAT the previous one, rather
296
+ * than running out its budget. One wasted re-shoot to discover the plateau
297
+ * is a fair price; two is just paying to be told again.
298
+ */
299
+ const previous = attempts[attempts.length - 2];
300
+ if (previous && typeof previous.score === 'number' && score <= previous.score) {
301
+ attempts[attempts.length - 1].stoppedBecause = 'plateau — re-shooting did not improve the score';
302
+ break;
303
+ }
304
+ if (i === maxAttempts - 1) break;
305
+ if (verdict?.betterPrompt) {
306
+ // Re-direct using the critic's own rewrite, then re-apply house direction
307
+ // so a plain rewrite does not undo the cinematic grade.
308
+ current = cinematicPrompt(verdict.betterPrompt, { mode }).prompt;
309
+ }
310
+ }
311
+
312
+ if (!best) return { ok: false, error: attempts[attempts.length - 1]?.error ?? 'no image was produced', attempts };
313
+ return {
314
+ ok: true,
315
+ ...best,
316
+ directedPrompt: directed.prompt,
317
+ strippedText: directed.strippedText,
318
+ attempts,
319
+ // Honest, and it belongs in the summary the user reads.
320
+ accepted: (best.score ?? 0) >= acceptScore,
321
+ };
322
+ }