acuvo-code 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +328 -0
  2. package/ENTERPRISE.md +927 -0
  3. package/LICENSE +120 -0
  4. package/README.md +1245 -0
  5. package/ROADMAP.md +556 -0
  6. package/bin/acuvo-mcp.mjs +208 -0
  7. package/bin/acuvo.mjs +3108 -0
  8. package/lib/acceptance-consent.mjs +168 -0
  9. package/lib/acceptance.mjs +859 -0
  10. package/lib/account.mjs +226 -0
  11. package/lib/acuvo-dir.mjs +72 -0
  12. package/lib/acuvo-models.mjs +141 -0
  13. package/lib/apply-patch.mjs +570 -0
  14. package/lib/ask-user.mjs +173 -0
  15. package/lib/audit.mjs +530 -0
  16. package/lib/auto-lease.mjs +174 -0
  17. package/lib/background.mjs +842 -0
  18. package/lib/best-of.mjs +334 -0
  19. package/lib/board.mjs +232 -0
  20. package/lib/breaker.mjs +93 -0
  21. package/lib/budget.mjs +1375 -0
  22. package/lib/builtin-skills.mjs +135 -0
  23. package/lib/cache-floor.mjs +204 -0
  24. package/lib/chain.mjs +303 -0
  25. package/lib/changed-paths.mjs +84 -0
  26. package/lib/chat.mjs +434 -0
  27. package/lib/checkpoint.mjs +637 -0
  28. package/lib/child-lifetime.mjs +71 -0
  29. package/lib/cli-args.mjs +1255 -0
  30. package/lib/code-review.mjs +1382 -0
  31. package/lib/colour.mjs +82 -0
  32. package/lib/command.mjs +2847 -0
  33. package/lib/compact.mjs +1151 -0
  34. package/lib/completion.mjs +515 -0
  35. package/lib/creative-engines.mjs +779 -0
  36. package/lib/db-inspect.mjs +1624 -0
  37. package/lib/delete.mjs +111 -0
  38. package/lib/design-loop.mjs +570 -0
  39. package/lib/diff-preview.mjs +1044 -0
  40. package/lib/doctor.mjs +2139 -0
  41. package/lib/dropped.mjs +216 -0
  42. package/lib/edit-diagnostics.mjs +277 -0
  43. package/lib/edit.mjs +460 -0
  44. package/lib/env-file.mjs +250 -0
  45. package/lib/escalate.mjs +702 -0
  46. package/lib/evaluate.mjs +284 -0
  47. package/lib/fetch-text.mjs +952 -0
  48. package/lib/fleet-budget.mjs +256 -0
  49. package/lib/gh.mjs +1536 -0
  50. package/lib/git.mjs +1341 -0
  51. package/lib/github.mjs +261 -0
  52. package/lib/h2.mjs +194 -0
  53. package/lib/handoff.mjs +417 -0
  54. package/lib/hooks.mjs +626 -0
  55. package/lib/http-probe.mjs +907 -0
  56. package/lib/image-director.mjs +322 -0
  57. package/lib/image-edit.mjs +522 -0
  58. package/lib/imagegen.mjs +998 -0
  59. package/lib/interrupt.mjs +234 -0
  60. package/lib/learned.mjs +353 -0
  61. package/lib/lease-watch.mjs +115 -0
  62. package/lib/lease.mjs +868 -0
  63. package/lib/localize.mjs +834 -0
  64. package/lib/log-tail.mjs +1052 -0
  65. package/lib/login.mjs +157 -0
  66. package/lib/lsp.mjs +1613 -0
  67. package/lib/mcp-consent.mjs +377 -0
  68. package/lib/mcp-defaults.mjs +780 -0
  69. package/lib/mcp-server.mjs +1343 -0
  70. package/lib/mcp.mjs +1263 -0
  71. package/lib/media.mjs +1283 -0
  72. package/lib/memory-workspace.mjs +179 -0
  73. package/lib/model-json.mjs +132 -0
  74. package/lib/model-tier.mjs +171 -0
  75. package/lib/model.mjs +1445 -0
  76. package/lib/parallel.mjs +144 -0
  77. package/lib/perchance.mjs +210 -0
  78. package/lib/plan-coherence.mjs +1461 -0
  79. package/lib/plan-ledger.mjs +981 -0
  80. package/lib/plan.mjs +461 -0
  81. package/lib/policy.mjs +783 -0
  82. package/lib/prefix-order.mjs +38 -0
  83. package/lib/project-memory.mjs +127 -0
  84. package/lib/prompt.mjs +109 -0
  85. package/lib/python.mjs +862 -0
  86. package/lib/rcfile.mjs +853 -0
  87. package/lib/read-window.mjs +743 -0
  88. package/lib/refute-tools.mjs +34 -0
  89. package/lib/refute.mjs +806 -0
  90. package/lib/repl-driver.mjs +264 -0
  91. package/lib/repl.mjs +324 -0
  92. package/lib/replay.mjs +1218 -0
  93. package/lib/repo-map.mjs +1101 -0
  94. package/lib/report.mjs +419 -0
  95. package/lib/search-rank.mjs +99 -0
  96. package/lib/search.mjs +659 -0
  97. package/lib/secret-paths.mjs +54 -0
  98. package/lib/session.mjs +1017 -0
  99. package/lib/skills.mjs +703 -0
  100. package/lib/slash.mjs +356 -0
  101. package/lib/spawn-argv.mjs +1151 -0
  102. package/lib/spend.mjs +250 -0
  103. package/lib/steer.mjs +280 -0
  104. package/lib/stream.mjs +253 -0
  105. package/lib/stuck.mjs +712 -0
  106. package/lib/subagent.mjs +749 -0
  107. package/lib/terminal-graphics.mjs +171 -0
  108. package/lib/tool-prefix.mjs +226 -0
  109. package/lib/tool-shortlist.mjs +162 -0
  110. package/lib/tools.mjs +2333 -0
  111. package/lib/tsserver.mjs +423 -0
  112. package/lib/turn.mjs +5672 -0
  113. package/lib/untrusted-block.mjs +271 -0
  114. package/lib/verify-claim.mjs +299 -0
  115. package/lib/vision.mjs +330 -0
  116. package/lib/voice-task.mjs +561 -0
  117. package/lib/warm-provider.mjs +255 -0
  118. package/lib/websearch.mjs +401 -0
  119. package/lib/workspace.mjs +928 -0
  120. package/lib/write-approval.mjs +235 -0
  121. package/lib/write-many.mjs +162 -0
  122. package/package.json +62 -0
  123. package/scripts/bundle.mjs +768 -0
  124. package/scripts/cache-floor.mjs +176 -0
  125. package/scripts/machine.mjs +226 -0
  126. package/scripts/test.mjs +139 -0
  127. package/skills/accessibility.md +87 -0
  128. package/skills/acuvo-design-system.md +123 -0
  129. package/skills/animation.md +84 -0
  130. package/skills/api-design.md +82 -0
  131. package/skills/auth-and-sessions.md +78 -0
  132. package/skills/build-with-a-framework.md +101 -0
  133. package/skills/colour-and-contrast.md +112 -0
  134. package/skills/creative-engines.md +81 -0
  135. package/skills/css-layout.md +85 -0
  136. package/skills/data-and-charts.md +77 -0
  137. package/skills/debugging.md +76 -0
  138. package/skills/designing-by-looking.md +84 -0
  139. package/skills/error-handling.md +78 -0
  140. package/skills/forms-and-validation.md +93 -0
  141. package/skills/nextjs-app-router.md +75 -0
  142. package/skills/page-composition.md +103 -0
  143. package/skills/performance.md +77 -0
  144. package/skills/plan-before-building.md +52 -0
  145. package/skills/planning-and-delegating.md +72 -0
  146. package/skills/refactoring.md +70 -0
  147. package/skills/security-basics.md +76 -0
  148. package/skills/state-management.md +73 -0
  149. package/skills/supabase-multitenant.md +72 -0
  150. package/skills/typescript-strict.md +90 -0
  151. package/skills/typography.md +135 -0
  152. package/skills/verify-your-own-work.md +62 -0
  153. package/skills/web-app-quality.md +62 -0
  154. package/skills/working-in-the-background.md +64 -0
@@ -0,0 +1,334 @@
1
+ /**
2
+ * ── ⭐⭐ BEST-OF-N — COST TURNED INTO CAPABILITY ─────────────────────────────
3
+ *
4
+ * Run the same task several times independently, then keep the attempt that
5
+ * actually PASSED. This is not a trick; it is the one thing our price makes
6
+ * possible and a competitor's price does not. A run here costs ~$0.001, so
7
+ * three attempts cost a third of a cent. An agent billing a hundred times that
8
+ * cannot offer this at all — not because they lack the idea, because the
9
+ * arithmetic forbids it.
10
+ *
11
+ * ── ⚠️⚠️ THE SELECTION MUST NOT BE THE MODEL'S OPINION ─────────────────────
12
+ *
13
+ * The obvious design is to generate N answers and ask a model which is best.
14
+ * That is N chances to be confidently wrong, aggregated by a judge with the
15
+ * same blind spots — and this codebase has measured exactly that failure: a
16
+ * critic scored five of six different pages identically, and an agent declared
17
+ * a page "clean and balanced" that it had never rendered.
18
+ *
19
+ * ⭐ SO THE WINNER IS CHOSEN BY WHAT RAN. `sessionFailed()` already encodes the
20
+ * honest verdict — did the verification command pass, did a declared acceptance
21
+ * criterion hold. Selection is that verdict first, and taste never.
22
+ *
23
+ * ── ⚠️ IT MUST NEVER BE WORSE THAN N=1 ──────────────────────────────────────
24
+ *
25
+ * A single run that fails verification still leaves its files on disk; the user
26
+ * gets something to look at. So when NO attempt verifies, this applies one
27
+ * anyway and says plainly that none passed. Applying nothing would make asking
28
+ * for three attempts worse than asking for one, which is the kind of surprise
29
+ * that makes a feature untrustworthy.
30
+ */
31
+
32
+ import { cpSync, mkdtempSync, rmSync, existsSync, readdirSync, statSync } from 'node:fs';
33
+ import { tmpdir } from 'node:os';
34
+ import { join, relative, sep } from 'node:path';
35
+
36
+ import { changedPaths } from './changed-paths.mjs';
37
+
38
+ /**
39
+ * ⚠️ THE SAME SKIP SET THE REST OF THE PACKAGE USES. Copying `node_modules` for
40
+ * each of three attempts turns a 40MB workspace into 1.2GB of I/O on a laptop
41
+ * that may be on battery — and none of it is read by the agent, which reaches
42
+ * dependencies through the executor, not by walking them.
43
+ */
44
+ export const COPY_SKIP_DIRS = new Set([
45
+ 'node_modules', '.git', '.next', 'dist', 'build', '.vercel', 'coverage', '.turbo', '.acuvo',
46
+ ]);
47
+
48
+ export const MIN_ATTEMPTS = 2;
49
+ export const MAX_ATTEMPTS = 5;
50
+
51
+ /**
52
+ * ⚠️ A CEILING ON WHAT WILL BE COPIED, because the alternative is discovering
53
+ * mid-run that a workspace contained a 4GB dataset. Refusing with the number is
54
+ * a decision the user can act on; silently copying it is a hang they cannot.
55
+ */
56
+ export const MAX_COPY_BYTES = 256 * 1024 * 1024;
57
+
58
+ export function measureWorkspace(root, { limit = MAX_COPY_BYTES } = {}) {
59
+ let bytes = 0;
60
+ let files = 0;
61
+ const walk = (dir) => {
62
+ let entries;
63
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
64
+ for (const e of entries) {
65
+ if (bytes > limit) return;
66
+ const full = join(dir, e.name);
67
+ if (e.isDirectory()) {
68
+ if (COPY_SKIP_DIRS.has(e.name)) continue;
69
+ walk(full);
70
+ } else if (e.isFile()) {
71
+ try { bytes += statSync(full).size; files += 1; } catch { /* vanished mid-walk */ }
72
+ }
73
+ }
74
+ };
75
+ walk(root);
76
+ return { bytes, files, overLimit: bytes > limit };
77
+ }
78
+
79
+ /**
80
+ * Score one finished attempt. Lower is better.
81
+ *
82
+ * ⚠️ THE FIRST DIGIT IS THE ONLY ONE THAT DECIDES CORRECTNESS. Rounds and cost
83
+ * break ties BETWEEN VERIFIED ATTEMPTS — they can never promote a failing
84
+ * attempt above a passing one, however cheap or fast it was. An agent that
85
+ * picked the cheapest answer would learn to do nothing.
86
+ */
87
+ export function scoreAttempt(attempt, { failed }) {
88
+ if (!attempt || attempt.error) return { rank: 3, why: 'the attempt crashed' };
89
+ const outcome = attempt.outcome;
90
+ if (!outcome) return { rank: 3, why: 'the attempt produced no result' };
91
+
92
+ const mutations = (outcome.executed ?? []).filter((e) => e?.mutated).length;
93
+
94
+ if (!failed(outcome)) {
95
+ const v = outcome.verification;
96
+ /**
97
+ * ── ⚠️⚠️ DOING NOTHING IS NOT SUCCEEDING ─────────────────────────────
98
+ *
99
+ * CAUGHT BY A REAL RUN, not by reasoning. An attempt that wrote no files
100
+ * and ran no verification came back `ok: true`, so `failed()` said false,
101
+ * so it ranked as a WIN — and then beat the attempt that actually did the
102
+ * work, because the tie-break prefers fewer rounds and doing nothing takes
103
+ * the fewest rounds of all.
104
+ *
105
+ * ⭐ That is the same trap the cheap-failure test was written to close, one
106
+ * level deeper: I guarded against a cheap FAILURE outranking an expensive
107
+ * success and missed that a cheap NO-OP outranks everything. An agent
108
+ * rewarded for this learns that the optimal move is to do nothing quickly.
109
+ *
110
+ * `failed()` cannot see it — a run with no verification is not a run that
111
+ * failed verification, and that distinction is correct everywhere else.
112
+ * So the emptiness is judged here, where the alternatives are visible.
113
+ */
114
+ if (!v?.ran && mutations === 0) {
115
+ return {
116
+ rank: 2,
117
+ why: 'changed nothing and verified nothing',
118
+ rounds: outcome.roundsUsed ?? 0,
119
+ cost: outcome.usage?.cost ?? 0,
120
+ };
121
+ }
122
+ return {
123
+ rank: 0,
124
+ why: v?.ran
125
+ ? `verified: ${v.command} passed`
126
+ : `changed ${mutations} file${mutations === 1 ? '' : 's'}, nothing left failing`,
127
+ rounds: outcome.roundsUsed ?? 0,
128
+ cost: outcome.usage?.cost ?? 0,
129
+ };
130
+ }
131
+
132
+ // Ran something and it failed — still more informative than never running.
133
+ if (outcome.verification?.ran) {
134
+ return { rank: 1, why: `ran ${outcome.verification.command} and it failed`, rounds: outcome.roundsUsed ?? 0, cost: outcome.usage?.cost ?? 0 };
135
+ }
136
+ return { rank: 2, why: outcome.stoppedBecause ? `stopped: ${outcome.stoppedBecause}` : 'nothing was verified', rounds: outcome.roundsUsed ?? 0, cost: outcome.usage?.cost ?? 0 };
137
+ }
138
+
139
+ export function pickWinner(attempts, { failed }) {
140
+ const scored = attempts.map((a, i) => ({ index: i, attempt: a, score: scoreAttempt(a, { failed }) }));
141
+ const ordered = [...scored].sort((a, b) => (
142
+ a.score.rank - b.score.rank
143
+ || (a.score.rounds ?? 99) - (b.score.rounds ?? 99)
144
+ || (a.score.cost ?? 0) - (b.score.cost ?? 0)
145
+ || a.index - b.index
146
+ ));
147
+ const winner = ordered[0];
148
+ return {
149
+ winner,
150
+ scored,
151
+ anyVerified: scored.some((s) => s.score.rank === 0),
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Copy the files an attempt changed back into the real workspace.
157
+ *
158
+ * ⚠️ ONLY THE FILES THE ATTEMPT ITSELF REPORTED WRITING. Mirroring the whole
159
+ * directory would also delete anything the user created while the attempts ran,
160
+ * and would resurrect files an attempt deliberately deleted in a way the
161
+ * report never mentioned. The change list is the attempt's own account of what
162
+ * it did, and applying exactly that keeps the summary and the disk in agreement.
163
+ */
164
+ export function applyAttempt(fromRoot, toRoot, outcome, { copy = cpSync, remove = rmSync } = {}) {
165
+ const applied = [];
166
+ const problems = [];
167
+ for (const record of outcome?.executed ?? []) {
168
+ /**
169
+ * ── ⚠️⚠️ THIS READ `record.args?.path`, AND THAT SILENTLY DROPPED FILES ──
170
+ *
171
+ * Measured by reading `write-many.mjs:128`: a `write_files` call carries
172
+ * `files[]` in its ARGUMENTS and reports `written[{path,…}]` in its
173
+ * RESULT. There is no top-level `args.path`, so a winning attempt that
174
+ * created its whole feature in one bulk call had every file skipped
175
+ * here — and `formatBestOf` still printed "kept attempt 2" over an empty
176
+ * change list. ⭐ `changedPaths` is now the one place that knows the
177
+ * shapes, and it also carries a delegated builder's files.
178
+ */
179
+ for (const path of changedPaths(record)) {
180
+ // ⚠️ Refuse anything that climbs out — the attempt ran in a copy, but the
181
+ // path it reports is still a string from a model.
182
+ if (path.includes('..') || path.startsWith('/') || path.startsWith('\\') || /^[A-Za-z]:/.test(path)) {
183
+ problems.push(`refused to apply a path that leaves the workspace: ${path}`);
184
+ continue;
185
+ }
186
+ const src = join(fromRoot, path);
187
+ const dest = join(toRoot, path);
188
+ try {
189
+ if (existsSync(src)) {
190
+ copy(src, dest, { recursive: true, force: true });
191
+ applied.push(path);
192
+ } else if (existsSync(dest)) {
193
+ // The attempt deleted it. Deleting it here is what "apply" means.
194
+ remove(dest, { recursive: true, force: true });
195
+ applied.push(path);
196
+ }
197
+ } catch (err) {
198
+ problems.push(`${path}: ${String(err?.message ?? err)}`);
199
+ }
200
+ }
201
+ }
202
+ return { applied, problems };
203
+ }
204
+
205
+ /**
206
+ * Run one task N times in isolated copies and apply the best result.
207
+ *
208
+ * `runOne({ root, label })` must resolve to a SessionDone-shaped outcome.
209
+ * Injected so this module never imports the turn loop — it is a strategy, not
210
+ * a second engine.
211
+ */
212
+ export async function runBestOf({
213
+ root,
214
+ attempts = 3,
215
+ runOne,
216
+ failed,
217
+ pool,
218
+ concurrency = 2,
219
+ onEvent = () => {},
220
+ makeTempDir = () => mkdtempSync(join(tmpdir(), 'acuvo-bestof-')),
221
+ copyDir = cpSync,
222
+ removeDir = rmSync,
223
+ /**
224
+ * ⚠️ INJECTABLE SO THE GUARD CAN BE TESTED AT ALL. With the limit hardcoded,
225
+ * the only way to exercise the refusal is to actually build a 256MB workspace
226
+ * in a test — so nobody does, and the one branch that protects a laptop from
227
+ * copying a dataset three times is the one branch never run.
228
+ */
229
+ maxBytes = MAX_COPY_BYTES,
230
+ }) {
231
+ const n = Math.max(MIN_ATTEMPTS, Math.min(MAX_ATTEMPTS, Number(attempts) || MIN_ATTEMPTS));
232
+
233
+ const size = measureWorkspace(root, { limit: maxBytes });
234
+ if (size.overLimit) {
235
+ return {
236
+ ok: false,
237
+ error: `this workspace holds more than ${Math.round(maxBytes / 1024 / 1024)}MB of files that would have to be copied ${n} times. `
238
+ + 'Run without --best-of here, or point --dir at the subdirectory the task actually touches.',
239
+ };
240
+ }
241
+
242
+ const roots = [];
243
+ try {
244
+ for (let i = 0; i < n; i += 1) {
245
+ const dir = makeTempDir();
246
+ /**
247
+ * ⚠️ `dereference: false` — a symlink copied as its target silently
248
+ * doubles a workspace and breaks any code that checks `isSymbolicLink`.
249
+ */
250
+ copyDir(root, dir, {
251
+ recursive: true,
252
+ dereference: false,
253
+ force: true,
254
+ filter: (src) => {
255
+ const rel = relative(root, src);
256
+ if (rel === '') return true;
257
+ return !rel.split(sep).some((part) => COPY_SKIP_DIRS.has(part));
258
+ },
259
+ });
260
+ roots.push(dir);
261
+ }
262
+
263
+ onEvent({ type: 'best-of-start', attempts: n, files: size.files, bytes: size.bytes });
264
+
265
+ const jobs = roots.map((dir, i) => () => runOne({ root: dir, label: `attempt ${i + 1}/${n}` })
266
+ .then((outcome) => ({ outcome }))
267
+ .catch((err) => ({ error: String(err?.message ?? err) })));
268
+
269
+ const results = pool
270
+ ? await pool(jobs, { concurrency })
271
+ : await Promise.all(jobs.map((j) => j()));
272
+
273
+ const { winner, scored, anyVerified } = pickWinner(results, { failed });
274
+
275
+ const { applied, problems } = applyAttempt(roots[winner.index], root, winner.attempt?.outcome, {
276
+ copy: copyDir,
277
+ remove: removeDir,
278
+ });
279
+
280
+ return {
281
+ ok: true,
282
+ attempts: n,
283
+ anyVerified,
284
+ winnerIndex: winner.index,
285
+ winner: winner.attempt?.outcome ?? null,
286
+ applied,
287
+ problems,
288
+ scored: scored.map((s) => ({
289
+ index: s.index,
290
+ rank: s.score.rank,
291
+ why: s.score.why,
292
+ rounds: s.score.rounds ?? null,
293
+ cost: s.score.cost ?? null,
294
+ })),
295
+ totalCost: results.reduce((sum, r) => sum + (r?.outcome?.usage?.cost ?? 0), 0),
296
+ };
297
+ } finally {
298
+ // ⚠️ ALWAYS, even when an attempt threw. Three abandoned copies of a
299
+ // workspace per invocation is how a temp directory becomes a disk problem
300
+ // nobody connects back to this feature.
301
+ for (const dir of roots) {
302
+ try { removeDir(dir, { recursive: true, force: true }); } catch { /* best effort */ }
303
+ }
304
+ }
305
+ }
306
+
307
+ /** Render the outcome for a human. */
308
+ export function formatBestOf(result) {
309
+ if (!result?.ok) return String(result?.error ?? 'best-of failed');
310
+ const lines = [];
311
+ const verifiedCount = result.scored.filter((s) => s.rank === 0).length;
312
+
313
+ lines.push(`best of ${result.attempts}: ${verifiedCount} verified, kept attempt ${result.winnerIndex + 1}`);
314
+ for (const s of result.scored) {
315
+ const mark = s.index === result.winnerIndex ? '→' : ' ';
316
+ const cost = s.cost ? ` · $${s.cost.toFixed(4)}` : '';
317
+ const rounds = s.rounds ? ` · ${s.rounds}r` : '';
318
+ lines.push(`${mark} attempt ${s.index + 1}: ${s.why}${rounds}${cost}`);
319
+ }
320
+
321
+ /**
322
+ * ⚠️ THE ALL-FAILED CASE SAYS SO IN THOSE WORDS. "kept attempt 2" on its own
323
+ * reads like a success, and a user who asked for three attempts precisely
324
+ * because the task was hard is the last person who should have to infer that
325
+ * none of them worked.
326
+ */
327
+ if (!result.anyVerified) {
328
+ lines.push('⚠ NONE of the attempts verified. The files from the least-bad one were applied so you have something to look at, but nothing here is proven.');
329
+ }
330
+ if (result.applied.length > 0) lines.push(`applied ${result.applied.length} file${result.applied.length === 1 ? '' : 's'} from the winner`);
331
+ for (const p of result.problems) lines.push(`⚠ ${p}`);
332
+ lines.push(`total spend across all attempts: $${result.totalCost.toFixed(4)}`);
333
+ return lines.join('\n');
334
+ }
package/lib/board.mjs ADDED
@@ -0,0 +1,232 @@
1
+ /**
2
+ * ── ⭐⭐ THE TASK BOARD — SEVEN TERMINALS, SEVEN JOBS, NOBODY DOING THE SAME ONE ─
3
+ *
4
+ * The stated direction for this tool is *"open seven terminals and have seven AI
5
+ * workers building your software in the same brain, and they are cheap"*. Nearly
6
+ * all of that already worked and was measured: seven terminals run today, they
7
+ * are $0.001–0.003 a task, `lease.mjs` stops two of them writing one file,
8
+ * `auto-lease.mjs` made that automatic, `fleet-budget.mjs` caps the whole
9
+ * workspace for the day, and the plan ledger is now per worker.
10
+ *
11
+ * The one missing piece was never a lock. It is that **nothing said what the
12
+ * work WAS**. Seven terminals meant seven people typing seven prompts, and
13
+ * nothing stopped two of them typing the same one.
14
+ *
15
+ * ── ⭐⭐ AND THE DESIGN IS: THERE IS NO NEW LOCKING HERE ─────────────────────
16
+ *
17
+ * A task board looks like it needs a queue with a mutex. It does not, and the
18
+ * reason is worth stating because it is the whole reason this file is short:
19
+ *
20
+ * a task IS a file, and claiming one IS taking a lease on that file.
21
+ *
22
+ * `.acuvo/board/<id>.json` is a real path inside the workspace, so
23
+ * `lease.mjs` — 786 lines of proven, raced-by-eight-real-processes,
24
+ * TTL-reclaiming, takeover-capped exclusive claim — works on it UNMODIFIED.
25
+ * Measured before this file was written: a lease on `.acuvo/board/t3.json` is
26
+ * granted, a second terminal is refused with `heldBy`, and `acuvo leases`
27
+ * already renders it. Nothing here re-invents any of that.
28
+ *
29
+ * ⭐ Which also means every property already argued for leases comes free: a
30
+ * worker that dies mid-task has its claim reclaimed after the TTL rather than
31
+ * blocking the board forever, and `acuvo leases` is already the answer to "who
32
+ * is doing what".
33
+ *
34
+ * ── ⚠️ WHAT THIS DELIBERATELY IS NOT ────────────────────────────────────────
35
+ *
36
+ * There are no `board_*` TOOLS. The model cannot add, claim or complete a task,
37
+ * and that is a decision rather than an omission: agent-callable claiming is a
38
+ * second unproven behaviour riding on a board that has not yet been used once.
39
+ * The workflow that actually exists is a person opening seven windows and
40
+ * typing, so the board is a CLI surface for that person. Give it a week of real
41
+ * use before letting the thing being scheduled do the scheduling.
42
+ */
43
+
44
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
45
+ import { join } from 'node:path';
46
+
47
+ import { resolveInWorkspace } from './workspace.mjs';
48
+ import { acquire, release, inspect } from './lease.mjs';
49
+
50
+ export const BOARD_DIR = '.acuvo/board';
51
+ /** A task longer than this is a project, and a project is not a board entry. */
52
+ export const MAX_TASK_CHARS = 400;
53
+ /** Enough for any real fleet; a bound so a runaway script cannot fill a disk. */
54
+ export const MAX_TASKS = 200;
55
+
56
+ const nowIso = () => new Date().toISOString();
57
+
58
+ /**
59
+ * ⚠️ SORTABLE AND COLLISION-RESISTANT, in that order of importance. Two
60
+ * terminals can add a task in the same millisecond, so the timestamp alone is
61
+ * not an id — but the timestamp PREFIX is what makes `readdirSync` return the
62
+ * board in the order it was written without anybody sorting by a parsed field.
63
+ */
64
+ function newId(clock = Date.now, rand = () => Math.random()) {
65
+ const stamp = new Date(clock()).toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
66
+ const salt = Math.floor(rand() * 46_656).toString(36).padStart(3, '0');
67
+ return `${stamp}-${salt}`;
68
+ }
69
+
70
+ function boardPathFor(root, id) {
71
+ return resolveInWorkspace(root, `${BOARD_DIR}/${id}.json`, 'write');
72
+ }
73
+
74
+ /**
75
+ * Add one task. Returns the id, which is what a person needs to talk about it.
76
+ */
77
+ export function boardAdd(root, text, { clock = Date.now, rand = () => Math.random() } = {}) {
78
+ const task = String(text ?? '').trim().replace(/\s+/g, ' ');
79
+ if (!task) return { ok: false, error: 'a board task needs some text — an empty one cannot be picked up' };
80
+ if (task.length > MAX_TASK_CHARS) {
81
+ return { ok: false, error: `that task is ${task.length} characters, over the ${MAX_TASK_CHARS} limit. Put the detail in the repo, not on the board.` };
82
+ }
83
+
84
+ const existing = boardList(root);
85
+ if (existing.ok && existing.tasks.length >= MAX_TASKS) {
86
+ return { ok: false, error: `the board already holds ${existing.tasks.length} tasks (limit ${MAX_TASKS}) — finish or remove some before adding more` };
87
+ }
88
+
89
+ const id = newId(clock, rand);
90
+ const target = boardPathFor(root, id);
91
+ if (!target.ok) return { ok: false, error: target.reason };
92
+
93
+ try {
94
+ mkdirSync(join(root, BOARD_DIR), { recursive: true });
95
+ /**
96
+ * ⚠️ `wx` — EXCLUSIVE CREATE. Two terminals adding in the same millisecond
97
+ * would otherwise silently overwrite one another's task, and a board that
98
+ * loses work is worse than no board. The id has a random salt, so a
99
+ * collision is already unlikely; refusing outright is what makes it
100
+ * impossible rather than unlikely.
101
+ */
102
+ writeFileSync(target.absolute, `${JSON.stringify({ id, task, addedAt: nowIso(), state: 'todo' }, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' });
103
+ } catch (err) {
104
+ return { ok: false, error: `could not add the task: ${err?.message ?? err}` };
105
+ }
106
+ return { ok: true, id, task };
107
+ }
108
+
109
+ /** Every task, with who holds it, oldest first. */
110
+ export function boardList(root) {
111
+ const dir = join(root, BOARD_DIR);
112
+ if (!existsSync(dir)) return { ok: true, tasks: [] };
113
+
114
+ let names;
115
+ try {
116
+ names = readdirSync(dir).filter((n) => n.endsWith('.json')).sort();
117
+ } catch (err) {
118
+ return { ok: false, error: `could not read the board: ${err?.message ?? err}`, tasks: [] };
119
+ }
120
+
121
+ /**
122
+ * ⭐ WHO HOLDS WHAT COMES FROM `lease.mjs`, NOT FROM THE TASK FILE. A holder
123
+ * written into the task itself would be a second copy of a fact the lease
124
+ * layer already owns — and it would go stale the moment a worker died,
125
+ * because a dead process cannot come back to correct its own file. The lease
126
+ * expires on its own; a field would not.
127
+ */
128
+ const held = new Map();
129
+ try {
130
+ for (const l of inspect(root)?.leases ?? []) held.set(l.path, l);
131
+ } catch { /* an unreadable lease dir means we cannot say who holds what, not that the board is gone */ }
132
+
133
+ const tasks = [];
134
+ for (const name of names) {
135
+ let rec;
136
+ try {
137
+ rec = JSON.parse(readFileSync(join(dir, name), 'utf8'));
138
+ } catch {
139
+ // ⚠️ Counted, not skipped silently — a task nobody can read is still work
140
+ // somebody wrote down, and pretending it is absent is how it gets lost.
141
+ tasks.push({ id: name.replace(/\.json$/, ''), task: '(unreadable — the file is not valid JSON)', state: 'damaged', heldBy: null });
142
+ continue;
143
+ }
144
+ const lease = held.get(`${BOARD_DIR}/${rec.id}.json`);
145
+ tasks.push({ ...rec, heldBy: lease?.holder ?? null, heldSince: lease?.acquiredAt ?? null });
146
+ }
147
+ return { ok: true, tasks };
148
+ }
149
+
150
+ /**
151
+ * Claim the oldest task nobody else holds.
152
+ *
153
+ * ⚠️ THE LEASE IS THE CLAIM, AND THE RACE IS ALREADY SOLVED. Two terminals
154
+ * calling this in the same instant both see the same "first free" task; exactly
155
+ * one wins the `acquire`, and the loser simply moves to the next one. That is
156
+ * `lease.mjs`'s exclusive-create doing the work, which is precisely why this
157
+ * function has no lock of its own.
158
+ */
159
+ export function boardClaim(root, { holder, ttlMs, acquireImpl = acquire } = {}) {
160
+ const who = String(holder ?? '').trim();
161
+ if (!who) return { ok: false, error: 'claiming a task needs a --holder, so the board can say who is doing what' };
162
+
163
+ const listed = boardList(root);
164
+ if (!listed.ok) return listed;
165
+
166
+ const open = listed.tasks.filter((t) => t.state === 'todo' && !t.heldBy);
167
+ if (open.length === 0) {
168
+ const total = listed.tasks.length;
169
+ return {
170
+ ok: false,
171
+ empty: true,
172
+ error: total === 0
173
+ ? `the board is empty — add work with \`acuvo board add "…"\``
174
+ : `every task on the board is either done or already held by another terminal (${total} total). \`acuvo board\` shows who has what.`,
175
+ };
176
+ }
177
+
178
+ for (const t of open) {
179
+ const got = acquireImpl(root, { path: `${BOARD_DIR}/${t.id}.json`, holder: who, pid: process.pid, ...(ttlMs ? { ttlMs } : {}) });
180
+ if (got?.ok) return { ok: true, id: t.id, task: t.task, lease: got.lease };
181
+ // Somebody won it between the list and the acquire. Try the next one —
182
+ // that is the race resolving itself, not an error worth reporting.
183
+ }
184
+ return { ok: false, empty: true, error: 'every open task was claimed by another terminal while this one was looking' };
185
+ }
186
+
187
+ /** Mark a task finished and let go of it. */
188
+ export function boardDone(root, id, { lease = null, releaseImpl = release } = {}) {
189
+ const target = boardPathFor(root, String(id ?? '').trim());
190
+ if (!target.ok) return { ok: false, error: target.reason };
191
+ if (!existsSync(target.absolute)) return { ok: false, error: `no task on the board has the id "${id}"` };
192
+
193
+ let rec;
194
+ try {
195
+ rec = JSON.parse(readFileSync(target.absolute, 'utf8'));
196
+ } catch (err) {
197
+ return { ok: false, error: `that task file could not be read: ${err?.message ?? err}` };
198
+ }
199
+ try {
200
+ writeFileSync(target.absolute, `${JSON.stringify({ ...rec, state: 'done', doneAt: nowIso() }, null, 2)}\n`, 'utf8');
201
+ } catch (err) {
202
+ return { ok: false, error: `could not mark the task done: ${err?.message ?? err}` };
203
+ }
204
+ if (lease) { try { releaseImpl(lease); } catch { /* the TTL clears it */ } }
205
+ return { ok: true, id: rec.id, task: rec.task };
206
+ }
207
+
208
+ /** The human view. Same shape as every other report in this package. */
209
+ export function formatBoard(listed) {
210
+ if (!listed?.ok) return `board: ${listed?.error ?? 'unavailable'}`;
211
+ if (listed.tasks.length === 0) {
212
+ return 'The board is empty.\n\n Add work with: acuvo board add "make the failing suite pass"\n Then claim it: acuvo --holder t1 --claim';
213
+ }
214
+ const lines = [];
215
+ const todo = listed.tasks.filter((t) => t.state === 'todo');
216
+ const done = listed.tasks.filter((t) => t.state === 'done');
217
+ lines.push(`${listed.tasks.length} task${listed.tasks.length === 1 ? '' : 's'} — ${todo.filter((t) => !t.heldBy).length} open, ${todo.filter((t) => t.heldBy).length} in progress, ${done.length} done`);
218
+ lines.push('');
219
+ for (const t of listed.tasks) {
220
+ const mark = t.state === 'done' ? '✔' : t.heldBy ? '▶' : '·';
221
+ /**
222
+ * ⚠️ A FINISHED TASK SHOWS NO HOLDER. Its lease often outlives it — a
223
+ * worker that finished and exited without releasing leaves the record until
224
+ * the TTL clears it — and "✔ done ← t1" reads as *t1 is working on this*,
225
+ * which is the opposite of what it means. The holder is only interesting
226
+ * while the work is in flight.
227
+ */
228
+ const who = t.heldBy && t.state !== 'done' ? ` ← ${t.heldBy}` : '';
229
+ lines.push(` ${mark} ${t.id} ${t.task}${who}`);
230
+ }
231
+ return lines.join('\n');
232
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * ── ⚠️⭐ ONE DEAD SERVICE MUST NOT EAT THE WHOLE SESSION ─────────────────────
3
+ *
4
+ * Measured 2026-08-10 on a real multi-page website build. `generate_image` was
5
+ * unreachable. Each attempt waited the full 180-second timeout; the model tried
6
+ * twice in one round, then — following our own error text, which said "try once
7
+ * more" — retried in the next. Six minutes of a run spent on a service that was
8
+ * never going to answer, and the user saw a session that appeared to hang and
9
+ * produced nothing.
10
+ *
11
+ * ⭐ THE ASYMMETRY IS THE WHOLE ARGUMENT. Being wrong about a service being dead
12
+ * costs one missing image. Being wrong about it being alive costs three minutes
13
+ * PER ATTEMPT out of a budget measured in minutes. So: one unreachable answer,
14
+ * and this endpoint is dead for the rest of the process.
15
+ *
16
+ * ⚠️ IT TRIPS ON UNREACHABLE, NEVER ON A REFUSAL. An HTTP 400 means the request
17
+ * was wrong and the next one may well be right — tripping on that would disable
18
+ * a working service because the model sent a bad prompt once. Only "no response"
19
+ * counts: a timeout, a DNS failure, a refused connection.
20
+ *
21
+ * ⚠️ AND IT IS PER-PROCESS, DELIBERATELY. A CLI run is minutes long, so there is
22
+ * no half-life worth modelling and no reason to make a dead service recoverable
23
+ * mid-run — the next invocation gets a clean slate for free, which is exactly
24
+ * the retry policy a human would apply.
25
+ */
26
+
27
+ /** endpoint URL → the reason it was declared dead. */
28
+ const dead = new Map();
29
+
30
+ /** Reset between tests. Never called in normal operation — a run is short. */
31
+ export function resetBreakers() {
32
+ dead.clear();
33
+ }
34
+
35
+ /**
36
+ * Is this endpoint already known to be unreachable this run?
37
+ * Returns the recorded reason, or null.
38
+ */
39
+ export function deadReason(url) {
40
+ return dead.get(String(url)) ?? null;
41
+ }
42
+
43
+ /**
44
+ * Record that an endpoint did not answer.
45
+ *
46
+ * ⚠️ The CALLER decides whether the failure was "no response" or merely "no",
47
+ * because only the caller can tell an AbortError apart from an HTTP 422. Passing
48
+ * an application-level refusal here would be the bug this module exists to
49
+ * avoid.
50
+ */
51
+ export function markUnreachable(url, reason) {
52
+ dead.set(String(url), String(reason ?? 'no response'));
53
+ }
54
+
55
+ /**
56
+ * The message a later caller gets. It must NOT invite another attempt.
57
+ *
58
+ * ⚠️ THE ORIGINAL TEXT SAID "try once more" AND THE MODEL DID, twice. An error
59
+ * string is an instruction to whatever reads it, and a model reads it literally.
60
+ * This one closes the door and names the alternative, so the round is spent on
61
+ * work instead of on waiting.
62
+ */
63
+ export function skipMessage(label, url) {
64
+ const why = deadReason(url);
65
+ return `${label} is not responding this session (${why}). Skipping it rather than waiting again — `
66
+ + 'earlier attempts already spent the full timeout. Carry on without it and say so in your summary; '
67
+ + 'do not call this tool again in this run.';
68
+ }
69
+
70
+ /**
71
+ * Wrap a fetch so the breaker is consulted first and tripped on a network-level
72
+ * failure. Returns `{ ok: false, skipped: true, error }` when already dead.
73
+ *
74
+ * ⭐ Kept as a wrapper rather than a rule each call site reimplements: the two
75
+ * existing callers already disagreed about what an unreachable service should
76
+ * say, which is how "try once more" survived in one of them.
77
+ */
78
+ export async function throughBreaker(url, label, run) {
79
+ const already = deadReason(url);
80
+ if (already) return { ok: false, skipped: true, error: skipMessage(label, url) };
81
+ try {
82
+ return await run();
83
+ } catch (err) {
84
+ const networkLevel = err?.name === 'TimeoutError'
85
+ || err?.name === 'AbortError'
86
+ || Boolean(err?.cause?.code);
87
+ if (networkLevel) {
88
+ const why = err?.name === 'TimeoutError' ? 'timed out' : (err?.cause?.code ?? err?.name ?? 'no response');
89
+ markUnreachable(url, why);
90
+ }
91
+ throw err;
92
+ }
93
+ }