acuvo-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
package/lib/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
+ }