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/rcfile.mjs
ADDED
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ THE CONFIG FILE — AND WHY A "PREFERENCE" IS A SECURITY DECISION ──────
|
|
3
|
+
*
|
|
4
|
+
* Today every preference is retyped on every invocation. There is no way to say
|
|
5
|
+
* "in this checkout, three rounds is plenty" or "on this machine, use acuvo-pro"
|
|
6
|
+
* without a shell alias, and an alias is invisible to `--doctor`, invisible in
|
|
7
|
+
* the audit log, and not shareable with a teammate.
|
|
8
|
+
*
|
|
9
|
+
* ⚠️⚠️ BUT A CONFIG FILE IN THE WORKSPACE IS WRITTEN BY THE REPOSITORY, AND THE
|
|
10
|
+
* REPOSITORY MAY BE ONE THE USER CLONED THIS MINUTE. `lib/policy.mjs` states the
|
|
11
|
+
* problem exactly — *"the policy file lives in the workspace, and the agent can
|
|
12
|
+
* write to the workspace"* — and solves it by refusing to fight for
|
|
13
|
+
* authenticity: every field is monotone downward, so a layer can only ever
|
|
14
|
+
* REMOVE permission and a hostile write buys nothing.
|
|
15
|
+
*
|
|
16
|
+
* This file obeys the same rule, and had to sharpen it in three places.
|
|
17
|
+
*
|
|
18
|
+
* ── ⚠️ 1. "MONOTONE" IS NOT "LOOKS RESTRICTIVE" ─────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* `checkpoint: false` grants the agent nothing. It is a `false`. It reads, at a
|
|
21
|
+
* glance, like the safe direction. What it actually does is delete your UNDO —
|
|
22
|
+
* `acuvo rewind` has nothing to put back. `autoLease: false` is the same shape:
|
|
23
|
+
* it removes the refusal that stops a second terminal overwriting your file.
|
|
24
|
+
*
|
|
25
|
+
* ⭐ So the test is not "does this value narrow the agent" but **"does this
|
|
26
|
+
* value remove something the USER is relying on, or spend money they did not
|
|
27
|
+
* agree to"**. Two rules, and every line of the table below falls out of them:
|
|
28
|
+
*
|
|
29
|
+
* R1 — a workspace layer may never increase what a run COSTS.
|
|
30
|
+
* R2 — a workspace layer may never remove a PROTECTION the user relies on.
|
|
31
|
+
*
|
|
32
|
+
* R1 is why `refute` and `bestOf` are home-only despite both being "more
|
|
33
|
+
* checking": each is n× the paid runs. R2 is why `checkpoint` and `autoLease`
|
|
34
|
+
* are home-only despite both being `false`.
|
|
35
|
+
*
|
|
36
|
+
* ── ⚠️ 2. SOME KEYS HAVE NO STRICTER DIRECTION AT ALL ───────────────────────
|
|
37
|
+
*
|
|
38
|
+
* `model` is the one that matters. There is no ordering on model ids — you
|
|
39
|
+
* cannot say `acuvo-pro` is stricter than `acuvo-flash` — so "may only narrow"
|
|
40
|
+
* has nothing to mean, and a repository choosing the model is a repository
|
|
41
|
+
* choosing what your key is spent on. Same for `holder` (it is the identity
|
|
42
|
+
* leases are attributed to; a repo pinning one name lets two terminals share a
|
|
43
|
+
* holder and step on each other's leases) and `json` (it changes the shape of
|
|
44
|
+
* stdout, which is what somebody's script is parsing).
|
|
45
|
+
*
|
|
46
|
+
* ⭐ THE WAY OUT IS NAMED, NOT WITHHELD. A team that wants to constrain the
|
|
47
|
+
* model already has the right tool: `.acuvo/policy.json`'s `allowModels`, which
|
|
48
|
+
* INTERSECTS and therefore cannot widen. A repo can say "only DeepSeek here";
|
|
49
|
+
* it cannot say "use this one".
|
|
50
|
+
*
|
|
51
|
+
* ── ⚠️⚠️ 3. PRECEDENCE IS NOT TRUST, AND CONFLATING THEM WOULD HAVE MADE THIS
|
|
52
|
+
* WHOLE FILE DECORATIVE ───────────────────────────────────────────
|
|
53
|
+
*
|
|
54
|
+
* The required order is flag > env > home > workspace > default. Read
|
|
55
|
+
* carelessly that says "the environment is trusted above the home file". It is
|
|
56
|
+
* not: `lib/env-file.mjs` loads `.env` and `.env.local` **from inside the
|
|
57
|
+
* workspace** into `process.env`. Anything the env layer may set, a cloned
|
|
58
|
+
* repository may set — with a precedence ABOVE the user's own config file.
|
|
59
|
+
*
|
|
60
|
+
* So the two axes are kept apart:
|
|
61
|
+
*
|
|
62
|
+
* PRECEDENCE — who wins. env beats home, as specified.
|
|
63
|
+
* TRUST — what may be stated at all. An env var whose NAME appears in
|
|
64
|
+
* a `.env` file under the workspace root is treated exactly
|
|
65
|
+
* like the workspace config file.
|
|
66
|
+
*
|
|
67
|
+
* ⚠️ AND IT IS IGNORE-WITH-A-NOTE THERE, NEVER AN ERROR. `loadEnvFile` does not
|
|
68
|
+
* overwrite a real `export`, so a name present in the repo's `.env` may still be
|
|
69
|
+
* carrying a value the human typed — we genuinely cannot tell. Erroring would
|
|
70
|
+
* hand any repository a way to brick every run; taking the safe value and SAYING
|
|
71
|
+
* SO is the only reading that is wrong in neither direction.
|
|
72
|
+
*
|
|
73
|
+
* ⚠️⚠️ AND IT ONLY WORKS IF THE CALLER PASSES `untrustedEnvNames`. The default
|
|
74
|
+
* is `[]` — i.e. the whole environment is trusted — because the alternative
|
|
75
|
+
* default (distrust everything) silently clamps a legitimate `export
|
|
76
|
+
* ACUVO_MAX_ROUNDS=12` to 5 for everyone. `readWorkspaceEnvNames(root)` computes
|
|
77
|
+
* the set in one line and `bin/` must pass it. Until it does, a workspace `.env`
|
|
78
|
+
* is trusted as if the user had typed it. That is the one unwired edge in this
|
|
79
|
+
* module and it is stated here rather than discovered later.
|
|
80
|
+
*
|
|
81
|
+
* ── WHERE THE FILES LIVE, AND WHY ───────────────────────────────────────────
|
|
82
|
+
*
|
|
83
|
+
* workspace .acuvo/config.json
|
|
84
|
+
* home ~/.acuvo/config.json (or $ACUVO_CONFIG_FILE, or $ACUVO_HOME)
|
|
85
|
+
*
|
|
86
|
+
* ⭐ `.acuvo/` RATHER THAN A NEW `.acuvorc` AT THE REPO ROOT, and the reason is
|
|
87
|
+
* not consistency with `policy.json` / `commands.json` / `mcp.json` — though it
|
|
88
|
+
* is that too. It is that **two independent guards already stand over that
|
|
89
|
+
* directory**: `workspace.mjs`'s `AGENT_CONFIG_DIR` HARD-REFUSES every agent
|
|
90
|
+
* write under `.acuvo/` (reads are untouched), and `isPolicyProtectedPath`
|
|
91
|
+
* (`policy.mjs:675`) says the same thing from the other side, with
|
|
92
|
+
* `test/agent-cannot-rewrite-its-own-leash.test.mjs` asserting the two agree.
|
|
93
|
+
* A root-level `.acuvorc` would be an ordinary file the agent can rewrite
|
|
94
|
+
* mid-run; putting the config where those guards already stand means the
|
|
95
|
+
* agent's own `write_file` is refused before monotonicity is ever needed.
|
|
96
|
+
*
|
|
97
|
+
* ⚠️ MONOTONICITY IS STILL THE BOUNDARY, NOT THAT GUARD. It lives in the same
|
|
98
|
+
* process as the agent and covers only the verbs that consult it — `npm run`
|
|
99
|
+
* a script that writes the file and it never fires. The guard is the second
|
|
100
|
+
* lock, and this module is written as though it were not there at all.
|
|
101
|
+
*
|
|
102
|
+
* ⚠️ AND `policy.mjs`'s OWN HEADER IS NOW STALE ABOUT THIS: it says `.acuvo/` is
|
|
103
|
+
* not in `WRITE_FORBIDDEN_ROOTS` so `write_file(".acuvo/policy.json", "{}")`
|
|
104
|
+
* "succeeds today", and recommends adding it. `AGENT_CONFIG_DIR` closed that,
|
|
105
|
+
* separately. Reported, not edited — it is another lane's file.
|
|
106
|
+
*
|
|
107
|
+
* ⚠️ ONE HONEST COST OF THAT CHOICE, REPORTED NOT HIDDEN: `acuvo-dir.mjs` writes
|
|
108
|
+
* `.acuvo/.gitignore` containing `*`, so a workspace config committed there
|
|
109
|
+
* needs `git add -f`. The same is already true of `policy.json`, which
|
|
110
|
+
* `policy.mjs:64` calls "committable, reviewable in a PR". Both cannot be right.
|
|
111
|
+
* That is a real inconsistency in someone else's file and it is reported rather
|
|
112
|
+
* than patched here.
|
|
113
|
+
*
|
|
114
|
+
* ⭐ PURE. Parsing and merging never touch a disk; the two functions at the
|
|
115
|
+
* bottom that do take their reader as an argument, exactly as `policy.mjs` does.
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
import { MAX_ROUNDS_LIMIT, DEFAULT_MAX_ROUNDS } from './cli-args.mjs';
|
|
119
|
+
import { DEFAULT_MAX_TOKENS, DEFAULT_TIMEOUT_MS } from './model.mjs';
|
|
120
|
+
import { DEFAULT_COMMAND_TIMEOUT_MS, MAX_COMMAND_TIMEOUT_MS } from './command.mjs';
|
|
121
|
+
import { DEFAULT_BUDGET_USD, parseBudgetUsd, formatUsd } from './budget.mjs';
|
|
122
|
+
import { resolveModelName } from './acuvo-models.mjs';
|
|
123
|
+
import { TIERS } from './escalate.mjs';
|
|
124
|
+
/**
|
|
125
|
+
* ⭐ THE VALUE-TAKING FLAGS COME FROM THE COMPLETION TABLE, not a second list.
|
|
126
|
+
* `explicitKeysFromArgv` has to skip a flag's VALUE while scanning (`--model
|
|
127
|
+
* --json` is a model id, not two flags), and a private copy of "which flags take
|
|
128
|
+
* values" is precisely the shape of the invented-constant bug this repo has paid
|
|
129
|
+
* for twice. One table, two consumers, and the completion drift test already
|
|
130
|
+
* pins that table to the parser.
|
|
131
|
+
*/
|
|
132
|
+
import { FLAGS } from './completion.mjs';
|
|
133
|
+
|
|
134
|
+
/** Where a project states its convention. See the header on why `.acuvo/`. */
|
|
135
|
+
export const WORKSPACE_CONFIG_FILE = '.acuvo/config.json';
|
|
136
|
+
/** Where a person states their own preferences, outside the agent's reach. */
|
|
137
|
+
export const HOME_CONFIG_FILE = 'config.json';
|
|
138
|
+
/** Relocates the home file wholesale, for CI and for testing. */
|
|
139
|
+
export const CONFIG_FILE_ENV = 'ACUVO_CONFIG_FILE';
|
|
140
|
+
/** The same variable `account.mjs` uses to move the whole `~/.acuvo` directory. */
|
|
141
|
+
export const ACUVO_HOME_ENV = 'ACUVO_HOME';
|
|
142
|
+
|
|
143
|
+
// ── the key table ──────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `trust`:
|
|
147
|
+
* 'any' — an untrusted layer may state it, but only to NARROW.
|
|
148
|
+
* 'home' — only a trusted layer may state it (see R1/R2 in the header).
|
|
149
|
+
* 'never' — no file and no variable may state it, ever.
|
|
150
|
+
*
|
|
151
|
+
* `reduce` (only meaningful for `trust: 'any'`):
|
|
152
|
+
* 'min' smaller wins; `null` means "no ceiling" and loses to every number.
|
|
153
|
+
* 'and' `false` wins.
|
|
154
|
+
* 'or' `true` wins.
|
|
155
|
+
* 'ladder' the earlier entry in `TIERS` wins.
|
|
156
|
+
*/
|
|
157
|
+
const key = (spec) => Object.freeze(spec);
|
|
158
|
+
|
|
159
|
+
export const CONFIG_KEYS = Object.freeze({
|
|
160
|
+
/** ⚠️ home-only: model ids have no stricter direction. See header §2. */
|
|
161
|
+
model: key({ option: 'model', kind: 'model', trust: 'home', env: ['ACUVO_MODEL', 'OPENROUTER_CODEGEN_MODEL'] }),
|
|
162
|
+
|
|
163
|
+
maxRounds: key({ option: 'maxRounds', kind: 'int', min: 1, max: MAX_ROUNDS_LIMIT, trust: 'any', reduce: 'min', env: ['ACUVO_MAX_ROUNDS'] }),
|
|
164
|
+
budget: key({ option: 'budgetUsd', kind: 'money', trust: 'any', reduce: 'min', env: ['ACUVO_BUDGET'] }),
|
|
165
|
+
maxTokens: key({ option: 'maxTokens', kind: 'int', min: 256, max: 64_000, trust: 'any', reduce: 'min', env: ['ACUVO_MAX_TOKENS'] }),
|
|
166
|
+
timeout: key({ option: 'timeoutMs', kind: 'seconds', min: 5, max: 900, trust: 'any', reduce: 'min', env: ['ACUVO_TIMEOUT'] }),
|
|
167
|
+
commandTimeout: key({ option: 'commandTimeoutMs', kind: 'seconds', min: 1, max: MAX_COMMAND_TIMEOUT_MS / 1000, trust: 'any', reduce: 'min', env: ['ACUVO_COMMAND_TIMEOUT'] }),
|
|
168
|
+
concurrency: key({ option: 'concurrency', kind: 'int', min: 1, max: 4, trust: 'any', reduce: 'min', env: ['ACUVO_CONCURRENCY'] }),
|
|
169
|
+
maxTier: key({ option: 'maxTier', kind: 'enum', choices: TIERS, trust: 'any', reduce: 'ladder', env: ['ACUVO_MAX_TIER'] }),
|
|
170
|
+
/** `false` is the strict side, and it is what `--no-run` sets. */
|
|
171
|
+
allowRun: key({ option: 'allowRun', kind: 'bool', trust: 'any', reduce: 'and', env: ['ACUVO_ALLOW_RUN'] }),
|
|
172
|
+
/** `true` is the strict side: nothing reaches disk. */
|
|
173
|
+
dryRun: key({ option: 'dryRun', kind: 'bool', trust: 'any', reduce: 'or', env: ['ACUVO_DRY_RUN'] }),
|
|
174
|
+
|
|
175
|
+
/** ⚠️ R1 — n paid runs instead of one. A repository does not spend your money. */
|
|
176
|
+
bestOf: key({ option: 'bestOf', kind: 'int', min: 2, max: 5, trust: 'home', env: ['ACUVO_BEST_OF'] }),
|
|
177
|
+
/** ⚠️ R1 — a second full run, every time. Same argument as `bestOf`. */
|
|
178
|
+
refute: key({ option: 'refute', kind: 'bool', trust: 'home', env: ['ACUVO_REFUTE'] }),
|
|
179
|
+
/** ⚠️ R2 — turning this off deletes `acuvo rewind`'s ability to undo. */
|
|
180
|
+
checkpoint: key({ option: 'checkpoint', kind: 'bool', trust: 'home', env: ['ACUVO_CHECKPOINT'] }),
|
|
181
|
+
/** ⚠️ R2 — turning this off removes the refusal that stops two terminals
|
|
182
|
+
* overwriting the same file. */
|
|
183
|
+
autoLease: key({ option: 'autoLease', kind: 'bool', trust: 'home', env: ['ACUVO_AUTO_LEASE'] }),
|
|
184
|
+
/** ⚠️ §2 — changes the shape of stdout, which is what a script is parsing. */
|
|
185
|
+
json: key({ option: 'json', kind: 'bool', trust: 'home', env: ['ACUVO_JSON'] }),
|
|
186
|
+
/** ⚠️ §2 — changes the exit code a cron job reads. Not a repo's call. */
|
|
187
|
+
unattended: key({ option: 'unattended', kind: 'bool', trust: 'home', env: ['ACUVO_UNATTENDED'] }),
|
|
188
|
+
/** ⚠️ §2 — lease attribution. Two terminals sharing a holder can take each
|
|
189
|
+
* other's leases, which is the exact collision leases exist to prevent. */
|
|
190
|
+
holder: key({ option: 'holder', kind: 'string', trust: 'home', env: ['ACUVO_HOLDER'] }),
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* ── ⚠️⚠️ `shell` IS A KNOWN KEY THAT NOTHING MAY SET, AND THAT IS THE POINT ─
|
|
194
|
+
*
|
|
195
|
+
* `--shell` is the one flag that REMOVES a safety property rather than adding
|
|
196
|
+
* one (`cli-args.mjs:551`): it lets the agent run any program on the machine
|
|
197
|
+
* at the user's privileges. A config file that could turn it on would make
|
|
198
|
+
* `acuvo "…"` mean something different depending on a file, and — because
|
|
199
|
+
* `.env` reaches the environment — a cloned repository could mean it.
|
|
200
|
+
*
|
|
201
|
+
* ⭐ It is listed rather than simply unknown so the refusal can NAME THE WAY
|
|
202
|
+
* OUT. "unknown setting shell" teaches nothing; the message below teaches the
|
|
203
|
+
* alias, which keeps the decision on the command line where the audit log
|
|
204
|
+
* records it per run.
|
|
205
|
+
*/
|
|
206
|
+
shell: key({ option: 'shell', kind: 'bool', trust: 'never', env: [] }),
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* ⭐ THE ESCAPE HATCH, and the direct sibling of policy.mjs's
|
|
210
|
+
* `allowWorkspacePolicy`. Because a workspace file that oversteps is a hard
|
|
211
|
+
* error, a hostile — or merely wrong — repository could otherwise stop every
|
|
212
|
+
* run in that checkout. This turns the workspace layer off entirely. Home
|
|
213
|
+
* only, and it is not a lattice value: it decides which layers are READ.
|
|
214
|
+
*/
|
|
215
|
+
workspaceConfig: key({ option: null, kind: 'bool', trust: 'home', env: [] }),
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
export const KEY_NAMES = Object.freeze(Object.keys(CONFIG_KEYS));
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Flag → config key, for working out what the user typed. Only flags that set
|
|
222
|
+
* something a config file can also set appear here.
|
|
223
|
+
*
|
|
224
|
+
* ⚠️ `--no-run`, `--no-checkpoint` and `--no-auto-lease` are the NEGATIVE
|
|
225
|
+
* spellings of positive keys. What matters is only that the key was DECIDED on
|
|
226
|
+
* the command line, so the value is irrelevant here — this map answers "did the
|
|
227
|
+
* user speak about this", not "what did they say".
|
|
228
|
+
*/
|
|
229
|
+
export const FLAG_KEYS = Object.freeze({
|
|
230
|
+
'--model': 'model',
|
|
231
|
+
'--max-rounds': 'maxRounds',
|
|
232
|
+
'--budget': 'budget',
|
|
233
|
+
'--max-tokens': 'maxTokens',
|
|
234
|
+
'--timeout': 'timeout',
|
|
235
|
+
'--command-timeout': 'commandTimeout',
|
|
236
|
+
'--concurrency': 'concurrency',
|
|
237
|
+
'--max-tier': 'maxTier',
|
|
238
|
+
'--best-of': 'bestOf',
|
|
239
|
+
'--no-run': 'allowRun',
|
|
240
|
+
'--dry-run': 'dryRun',
|
|
241
|
+
'--refute': 'refute',
|
|
242
|
+
'--json': 'json',
|
|
243
|
+
'--no-checkpoint': 'checkpoint',
|
|
244
|
+
'--no-auto-lease': 'autoLease',
|
|
245
|
+
'--unattended': 'unattended',
|
|
246
|
+
'--holder': 'holder',
|
|
247
|
+
'--shell': 'shell',
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/** Every flag that consumes the following argv entry — see the import note. */
|
|
251
|
+
const VALUE_FLAGS = new Set(FLAGS.filter((f) => f.value).map((f) => f.name));
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Which config keys the user decided on the command line.
|
|
255
|
+
*
|
|
256
|
+
* ⚠️ SCANS `argv`, NOT THE PARSED OPTIONS, and it has to. `parseArgv` returns
|
|
257
|
+
* defaults for everything, so a parsed `maxRounds: 5` is indistinguishable from
|
|
258
|
+
* a typed `--max-rounds 5` — and a config layer that cannot tell those apart
|
|
259
|
+
* would silently override a number the user typed, which is the one thing
|
|
260
|
+
* precedence exists to prevent.
|
|
261
|
+
*
|
|
262
|
+
* ⚠️ THE VALUE OF A VALUE-TAKING FLAG IS SKIPPED. `--holder --json` records a
|
|
263
|
+
* holder called "--json"; without the skip we would also record that the user
|
|
264
|
+
* asked for JSON output.
|
|
265
|
+
*
|
|
266
|
+
* ⚠️ `--flag=value` IS NOT SUPPORTED, because `parseArgv` does not support it
|
|
267
|
+
* either (it answers "Unknown option --model=x"). Recognising a spelling the
|
|
268
|
+
* parser refuses would make this the only layer that understood it.
|
|
269
|
+
*/
|
|
270
|
+
export function explicitKeysFromArgv(argv = []) {
|
|
271
|
+
const out = new Set();
|
|
272
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
273
|
+
const arg = String(argv[i] ?? '');
|
|
274
|
+
if (FLAG_KEYS[arg]) out.add(FLAG_KEYS[arg]);
|
|
275
|
+
if (VALUE_FLAGS.has(arg)) i += 1;
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── reading one value ──────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
const ENV_TRUE = new Set(['1', 'true', 'yes', 'on']);
|
|
283
|
+
const ENV_FALSE = new Set(['0', 'false', 'no', 'off']);
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* @param {object} spec
|
|
287
|
+
* @param {unknown} raw
|
|
288
|
+
* @param {{ source: 'file'|'env', label: string, name: string }} ctx
|
|
289
|
+
* @returns {{ ok: true, value: unknown } | { ok: false, error: string }}
|
|
290
|
+
*/
|
|
291
|
+
function readValue(spec, raw, { source, label, name }) {
|
|
292
|
+
const where = `${label}: "${name}"`;
|
|
293
|
+
|
|
294
|
+
if (spec.kind === 'bool') {
|
|
295
|
+
if (source === 'file') {
|
|
296
|
+
/**
|
|
297
|
+
* ⚠️ NOT TRUTHINESS — the same refusal `policy.mjs:237` makes. The string
|
|
298
|
+
* `"false"` is truthy, and for `checkpoint`, `allowRun` and `dryRun` the
|
|
299
|
+
* wrong reading is the unsafe one in every case.
|
|
300
|
+
*/
|
|
301
|
+
if (typeof raw !== 'boolean') return { ok: false, error: `${where} must be true or false (got ${JSON.stringify(raw)})` };
|
|
302
|
+
return { ok: true, value: raw };
|
|
303
|
+
}
|
|
304
|
+
const s = String(raw ?? '').trim().toLowerCase();
|
|
305
|
+
if (ENV_TRUE.has(s)) return { ok: true, value: true };
|
|
306
|
+
if (ENV_FALSE.has(s)) return { ok: true, value: false };
|
|
307
|
+
return { ok: false, error: `${where} must be one of ${[...ENV_TRUE].join('/')} or ${[...ENV_FALSE].join('/')} (got ${JSON.stringify(String(raw))})` };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (spec.kind === 'string') {
|
|
311
|
+
const s = typeof raw === 'string' ? raw.trim() : '';
|
|
312
|
+
if (!s) return { ok: false, error: `${where} must be a non-empty string (got ${JSON.stringify(raw)})` };
|
|
313
|
+
return { ok: true, value: s };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (spec.kind === 'model') {
|
|
317
|
+
const picked = resolveModelName(typeof raw === 'string' ? raw : '');
|
|
318
|
+
/**
|
|
319
|
+
* ⭐ RESOLVED HERE FOR THE SAME REASON `cli-args.mjs:598` resolves it at
|
|
320
|
+
* parse time: `acuvo-pro` must mean the same thing in a config file as on
|
|
321
|
+
* the command line, and a typo must cost a message rather than a round trip
|
|
322
|
+
* to a provider that answers "no endpoints found".
|
|
323
|
+
*/
|
|
324
|
+
if (!picked.ok) return { ok: false, error: `${where} — ${picked.error}` };
|
|
325
|
+
return { ok: true, value: picked.id };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (spec.kind === 'enum') {
|
|
329
|
+
const s = typeof raw === 'string' ? raw.trim() : '';
|
|
330
|
+
if (!spec.choices.includes(s)) {
|
|
331
|
+
return { ok: false, error: `${where} must be one of ${spec.choices.join(', ')} (got ${JSON.stringify(raw)})` };
|
|
332
|
+
}
|
|
333
|
+
return { ok: true, value: s };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (spec.kind === 'money') {
|
|
337
|
+
const s = typeof raw === 'number' ? String(raw) : String(raw ?? '').trim();
|
|
338
|
+
if (/^(none|off|unlimited)$/i.test(s)) return { ok: true, value: null };
|
|
339
|
+
/**
|
|
340
|
+
* ⚠️ PARSED BY `budget.mjs`, and its refusal is returned verbatim — the same
|
|
341
|
+
* discipline `cli-args.mjs:620` records. `25c` and `$2` must mean here what
|
|
342
|
+
* they mean on the flag, and one mistake must not get two wordings.
|
|
343
|
+
*/
|
|
344
|
+
const parsed = parseBudgetUsd(s);
|
|
345
|
+
if (!parsed.ok) return { ok: false, error: `${where} — ${parsed.message}` };
|
|
346
|
+
return { ok: true, value: parsed.usd };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// int and seconds
|
|
350
|
+
const n = Number(typeof raw === 'string' ? raw.trim() : raw);
|
|
351
|
+
if (!Number.isInteger(n)) {
|
|
352
|
+
return { ok: false, error: `${where} must be a whole number between ${spec.min} and ${spec.max} (got ${JSON.stringify(raw)})` };
|
|
353
|
+
}
|
|
354
|
+
if (n < spec.min || n > spec.max) {
|
|
355
|
+
return { ok: false, error: `${where} must be between ${spec.min} and ${spec.max} (got ${n})` };
|
|
356
|
+
}
|
|
357
|
+
return { ok: true, value: spec.kind === 'seconds' ? n * 1000 : n };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── the lattice, for the keys an untrusted layer may state ─────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* ⭐⭐ THE MEET, per key. Same shape as `policy.mjs:mergePolicies` and for the
|
|
364
|
+
* same reason: whatever an untrusted layer writes, the result can never permit
|
|
365
|
+
* more, or cost more, than the value it is overriding.
|
|
366
|
+
*
|
|
367
|
+
* @returns {{ value: unknown, clamped: boolean }}
|
|
368
|
+
*/
|
|
369
|
+
export function narrow(spec, current, proposed) {
|
|
370
|
+
let value = proposed;
|
|
371
|
+
if (spec.reduce === 'min') {
|
|
372
|
+
// `null` is "no ceiling", i.e. +∞, so it loses to every number.
|
|
373
|
+
if (current === null || current === undefined) value = proposed;
|
|
374
|
+
else if (proposed === null || proposed === undefined) value = current;
|
|
375
|
+
else value = Math.min(current, proposed);
|
|
376
|
+
} else if (spec.reduce === 'and') {
|
|
377
|
+
value = Boolean(current) && Boolean(proposed);
|
|
378
|
+
} else if (spec.reduce === 'or') {
|
|
379
|
+
value = Boolean(current) || Boolean(proposed);
|
|
380
|
+
} else if (spec.reduce === 'ladder') {
|
|
381
|
+
const a = spec.choices.indexOf(current);
|
|
382
|
+
const b = spec.choices.indexOf(proposed);
|
|
383
|
+
value = spec.choices[Math.min(a === -1 ? b : a, b === -1 ? a : b)];
|
|
384
|
+
} else {
|
|
385
|
+
/**
|
|
386
|
+
* ⚠️ FAIL CLOSED ON A KEY WITH NO REDUCER. A `default: return proposed` here
|
|
387
|
+
* would mean that adding a `trust: 'any'` key and forgetting its `reduce`
|
|
388
|
+
* silently hands an untrusted layer a free widening — a hole opened by an
|
|
389
|
+
* omission rather than a decision, which is the kind nobody reviews.
|
|
390
|
+
*/
|
|
391
|
+
value = current;
|
|
392
|
+
}
|
|
393
|
+
return { value, clamped: value !== proposed };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// ── parsing one document ───────────────────────────────────────────────────
|
|
397
|
+
|
|
398
|
+
const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Parse ONE config file's text into a partial config.
|
|
402
|
+
*
|
|
403
|
+
* Returns only the keys the document actually stated, so merging can tell
|
|
404
|
+
* "said nothing" from "said the permissive thing" — the distinction
|
|
405
|
+
* `policy.mjs:246` also depends on.
|
|
406
|
+
*
|
|
407
|
+
* @param {string} text
|
|
408
|
+
* @param {{ label?: string, trusted?: boolean }} [opts]
|
|
409
|
+
*/
|
|
410
|
+
export function parseConfigDocument(text, { label = 'config', trusted = false } = {}) {
|
|
411
|
+
if (typeof text !== 'string') return { ok: false, error: `${label}: expected the file's text` };
|
|
412
|
+
const trimmed = text.trim();
|
|
413
|
+
if (!trimmed) return { ok: false, error: `${label}: the file is empty. Write {} for a config that changes nothing.` };
|
|
414
|
+
|
|
415
|
+
let doc;
|
|
416
|
+
try {
|
|
417
|
+
doc = JSON.parse(trimmed);
|
|
418
|
+
} catch (err) {
|
|
419
|
+
return { ok: false, error: `${label}: not valid JSON — ${err instanceof Error ? err.message : String(err)}` };
|
|
420
|
+
}
|
|
421
|
+
if (!isPlainObject(doc)) return { ok: false, error: `${label}: the top level must be a JSON object` };
|
|
422
|
+
|
|
423
|
+
const out = {};
|
|
424
|
+
for (const name of Object.keys(doc)) {
|
|
425
|
+
const spec = CONFIG_KEYS[name];
|
|
426
|
+
/**
|
|
427
|
+
* ⚠️ AN UNKNOWN KEY STOPS THE RUN, for policy.mjs's reason: `{"maxRoundz":
|
|
428
|
+
* 2}` is valid JSON, sets nothing, and reads to a human as a cap. A typo
|
|
429
|
+
* that silently means "the default" is the accident this file exists to
|
|
430
|
+
* prevent.
|
|
431
|
+
*/
|
|
432
|
+
if (!spec) {
|
|
433
|
+
return { ok: false, error: `${label}: unknown setting "${name}". A misspelled preference sets nothing and reads like a preference. Known: ${KEY_NAMES.join(', ')}` };
|
|
434
|
+
}
|
|
435
|
+
if (spec.trust === 'never') {
|
|
436
|
+
return {
|
|
437
|
+
ok: false,
|
|
438
|
+
error: `${label}: "${name}" cannot be set in a config file at all. `
|
|
439
|
+
+ '--shell lets the agent run any program on this machine at your privileges, so it stays on the command line where the audit log records which runs had it. '
|
|
440
|
+
+ "If you want it every time, alias it: alias acuvo='acuvo --shell'",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (spec.trust === 'home' && !trusted) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
error: `${label}: "${name}" may only be set in your own config (${CONFIG_FILE_ENV}, or ~/.acuvo/${HOME_CONFIG_FILE}), not in a repository. `
|
|
447
|
+
+ reasonForHomeOnly(name)
|
|
448
|
+
+ ` If this checkout is not yours to change, switch the workspace layer off with {"workspaceConfig": false} in ~/.acuvo/${HOME_CONFIG_FILE}.`,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
const read = readValue(spec, doc[name], { source: 'file', label, name });
|
|
452
|
+
if (!read.ok) return read;
|
|
453
|
+
out[name] = read.value;
|
|
454
|
+
}
|
|
455
|
+
return { ok: true, config: out };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** One sentence per home-only key, so the refusal teaches rather than blocks. */
|
|
459
|
+
function reasonForHomeOnly(name) {
|
|
460
|
+
switch (name) {
|
|
461
|
+
case 'model': return 'A model id has no stricter direction, so "may only narrow" cannot apply to it — use policy.json\'s allowModels to limit which models this repo may use.';
|
|
462
|
+
case 'bestOf': return 'It multiplies the number of paid runs.';
|
|
463
|
+
case 'refute': return 'It adds a second paid run to every task.';
|
|
464
|
+
case 'checkpoint': return 'Turning it off removes your ability to `acuvo rewind` this run.';
|
|
465
|
+
case 'autoLease': return 'Turning it off removes the refusal that stops two terminals overwriting the same file.';
|
|
466
|
+
case 'json': return 'It changes the shape of stdout, which is what your scripts parse.';
|
|
467
|
+
case 'unattended': return 'It changes the exit code your scheduler reads.';
|
|
468
|
+
case 'holder': return 'It is who leases are attributed to, and two terminals sharing a holder can take each other\'s leases.';
|
|
469
|
+
case 'workspaceConfig': return 'It decides which layers are read at all, so a repository cannot be the one to answer it.';
|
|
470
|
+
default: return '';
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── resolving all the layers ───────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
/** The value each key has when nobody has said anything. */
|
|
477
|
+
export function defaultConfig() {
|
|
478
|
+
return {
|
|
479
|
+
/**
|
|
480
|
+
* ⚠️⚠️ `null`, NOT `DEFAULT_MODEL`, AND THE SUITE CAUGHT ME PUTTING THE
|
|
481
|
+
* CONSTANT HERE. `parseArgv` produces `model: null` meaning *"nobody chose;
|
|
482
|
+
* resolve it later"* — the chain then reads `$OPENROUTER_CODEGEN_MODEL` and
|
|
483
|
+
* falls back to `DEFAULT_MODEL` itself (`model.mjs:41`). Seeding this layer
|
|
484
|
+
* with the constant makes the config layer look like it CHOSE the default,
|
|
485
|
+
* and a layer that claims to have chosen is a layer that can override
|
|
486
|
+
* something downstream that had a better answer.
|
|
487
|
+
*
|
|
488
|
+
* ⭐ The rule this is an instance of: a default in a merging layer must be
|
|
489
|
+
* the same VALUE the layer it merges into produces, not the same MEANING.
|
|
490
|
+
*/
|
|
491
|
+
model: null,
|
|
492
|
+
maxRounds: DEFAULT_MAX_ROUNDS,
|
|
493
|
+
budget: DEFAULT_BUDGET_USD,
|
|
494
|
+
maxTokens: DEFAULT_MAX_TOKENS,
|
|
495
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
496
|
+
commandTimeout: DEFAULT_COMMAND_TIMEOUT_MS,
|
|
497
|
+
concurrency: 2,
|
|
498
|
+
maxTier: 'best-of',
|
|
499
|
+
allowRun: true,
|
|
500
|
+
dryRun: false,
|
|
501
|
+
bestOf: 0,
|
|
502
|
+
refute: false,
|
|
503
|
+
checkpoint: true,
|
|
504
|
+
autoLease: true,
|
|
505
|
+
json: false,
|
|
506
|
+
unattended: false,
|
|
507
|
+
holder: null,
|
|
508
|
+
shell: false,
|
|
509
|
+
workspaceConfig: true,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Read the env layer into a partial config, tagging each key with whether the
|
|
515
|
+
* variable that supplied it is trusted.
|
|
516
|
+
*
|
|
517
|
+
* @param {object} env
|
|
518
|
+
* @param {ReadonlySet<string>|readonly string[]} untrustedEnvNames
|
|
519
|
+
*/
|
|
520
|
+
function readEnvLayer(env, untrustedEnvNames, notes) {
|
|
521
|
+
const untrusted = untrustedEnvNames instanceof Set ? untrustedEnvNames : new Set(untrustedEnvNames ?? []);
|
|
522
|
+
const stated = [];
|
|
523
|
+
for (const [name, spec] of Object.entries(CONFIG_KEYS)) {
|
|
524
|
+
for (const varName of spec.env ?? []) {
|
|
525
|
+
const raw = env?.[varName];
|
|
526
|
+
if (raw === undefined || String(raw).trim() === '') continue;
|
|
527
|
+
const trusted = !untrusted.has(varName);
|
|
528
|
+
const read = readValue(spec, raw, { source: 'env', label: varName, name });
|
|
529
|
+
if (!read.ok) {
|
|
530
|
+
/**
|
|
531
|
+
* ⚠️ AN UNPARSEABLE ENVIRONMENT VARIABLE IS A NOTE, NOT A STOPPED RUN —
|
|
532
|
+
* unlike a config file. A file is a thing someone wrote for this tool; a
|
|
533
|
+
* variable may have been set by a parent process, a CI runner, or a
|
|
534
|
+
* `.env` we do not own, and refusing to start because a stranger's
|
|
535
|
+
* variable is malformed fails correct work.
|
|
536
|
+
*/
|
|
537
|
+
notes.push(`ignored ${varName}: ${read.error}`);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
stated.push({ name, value: read.value, trusted, label: varName });
|
|
541
|
+
break; // first variable in the list wins — see CONFIG_KEYS.model
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return stated;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Build the effective configuration.
|
|
549
|
+
*
|
|
550
|
+
* PURE — it takes TEXT, not paths, so the whole decision surface is testable
|
|
551
|
+
* with no disk. `readConfigSources` below turns paths into text.
|
|
552
|
+
*
|
|
553
|
+
* ── PRECEDENCE: flag > env > home > workspace > default ────────────────────
|
|
554
|
+
* Applied lowest first. A TRUSTED layer installs its value outright. An
|
|
555
|
+
* UNTRUSTED layer installs `narrow(current, proposed)` — so it can move a value
|
|
556
|
+
* only toward less permission and less spend, whatever it writes.
|
|
557
|
+
*
|
|
558
|
+
* @param {{
|
|
559
|
+
* argv?: readonly string[],
|
|
560
|
+
* explicitKeys?: ReadonlySet<string>,
|
|
561
|
+
* env?: object,
|
|
562
|
+
* untrustedEnvNames?: ReadonlySet<string>|readonly string[],
|
|
563
|
+
* homeText?: string|null, homeLabel?: string,
|
|
564
|
+
* workspaceText?: string|null, workspaceLabel?: string,
|
|
565
|
+
* }} input
|
|
566
|
+
*/
|
|
567
|
+
export function resolveConfig({
|
|
568
|
+
argv = null,
|
|
569
|
+
explicitKeys = null,
|
|
570
|
+
env = {},
|
|
571
|
+
untrustedEnvNames = [],
|
|
572
|
+
homeText = null,
|
|
573
|
+
homeLabel = `~/.acuvo/${HOME_CONFIG_FILE}`,
|
|
574
|
+
workspaceText = null,
|
|
575
|
+
workspaceLabel = WORKSPACE_CONFIG_FILE,
|
|
576
|
+
} = {}) {
|
|
577
|
+
const typed = explicitKeys ?? explicitKeysFromArgv(argv ?? []);
|
|
578
|
+
const values = defaultConfig();
|
|
579
|
+
const notes = [];
|
|
580
|
+
const sources = [];
|
|
581
|
+
/** Which keys were last set by a layer we trust — see `budgetExplicit`. */
|
|
582
|
+
const trustedlySet = new Set();
|
|
583
|
+
/**
|
|
584
|
+
* ⚠️⚠️ WHICH KEYS A LAYER ACTUALLY SPOKE ABOUT — *not* which ones differ from
|
|
585
|
+
* the default, and the suite caught the difference. `ACUVO_MODEL=acuvo-flash`
|
|
586
|
+
* resolves to exactly `DEFAULT_MODEL`, so a "did it change?" test dropped a
|
|
587
|
+
* setting the user had explicitly made. It happened to be harmless for that
|
|
588
|
+
* one key and would not have been for the next one; "equals the default" and
|
|
589
|
+
* "nobody said anything" are different facts and must be stored separately.
|
|
590
|
+
*/
|
|
591
|
+
const statedKeys = new Set();
|
|
592
|
+
|
|
593
|
+
let home = {};
|
|
594
|
+
if (homeText !== null && homeText !== undefined) {
|
|
595
|
+
const parsed = parseConfigDocument(homeText, { label: homeLabel, trusted: true });
|
|
596
|
+
if (!parsed.ok) return parsed;
|
|
597
|
+
home = parsed.config;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// ── workspace (untrusted, lowest precedence above the defaults) ──────────
|
|
601
|
+
if (workspaceText !== null && workspaceText !== undefined) {
|
|
602
|
+
if (home.workspaceConfig === false) {
|
|
603
|
+
sources.push({ label: workspaceLabel, trusted: false, ignored: true });
|
|
604
|
+
notes.push(`${workspaceLabel} ignored — your config sets {"workspaceConfig": false}`);
|
|
605
|
+
} else {
|
|
606
|
+
const parsed = parseConfigDocument(workspaceText, { label: workspaceLabel, trusted: false });
|
|
607
|
+
if (!parsed.ok) return parsed;
|
|
608
|
+
for (const [name, proposed] of Object.entries(parsed.config)) {
|
|
609
|
+
const spec = CONFIG_KEYS[name];
|
|
610
|
+
const { value, clamped } = narrow(spec, values[name], proposed);
|
|
611
|
+
if (clamped) {
|
|
612
|
+
notes.push(`${workspaceLabel}: ${name} ${describeValue(name, proposed)} would loosen the current ${describeValue(name, values[name])}, so it was ignored — a repository may only tighten.`);
|
|
613
|
+
}
|
|
614
|
+
values[name] = value;
|
|
615
|
+
statedKeys.add(name);
|
|
616
|
+
}
|
|
617
|
+
sources.push({ label: workspaceLabel, trusted: false });
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── home (trusted) ──────────────────────────────────────────────────────
|
|
622
|
+
if (homeText !== null && homeText !== undefined) {
|
|
623
|
+
for (const [name, value] of Object.entries(home)) {
|
|
624
|
+
values[name] = value;
|
|
625
|
+
trustedlySet.add(name);
|
|
626
|
+
statedKeys.add(name);
|
|
627
|
+
}
|
|
628
|
+
sources.push({ label: homeLabel, trusted: true });
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── environment (mixed trust, higher precedence than home) ──────────────
|
|
632
|
+
for (const stated of readEnvLayer(env, untrustedEnvNames, notes)) {
|
|
633
|
+
const spec = CONFIG_KEYS[stated.name];
|
|
634
|
+
if (stated.trusted) {
|
|
635
|
+
values[stated.name] = stated.value;
|
|
636
|
+
trustedlySet.add(stated.name);
|
|
637
|
+
statedKeys.add(stated.name);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (spec.trust !== 'any') {
|
|
641
|
+
notes.push(`${stated.label} ignored: it came from a .env file inside this workspace, and ${stated.name} is not a repository's to set.`);
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
const { value, clamped } = narrow(spec, values[stated.name], stated.value);
|
|
645
|
+
if (clamped) notes.push(`${stated.label} came from a .env file inside this workspace, so it may only tighten — kept ${describeValue(stated.name, values[stated.name])}.`);
|
|
646
|
+
statedKeys.add(stated.name);
|
|
647
|
+
/**
|
|
648
|
+
* ⭐ `trustedlySet` IS NOT CLEARED HERE, deliberately. An untrusted layer can
|
|
649
|
+
* only ever TIGHTEN, so a ceiling the human chose and a repository then
|
|
650
|
+
* lowered is still a ceiling the human chose — `--until-done` should keep
|
|
651
|
+
* working. What must never happen is the opposite: an untrusted layer
|
|
652
|
+
* INTRODUCING a budget and thereby unlocking the unbounded mode, and that
|
|
653
|
+
* cannot happen because it never gets into `trustedlySet` in the first place.
|
|
654
|
+
*/
|
|
655
|
+
values[stated.name] = value;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* ⭐ WHAT THE CALLER GETS BACK CANNOT CONTAIN A KEY THE USER TYPED. The
|
|
660
|
+
* wiring is then `Object.assign(options, resolved.values)` and it is
|
|
661
|
+
* impossible to get the precedence wrong at the call site — which is where
|
|
662
|
+
* every layered-config bug actually lives.
|
|
663
|
+
*/
|
|
664
|
+
const out = {};
|
|
665
|
+
for (const [name, spec] of Object.entries(CONFIG_KEYS)) {
|
|
666
|
+
if (!spec.option) continue;
|
|
667
|
+
if (typed.has(name)) continue;
|
|
668
|
+
if (!statedKeys.has(name)) continue;
|
|
669
|
+
out[spec.option] = values[name];
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* ⚠️⚠️ `budgetExplicit` IS A SECURITY FIELD, NOT BOOKKEEPING. `--until-done`
|
|
674
|
+
* refuses to run without it (`cli-args.mjs:847`), because an unbounded loop
|
|
675
|
+
* against a paid API must have a ceiling a HUMAN chose. A budget that arrived
|
|
676
|
+
* from a workspace file therefore must NOT set it — otherwise a cloned
|
|
677
|
+
* repository could unlock the unbounded mode by writing one number.
|
|
678
|
+
*/
|
|
679
|
+
if (!typed.has('budget') && trustedlySet.has('budget')) out.budgetExplicit = true;
|
|
680
|
+
|
|
681
|
+
return { ok: true, values, options: out, notes, sources, typed: [...typed].sort() };
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** Render a value the way its flag would be typed, for the notes. */
|
|
685
|
+
function describeValue(name, value) {
|
|
686
|
+
if (value === null || value === undefined) return 'none';
|
|
687
|
+
if (name === 'budget') return formatUsd(value);
|
|
688
|
+
if (name === 'timeout' || name === 'commandTimeout') return `${value / 1000}s`;
|
|
689
|
+
return String(value);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Merge a resolved config into parsed CLI options. Never mutates its input, and
|
|
694
|
+
* never touches a key the user typed — `resolveConfig` already removed those.
|
|
695
|
+
*/
|
|
696
|
+
export function applyConfig(options, resolved) {
|
|
697
|
+
if (!resolved?.ok) return options;
|
|
698
|
+
return { ...options, ...resolved.options };
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* The lines to print when a config is in force.
|
|
703
|
+
*
|
|
704
|
+
* ⭐ Same argument as `describePolicy`: a setting nobody can see is
|
|
705
|
+
* indistinguishable from a broken tool. Returns `[]` when nothing was
|
|
706
|
+
* configured, so the ninety-nine users with no config file never see a line
|
|
707
|
+
* about a feature they are not using.
|
|
708
|
+
*/
|
|
709
|
+
export function describeConfig(resolved) {
|
|
710
|
+
if (!resolved?.ok) return [];
|
|
711
|
+
const changed = Object.entries(resolved.options).filter(([k]) => k !== 'budgetExplicit');
|
|
712
|
+
if (!changed.length && !resolved.notes.length) return [];
|
|
713
|
+
const where = (resolved.sources ?? []).map((s) => `${s.label}${s.ignored ? ' (ignored)' : ''}`).join(' + ') || 'environment';
|
|
714
|
+
return [
|
|
715
|
+
`config from ${where}:`,
|
|
716
|
+
...changed.map(([k, v]) => ` · ${k} = ${v === null ? 'none' : v}`),
|
|
717
|
+
...resolved.notes.map((n) => ` ⚠ ${n}`),
|
|
718
|
+
];
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ── the disk ───────────────────────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Turn a workspace root and an environment into the two texts `resolveConfig`
|
|
725
|
+
* wants. The only function here that reads a file, and it takes its reader as an
|
|
726
|
+
* argument so nothing above it needs one.
|
|
727
|
+
*
|
|
728
|
+
* ⚠️ ABSENT IS NOT MALFORMED — `policy.mjs:733`'s distinction, for the same
|
|
729
|
+
* reason. A missing file is the common case and means "nothing said here"; an
|
|
730
|
+
* unreadable-but-present file is a broken instruction and stops the run, because
|
|
731
|
+
* a permissions error quietly meaning "no config" is how a ceiling disappears.
|
|
732
|
+
*
|
|
733
|
+
* @param {string} root
|
|
734
|
+
* @param {{ env?: object, home?: string|null, readFileImpl?: (p:string)=>string, joinImpl?: (...p:string[])=>string }} deps
|
|
735
|
+
*/
|
|
736
|
+
export function readConfigSources(root, { env = {}, home = null, readFileImpl, joinImpl } = {}) {
|
|
737
|
+
const join = joinImpl ?? ((...p) => p.join('/').replace(/\/+/g, '/'));
|
|
738
|
+
const read = readFileImpl;
|
|
739
|
+
if (typeof read !== 'function') return { ok: false, error: 'readConfigSources needs a reader' };
|
|
740
|
+
|
|
741
|
+
const slurp = (abs) => {
|
|
742
|
+
try {
|
|
743
|
+
return { found: true, text: read(abs) };
|
|
744
|
+
} catch (err) {
|
|
745
|
+
const code = err && typeof err === 'object' ? err.code : null;
|
|
746
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') return { found: false, text: null };
|
|
747
|
+
return { found: true, text: null, error: `${abs}: ${err instanceof Error ? err.message : String(err)}` };
|
|
748
|
+
}
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
const override = String(env?.[CONFIG_FILE_ENV] ?? '').trim();
|
|
752
|
+
const acuvoHome = String(env?.[ACUVO_HOME_ENV] ?? '').trim();
|
|
753
|
+
const homeDir = acuvoHome || (home ? join(home, '.acuvo') : null);
|
|
754
|
+
const homePath = override || (homeDir ? join(homeDir, HOME_CONFIG_FILE) : null);
|
|
755
|
+
|
|
756
|
+
let homeText = null;
|
|
757
|
+
if (homePath) {
|
|
758
|
+
const r = slurp(homePath);
|
|
759
|
+
if (r.error) return { ok: false, error: `your config could not be read — ${r.error}` };
|
|
760
|
+
/**
|
|
761
|
+
* ⚠️ AN EXPLICIT `ACUVO_CONFIG_FILE` POINTING AT NOTHING IS AN ERROR, while
|
|
762
|
+
* an absent `~/.acuvo/config.json` is not — `policy.mjs:762`'s reasoning.
|
|
763
|
+
* Somebody set that variable deliberately; a typo in it silently meaning
|
|
764
|
+
* "no preferences" is the deployment accident worth refusing.
|
|
765
|
+
*/
|
|
766
|
+
if (!r.found && override) return { ok: false, error: `${CONFIG_FILE_ENV} points at ${override}, which does not exist` };
|
|
767
|
+
homeText = r.found ? r.text : null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const wsPath = join(String(root ?? '.'), WORKSPACE_CONFIG_FILE);
|
|
771
|
+
const w = slurp(wsPath);
|
|
772
|
+
if (w.error) return { ok: false, error: `workspace config could not be read — ${w.error}` };
|
|
773
|
+
|
|
774
|
+
return {
|
|
775
|
+
ok: true,
|
|
776
|
+
homeText,
|
|
777
|
+
homeLabel: homePath ?? `~/.acuvo/${HOME_CONFIG_FILE}`,
|
|
778
|
+
workspaceText: w.found ? w.text : null,
|
|
779
|
+
workspaceLabel: WORKSPACE_CONFIG_FILE,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* ── ⭐⭐ WHICH ENVIRONMENT VARIABLES CAME OUT OF THE REPOSITORY ──────────────
|
|
785
|
+
*
|
|
786
|
+
* The header's §3 in one function. `env-file.mjs` loads `.env.local` and `.env`
|
|
787
|
+
* into `process.env` and WALKS UP past the workspace root, so only the files at
|
|
788
|
+
* or under the root are the agent's to write — a `.env` two directories above a
|
|
789
|
+
* monorepo package is outside its reach and stays trusted.
|
|
790
|
+
*
|
|
791
|
+
* ⚠️ NAMES ONLY. We never read a value here: the point is "could this variable
|
|
792
|
+
* have been supplied by the repository", and reading secrets out of a `.env` to
|
|
793
|
+
* answer that would be a worse cure than the disease.
|
|
794
|
+
*
|
|
795
|
+
* ⚠️ AND IT NEVER THROWS. An unreadable `.env` yields an empty set and the run
|
|
796
|
+
* continues at full trust — the same trade `readAccount` makes. Refusing to
|
|
797
|
+
* start because a file we do not own is unreadable fails correct work.
|
|
798
|
+
*
|
|
799
|
+
* @param {string} root
|
|
800
|
+
* @param {{ candidatesImpl?: (from:string)=>string[], readImpl?: (p:string)=>string, resolveImpl?: (p:string)=>string }} deps
|
|
801
|
+
* @returns {Set<string>}
|
|
802
|
+
*/
|
|
803
|
+
export function readWorkspaceEnvNames(root, { candidatesImpl, readImpl, resolveImpl = (p) => p } = {}) {
|
|
804
|
+
const names = new Set();
|
|
805
|
+
if (typeof candidatesImpl !== 'function' || typeof readImpl !== 'function') return names;
|
|
806
|
+
|
|
807
|
+
let base;
|
|
808
|
+
try { base = resolveImpl(String(root ?? '.')); } catch { return names; }
|
|
809
|
+
|
|
810
|
+
let paths = [];
|
|
811
|
+
try { paths = candidatesImpl(String(root ?? '.')) ?? []; } catch { return names; }
|
|
812
|
+
|
|
813
|
+
for (const p of paths) {
|
|
814
|
+
let abs;
|
|
815
|
+
try { abs = resolveImpl(p); } catch { continue; }
|
|
816
|
+
// Only files INSIDE the workspace are the agent's to write.
|
|
817
|
+
if (!abs.startsWith(base)) continue;
|
|
818
|
+
let text;
|
|
819
|
+
try { text = readImpl(abs); } catch { continue; }
|
|
820
|
+
for (const m of String(text).matchAll(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/gm)) {
|
|
821
|
+
names.add(m[1]);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return names;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* ── ⭐⭐ APPLY A RESOLVED CONFIG ONTO PARSED OPTIONS ─────────────────────────
|
|
829
|
+
*
|
|
830
|
+
* ⚠️⚠️ A KEY THE USER TYPED IS NEVER OVERWRITTEN, and that rule lives HERE
|
|
831
|
+
* rather than in `resolveConfig`, because the resolver is told WHICH keys were
|
|
832
|
+
* explicit but never sees their VALUES — so its `values` still carry the file's
|
|
833
|
+
* number for a key the flag also set. Applying that blindly lets a config file
|
|
834
|
+
* silently beat a flag the person just typed, which is the one behaviour a
|
|
835
|
+
* config system must never have.
|
|
836
|
+
*
|
|
837
|
+
* ⭐ Extracted from `bin/acuvo.mjs` so the precedence rule is testable. Inline
|
|
838
|
+
* in the binary it was reachable only by running the whole CLI, which is how a
|
|
839
|
+
* rule this important ends up unverified.
|
|
840
|
+
*
|
|
841
|
+
* Mutates and returns `options` — the caller owns a freshly parsed object.
|
|
842
|
+
*/
|
|
843
|
+
export function applyConfigToOptions(options, values, typedKeys) {
|
|
844
|
+
if (!options || !values) return options;
|
|
845
|
+
const typed = typedKeys instanceof Set ? typedKeys : new Set(typedKeys ?? []);
|
|
846
|
+
for (const [key, spec] of Object.entries(CONFIG_KEYS)) {
|
|
847
|
+
if (typed.has(key)) continue;
|
|
848
|
+
const value = values[key];
|
|
849
|
+
if (value === undefined || value === null) continue;
|
|
850
|
+
options[spec.option] = value;
|
|
851
|
+
}
|
|
852
|
+
return options;
|
|
853
|
+
}
|