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.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/account.mjs
ADDED
|
@@ -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
|
+
}
|