acuvo-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/delete.mjs ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * ── ⭐ DELETE — THE MISSING PRIMITIVE THAT DERAILED WHOLE SESSIONS ───────────
3
+ *
4
+ * FOUND BY RUNNING THE CLI, 2026-08-09, and it is the clearest example in this
5
+ * package of why running it beats reasoning about it.
6
+ *
7
+ * The task was "fix slugify, then commit it". The model fixed it, wrote a
8
+ * `check.mjs` to verify, ran it, and it passed. Then, with three rounds left,
9
+ * it tried to tidy up — and could not, because `write_file` was the only verb
10
+ * that touched the filesystem. Its own words in round 8:
11
+ *
12
+ * "I need to remove the scratch file. Since I can't use rm, let me check
13
+ * if there's another way. Actually, the empty file is still there."
14
+ *
15
+ * ⚠️ SO IT OVERWROTE THE FILE WITH ZERO BYTES — the only deletion available to
16
+ * it — and then spent every remaining round circling the problem. **The commit
17
+ * never happened.** A missing primitive does not merely block its own task; it
18
+ * captures the loop, and everything downstream of it silently does not occur.
19
+ * The observable symptom was "the agent won't commit", and the cause was three
20
+ * tools away.
21
+ *
22
+ * ── ⚠️ WHY THIS IS THE MOST DANGEROUS TOOL IN THE PACKAGE, AND WHAT BOUNDS IT ─
23
+ * `write_file` can destroy a file's contents, but the file is still there and
24
+ * `git diff` still shows what it was. Delete removes the evidence too. So:
25
+ *
26
+ * 1. **One path per call.** No globs, no recursion, no arrays. Deleting a
27
+ * tree is the operation nobody can review, and an agent that can only
28
+ * remove one named file at a time cannot do it by accident.
29
+ * 2. **Files only.** A directory is refused outright — see above.
30
+ * 3. **The workspace rules, unchanged.** `resolveInWorkspace(…, 'write')`
31
+ * already forbids `.git`, `node_modules`, `.next` and `.vercel`, so the
32
+ * repository's own history is not reachable from here. That is inherited,
33
+ * not re-implemented — one place decides what "writable" means.
34
+ * 4. **Size-capped, and the size is REPORTED.** Removing a 4KB scratch file
35
+ * and removing a 400KB source file should not read identically in a
36
+ * summary, so the byte count comes back and the summary prints it.
37
+ *
38
+ * ⭐ AND THE HONEST LIMIT: this does not stop a model deleting a real source
39
+ * file it wrongly believes is scratch. Nothing here can, short of refusing to
40
+ * delete anything the session did not create — which would forbid "remove the
41
+ * deprecated module", a legitimate and common request. The mitigation is that
42
+ * it is one file, named, printed, and (in a repo) recoverable with git. That is
43
+ * the trade, stated rather than papered over.
44
+ */
45
+
46
+ import { unlinkSync, statSync } from 'node:fs';
47
+ import { resolveInWorkspace } from './workspace.mjs';
48
+
49
+ /**
50
+ * @typedef {{ ok: false, error: string }} DeleteRefused
51
+ * @param {string} root
52
+ * @param {unknown} rawPath
53
+ * @param {{ dryRun?: boolean }} [opts]
54
+ * @returns {DeleteRefused | { ok: true, path: string, bytes: number, dryRun: boolean }}
55
+ */
56
+ export function deleteFile(root, rawPath, { dryRun = false } = {}) {
57
+ const target = resolveInWorkspace(root, rawPath, 'write');
58
+ if (!target.ok) return { ok: false, error: target.reason };
59
+
60
+ let stat;
61
+ try {
62
+ stat = statSync(target.absolute);
63
+ } catch {
64
+ /**
65
+ * ⚠️ "ALREADY GONE" IS REPORTED AS A REFUSAL, NOT QUIETLY AS SUCCESS, and
66
+ * the wording matters. A model told "deleted" for a file that never existed
67
+ * will believe a path it invented was real, and carry that belief into the
68
+ * next round. Saying it plainly is what stops the invention compounding.
69
+ */
70
+ return { ok: false, error: `no such file: ${target.relative} — nothing was deleted` };
71
+ }
72
+ if (stat.isDirectory()) {
73
+ return {
74
+ ok: false,
75
+ error: `${target.relative} is a directory. This agent deletes one FILE at a time and never a directory — removing a tree is the operation nobody can review.`,
76
+ };
77
+ }
78
+
79
+ const bytes = stat.size;
80
+ if (!dryRun) {
81
+ try {
82
+ unlinkSync(target.absolute);
83
+ } catch (err) {
84
+ return { ok: false, error: `could not delete ${target.relative}: ${err instanceof Error ? err.message : String(err)}` };
85
+ }
86
+ }
87
+ return { ok: true, path: target.relative, bytes, dryRun };
88
+ }
89
+
90
+ export function deleteToolSchema() {
91
+ return {
92
+ type: 'function',
93
+ function: {
94
+ name: 'delete_file',
95
+ description: [
96
+ 'Delete ONE file from the workspace.',
97
+ 'Use it to remove a scratch or temporary file you created while verifying your work —',
98
+ 'do NOT leave an empty file behind by writing "" to it, delete it properly.',
99
+ 'One file per call: no globs, no directories, no recursion.',
100
+ 'Be certain before deleting anything you did not create in this session.',
101
+ ].join(' '),
102
+ parameters: {
103
+ type: 'object',
104
+ properties: {
105
+ path: { type: 'string', description: 'Workspace-relative path of the file to delete.' },
106
+ },
107
+ required: ['path'],
108
+ },
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,570 @@
1
+ /**
2
+ * ── ⭐⭐ THE DESIGN LOOP — THE AGENT LOOKS AT WHAT IT BUILT ───────────────────
3
+ *
4
+ * Two finished capabilities already sit in this package and have never been
5
+ * joined: `seePage` (lib/media.mjs) renders HTML in a real browser and returns
6
+ * MEASURED layout facts, and `renderImage` (lib/terminal-graphics.mjs) draws a
7
+ * PNG inline on kitty and iTerm2. Between them is the loop: build the page,
8
+ * look at it, hand the model a verdict it can act on, and hand the HUMAN the
9
+ * actual picture.
10
+ *
11
+ * ── ⚠️ THE CLAIM. THE OLD ONE WAS FALSE AND WAS STRUCK ON 2026-08-10 ─────────
12
+ *
13
+ * "No other terminal agent can see its own output" is WRONG. Playwright MCP and
14
+ * Chrome DevTools MCP are free, first-party and one `claude mcp add` away; some
15
+ * agents ship a browser. A pitch built on that claim dies at the first customer
16
+ * who has heard of Playwright, and it makes everything else we say suspect.
17
+ *
18
+ * ⭐ THE DEFENSIBLE CLAIM IS THE RETURN VALUE, NOT THE BROWSER. The competing
19
+ * shape is: take a screenshot, hand the PNG back to the model, ask it to
20
+ * interpret its own work. That costs the image's full token price EVERY LOOK —
21
+ * for the 1280×900 viewport this service uses, `imageTokenCost(1280, 900)` =
22
+ * **1,536 tokens** (a taller full-page shot runs to ~3,072) — and it asks the
23
+ * model to do the one thing models are measurably worst at: read fine layout
24
+ * detail out of its own render.
25
+ *
26
+ * This module measures in CODE and returns an ordered, specific verdict that
27
+ * runs about **89 tokens**. Same information, one to two orders of magnitude
28
+ * cheaper, and no vision hop in the middle. `cost` on every pass reports both
29
+ * numbers COMPUTED FROM THE ACTUAL VIEWPORT, so the ratio is never a quoted
30
+ * marketing figure — if a page renders at a size that narrows the gap, the pass
31
+ * says so itself.
32
+ *
33
+ * ⚠️ AND IT BUYS A HEAD START, NEVER A MOAT. A competent developer rebuilds this
34
+ * in a weekend. Price and pitch accordingly.
35
+ *
36
+ * ── ⚠️⚠️ THE DEFECT THIS MODULE EXISTS TO CLOSE: THE NAKED PAGE ──────────────
37
+ *
38
+ * On 2026-08-10 `see_page` was fixed after reading `json.findings` from a
39
+ * service that replies `{ ok, measurement: {…} }` — every call had been
40
+ * returning "I looked and it was fine" for pages it never saw. Once fixed, it
41
+ * PASSED FOUR NAKED PAGES: the render service takes HTML **text**, so
42
+ * `<link rel="stylesheet" href="styles.css">` resolved to nothing and every
43
+ * multi-file page arrived unstyled. `inlineLocalAssets` now bundles the page's
44
+ * own siblings, which fixes the common case.
45
+ *
46
+ * ⚠️ BUT MEASUREMENT CAN NEVER DETECT THE REMAINING CASES, AND IT IS WORTH
47
+ * BEING PRECISE ABOUT WHY. Measured live 2026-08-11, the same page twice:
48
+ *
49
+ * naked → paintedRatio 0.0986 · lowContrastText [] · consoleErrors []
50
+ * styled → paintedRatio 0.1514 · lowContrastText [] · consoleErrors []
51
+ *
52
+ * Both produce `findingsFrom() === []`. Black Times on white has *excellent*
53
+ * contrast; 9.8% of the viewport really is painted. The unstyled page is not a
54
+ * page that fails the checks — it is a page that passes them, which is why it
55
+ * survived a full audit and shipped.
56
+ *
57
+ * ⚠️⚠️ AND IT IS WORSE THAN "THE CHECKS PASS" — THE UNSTYLED RENDER MASKS REAL
58
+ * DEFECTS. Measured live 2026-08-11 on one page containing a deliberate
59
+ * `.ghost { color: #0c0e11 }` footer note on a `#0b0d10` background:
60
+ *
61
+ * styled render → `unreadable text (contrast 1.01:1, needs 4.5)` ← caught
62
+ * naked render → no findings at all ← MISSED
63
+ *
64
+ * The invisible text is present in both files. In the naked render the rule
65
+ * never applies, so the text comes back as black on white and reads as
66
+ * perfectly legible. So a missing stylesheet does not merely fail to find new
67
+ * problems; it SUPPRESSES the true positives the audit exists to produce. That
68
+ * is why an untrustworthy render is reported as a finding in its own right and
69
+ * never allowed to reach the model as an all-clear.
70
+ *
71
+ * ⭐ SO THE TRUTH IS NOT IN THE PIXELS, IT IS IN THE REQUEST. Did the HTML we
72
+ * handed the browser still point at a stylesheet the browser could not fetch?
73
+ * That is a fact about bytes we sent. `stylingTrust` reads it off the payload,
74
+ * and a pass that fails it is marked UNRELIABLE — because a clean measurement
75
+ * of an undesigned page is worse than no measurement at all.
76
+ *
77
+ * ⚠️ IT CATCHES ONE CASE `inlineLocalAssets` CORRECTLY DOES NOT REPORT. A
78
+ * root-absolute `/styles.css` is server-rooted, so it is not the workspace's to
79
+ * resolve — media.mjs leaves it alone and says nothing. The render service has
80
+ * no server behind it, so the page still arrives naked with no missing-asset
81
+ * finding to explain it. Only the request tells you.
82
+ *
83
+ * ── ⚠️ ABSENT IS NOT CLEAN ──────────────────────────────────────────────────
84
+ *
85
+ * `findingsFrom` reads `(m.lowContrastText ?? [])`. A service that stops
86
+ * measuring contrast therefore reads as a page with *perfect* contrast — the
87
+ * identical failure mode as the envelope bug, one field down, and it would be
88
+ * introduced by a change nobody in this repo made. `checkCoverage` separates
89
+ * "measured and clean" from "not measured", and the verdict prints the second
90
+ * as NOT CHECKED.
91
+ *
92
+ * ⭐ "I could not determine the contrast" is a useful sentence. A confident
93
+ * wrong one is not. Every function here abstains rather than guesses.
94
+ *
95
+ * ── ⚠️ WHAT THIS FILE DOES NOT DO ───────────────────────────────────────────
96
+ * It does not modify `media.mjs` and it does not re-implement the render call.
97
+ * It wraps `seePage`, so the inlining, the path safety, the circuit breaker and
98
+ * the envelope check all stay in exactly one place. It OBSERVES the request and
99
+ * the reply by wrapping `fetchImpl` — which is why the styling fact and the
100
+ * coverage fact are available here without a second network round trip.
101
+ *
102
+ * Zero dependencies. Node built-ins only.
103
+ */
104
+
105
+ import { resolve } from 'node:path';
106
+ import { seePage } from './media.mjs';
107
+ import { renderImage } from './terminal-graphics.mjs';
108
+
109
+ /**
110
+ * The checks the render service performs, and the plain-English name each one
111
+ * gets in a verdict.
112
+ *
113
+ * ⚠️ THE LIST IS DATA, NOT PROSE IN A PROMPT. An all-clear has to be able to
114
+ * say what it is an all-clear FOR, and that sentence must go stale the moment
115
+ * the service stops returning a field — which it cannot do if the names live in
116
+ * a system prompt somewhere.
117
+ */
118
+ export const CHECKS = Object.freeze([
119
+ { key: 'consoleErrors', label: 'console errors' },
120
+ { key: 'paintedRatio', label: 'painted area' },
121
+ { key: 'lowContrastText', label: 'text contrast' },
122
+ { key: 'clippedText', label: 'clipped text' },
123
+ { key: 'overlaps', label: 'overlapping elements' },
124
+ { key: 'brokenImages', label: 'broken images' },
125
+ { key: 'scrollWidth', label: 'horizontal overflow' },
126
+ ]);
127
+
128
+ /** Contrast source values that mean "we did not actually measure it". */
129
+ const CONTRAST_NOT_MEASURED = new Set(['none', 'unavailable', 'skipped', 'error', 'failed']);
130
+
131
+ /**
132
+ * Which checks the measurement can actually answer.
133
+ *
134
+ * ⚠️ PRESENCE IS NOT ENOUGH FOR TWO OF THEM. `lowContrastText: []` alongside
135
+ * `contrastSource: "none"` means the service tried and could not — reporting
136
+ * that as "contrast is fine" is precisely the confident-wrong sentence this
137
+ * module is built to refuse. And `scrollWidth` cannot answer overflow without a
138
+ * viewport width to compare it against.
139
+ *
140
+ * Pure: data in, data out. No clock, no network.
141
+ */
142
+ export function checkCoverage(measurement) {
143
+ const m = measurement && typeof measurement === 'object' && !Array.isArray(measurement) ? measurement : null;
144
+ if (!m) return { known: false, checked: [], undetermined: CHECKS.map((c) => c.label) };
145
+
146
+ const checked = [];
147
+ const undetermined = [];
148
+ for (const { key, label } of CHECKS) {
149
+ let ok = m[key] !== undefined && m[key] !== null;
150
+ if (ok && key === 'lowContrastText') {
151
+ const src = typeof m.contrastSource === 'string' ? m.contrastSource.trim().toLowerCase() : null;
152
+ if (src && CONTRAST_NOT_MEASURED.has(src)) ok = false;
153
+ }
154
+ if (ok && key === 'paintedRatio') ok = typeof m.paintedRatio === 'number' && Number.isFinite(m.paintedRatio);
155
+ if (ok && key === 'scrollWidth') {
156
+ ok = typeof m.scrollWidth === 'number' && typeof m.viewport?.width === 'number';
157
+ }
158
+ (ok ? checked : undetermined).push(label);
159
+ }
160
+ return { known: true, checked, undetermined };
161
+ }
162
+
163
+ /** References the browser can fetch on its own. Everything else is ours to have inlined. */
164
+ const FETCHABLE = /^(https?:|\/\/|data:)/i;
165
+
166
+ /**
167
+ * Remove the parts of an HTML document that only LOOK like markup.
168
+ *
169
+ * ⚠️ A CHECK THAT FAILS CORRECT WORK IS WORSE THAN NO CHECK. A commented-out
170
+ * `<link rel="stylesheet">` is not a stylesheet reference, and a `<style>` tag
171
+ * quoted inside a script is not a style block. Accusing either would make this
172
+ * module fire on pages that are perfectly fine — the exact failure this repo has
173
+ * been bitten by four times in a day.
174
+ */
175
+ function stripInert(html) {
176
+ const withoutComments = html.replace(/<!--[\s\S]*?-->/g, ' ');
177
+ let hadScript = false;
178
+ const withoutScripts = withoutComments.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, () => {
179
+ hadScript = true;
180
+ return ' ';
181
+ });
182
+ if (/<script\b/i.test(withoutScripts)) hadScript = true;
183
+ return { html: withoutScripts, hadScript };
184
+ }
185
+
186
+ /**
187
+ * ── ⭐⭐ CAN THIS RENDER BE BELIEVED? ────────────────────────────────────────
188
+ *
189
+ * Reads the HTML that was actually POSTed to the render service and answers one
190
+ * question the pixels cannot: did the page reach the browser with its design?
191
+ *
192
+ * Returns:
193
+ * · `known` — false when we never observed the request; then every other
194
+ * field is null, because guessing here is the whole bug.
195
+ * · `trustworthy` — false when a stylesheet reference survived into the
196
+ * payload. The browser fetched nothing for it, so the page
197
+ * rendered naked and every measurement below is meaningless.
198
+ * · `styled` — the page carries SOME styling (inline block, remote sheet,
199
+ * or style attributes).
200
+ * · `noCss` — no styling of any kind AND no script that could inject it.
201
+ * That is a real, common defect in model-written pages, and
202
+ * it is stated as an observation, never as a verdict on
203
+ * taste.
204
+ *
205
+ * Pure: a string in, a plain object out.
206
+ */
207
+ export function stylingTrust(sentHtml) {
208
+ if (typeof sentHtml !== 'string') {
209
+ return {
210
+ known: false,
211
+ trustworthy: null,
212
+ styled: null,
213
+ noCss: null,
214
+ unresolved: [],
215
+ reason: null,
216
+ };
217
+ }
218
+
219
+ const { html, hadScript } = stripInert(sentHtml);
220
+
221
+ const unresolved = [];
222
+ let remoteSheet = false;
223
+ for (const tag of html.match(/<link\b[^>]*>/gi) ?? []) {
224
+ if (!/rel\s*=\s*["']?stylesheet/i.test(tag)) continue;
225
+ const href = (/href\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? '').trim();
226
+ if (!href) { unresolved.push('(a stylesheet link with no href)'); continue; }
227
+ if (FETCHABLE.test(href)) { remoteSheet = true; continue; }
228
+ unresolved.push(href);
229
+ }
230
+
231
+ let hasStyleBlock = false;
232
+ for (const m of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
233
+ if (m[1].trim()) { hasStyleBlock = true; break; }
234
+ }
235
+ const hasStyleAttr = /\sstyle\s*=\s*["'][^"']*\S[^"']*["']/i.test(html);
236
+
237
+ const styled = hasStyleBlock || remoteSheet || hasStyleAttr;
238
+ const trustworthy = unresolved.length === 0;
239
+ /**
240
+ * ⚠️ ABSTAIN WHEN A SCRIPT COULD BE DOING IT. Plenty of legitimate pages build
241
+ * their stylesheet at runtime; accusing those of having "no CSS" would be a
242
+ * confident wrong sentence about a page that is fine.
243
+ */
244
+ const noCss = !styled && unresolved.length === 0 && !hadScript;
245
+
246
+ const reason = unresolved.length > 0
247
+ ? `the page was rendered WITHOUT its stylesheet — the render service was handed HTML text that still links `
248
+ + `${unresolved.map((u) => `"${u}"`).join(', ')}, which the browser could not fetch, so every check below `
249
+ + `ran on an unstyled page and a clean result means nothing`
250
+ : null;
251
+
252
+ return { known: true, trustworthy, styled, noCss, unresolved, reason };
253
+ }
254
+
255
+ /**
256
+ * What handing this screenshot to a model would cost instead.
257
+ *
258
+ * Uses the published image-token arithmetic: an image is scaled so its longest
259
+ * edge is at most 1568px, then costs roughly `width × height / 750` tokens.
260
+ *
261
+ * ⚠️ COMPUTED, NEVER QUOTED. The README once carried "3,072 tokens" as a flat
262
+ * figure; it is only true for one image size. A number that changes with the
263
+ * viewport has to be derived from the viewport, or the first person who checks
264
+ * it finds us wrong about our own headline claim.
265
+ *
266
+ * ⚠️ Junk in gets `null`, not a guess.
267
+ */
268
+ export const IMAGE_MAX_EDGE = 1568;
269
+ export const IMAGE_PIXELS_PER_TOKEN = 750;
270
+
271
+ export function imageTokenCost(width, height) {
272
+ if (typeof width !== 'number' || typeof height !== 'number') return null;
273
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return null;
274
+ const scale = Math.min(1, IMAGE_MAX_EDGE / Math.max(width, height));
275
+ const w = Math.round(width * scale);
276
+ const h = Math.round(height * scale);
277
+ return Math.ceil((w * h) / IMAGE_PIXELS_PER_TOKEN);
278
+ }
279
+
280
+ /**
281
+ * A token estimate for text, honest about being an estimate.
282
+ *
283
+ * ⚠️ ~4 ASCII CHARACTERS PER TOKEN, AND ONE TOKEN PER NON-ASCII CHARACTER. The
284
+ * second half matters: CJK and emoji do not compress at four-to-one, and a
285
+ * naive `length / 4` would UNDER-count our own side of the comparison — that is,
286
+ * it would flatter the claim this module makes. Over-counting ourselves is the
287
+ * honest direction to be wrong in.
288
+ */
289
+ export function approxTokens(text) {
290
+ if (typeof text !== 'string' || text.length === 0) return 0;
291
+ let ascii = 0;
292
+ let wide = 0;
293
+ for (const ch of text) {
294
+ if (ch.codePointAt(0) < 128) ascii += 1; else wide += 1;
295
+ }
296
+ return Math.ceil(ascii / 4) + wide;
297
+ }
298
+
299
+ /**
300
+ * ── ⭐ THE RETURN VALUE. THIS IS THE PRODUCT ────────────────────────────────
301
+ *
302
+ * An ordered, specific, ~89-token verdict. Every line is a thing the next round
303
+ * can act on, and the things we could not determine are stated as such.
304
+ *
305
+ * ⚠️ THE THREE SENTENCES IT MUST NEVER GET WRONG:
306
+ * 1. A failed look makes NO claim, and forbids the model from making one. The
307
+ * original bug shipped "I looked and it was fine" for pages never rendered.
308
+ * 2. An untrustworthy render is never phrased as an all-clear, even when the
309
+ * findings list is genuinely empty — an unstyled page passes every check.
310
+ * 3. An all-clear NAMES THE CHECKS it covers, so "fine" and "not measured" can
311
+ * never be read as the same thing.
312
+ *
313
+ * Pure: no clock, no network, no fs.
314
+ */
315
+ export function buildVerdict({ path, viewport, findings = [], coverage = null, trust = null, looked = false, error = null, screenshot = null } = {}) {
316
+ const where = path ? String(path) : 'the page';
317
+
318
+ if (!looked) {
319
+ return [
320
+ `COULD NOT LOOK at ${where}${error ? ` — ${String(error).slice(0, 300)}` : ''}.`,
321
+ 'No visual claim about this page is available: do not report it as working and do not describe how it looks.',
322
+ ].join('\n');
323
+ }
324
+
325
+ const dims = Number.isFinite(viewport?.width) && Number.isFinite(viewport?.height)
326
+ ? ` — ${viewport.width}×${viewport.height}`
327
+ : '';
328
+ const lines = [`LOOKED AT ${where}${dims}`];
329
+
330
+ const list = Array.isArray(findings) ? findings.filter((f) => typeof f === 'string' && f.trim()) : [];
331
+ if (list.length > 0) {
332
+ list.forEach((f, i) => lines.push(`${i + 1}. ${f}`));
333
+ } else if (trust?.known && trust.trustworthy === false) {
334
+ // Unreachable in practice — the trust failure is itself finding #1 — but a
335
+ // formatter that could ever emit a bare all-clear for an unstyled page is
336
+ // the exact bug, so it is closed here too.
337
+ lines.push('Nothing else was measurable.');
338
+ } else if (coverage?.checked?.length) {
339
+ lines.push(`No measured problems. Checked: ${coverage.checked.join(', ')}.`);
340
+ } else {
341
+ lines.push('No problems were reported, and no check is known to have run — treat this as unverified.');
342
+ }
343
+
344
+ /**
345
+ * ── ⚠️⚠️ THE SCREENSHOT EXISTED AND NOBODY WAS TOLD WHERE ──────────────────
346
+ *
347
+ * `seePage` writes the PNG and returns its path, and this verdict — the only
348
+ * thing the model ever sees — never mentioned it. So the agent was told "I
349
+ * looked" and handed a list of MEASUREMENTS, with no way to look itself.
350
+ *
351
+ * ⚠️ AND IT COULD NOT HAVE FOUND IT BY GUESSING. The file lands in `.acuvo/`,
352
+ * and `find_files` refuses to search hidden directories — measured: it returns
353
+ * `skipped: [{path: ".acuvo", reason: "hidden directory, not searched"}]`. Two
354
+ * tools disagreeing about whether that directory exists.
355
+ *
356
+ * ⭐ Naming it turns three finished halves into a loop: render the page, read
357
+ * the image with `read_image`, change the code. That is the design loop this
358
+ * package is named for, and it was one sentence away the whole time.
359
+ */
360
+ if (typeof screenshot === 'string' && screenshot.trim()) {
361
+ lines.push(`SCREENSHOT: ${screenshot} — open it with read_image to see the page yourself; the findings above are measurements, not a look.`);
362
+ }
363
+ if (coverage?.undetermined?.length) {
364
+ lines.push(`NOT CHECKED (nothing was measured, so no claim either way): ${coverage.undetermined.join(', ')}.`);
365
+ }
366
+ if (trust?.known === false) {
367
+ lines.push('NOT CHECKED: whether the page reached the browser with its own stylesheet.');
368
+ }
369
+ if (trust?.known && trust.trustworthy === false) {
370
+ lines.push('⚠ This is not an all-clear: an unstyled page passes every check above. Inline the stylesheet or fix the reference, then look again.');
371
+ }
372
+
373
+ return lines.join('\n');
374
+ }
375
+
376
+ /**
377
+ * Wrap a fetch so we can read the request we sent and the reply we got, without
378
+ * consuming either from the caller's point of view.
379
+ *
380
+ * ⚠️ A REAL `Response` BODY CAN ONLY BE READ ONCE, and `seePage` reads it. So the
381
+ * observed body is handed back through a duck-typed stand-in carrying exactly
382
+ * the surface `postJson` uses (`ok`, `status`, `statusText`, `text()`). Building
383
+ * a fresh `Response` would be prettier and would throw on null-body statuses —
384
+ * this cannot.
385
+ */
386
+ function observingFetch(fetchImpl) {
387
+ const seen = { calls: 0, requestHtml: null, measurement: null };
388
+ const wrapped = async (url, init) => {
389
+ seen.calls += 1;
390
+ try {
391
+ const body = JSON.parse(init?.body ?? '{}');
392
+ seen.requestHtml = typeof body?.html === 'string' ? body.html : null;
393
+ } catch { seen.requestHtml = null; }
394
+
395
+ const res = await fetchImpl(url, init);
396
+ let text = '';
397
+ try { text = await res.text(); } catch { text = ''; }
398
+ try {
399
+ const parsed = JSON.parse(text);
400
+ seen.measurement = parsed?.measurement ?? parsed?.measurements ?? null;
401
+ } catch { seen.measurement = null; }
402
+
403
+ return {
404
+ ok: res.ok,
405
+ status: res.status,
406
+ statusText: res.statusText,
407
+ headers: res.headers,
408
+ text: async () => text,
409
+ json: async () => JSON.parse(text),
410
+ };
411
+ };
412
+ return { wrapped, seen };
413
+ }
414
+
415
+ /**
416
+ * ── ⭐⭐ THE PASS ────────────────────────────────────────────────────────────
417
+ *
418
+ * Render the page, read what came back, and return something the model can act
419
+ * on plus something the human can look at.
420
+ *
421
+ * The return is a strict SUPERSET of `seePage`'s, deliberately: every existing
422
+ * consumer (`tools.mjs`, `report.mjs`, `parallel.mjs`, `turn.mjs`) reads `ok`,
423
+ * `path`, `screenshot`, `viewport`, `findings` and `looked`, and all six survive
424
+ * unchanged. Wiring this in is a swap, not a migration.
425
+ *
426
+ * Added on top:
427
+ * · `verdict` the ~89-token string to put in the tool result
428
+ * · `trustworthy` false when the render cannot be believed
429
+ * · `trust` why, in detail
430
+ * · `undetermined` checks that abstained
431
+ * · `checked` checks an all-clear actually covers
432
+ * · `screenshotAbsolute` the path `renderImage` needs to draw pixels
433
+ * · `cost` the measured token comparison, computed per page
434
+ *
435
+ * ⚠️ NEVER THROWS. A picture failing to arrive must not end a coding session.
436
+ */
437
+ export async function designPass(root, htmlPath, { env = process.env, fetchImpl = fetch, dryRun = false, seeImpl = seePage } = {}) {
438
+ const { wrapped, seen } = observingFetch(fetchImpl);
439
+
440
+ let seen_ = null;
441
+ try {
442
+ seen_ = await seeImpl(root, htmlPath, { env, fetchImpl: wrapped, dryRun });
443
+ } catch (err) {
444
+ seen_ = { ok: false, error: `the render step threw: ${err?.message ?? err}` };
445
+ }
446
+ const looked = seen_?.ok === true && seen_?.looked === true;
447
+
448
+ const trust = stylingTrust(seen.requestHtml);
449
+ const coverage = looked ? checkCoverage(seen.measurement) : { known: false, checked: [], undetermined: CHECKS.map((c) => c.label) };
450
+
451
+ if (!looked) {
452
+ const error = seen_?.error ?? 'the render step returned nothing usable';
453
+ return {
454
+ ok: false,
455
+ error,
456
+ path: typeof htmlPath === 'string' ? htmlPath : null,
457
+ looked: false,
458
+ trustworthy: false,
459
+ trust,
460
+ screenshot: null,
461
+ screenshotAbsolute: null,
462
+ screenshotBytes: 0,
463
+ viewport: null,
464
+ findings: [],
465
+ checked: [],
466
+ undetermined: coverage.undetermined,
467
+ verdict: buildVerdict({ path: htmlPath, looked: false, error }),
468
+ cost: null,
469
+ };
470
+ }
471
+
472
+ /**
473
+ * ⚠️ THE TRUST FINDING GOES FIRST, above the console error, because it
474
+ * invalidates every line under it. Same rule `findingsFrom` applies to console
475
+ * errors, one level up: the thing that explains the others leads.
476
+ *
477
+ * ⚠️ AND IT IS IN `findings`, NOT ONLY IN `verdict`. Downstream readers
478
+ * (`turn.mjs` counts looked-at pages, `report.mjs` summarises) read the array.
479
+ * A warning that exists only in the prose is a warning half the pipeline
480
+ * cannot see — which is how this package keeps shipping dead capability.
481
+ */
482
+ const findings = [];
483
+ if (trust.known && trust.trustworthy === false && trust.reason) findings.push(trust.reason);
484
+ if (trust.known && trust.noCss === true) {
485
+ findings.push('the page carries no CSS at all — no <style> block, no stylesheet link, no style attributes; '
486
+ + 'the browser rendered its own defaults, so this is what the user will see');
487
+ }
488
+ for (const f of Array.isArray(seen_.findings) ? seen_.findings : []) {
489
+ if (!findings.includes(f)) findings.push(f);
490
+ }
491
+
492
+ const verdict = buildVerdict({
493
+ path: seen_.path ?? htmlPath,
494
+ viewport: seen_.viewport,
495
+ findings,
496
+ coverage,
497
+ trust,
498
+ looked: true,
499
+ // ⭐ The PNG is already on disk and already returned below as `screenshot`.
500
+ // Until now it never reached the verdict, which is the only thing the model
501
+ // reads — so the file existed and nobody was told where.
502
+ screenshot: seen_.screenshot ?? null,
503
+ });
504
+
505
+ const screenshotTokens = imageTokenCost(seen_.viewport?.width, seen_.viewport?.height);
506
+ const verdictTokens = approxTokens(verdict);
507
+
508
+ return {
509
+ ok: true,
510
+ path: seen_.path ?? htmlPath,
511
+ looked: true,
512
+ trustworthy: trust.known ? trust.trustworthy : null,
513
+ trust,
514
+ screenshot: seen_.screenshot ?? null,
515
+ /**
516
+ * ⭐ THE HALF THAT SHOWS THE HUMAN. `renderImage` needs an absolute path;
517
+ * `seePage` returns a workspace-relative one because that is what belongs in
518
+ * a summary. Both are returned so neither consumer has to guess.
519
+ */
520
+ screenshotAbsolute: seen_.screenshot ? resolve(root, seen_.screenshot) : null,
521
+ screenshotBytes: seen_.screenshotBytes ?? 0,
522
+ viewport: seen_.viewport ?? null,
523
+ findings: findings.slice(0, 20),
524
+ checked: coverage.checked,
525
+ undetermined: coverage.undetermined,
526
+ verdict,
527
+ cost: {
528
+ verdictChars: verdict.length,
529
+ verdictTokensApprox: verdictTokens,
530
+ screenshotTokens,
531
+ // Abstains rather than dividing by an unknown viewport.
532
+ ratio: screenshotTokens && verdictTokens ? Number((screenshotTokens / verdictTokens).toFixed(1)) : null,
533
+ },
534
+ };
535
+ }
536
+
537
+ /**
538
+ * ── ⭐ AND THEN SHOW THE PICTURE ────────────────────────────────────────────
539
+ *
540
+ * Turn a pass into terminal lines: the verdict, the screenshot path, and — on a
541
+ * terminal that positively speaks kitty or iTerm2 — the actual pixels.
542
+ *
543
+ * ⚠️ THE PATH LINE STAYS EITHER WAY. The image is an ADDITION to the report,
544
+ * never a replacement for it: on Windows Terminal, in CI, or through a pipe,
545
+ * `renderImage` returns nothing at all and the human still gets a file to open.
546
+ *
547
+ * ⚠️ AND IT NEVER THROWS ON JUNK. Being handed a malformed pass must degrade to
548
+ * fewer lines, not to a crashed session over a picture.
549
+ */
550
+ export function formatDesignPass(pass, { root = '.', env = process.env, isTTY = process.stdout.isTTY, renderImpl = renderImage } = {}) {
551
+ const out = [];
552
+ if (!pass || typeof pass !== 'object') return out;
553
+
554
+ if (typeof pass.verdict === 'string' && pass.verdict.trim()) out.push(pass.verdict);
555
+
556
+ if (typeof pass.screenshot === 'string' && pass.screenshot) {
557
+ const kb = Number.isFinite(pass.screenshotBytes) && pass.screenshotBytes > 0
558
+ ? ` (${Math.max(1, Math.round(pass.screenshotBytes / 1024))}KB)`
559
+ : '';
560
+ out.push(` screenshot: ${pass.screenshot}${kb}`);
561
+ const absolute = typeof pass.screenshotAbsolute === 'string' && pass.screenshotAbsolute
562
+ ? pass.screenshotAbsolute
563
+ : resolve(root, pass.screenshot);
564
+ let drawn = null;
565
+ try { drawn = renderImpl(absolute, { env, isTTY }); } catch { drawn = null; }
566
+ if (drawn?.text) out.push(String(drawn.text).replace(/\n+$/, ''));
567
+ }
568
+
569
+ return out;
570
+ }