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,226 @@
1
+ /**
2
+ * ── ⭐⭐ THE ACCOUNT — ACUVO'S KEY, NOT THE USER'S ───────────────────────────
3
+ *
4
+ * Acuvo Code is meant to work the way Claude Code does: you buy Acuvo credits,
5
+ * you run the tool, and you never see a provider key. Today it does the
6
+ * opposite — `readModelConfig` reads `OPENROUTER_API_KEY` out of the user's
7
+ * environment and the first-run message tells a stranger to go and create one.
8
+ * That is BYOK, it was never the plan, and it makes the product's storefront an
9
+ * advertisement for somebody else.
10
+ *
11
+ * This module is the CLI half of the fix: where the account token lives, how it
12
+ * is read and written, and how a run decides whether it is authenticated as an
13
+ * Acuvo customer or falling back to a key the user brought.
14
+ *
15
+ * ── ⚠️⚠️ WHY THIS IS NOT IN THE WORKSPACE, AND IT IS NOT A STYLE CHOICE ─────
16
+ *
17
+ * This package already paid for that lesson once: a trust store that lives in
18
+ * the workspace can be written by the agent that the trust store exists to
19
+ * bound. `WRITE_FORBIDDEN_ROOTS` in workspace.mjs does not contain `.acuvo`, so
20
+ * an agent can write `.acuvo/anything` — which would mean an agent able to mint
21
+ * its own credentials, or to point its own gateway at a host it chose.
22
+ *
23
+ * ⭐ So the credential lives under HOME, outside every workspace, where no tool
24
+ * in this package can reach it: nothing in `lib/tools.mjs` can read or write a
25
+ * path outside the workspace root, by construction, and that containment is
26
+ * already tested. The agent cannot exfiltrate a token it cannot open.
27
+ *
28
+ * ── ⚠️ THE PERMISSION PROMISE WE MUST NOT MAKE ──────────────────────────────
29
+ *
30
+ * `chmod 600` is a NO-OP on win32 — measured, and this project is developed on
31
+ * Windows. So this returns what it ACTUALLY achieved rather than asserting a
32
+ * mode it may not have got. A security control that reports success it did not
33
+ * accomplish is worse than one that is absent, because it stops people looking.
34
+ */
35
+
36
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, chmodSync } from 'node:fs';
37
+ import { homedir, platform } from 'node:os';
38
+ import { join } from 'node:path';
39
+
40
+ /** Where an account lives. Under HOME, never under a workspace. */
41
+ export function accountDir(env = process.env, home = homedir()) {
42
+ const override = String(env?.ACUVO_HOME ?? '').trim();
43
+ return override || join(home, '.acuvo');
44
+ }
45
+
46
+ export function credentialsPath(env = process.env, home = homedir()) {
47
+ return join(accountDir(env, home), 'credentials.json');
48
+ }
49
+
50
+ /**
51
+ * ── ⭐ THE GATEWAY, AND WHY ITS URL IS NOT AN ARBITRARY OVERRIDE ────────────
52
+ *
53
+ * `model.mjs`'s `resolveApiUrl` accepts `ACUVO_API_URL` ONLY for loopback and
54
+ * throws otherwise, because that variable decides where
55
+ * `Authorization: Bearer <the user's provider key>` is SENT — a documented
56
+ * exfiltration primitive if it were free-form.
57
+ *
58
+ * ⚠️ THE GATEWAY IS A DIFFERENT CASE AND THE DIFFERENCE IS THE CREDENTIAL. What
59
+ * travels to the gateway is an ACUVO ACCOUNT TOKEN, which is ours, is scoped to
60
+ * one account, and is revocable by us. It is not a provider key and it is not
61
+ * the user's. So a configurable gateway host is a deployment knob, not a hole —
62
+ * but only while that stays true, which is why `readAccount` never returns a
63
+ * provider key and the gateway leg never receives one.
64
+ */
65
+ export const DEFAULT_GATEWAY_URL = 'https://acuvo.xxiautomate.com/api/cli/v1/chat/completions';
66
+
67
+ /**
68
+ * Read the stored account, if there is one.
69
+ *
70
+ * ⚠️ NEVER THROWS. A corrupt or half-written credentials file must degrade to
71
+ * "not signed in" and let the run continue on whatever else is configured — a
72
+ * crash on startup because a JSON file has a stray byte is a worse failure than
73
+ * an unauthenticated run, and it is the one a user cannot diagnose.
74
+ *
75
+ * @returns {{ token: string, email: string | null, gatewayUrl: string } | null}
76
+ */
77
+ export function readAccount(env = process.env, home = homedir()) {
78
+ /**
79
+ * ⭐ THE ENVIRONMENT WINS OVER THE FILE, so CI can authenticate without a
80
+ * login step and without writing a credential to a build agent's disk.
81
+ */
82
+ const fromEnv = String(env?.ACUVO_TOKEN ?? '').trim();
83
+ if (fromEnv) {
84
+ return { token: fromEnv, email: null, gatewayUrl: gatewayUrlFrom(env, null) };
85
+ }
86
+
87
+ let raw;
88
+ try {
89
+ raw = readFileSync(credentialsPath(env, home), 'utf8');
90
+ } catch {
91
+ return null;
92
+ }
93
+
94
+ let parsed;
95
+ try {
96
+ parsed = JSON.parse(raw);
97
+ } catch {
98
+ return null;
99
+ }
100
+
101
+ const token = typeof parsed?.token === 'string' ? parsed.token.trim() : '';
102
+ if (!token) return null;
103
+
104
+ return {
105
+ token,
106
+ email: typeof parsed?.email === 'string' && parsed.email.trim() ? parsed.email.trim() : null,
107
+ gatewayUrl: gatewayUrlFrom(env, parsed),
108
+ };
109
+ }
110
+
111
+ /**
112
+ * ⚠️ PRECEDENCE IS DELIBERATE: environment, then the stored file, then the
113
+ * built-in default. The environment comes first so a developer can point a
114
+ * local build at a staging gateway without editing a credential file — and so
115
+ * the value used is always the one most recently and most explicitly chosen.
116
+ */
117
+ function gatewayUrlFrom(env, stored) {
118
+ const fromEnv = String(env?.ACUVO_GATEWAY_URL ?? '').trim();
119
+ if (fromEnv) return fromEnv;
120
+ const fromFile = typeof stored?.gatewayUrl === 'string' ? stored.gatewayUrl.trim() : '';
121
+ return fromFile || DEFAULT_GATEWAY_URL;
122
+ }
123
+
124
+ /**
125
+ * Store an account.
126
+ *
127
+ * @returns {{ ok: true, path: string, restricted: boolean, note: string | null }}
128
+ * `restricted` says whether the file permissions were actually narrowed —
129
+ * NOT whether we asked for it.
130
+ */
131
+ export function writeAccount({ token, email = null, gatewayUrl = null }, env = process.env, home = homedir()) {
132
+ const trimmed = String(token ?? '').trim();
133
+ if (!trimmed) return { ok: false, error: 'a token is required' };
134
+
135
+ const dir = accountDir(env, home);
136
+ const path = credentialsPath(env, home);
137
+ mkdirSync(dir, { recursive: true });
138
+
139
+ const body = { token: trimmed };
140
+ if (email) body.email = String(email).trim();
141
+ if (gatewayUrl) body.gatewayUrl = String(gatewayUrl).trim();
142
+ writeFileSync(path, `${JSON.stringify(body, null, 2)}\n`, 'utf8');
143
+
144
+ /**
145
+ * ⚠️ MEASURED: `chmod 600` is accepted and does nothing on win32. Reporting
146
+ * "permissions restricted" there would be a security control announcing a
147
+ * success it did not achieve, which stops the reader looking any further.
148
+ */
149
+ let restricted = false;
150
+ let note = null;
151
+ if (platform() === 'win32') {
152
+ note = 'file permissions are not restricted on Windows — this file is readable by your user account';
153
+ } else {
154
+ try {
155
+ chmodSync(path, 0o600);
156
+ restricted = true;
157
+ } catch {
158
+ note = 'could not restrict file permissions';
159
+ }
160
+ }
161
+
162
+ return { ok: true, path, restricted, note };
163
+ }
164
+
165
+ /** Remove a stored account. Idempotent — signing out twice is not an error. */
166
+ export function clearAccount(env = process.env, home = homedir()) {
167
+ const path = credentialsPath(env, home);
168
+ const existed = existsSync(path);
169
+ try {
170
+ rmSync(path, { force: true });
171
+ } catch {
172
+ return { ok: false, error: `could not remove ${path}` };
173
+ }
174
+ return { ok: true, existed, path };
175
+ }
176
+
177
+ /**
178
+ * ── ⭐⭐ WHICH KEY IS THIS RUN USING, AND WHOSE MONEY IS IT? ─────────────────
179
+ *
180
+ * The one function that decides. It exists so the answer is computed in a single
181
+ * place and can be REPORTED — a user must always be able to tell whether they
182
+ * are spending Acuvo credits or their own provider balance, because those are
183
+ * different people's money and confusing them is unforgivable.
184
+ *
185
+ * ── ⚠️⚠️ BYOK IS NOT A SUPPORTED PRODUCT MODE. IT IS A TRANSITIONAL ONE ─────
186
+ *
187
+ * This comment used to read: *"BYOK STAYS SUPPORTED, and that is not
188
+ * indecision."* ⚠️ It was indecision, and it contradicted a product decision
189
+ * that had already been made. Roman, twice (2026-08-14 and again 2026-08-16):
190
+ * **BYOK = never — "it defeats the product completely."** If the user brings
191
+ * their own key we are a free wrapper around somebody else's margin, and there
192
+ * is no business under it. The reasoning is not about capability, it is that
193
+ * the whole company is the gateway.
194
+ *
195
+ * ⚠️ SO WHY IS `'byok'` STILL HERE? Because removing it today would brick the
196
+ * CLI, not free it. Measured 2026-08-16: there is **no `acuvo login` command
197
+ * and no gateway server**, so `readAccount` can only succeed if somebody
198
+ * hand-writes a credentials file — which means `'byok'` is currently the ONLY
199
+ * mode that works, including for our own Terminal-Bench runs. Deleting the mode
200
+ * before the thing that replaces it exists is not enforcing the rule, it is
201
+ * shipping an unusable package.
202
+ *
203
+ * ⭐ THE ORDER, so nobody has to re-derive it:
204
+ * 1. the gateway exists (holds OUR key, meters, bills, refuses at $0)
205
+ * 2. `acuvo login` exists and writes an account
206
+ * 3. THEN `'byok'` stops being a peer mode here — internal/dev only, or gone
207
+ *
208
+ * ⚠️⚠️ AND UNTIL THEN, NOBODY "IMPROVES" BYOK. No nicer key prompts, no BYOK
209
+ * onboarding, no docs that present it as a way to use the product. Every such
210
+ * change makes the paid path harder to introduce later and is work against the
211
+ * business. This paragraph exists because the sentence it replaced was a
212
+ * reasonable-sounding argument for exactly that, sitting in the codebase
213
+ * looking like a decision.
214
+ *
215
+ * @returns {{ mode: 'account' | 'byok' | 'unconfigured', token: string,
216
+ * url: string | null, email: string | null }}
217
+ */
218
+ export function resolveCredential(env = process.env, home = homedir()) {
219
+ const account = readAccount(env, home);
220
+ if (account) {
221
+ return { mode: 'account', token: account.token, url: account.gatewayUrl, email: account.email };
222
+ }
223
+ const byok = String(env?.OPENROUTER_API_KEY ?? '').trim();
224
+ if (byok) return { mode: 'byok', token: byok, url: null, email: null };
225
+ return { mode: 'unconfigured', token: '', url: null, email: null };
226
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * ── ⚠️ WE DIRTIED THE USER'S GIT TREE WITH OUR OWN BOOKKEEPING ──────────────
3
+ *
4
+ * Found by our own bench on 2026-08-13, on the `git` task. The agent did the job
5
+ * correctly — fixed the function, matched the repo's commit style, committed the
6
+ * one file — and the task still FAILED:
7
+ *
8
+ * left the tree dirty: ?? .acuvo/
9
+ *
10
+ * Every run writes an audit line, a session and sometimes a rendered artifact
11
+ * into `.acuvo/`. In a git repository that is an untracked directory the user
12
+ * never asked for, sitting in every `git status` they run afterwards. For a tool
13
+ * whose own bench includes "tidy up and commit", leaving litter in the tree is
14
+ * not a cosmetic issue: it breaks the workflow it is being graded on.
15
+ *
16
+ * ⭐ AND WE ALREADY KNEW. `doctor.mjs`'s `gitignoreCoversAcuvo` detects exactly
17
+ * this and prints `add ".acuvo/" to .gitignore`. Detection without action, which
18
+ * is the shape of nearly every defect found in this repo this week.
19
+ *
20
+ * ── WHY A SELF-IGNORING DIRECTORY AND NOT AN EDIT TO THEIR .gitignore ───────
21
+ *
22
+ * A `.gitignore` containing `*` INSIDE `.acuvo/` makes git ignore the whole
23
+ * directory — including that file — without touching a single byte the user
24
+ * owns. Editing their root `.gitignore` would be a write to a tracked file they
25
+ * did not ask us to change, which would show up in the very diff we are trying
26
+ * to keep clean, and would need reverting if they uninstall.
27
+ *
28
+ * ⚠️ IT IS NEVER OVERWRITTEN. If the file exists we leave it alone, whatever it
29
+ * says: a user who deliberately un-ignored something has made a decision, and
30
+ * silently reversing it every run is worse than the litter.
31
+ *
32
+ * ⚠️ AND IT NEVER THROWS. A read-only checkout or a permission error must not
33
+ * take down a run that was otherwise fine — the litter is a nuisance, failing
34
+ * the user's task over it is not.
35
+ */
36
+
37
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
38
+ import { join } from 'node:path';
39
+
40
+ export const ACUVO_DIR = '.acuvo';
41
+
42
+ /** `*` ignores every entry including this file itself. */
43
+ export const SELF_IGNORE_BODY = [
44
+ '# Acuvo Code writes its audit log, sessions and artifacts here.',
45
+ '# This file keeps them out of your repository without touching your .gitignore.',
46
+ '*',
47
+ '',
48
+ ].join('\n');
49
+
50
+ /**
51
+ * Make sure `.acuvo/` exists and cannot dirty a git tree.
52
+ *
53
+ * @param {string} root workspace root
54
+ * @returns {{ created: boolean, ignored: boolean, error: string|null }}
55
+ */
56
+ export function ensureAcuvoDirIgnored(root) {
57
+ const dir = join(String(root ?? ''), ACUVO_DIR);
58
+ const ignoreFile = join(dir, '.gitignore');
59
+ let created = false;
60
+
61
+ try {
62
+ if (!existsSync(dir)) {
63
+ mkdirSync(dir, { recursive: true });
64
+ created = true;
65
+ }
66
+ if (existsSync(ignoreFile)) return { created, ignored: true, error: null };
67
+ writeFileSync(ignoreFile, SELF_IGNORE_BODY, 'utf8');
68
+ return { created, ignored: true, error: null };
69
+ } catch (err) {
70
+ return { created, ignored: false, error: err instanceof Error ? err.message : String(err) };
71
+ }
72
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * ── ⭐⭐ OUR MODELS HAVE OUR NAMES ───────────────────────────────────────────
3
+ *
4
+ * Everywhere a user could see a model, they saw `deepseek/deepseek-v4-flash-0731`.
5
+ * That is somebody else's product name in the middle of ours, and it is wrong
6
+ * for three separate reasons:
7
+ *
8
+ * 1. **It sells the wrong thing.** A buyer comparing us to Claude Code sees
9
+ * "Sonnet vs Opus" against "deepseek-v4-flash-0731", and concludes we are
10
+ * a wrapper. The work that makes this good — the harness, the pin, the
11
+ * independent reviewer, the budget — is ours and is invisible in that name.
12
+ * 2. **It leaks a decision we must be free to change.** The day a better or
13
+ * cheaper model appears, `--model deepseek/...` is in scripts, CI configs
14
+ * and muscle memory. A name we own is a name we can re-point.
15
+ * 3. **It exposes a supplier to be approached directly.** Our margin comes
16
+ * from the harness and the routing, not from secrecy — but there is no
17
+ * reason to print the supplier list on the product.
18
+ *
19
+ * ⚠️ AND IT IS NOT A LIE. The underlying id is always one command away
20
+ * (`acuvo doctor` prints it, the audit log records it, `--json` carries it), and
21
+ * `resolveModelName` accepts a raw vendor id unchanged so nothing existing
22
+ * breaks. Renaming is branding; hiding would be dishonesty, and this package
23
+ * does not get to have a `refusedCommitPath` and also a secret supplier.
24
+ *
25
+ * ── ⚠️ TWO OF THESE ARE NOT USER-SELECTABLE, ON PURPOSE ─────────────────────
26
+ *
27
+ * The reviewer and the vision model are INFRASTRUCTURE. A user choosing the
28
+ * model that reviews their work can (accidentally or otherwise) pick the same
29
+ * one that wrote it, which turns an independent check into self-review — the
30
+ * exact defect `chooseRefuteModel` exists to prevent. They are listed here so
31
+ * the catalogue is complete and honest, and marked `internal` so no menu offers
32
+ * them.
33
+ */
34
+
35
+ /**
36
+ * ⭐ THE ONE MAPPING. Every other module asks this file rather than embedding a
37
+ * vendor id, so re-pointing a name is a one-line change here.
38
+ */
39
+ export const ACUVO_MODELS = Object.freeze({
40
+ 'acuvo-flash': Object.freeze({
41
+ name: 'acuvo-flash',
42
+ label: 'Acuvo Flash',
43
+ id: 'deepseek/deepseek-v4-flash-0731',
44
+ role: 'build',
45
+ internal: false,
46
+ /** Measured on our own 13-task bench, 2026-08-15. */
47
+ blurb: 'The default. 12 of 13 on our bench for 1.5 cents. Fast, and cheap enough that verifying everything is affordable.',
48
+ }),
49
+ 'acuvo-pro': Object.freeze({
50
+ name: 'acuvo-pro',
51
+ label: 'Acuvo Pro',
52
+ id: 'deepseek/deepseek-v4-pro-0813',
53
+ role: 'build',
54
+ internal: false,
55
+ blurb: 'The strong model. Costs 1.2x Flash on a long warm session and 6x on a short cold one — worth it for hard, sustained work.',
56
+ }),
57
+ /**
58
+ * ⚠️ INTERNAL. See the header: a user who could point the reviewer at their
59
+ * own builder would silently convert an independent check into self-review.
60
+ */
61
+ 'acuvo-review': Object.freeze({
62
+ name: 'acuvo-review',
63
+ label: 'Acuvo Review',
64
+ id: 'qwen/qwen3.7-flash',
65
+ role: 'review',
66
+ internal: true,
67
+ blurb: 'Reviews the builder\'s work from a different model family, so its blind spots are not the same ones.',
68
+ }),
69
+ 'acuvo-vision': Object.freeze({
70
+ name: 'acuvo-vision',
71
+ label: 'Acuvo Vision',
72
+ id: 'qwen/qwen3.7-flash',
73
+ role: 'vision',
74
+ internal: true,
75
+ blurb: 'Looks at rendered pages and images.',
76
+ }),
77
+ });
78
+
79
+ /** The names a user may choose between — the `/model` menu. */
80
+ export function selectableModels() {
81
+ return Object.values(ACUVO_MODELS).filter((m) => !m.internal);
82
+ }
83
+
84
+ /**
85
+ * Resolve whatever the user typed into a provider model id.
86
+ *
87
+ * ⚠️ A RAW VENDOR ID PASSES THROUGH UNCHANGED, and that is deliberate rather
88
+ * than lazy: every existing script, CI file and test that names
89
+ * `deepseek/deepseek-v4-flash-0731` keeps working, and anyone who wants a model
90
+ * we have never heard of can still use it. Renaming must not become a gate.
91
+ *
92
+ * @param {string} input `acuvo-pro`, `Acuvo Pro`, or a raw vendor id
93
+ * @returns {{ ok: true, id: string, model: object|null } | { ok: false, error: string }}
94
+ */
95
+ export function resolveModelName(input) {
96
+ const raw = String(input ?? '').trim();
97
+ if (!raw) return { ok: false, error: 'no model named' };
98
+
99
+ const key = raw.toLowerCase().replace(/\s+/g, '-');
100
+ const hit = ACUVO_MODELS[key];
101
+ if (hit) {
102
+ if (hit.internal) {
103
+ return {
104
+ ok: false,
105
+ error: `${hit.label} is chosen for you — it ${hit.role === 'review' ? 'reviews the builder\'s work, and letting you point it at the builder\'s own model would turn an independent check into self-review' : 'is used internally'}. Pick from: ${selectableModels().map((m) => m.name).join(', ')}.`,
106
+ };
107
+ }
108
+ return { ok: true, id: hit.id, model: hit };
109
+ }
110
+
111
+ /**
112
+ * ⚠️ A vendor id is recognised by its slash. Anything else that is not a
113
+ * known name is a TYPO, and a typo must not be silently posted to a provider
114
+ * as a model id — that costs a round trip to learn "no endpoints found".
115
+ */
116
+ if (raw.includes('/')) {
117
+ const known = Object.values(ACUVO_MODELS).find((m) => m.id === raw);
118
+ return { ok: true, id: raw, model: known ?? null };
119
+ }
120
+ return {
121
+ ok: false,
122
+ error: `"${raw}" is not a model this understands. Choose ${selectableModels().map((m) => m.name).join(' or ')}, or give a full provider id like deepseek/deepseek-v4-flash-0731.`,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * The Acuvo name for a provider id, for anything a user reads.
128
+ *
129
+ * ⚠️ FALLS BACK TO THE RAW ID rather than inventing a name. A model we did not
130
+ * ship is still a model somebody is running, and printing "Acuvo Something" over
131
+ * it would be a lie in the one place — the receipt — that has to be true.
132
+ */
133
+ export function labelForModelId(id) {
134
+ const hit = Object.values(ACUVO_MODELS).find((m) => m.id === String(id ?? ''));
135
+ return hit ? hit.label : String(id ?? '');
136
+ }
137
+
138
+ /** One line per selectable model, for `--help` and the `/model` menu. */
139
+ export function formatModelMenu() {
140
+ return selectableModels().map((m) => ` ${m.name.padEnd(12)} ${m.label.padEnd(12)} ${m.blurb}`);
141
+ }