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,859 @@
1
+ /**
2
+ * ── ⭐⭐ THE VERDICT IS ABOUT THE COMMAND THE USER NAMED ─────────────────────
3
+ *
4
+ * `turn.mjs` already refuses to call a failing run a pass — its verdict comes
5
+ * from an exit code this process observed, never from the model's prose. That
6
+ * fixed the loud lie. It does not touch the quiet one, which is the one that
7
+ * has actually shipped:
8
+ *
9
+ * THE RUN VERIFIES SOMETHING, AND SOMETHING IS NOT THE THING THAT WAS ASKED.
10
+ *
11
+ * Four probes, all exit 0, all with a green banner, 2026-08-10:
12
+ *
13
+ * probe 1 run 1 told "It MUST pass: npm test"
14
+ * printed ✔ VERIFIED — `node --test test/api.test.js` exited 0
15
+ * the delivered project's own `npm test` runs ZERO tests
16
+ * probe 1 runs 3+4 printed ✔ VERIFIED — `evaluate` exited 0, where `evaluate`
17
+ * was a throwaway snippet used to look at stdout. Run 3 never
18
+ * wrote the README it was asked for.
19
+ * probe 2 probe3 told "Then run the test suite", called run_command ZERO
20
+ * times, printed ✔ VERIFIED — `evaluate` exited 0 over a
21
+ * half-migrated refactor (git.mjs:282 and command.mjs:539-540
22
+ * still called the new signature positionally).
23
+ * probe 4 run 2 exited 0 with --json {"verification":{"passed":true}} on a
24
+ * run that silently dropped the requested commit — so the
25
+ * pipeline our own README advertises,
26
+ * `acuvo --json | jq '.verification.passed'`, returned true,
27
+ * and `acuvo … && git push` would have pushed it.
28
+ *
29
+ * ⭐ THE COMMON SHAPE: verification was DERIVED FROM WHAT HAPPENED. Whatever the
30
+ * model chose to run last became the criterion. A criterion the subject picks
31
+ * after the fact is not a criterion, it is a summary — and `turn.mjs:1164`'s
32
+ * `promisedButMissing` cannot close it either, because it scans the paths named
33
+ * in the MODEL's final sentence and fired on none of probe 1's four runs.
34
+ *
35
+ * So the criterion is fixed BEFORE the work, from the USER's words, and the
36
+ * only thing that can satisfy it is that exact command exiting 0.
37
+ *
38
+ * ── ⚠️ WHY `evaluate` CAN NEVER SATISFY ONE ─────────────────────────────────
39
+ * `evaluate` is a scratch snippet: written to a temp file, run, deleted. It is
40
+ * genuinely useful and `turn.mjs` is right to count it as "code was proven to
41
+ * run". It is also the exact mechanism by which three of the four false ticks
42
+ * were produced, because a snippet the model wrote to inspect stdout exits 0
43
+ * whatever the project does. Only `run_command`/`run_program` records can
44
+ * satisfy a criterion here. Nothing else — not `see_page`, not `git_*`, not a
45
+ * media tool.
46
+ *
47
+ * ── ⚠️ WHAT THIS MODULE DELIBERATELY DOES NOT DO ────────────────────────────
48
+ * It does not set the exit code, and it does not touch `verification`. It
49
+ * returns a verdict object; the wiring decides what to do with it. A module
50
+ * that both judges and sentences is a module nobody can test one half of.
51
+ *
52
+ * It also never REWRITES a criterion into something that happens to run. If the
53
+ * runner cannot execute `npm test` because the script body contains a glob, the
54
+ * answer is "you asked for `npm test`, and this runner refuses it because …" —
55
+ * substituting a command that works is precisely what probe 1 run 1 did.
56
+ */
57
+
58
+ import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
59
+ import { dirname } from 'node:path';
60
+
61
+ import { validateCommand, validateNpmScriptChain } from './command.mjs';
62
+ import { resolveInWorkspace } from './workspace.mjs';
63
+ import { trustAuthoredCriteria } from './acceptance-consent.mjs';
64
+
65
+ /**
66
+ * ⚠️ NOT IMPORTED ON PURPOSE: `executeRunCommand`. This module is allowed to
67
+ * decide whether a criterion passed; it is not allowed to be a second way of
68
+ * starting a process. The wiring injects a runner (a closure over
69
+ * `executeRunCommand`, or over the sandbox runner in the browser client), which
70
+ * keeps every spawn in this package behind the one gate that was audited for it.
71
+ */
72
+
73
+ /** Where the declaration lives, so a resumed session inherits it. Same
74
+ * `.acuvo/` directory as `mcp.json` and the audit log. */
75
+ export const ACCEPTANCE_FILE = '.acuvo/acceptance.json';
76
+
77
+ /**
78
+ * ⚠️ FIVE. Not a round number chosen for tidiness — a run with nine acceptance
79
+ * criteria has no acceptance criterion, it has a wishlist, and the verdict
80
+ * degenerates into "something is red" which is what we already had. The refusal
81
+ * says which one to keep.
82
+ */
83
+ export const MAX_CRITERIA = 5;
84
+ /** A criterion longer than this is a paragraph, and a paragraph cannot exit 0. */
85
+ export const MAX_CRITERION_CHARS = 240;
86
+ /** Per criterion, in `check_acceptance`. The model pays for every character. */
87
+ export const MAX_OUTPUT_CHARS = 400;
88
+ /** Scanning more than this of a task string buys nothing and costs time. */
89
+ const MAX_TASK_SCAN = 8_000;
90
+
91
+ /** Bump when a stored field changes meaning; a stale file must not be read as
92
+ * a current one by a later version. */
93
+ export const ACCEPTANCE_SCHEMA_VERSION = 1;
94
+
95
+ /**
96
+ * The tools whose records are allowed to SATISFY a criterion. `run_program` is
97
+ * here because the browser client names its executor's verb that; both mean
98
+ * "a real process ran and this is its exit code".
99
+ */
100
+ const SATISFYING_TOOLS = new Set(['run_command', 'run_program']);
101
+ /**
102
+ * The tools that count as "something was executed at all" — which is a
103
+ * different question, and the difference is the whole point. `evaluate` ran
104
+ * code, so the run is not inert; it still cannot satisfy anything.
105
+ */
106
+ const EXECUTION_TOOLS = new Set(['run_command', 'run_program', 'evaluate']);
107
+
108
+ /* ────────────────────────────────────────────────────────────────────────────
109
+ * DERIVING — the pure half, and the part that has to be conservative
110
+ * ──────────────────────────────────────────────────────────────────────────── */
111
+
112
+ /**
113
+ * A command starts with one of these or it is not treated as a command. This is
114
+ * the whitelist that makes extraction safe to run over arbitrary prose: a
115
+ * backticked `lib/thing.mjs` or a quoted 'don't' cannot become a criterion,
116
+ * because neither starts with a program name.
117
+ */
118
+ const COMMAND_LEADS = [
119
+ 'npm', 'npx', 'pnpm', 'yarn', 'bun', 'node', 'deno', 'tsc', 'vitest', 'jest', 'mocha',
120
+ 'make', 'cargo', 'go', 'python3', 'python', 'pytest', 'ruby', 'rake', 'dotnet', 'gradle', 'mvn',
121
+ ];
122
+
123
+ /**
124
+ * ── ⚠️⚠️ `make` AND `go` ARE ALSO ORDINARY ENGLISH VERBS ─────────────────────
125
+ *
126
+ * Measured 2026-08-13 on sentences a person would actually type:
127
+ *
128
+ * "run npm test and make sure it passes" -> npm test, **make**
129
+ * "make sure the build works" -> **make**
130
+ * "please make certain the suite is green" -> **make certain**
131
+ * "go through the tests and make sure they pass"-> **go through**, **make**
132
+ *
133
+ * Every bolded one is a criterion the user never asked for, and it then reports
134
+ * `✖ UNMET — you asked that make pass; it was never run`. That is this
135
+ * package's worst failure mode by its own rule: correct work reported as failed,
136
+ * on the most ordinary phrasing in software — "make sure it passes" is how
137
+ * people ASK for verification, and asking for verification was inventing a
138
+ * phantom build.
139
+ *
140
+ * ⭐ ONLY THESE TWO LEADS NEED IT. Nobody writes "npm the thing" or "pytest the
141
+ * suite"; `make` and `go` are the only entries in COMMAND_LEADS that are common
142
+ * English verbs, so a general rule would cost precision for no gain.
143
+ *
144
+ * ⚠️ A STOPWORD LIST, NOT A TARGET WHITELIST, deliberately. Whitelisting known
145
+ * targets (`make test`, `make build`) would refuse a project's own real target —
146
+ * `make migrate`, `make e2e` — and refusing a criterion the user really typed is
147
+ * the same class of error in the other direction. These words are never a
148
+ * Makefile target or a `go` subcommand in any repository.
149
+ */
150
+ const ENGLISH_AFTER_LEAD = new Set([
151
+ 'sure', 'certain', 'it', 'its', "it's", 'them', 'they', 'the', 'a', 'an', 'this', 'that',
152
+ 'those', 'these', 'your', 'my', 'our', 'us', 'ahead', 'through', 'on', 'back', 'over',
153
+ 'and', 'to', 'into', 'for', 'no', 'not', 'any', 'all', 'both', 'each', 'everything',
154
+ ]);
155
+
156
+ /**
157
+ * Is this token a command lead, or just a word in a sentence?
158
+ * `make test` is a command. `make sure` is a request.
159
+ */
160
+ function leadIsCommand(lead, next) {
161
+ if (lead !== 'make' && lead !== 'go') return true;
162
+ const w = String(next ?? '').toLowerCase().replace(/[^a-z'-]/g, '');
163
+ // A lead with nothing after it is prose too — "just make it pass".
164
+ if (!w) return false;
165
+ return !ENGLISH_AFTER_LEAD.has(w);
166
+ }
167
+
168
+ /**
169
+ * ⚠️ THE OPENING QUOTE MUST BE IMMEDIATELY FOLLOWED BY A PROGRAM NAME, and the
170
+ * closing one is a backreference. Written the obvious way — `/'([^']+)'/` — the
171
+ * apostrophe in "don't run 'npm test'" opens a span, and the real criterion is
172
+ * eaten by the mispairing. Anchoring on the lead makes that impossible instead
173
+ * of unlikely.
174
+ */
175
+ const QUOTED_COMMAND = new RegExp(
176
+ `(['"\`])(${COMMAND_LEADS.join('|')})\\b([^'"\`\\n]{0,220})\\1`,
177
+ 'g',
178
+ );
179
+
180
+ /**
181
+ * A sentence has to CLAIM something before a command inside it is a criterion.
182
+ * Without this, "I used `npm test` yesterday" becomes a promise the run is then
183
+ * judged against.
184
+ */
185
+ const ACCEPTANCE_MARKER =
186
+ /\b(must|should|shall|has to|have to|needs? to|ensure|make sure|verify|verifies|confirm|prove|required|passes?|passing|green|succeeds?)\b|\bexits?\s+(?:with\s+)?(?:code\s+)?0\b/i;
187
+
188
+ /**
189
+ * ⭐ A PHRASE IS A CRITERION TOO, AND IT IS THE HONEST ONE. "Then run the test
190
+ * suite" names no command — probe 2's probe3 was given exactly that and ran
191
+ * nothing, then claimed a tick. Recording it with `command: null` means the
192
+ * verdict can say "you asked for the test suite and nothing was run" instead of
193
+ * the module inventing `npm test` and grading against a command the user never
194
+ * typed.
195
+ */
196
+ const PHRASE_PATTERNS = [
197
+ /\b(?:then\s+)?run\s+the\s+(?:full\s+|whole\s+|entire\s+)?(?:test\s+suite|tests|suite|unit\s+tests)\b/i,
198
+ /\bmake\s+sure\s+(?:the\s+|all\s+)?tests?\s+(?:still\s+)?pass(?:es)?\b/i,
199
+ /\ball\s+(?:the\s+)?tests?\s+(?:must\s+|should\s+)?pass\b/i,
200
+ ];
201
+
202
+ /** Words that end a bare command. `npm test must pass` is two tokens of
203
+ * command and then English. */
204
+ const STOP_WORDS = new Set([
205
+ 'must', 'should', 'shall', 'has', 'have', 'had', 'needs', 'need', 'will', 'would', 'can', 'could',
206
+ 'and', 'or', 'but', 'then', 'at', 'with', 'to', 'so', 'that', 'this', 'it', 'is', 'are', 'was',
207
+ 'were', 'be', 'exit', 'exits', 'exited', 'pass', 'passes', 'passing', 'passed', 'green', 'all',
208
+ 'the', 'a', 'an', 'in', 'on', 'of', 'from', 'after', 'before', 'when', 'if', 'does', 'do', 'still',
209
+ 'again', 'please', 'make', 'sure', 'ensure', 'verify', 'print', 'prints', 'printing', 'output',
210
+ 'outputs', 'usage', 'end', 'finally', 'also', 'without', 'error', 'errors', 'clean', 'first',
211
+ ]);
212
+
213
+ /** trim + collapse internal whitespace. Case-SENSITIVE: `NPM TEST` is not the
214
+ * command, and pretending otherwise would let a near-miss count. */
215
+ function normaliseCommand(raw) {
216
+ return String(raw).trim().replace(/\s+/g, ' ');
217
+ }
218
+
219
+ /** Sentence boundaries WITH offsets, so "order of appearance" is a real order.
220
+ * `:` is deliberately not a boundary — "It MUST pass: npm test" is one claim,
221
+ * and splitting it would strand the command in a sentence with no marker. */
222
+ function splitSentences(text) {
223
+ const out = [];
224
+ const boundary = /(?<=[.!?;])\s+|\n+/g;
225
+ let start = 0;
226
+ let m;
227
+ while ((m = boundary.exec(text)) !== null) {
228
+ out.push({ text: text.slice(start, m.index), start });
229
+ start = m.index + m[0].length;
230
+ }
231
+ out.push({ text: text.slice(start), start });
232
+ return out.filter((s) => s.text.trim().length > 0);
233
+ }
234
+
235
+ /** Strip the punctuation English leaves on the end of a token. Interior dots
236
+ * survive, because `todo.js` is a filename and `test.` is not a command. */
237
+ const cleanToken = (t) => t.replace(/[.,;:!?)\]]+$/, '');
238
+
239
+ /** Consume a bare command starting at token `i`, stopping at the first word
240
+ * that is English rather than argument. Bounded at 8 tokens. */
241
+ function bareCommandFrom(tokens, i) {
242
+ const parts = [];
243
+ for (let j = i; j < tokens.length && parts.length < 8; j += 1) {
244
+ const raw = tokens[j];
245
+ const cleaned = cleanToken(raw);
246
+ if (!cleaned) break;
247
+ if (j > i) {
248
+ if (!/^[A-Za-z0-9._\-/=:@]+$/.test(cleaned)) break;
249
+ if (STOP_WORDS.has(cleaned.toLowerCase())) break;
250
+ }
251
+ parts.push(cleaned);
252
+ if (/[.,;:!?]$/.test(raw)) break;
253
+ }
254
+ return parts.join(' ');
255
+ }
256
+
257
+ /**
258
+ * Extract the criteria a user literally named.
259
+ *
260
+ * PURE. Returns `[{ command, phrase, kind, confidence }]`, deduped, in order of
261
+ * appearance, at most MAX_CRITERIA. `command` is null for a phrase criterion.
262
+ *
263
+ * ⚠️ IT NEVER INVENTS A COMMAND. Every returned `command` is a substring of the
264
+ * input. "Then run the test suite" does NOT become `npm test`, however obvious
265
+ * that guess looks — an invented criterion fails a run that did the right thing
266
+ * under a different name, and once it does that twice nobody reads the verdict.
267
+ *
268
+ * ⚠️ `source` EXISTS TO STOP THE MODEL GRADING ITSELF. The only sources are the
269
+ * user's task text and an explicit `declare_acceptance` call. Pointed at the
270
+ * model's own output this returns nothing at all, because a model that can
271
+ * write its own criterion has no criterion — that is the failure being fixed,
272
+ * arriving through a different door.
273
+ *
274
+ * @param {unknown} taskText
275
+ * @param {{ source?: 'user' | 'model' | string }} [opts]
276
+ */
277
+ export function deriveAcceptance(taskText, { source = 'user' } = {}) {
278
+ if (source !== 'user') return [];
279
+ if (typeof taskText !== 'string' || !taskText.trim()) return [];
280
+ const text = taskText.slice(0, MAX_TASK_SCAN);
281
+
282
+ /** @type {{ command: string | null, phrase: string, kind: string, confidence: number, at: number }[]} */
283
+ const found = [];
284
+
285
+ for (const sentence of splitSentences(text)) {
286
+ const claims = ACCEPTANCE_MARKER.test(sentence.text);
287
+ const covered = [];
288
+
289
+ if (claims) {
290
+ QUOTED_COMMAND.lastIndex = 0;
291
+ let q;
292
+ while ((q = QUOTED_COMMAND.exec(sentence.text)) !== null) {
293
+ const command = normaliseCommand(`${q[2]}${q[3]}`);
294
+ covered.push([q.index, q.index + q[0].length]);
295
+ if (!command || command.length > MAX_CRITERION_CHARS) continue;
296
+ found.push({
297
+ command,
298
+ phrase: sentence.text.trim().slice(0, MAX_CRITERION_CHARS),
299
+ kind: q[1] === '`' ? 'backticked' : 'quoted',
300
+ confidence: q[1] === '`' ? 0.9 : 0.8,
301
+ at: sentence.start + q.index,
302
+ });
303
+ }
304
+
305
+ // Bare: `MUST pass: npm test`, and `npm test must pass at the end`.
306
+ const tokenRe = /\S+/g;
307
+ const tokens = [];
308
+ let t;
309
+ while ((t = tokenRe.exec(sentence.text)) !== null) tokens.push({ value: t[0], index: t.index });
310
+ for (let i = 0; i < tokens.length; i += 1) {
311
+ const { value, index } = tokens[i];
312
+ if (covered.some(([a, b]) => index >= a && index < b)) continue;
313
+ const lead = cleanToken(value).toLowerCase();
314
+ if (!COMMAND_LEADS.includes(lead)) continue;
315
+ // "make sure it passes" is a request for verification, not a build.
316
+ if (!leadIsCommand(lead, cleanToken(tokens[i + 1]?.value ?? ''))) continue;
317
+ const command = normaliseCommand(bareCommandFrom(tokens.slice(i).map((x) => x.value), 0));
318
+ if (!command || command.length > MAX_CRITERION_CHARS) continue;
319
+ found.push({
320
+ command,
321
+ phrase: sentence.text.trim().slice(0, MAX_CRITERION_CHARS),
322
+ kind: 'bare',
323
+ confidence: 0.6,
324
+ at: sentence.start + index,
325
+ });
326
+ }
327
+ }
328
+
329
+ // A phrase only counts when the sentence named no command — otherwise
330
+ // "run the tests with `npm test`" would be recorded twice, once vaguely.
331
+ const namedACommand = found.some(
332
+ (f) => f.at >= sentence.start && f.at < sentence.start + sentence.text.length,
333
+ );
334
+ if (!namedACommand) {
335
+ for (const pattern of PHRASE_PATTERNS) {
336
+ const m = sentence.text.match(pattern);
337
+ if (!m) continue;
338
+ found.push({
339
+ command: null,
340
+ phrase: m[0].trim().slice(0, MAX_CRITERION_CHARS),
341
+ kind: 'phrase',
342
+ confidence: 0.3,
343
+ at: sentence.start + (m.index ?? 0),
344
+ });
345
+ break; // one phrase per sentence; they overlap by design
346
+ }
347
+ }
348
+ }
349
+
350
+ found.sort((a, b) => a.at - b.at);
351
+ const seen = new Set();
352
+ const out = [];
353
+ for (const f of found) {
354
+ const key = f.command ?? `phrase:${f.phrase.toLowerCase()}`;
355
+ if (seen.has(key)) continue;
356
+ seen.add(key);
357
+ out.push({ command: f.command, phrase: f.phrase, kind: f.kind, confidence: f.confidence });
358
+ if (out.length >= MAX_CRITERIA) break;
359
+ }
360
+ return out;
361
+ }
362
+
363
+ /* ────────────────────────────────────────────────────────────────────────────
364
+ * DECLARING — the durable half
365
+ * ──────────────────────────────────────────────────────────────────────────── */
366
+
367
+ /** A workspace with no disk has nowhere to put this, and saying so beats
368
+ * writing a file into a Map nobody reads back. */
369
+ const isMemoryWorkspace = (root) => root === '(memory)' || root === undefined || root === null;
370
+
371
+ function acceptancePath(root) {
372
+ const r = resolveInWorkspace(root, ACCEPTANCE_FILE, 'write');
373
+ if (!r.ok) return { ok: false, error: `cannot use ${ACCEPTANCE_FILE} in this workspace: ${r.reason}` };
374
+ /**
375
+ * ⚠️ THE ASSERTION IS CHEAP AND THE FAILURE IT CATCHES IS NOT. This module is
376
+ * only ever allowed to write inside `.acuvo/`. `resolveInWorkspace` already
377
+ * guarantees "inside the workspace"; this guarantees "inside our own corner
378
+ * of it", so a future edit to ACCEPTANCE_FILE cannot quietly turn a verdict
379
+ * store into a file that overwrites someone's source.
380
+ */
381
+ if (r.relative !== ACCEPTANCE_FILE) {
382
+ return { ok: false, error: 'the acceptance file must live inside .acuvo/ — refusing to write anywhere else' };
383
+ }
384
+ return { ok: true, absolute: r.absolute, relative: r.relative };
385
+ }
386
+
387
+ /**
388
+ * For an `npm test` / `npm run <script>` criterion, ask the same question
389
+ * `executeRunCommand` will ask: can this runner execute what package.json says?
390
+ * Anything else is runnable as far as declaration can tell.
391
+ */
392
+ function npmChainVerdict(root, valid) {
393
+ if (valid.binary !== 'npm') return { ok: true };
394
+ const r = resolveInWorkspace(root, 'package.json', 'read');
395
+ if (!r.ok) return { ok: false, error: `cannot run npm here: ${r.reason}` };
396
+ let text;
397
+ try {
398
+ text = readFileSync(r.absolute, 'utf8');
399
+ } catch {
400
+ return { ok: false, error: 'cannot run npm here: there is no package.json in this workspace, so there is no script to run' };
401
+ }
402
+ const chain = validateNpmScriptChain(valid.npmScript, text);
403
+ return chain.ok ? { ok: true } : { ok: false, error: chain.error };
404
+ }
405
+
406
+ /**
407
+ * Record the exact strings that must pass. Called by the agent through
408
+ * `declare_acceptance`, or by the wiring with the output of `deriveAcceptance`.
409
+ *
410
+ * ⭐ EACH ONE IS VALIDATED AT DECLARATION TIME, AND THAT IS THE POINT. If the
411
+ * runner cannot execute `npm test` — because the script body contains a glob,
412
+ * say — the criterion is stored `runnable: false` WITH the runner's own reason,
413
+ * and the reason comes back immediately. The run then says
414
+ *
415
+ * "you asked for `npm test`, and this runner refuses its script body because
416
+ * of the glob"
417
+ *
418
+ * instead of quietly running something else and calling that verified, which is
419
+ * exactly what probe 1 run 1 did.
420
+ *
421
+ * @param {string} root
422
+ * @param {{ commands?: unknown }} args
423
+ */
424
+ export function declareAcceptance(root, { commands } = {}) {
425
+ if (isMemoryWorkspace(root)) {
426
+ return {
427
+ ok: false,
428
+ error: 'this workspace is held in memory, so there is no .acuvo/ to record criteria in. Pass the criteria straight to checkAcceptance instead of declaring them.',
429
+ };
430
+ }
431
+ if (!Array.isArray(commands)) {
432
+ return { ok: false, error: 'commands must be an array of command strings, e.g. ["npm test"]' };
433
+ }
434
+ if (commands.length === 0) {
435
+ return { ok: false, error: 'declare at least one command, or do not call this tool — an empty declaration is the same as declaring nothing' };
436
+ }
437
+ if (commands.length > MAX_CRITERIA) {
438
+ return {
439
+ ok: false,
440
+ error: `${commands.length} criteria were given and at most ${MAX_CRITERIA} are accepted. Declare only the commands whose failure means the task is not done — a longer list is a wishlist, and every entry weakens the verdict.`,
441
+ };
442
+ }
443
+
444
+ const criteria = [];
445
+ for (const raw of commands) {
446
+ if (typeof raw !== 'string') return { ok: false, error: 'every criterion must be a string' };
447
+ const command = normaliseCommand(raw);
448
+ if (!command) return { ok: false, error: 'a criterion cannot be empty' };
449
+ if (command.length > MAX_CRITERION_CHARS) {
450
+ return {
451
+ ok: false,
452
+ error: `a criterion is ${command.length} characters, over the ${MAX_CRITERION_CHARS} limit. Declare the command itself, not a description of it.`,
453
+ };
454
+ }
455
+ const verdict = validateCommand(command);
456
+ if (!verdict.ok) {
457
+ criteria.push({ command, runnable: false, reason: verdict.error });
458
+ continue;
459
+ }
460
+ /**
461
+ * ⚠️ `npm test` PASSES `validateCommand` AND STILL MAY NOT BE RUNNABLE, and
462
+ * that gap is the probe-1 defect exactly. `executeRunCommand` reads the
463
+ * script BODY before spawning npm (a script can be anything, including a
464
+ * glob this runner refuses) — so a declaration that stopped at the word
465
+ * "npm" would promise a criterion the runner will reject at the moment it
466
+ * matters, which is the moment the model has no rounds left to react.
467
+ *
468
+ * `validateNpmScriptChain` is the same pure function `executeRunCommand`
469
+ * uses, so the reason the model is given now is the reason it would have
470
+ * been given later, word for word.
471
+ */
472
+ const chained = npmChainVerdict(root, verdict);
473
+ criteria.push(chained.ok ? { command, runnable: true, reason: null } : { command, runnable: false, reason: chained.error });
474
+ }
475
+
476
+ const target = acceptancePath(root);
477
+ if (!target.ok) return target;
478
+ const payload = {
479
+ version: ACCEPTANCE_SCHEMA_VERSION,
480
+ declaredAt: new Date().toISOString(),
481
+ criteria,
482
+ };
483
+ try {
484
+ mkdirSync(dirname(target.absolute), { recursive: true });
485
+ writeFileSync(target.absolute, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
486
+ } catch (err) {
487
+ return { ok: false, error: `could not record the criteria: ${err instanceof Error ? err.message : String(err)}` };
488
+ }
489
+
490
+ /**
491
+ * ── ⭐ CONSENT ON AUTHORSHIP — WHY THIS FEATURE NEVER PROMPTS IN NORMAL USE ─
492
+ *
493
+ * A workspace acceptance file is now gated by consent, because a cloned
494
+ * repository shipping one could execute code and have it reported as ✔ (see
495
+ * `lib/acceptance-consent.mjs` for the proof). But criteria written HERE were
496
+ * written by the user's own session, milliseconds ago, at their instruction —
497
+ * asking them to approve that later would be asking them to consent to their
498
+ * own command, and a prompt with no real decision in it is one people learn
499
+ * to click through.
500
+ *
501
+ * ⚠️ Deliberately not fatal. Failing to record trust means the next run asks
502
+ * once, which is mildly annoying and completely safe; failing the DECLARATION
503
+ * because a trust file could not be written would break the feature to
504
+ * protect it.
505
+ */
506
+ try { trustAuthoredCriteria(criteria, { root }); } catch { /* the next run asks; nothing is lost */ }
507
+
508
+ const unrunnable = criteria.filter((c) => !c.runnable);
509
+ return {
510
+ ok: true,
511
+ path: target.relative,
512
+ criteria,
513
+ unrunnable,
514
+ /**
515
+ * ⚠️ RETURNED IMMEDIATELY, not saved for the summary. The model has rounds
516
+ * left right now; told at the end, "this runner cannot execute your
517
+ * criterion" is an obituary.
518
+ */
519
+ note: unrunnable.length
520
+ ? unrunnable.map((c) => `\`${c.command}\` cannot be run here: ${c.reason}`).join(' ')
521
+ : null,
522
+ };
523
+ }
524
+
525
+ /**
526
+ * Read back what a previous round (or a previous session) declared.
527
+ * `found: false` is not an error — most runs declare nothing.
528
+ */
529
+ export function loadAcceptance(root) {
530
+ if (isMemoryWorkspace(root)) return { ok: true, found: false, criteria: [] };
531
+ const target = acceptancePath(root);
532
+ if (!target.ok) return { ok: true, found: false, criteria: [] };
533
+ if (!existsSync(target.absolute)) return { ok: true, found: false, criteria: [] };
534
+ let parsed;
535
+ try {
536
+ parsed = JSON.parse(readFileSync(target.absolute, 'utf8'));
537
+ } catch (err) {
538
+ // Corrupt state must not read as "nothing was declared" — that is a silent
539
+ // downgrade to the behaviour this module exists to remove.
540
+ return { ok: false, error: `${ACCEPTANCE_FILE} is not valid JSON (${err instanceof Error ? err.message : String(err)}). Delete it or declare the criteria again.` };
541
+ }
542
+ const criteria = Array.isArray(parsed?.criteria) ? parsed.criteria.filter((c) => c && typeof c.command === 'string') : [];
543
+ return { ok: true, found: criteria.length > 0, criteria, declaredAt: parsed?.declaredAt ?? null };
544
+ }
545
+
546
+ /* ────────────────────────────────────────────────────────────────────────────
547
+ * EVALUATING — the rule that kills the false tick
548
+ * ──────────────────────────────────────────────────────────────────────────── */
549
+
550
+ /** Accept criteria as objects or as bare strings, so the wiring can pass
551
+ * either without a shim that could disagree with this one. */
552
+ function asCriterion(entry) {
553
+ if (typeof entry === 'string') {
554
+ const command = normaliseCommand(entry);
555
+ return command ? { command, phrase: command, kind: 'bare', runnable: true, reason: null } : null;
556
+ }
557
+ if (!entry || typeof entry !== 'object') return null;
558
+ const command = typeof entry.command === 'string' ? normaliseCommand(entry.command) : null;
559
+ const runnable = command ? entry.runnable !== false : false;
560
+ return {
561
+ command,
562
+ phrase: typeof entry.phrase === 'string' ? entry.phrase : (command ?? ''),
563
+ kind: typeof entry.kind === 'string' ? entry.kind : (command ? 'bare' : 'phrase'),
564
+ runnable,
565
+ reason: entry.reason ?? (command ? null : 'no command was named, so nothing can be run to satisfy it'),
566
+ };
567
+ }
568
+
569
+ /** The command a tool record actually ran, or null. Falls back to the
570
+ * arguments only when the result did not echo it — a refused command has no
571
+ * validated string of its own. */
572
+ function recordCommand(record) {
573
+ const fromResult = record?.result?.command;
574
+ if (typeof fromResult === 'string' && fromResult.trim()) return normaliseCommand(fromResult);
575
+ const fromArgs = record?.args?.command;
576
+ if (typeof fromArgs === 'string' && fromArgs.trim()) return normaliseCommand(fromArgs);
577
+ return null;
578
+ }
579
+
580
+ /**
581
+ * Decide the verdict. PURE.
582
+ *
583
+ * ⚠️ A CRITERION IS SATISFIED ONLY BY AN EXACT COMMAND MATCH AND exit 0. Not by
584
+ * output text ("all tests passed" is a string the model can write), not by a
585
+ * near-miss (`node --test test/api.test.js` is not `npm test`; that WAS the
586
+ * probe-1 defect, and the two commands there did genuinely different things),
587
+ * and never by an `evaluate` record.
588
+ *
589
+ * @param {{ declared?: unknown[], executed?: any[] }} args
590
+ */
591
+ export function evaluateAcceptance({ declared = [], executed = [] } = {}) {
592
+ const criteria = (Array.isArray(declared) ? declared : []).map(asCriterion).filter(Boolean).slice(0, MAX_CRITERIA);
593
+ const records = Array.isArray(executed) ? executed : [];
594
+
595
+ // Every command that a REAL runner exited 0 on. The only currency accepted.
596
+ const passedCommands = new Map(); // command → exitCode
597
+ const ranCommands = new Map(); // command → last exitCode seen (pass or fail)
598
+ let executedAnything = false;
599
+ let evaluateRan = false;
600
+
601
+ for (const r of records) {
602
+ const name = r?.name;
603
+ if (!EXECUTION_TOOLS.has(name)) continue;
604
+ if (r?.result?.ok !== true) continue; // a refusal is not an execution
605
+ executedAnything = true;
606
+ if (name === 'evaluate') { evaluateRan = true; continue; }
607
+ if (!SATISFYING_TOOLS.has(name)) continue;
608
+ const command = recordCommand(r);
609
+ if (!command) continue;
610
+ const exitCode = r.result.exitCode;
611
+ ranCommands.set(command, exitCode);
612
+ if (exitCode === 0) passedCommands.set(command, 0);
613
+ else passedCommands.delete(command); // a later red overrides an earlier green
614
+ }
615
+
616
+ const judged = criteria.map((c) => {
617
+ const ran = c.command !== null && ranCommands.has(c.command);
618
+ const satisfied = c.runnable && c.command !== null && passedCommands.has(c.command);
619
+ return {
620
+ command: c.command,
621
+ phrase: c.phrase,
622
+ kind: c.kind,
623
+ runnable: c.runnable,
624
+ reason: c.reason,
625
+ ran,
626
+ exitCode: ran ? ranCommands.get(c.command) : null,
627
+ satisfied,
628
+ };
629
+ });
630
+
631
+ const runnable = judged.filter((c) => c.runnable);
632
+ const unmet = runnable.filter((c) => !c.satisfied).map((c) => ({
633
+ command: c.command,
634
+ why: c.ran ? `it ran and exited ${c.exitCode}` : 'it was never run',
635
+ }));
636
+
637
+ const declaredSet = new Set(runnable.map((c) => c.command));
638
+ const incidental = [...passedCommands.keys()].filter((cmd) => !declaredSet.has(cmd)).slice(0, MAX_CRITERIA);
639
+ /** ⚠️ Named explicitly, because in three of the four probes it was the ONLY
640
+ * thing that "passed", and a verdict that omits it reads as "nothing ran". */
641
+ if (evaluateRan) incidental.push('evaluate');
642
+
643
+ let verdict;
644
+ let reason = null;
645
+ if (criteria.length === 0) {
646
+ verdict = 'none-declared';
647
+ reason = 'no acceptance criterion was declared for this run';
648
+ } else if (runnable.length === 0) {
649
+ verdict = 'unrunnable';
650
+ reason = judged.map((c) => c.reason).find(Boolean) ?? 'no declared criterion can be executed by this runner';
651
+ } else if (!executedAnything) {
652
+ verdict = 'not-run';
653
+ } else if (unmet.length === 0) {
654
+ verdict = 'met';
655
+ } else {
656
+ verdict = 'unmet';
657
+ }
658
+
659
+ return {
660
+ verdict,
661
+ reason,
662
+ criteria: judged,
663
+ unmet,
664
+ incidental,
665
+ declaredCount: criteria.length,
666
+ satisfiedCount: runnable.filter((c) => c.satisfied).length,
667
+ };
668
+ }
669
+
670
+ /**
671
+ * Three sentences that must never blur into each other, because the whole
672
+ * defect was one sentence being used for all three states.
673
+ *
674
+ * PURE.
675
+ */
676
+ export function formatVerdict(verdict) {
677
+ if (!verdict || typeof verdict !== 'object') return '— nothing was declared, so nothing here is a criterion';
678
+ const v = verdict.verdict;
679
+
680
+ if (v === 'none-declared') return '— nothing was declared, so nothing here is a criterion';
681
+
682
+ if (v === 'met') {
683
+ const met = verdict.criteria.filter((c) => c.satisfied).map((c) => `\`${c.command}\` exited 0`);
684
+ return `✔ MET — ${met.join('; ')}`;
685
+ }
686
+
687
+ if (v === 'unrunnable') {
688
+ const c = verdict.criteria[0] ?? {};
689
+ const named = c.command ? `\`${c.command}\`` : `"${c.phrase ?? 'the criterion'}"`;
690
+ // The runner's reason is a whole sentence of its own and already ends in a
691
+ // full stop as often as not; two in a row reads like a typo in the one line
692
+ // someone is meant to trust.
693
+ // ⚠️ AND IT SAYS WHEN IT CUT. Silently trimmed, the sentence read
694
+ // "…so run one plain command." — a truncation that looks like the runner's
695
+ // complete sentence is worse than a longer line.
696
+ const full = String(verdict.reason ?? 'no reason was recorded');
697
+ const cut = full.length > 220;
698
+ const because = `${full.slice(0, 220).replace(/[.\s]+$/, '')}${cut ? ' … (reason truncated)' : '.'}`;
699
+ return `⚠ NOT A VERDICT — you asked for ${named}, and it cannot be run here: ${because} Nothing in this run is checked against it.`;
700
+ }
701
+
702
+ // unmet / not-run — the same shape, deliberately, because both mean the thing
703
+ // you asked for did not pass. What differs is the tail.
704
+ /**
705
+ * ⚠️ ONE CRITERION KEEPS THE SHORT SENTENCE; SEVERAL PAIR EACH COMMAND WITH ITS
706
+ * OWN REASON. Joining the commands and the reasons as two separate lists
707
+ * produced "`a` and `b` pass; it was never run; it ran and exited 1", which
708
+ * leaves the reader to guess which reason belongs to which command — and
709
+ * guessing is the failure this whole module exists to remove.
710
+ */
711
+ const single = verdict.unmet.length <= 1;
712
+ const asked = single
713
+ ? verdict.unmet.map((u) => `\`${u.command}\``).join('')
714
+ : verdict.unmet.map((u) => `\`${u.command}\` (${u.why})`).join(' and ');
715
+ const why = v === 'not-run'
716
+ ? 'nothing was executed in this run'
717
+ : (single ? (verdict.unmet[0]?.why ?? 'it was never run') : 'none of them is satisfied');
718
+ const passers = verdict.incidental.filter((c) => c !== 'evaluate');
719
+ const tail = [];
720
+ if (passers.length) tail.push(`these did pass: ${passers.join(', ')}`);
721
+ if (verdict.incidental.includes('evaluate')) {
722
+ tail.push('`evaluate` exited 0, but it is a scratch snippet and can never satisfy a criterion');
723
+ }
724
+ return `✖ UNMET — you asked that ${asked} pass; ${why}${tail.length ? ` (${tail.join('; ')})` : ''}`;
725
+ }
726
+
727
+ /* ────────────────────────────────────────────────────────────────────────────
728
+ * CHECKING — running the criteria, through someone else's runner
729
+ * ──────────────────────────────────────────────────────────────────────────── */
730
+
731
+ /** Keep the tail: a test runner's verdict is at the bottom. */
732
+ function clampTail(text, max = MAX_OUTPUT_CHARS) {
733
+ const s = String(text ?? '').trim();
734
+ if (s.length <= max) return s;
735
+ return `… ${s.length - max} characters omitted …\n${s.slice(-max)}`;
736
+ }
737
+
738
+ /**
739
+ * Run every runnable criterion and return the verdict.
740
+ *
741
+ * ⚠️ `runner` IS REQUIRED AND IS THE ONLY WAY ANYTHING RUNS. This module does
742
+ * not import a spawner. The CLI injects a closure over `executeRunCommand`
743
+ * (allowlist, no shell, scrubbed environment); the browser client injects its
744
+ * sandbox. Neither is reimplemented here, which is what keeps there being one
745
+ * audited gate rather than two.
746
+ *
747
+ * @param {{ root?: string, runner?: (command: string) => Promise<any>, declared?: unknown[] }} args
748
+ */
749
+ export async function checkAcceptance({ root, runner, declared } = {}) {
750
+ if (typeof runner !== 'function') {
751
+ return {
752
+ ok: false,
753
+ error: 'check_acceptance has no runner. This is a wiring fault, not something to retry — the caller must inject one; nothing in this module may start a process by itself.',
754
+ };
755
+ }
756
+
757
+ let criteria = declared;
758
+ if (!Array.isArray(criteria)) {
759
+ if (isMemoryWorkspace(root)) {
760
+ const verdict = evaluateAcceptance({ declared: [], executed: [] });
761
+ return {
762
+ ok: true,
763
+ criteria: [],
764
+ verdict: { ...verdict, reason: 'this workspace is held in memory, so no criteria could be stored or read' },
765
+ };
766
+ }
767
+ const loaded = loadAcceptance(root);
768
+ if (!loaded.ok) return loaded;
769
+ criteria = loaded.criteria;
770
+ }
771
+
772
+ const normalised = criteria.map(asCriterion).filter(Boolean).slice(0, MAX_CRITERIA);
773
+ const rows = [];
774
+ /** Synthetic tool records, so ONE function decides the verdict whether the
775
+ * commands were run by the agent or by this tool. Two deciders would drift. */
776
+ const executed = [];
777
+
778
+ for (const c of normalised) {
779
+ if (!c.runnable || !c.command) {
780
+ rows.push({ command: c.command, ran: false, exitCode: null, passed: false, output: c.reason ?? 'not runnable' });
781
+ continue;
782
+ }
783
+ let result;
784
+ try {
785
+ result = await runner(c.command);
786
+ } catch (err) {
787
+ // A runner that throws is a bug in the wiring, not a failing criterion —
788
+ // reporting it as "exited 1" would blame the user's code for our fault.
789
+ rows.push({
790
+ command: c.command, ran: false, exitCode: null, passed: false,
791
+ output: `the runner threw: ${err instanceof Error ? err.message : String(err)}`,
792
+ });
793
+ continue;
794
+ }
795
+ if (!result || result.ok !== true) {
796
+ rows.push({
797
+ command: c.command, ran: false, exitCode: null, passed: false,
798
+ output: result?.error ?? 'the runner refused this command',
799
+ });
800
+ continue;
801
+ }
802
+ const exitCode = result.exitCode ?? null;
803
+ rows.push({
804
+ command: c.command,
805
+ ran: true,
806
+ exitCode,
807
+ passed: exitCode === 0 && result.timedOut !== true,
808
+ output: clampTail(`${result.stdout ?? ''}\n${result.stderr ?? ''}`),
809
+ });
810
+ executed.push({ name: 'run_command', args: { command: c.command }, result: { ok: true, command: c.command, exitCode } });
811
+ }
812
+
813
+ return { ok: true, criteria: rows, verdict: evaluateAcceptance({ declared: normalised, executed }) };
814
+ }
815
+
816
+ /* ────────────────────────────────────────────────────────────────────────────
817
+ * THE TOOLS
818
+ * ──────────────────────────────────────────────────────────────────────────── */
819
+
820
+ export function acceptanceToolSchemas() {
821
+ return [
822
+ {
823
+ type: 'function',
824
+ function: {
825
+ name: 'declare_acceptance',
826
+ description: [
827
+ 'Record the exact command(s) the USER said must pass, copied verbatim from their words.',
828
+ 'Call this ONCE, early, before doing the work — a criterion chosen after the fact is a summary, not a criterion.',
829
+ 'Do not paraphrase, do not substitute an equivalent command, and do not declare a command the user did not name:',
830
+ 'if they said `npm test`, declare `npm test`, even if you would rather run the test file directly.',
831
+ `At most ${MAX_CRITERIA} commands. If one cannot be run here you are told immediately, with the reason.`,
832
+ ].join(' '),
833
+ parameters: {
834
+ type: 'object',
835
+ properties: {
836
+ commands: {
837
+ type: 'array',
838
+ items: { type: 'string' },
839
+ description: 'The verbatim commands that must exit 0, e.g. ["npm test"].',
840
+ },
841
+ },
842
+ required: ['commands'],
843
+ },
844
+ },
845
+ },
846
+ {
847
+ type: 'function',
848
+ function: {
849
+ name: 'check_acceptance',
850
+ description: [
851
+ 'Run every declared acceptance criterion and report, per command, whether it exited 0.',
852
+ 'This is the only thing that clears the criteria — a passing `evaluate` snippet or a different',
853
+ 'command that happens to exit 0 never does.',
854
+ ].join(' '),
855
+ parameters: { type: 'object', properties: {}, required: [] },
856
+ },
857
+ },
858
+ ];
859
+ }