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/refute.mjs ADDED
@@ -0,0 +1,806 @@
1
+ /**
2
+ * ── ⭐⭐ THE SECOND OPINION — VERIFICATION THAT COSTS WHAT THE WORK COSTS ─────
3
+ *
4
+ * Every coding agent grades its own homework. It writes the code, decides the
5
+ * code is good, and reports success — and the failure mode this package has
6
+ * documented more than any other is not bad code, it is a TRUE-LOOKING CLAIM
7
+ * about code. Four separate probes in this repository's own history printed
8
+ * `✔ VERIFIED` over work that was wrong, incomplete, or never run.
9
+ *
10
+ * `acceptance.mjs` fixed half of that: the criterion is now the USER's command,
11
+ * decided before the work, and only that command exiting 0 satisfies it. What it
12
+ * cannot see is everything the criterion does not cover — a fix that passes the
13
+ * named test and breaks the caller, a function renamed in four places and used
14
+ * in five, a test quietly weakened to make itself pass.
15
+ *
16
+ * ⭐ SO A SECOND AGENT IS ASKED TO BREAK THE CLAIM. Not to review it, not to
17
+ * summarise it — to REFUTE it, with a fresh context and no sight of the first
18
+ * agent's reasoning, because inheriting the reasoning inherits the blind spot
19
+ * that produced it.
20
+ *
21
+ * ── ⭐⭐ AND THIS IS ONLY AFFORDABLE HERE ────────────────────────────────────
22
+ *
23
+ * A measured task on this stack costs $0.001–0.003. Doubling that to check the
24
+ * answer is a rounding error. An agent priced at frontier rates cannot make
25
+ * "verify everything, always" a default — it would double a bill somebody is
26
+ * already unhappy about — so this is a capability that follows from the cost
27
+ * base rather than from cleverness, and it is not one a competitor can simply
28
+ * decide to copy.
29
+ *
30
+ * ── ⚠️ THE BURDEN OF PROOF IS ON THE REFUTER, DELIBERATELY ──────────────────
31
+ *
32
+ * An adversarial reviewer that defaults to "something is probably wrong" fails
33
+ * correct work, which this package treats as worse than the bug it was hunting.
34
+ * So uncertainty is NOT a refutation: the refuter must produce a concrete,
35
+ * checkable reason — a command that fails, a caller that no longer resolves, a
36
+ * requirement in the task that nothing addresses. Anything softer is recorded
37
+ * as a doubt and changes no verdict.
38
+ *
39
+ * ⚠️ IT CANNOT WRITE. A refuter that fixes what it finds is no longer refuting,
40
+ * and its "fix" would land unreviewed on top of work somebody was about to
41
+ * inspect. Read and RUN only — running is essential, because the strongest
42
+ * refutation is a command that fails.
43
+ *
44
+ * ── ⚠️⚠️ AND IT MAY NOT RUN BLIND. THE SIGNAL GATE (added after measurement) ─
45
+ *
46
+ * The version of this file that shipped first was a PURE self-critique pass: a
47
+ * second model read the claim and decided, with no ground truth in front of it.
48
+ * That shape is measured to make results WORSE — intrinsic self-correction went
49
+ * down or flat in SIX settings out of six, and one benchmark lost 37.7 points in
50
+ * a single round. Paying for a second agent to degrade the first one's answer is
51
+ * the worst possible trade.
52
+ *
53
+ * ⭐ THE SAME MACHINERY WINS WHEN THE FEEDBACK IS REAL. Swapping a model's own
54
+ * feedback for an external signal moved repaired-and-passing from 33.3% to
55
+ * 52.6%, and one model's feedback handed to a weaker one beat BOTH models'
56
+ * self-repair. Cross-model was already solved here (`chooseRefuteModel`); the
57
+ * missing half was the signal.
58
+ *
59
+ * ⭐ SO THE RULE IS: AT LEAST ONE EXTERNAL SIGNAL, OR THE FIRST ANSWER SHIPS.
60
+ * A compiler exit code, a test result, or a linter result — obtained either from
61
+ * the builder's own run (free) or by running a DECLARED command before the
62
+ * critique starts. No signal and none obtainable means no critique, no spend and
63
+ * no change. Skipping is not a degradation here; it is the measured optimum.
64
+ */
65
+
66
+ import { existsSync, appendFileSync, statSync, mkdirSync } from 'node:fs';
67
+ import { join } from 'node:path';
68
+
69
+ import { REFUTER_TOOL_NAMES } from './refute-tools.mjs';
70
+ import { deriveAcceptance, loadAcceptance } from './acceptance.mjs';
71
+
72
+ /** Enough to look, run something, and look again. Not enough to go exploring. */
73
+ export const DEFAULT_REFUTE_ROUNDS = 4;
74
+ export const MAX_REFUTE_ROUNDS = 8;
75
+
76
+ /**
77
+ * ── ⚠️⚠️ THE SECOND OPINION WAS THE SAME BRAIN ─────────────────────────────
78
+ *
79
+ * `refuteClaim` passed the caller's `config` straight through, so the reviewer
80
+ * ran on THE SAME MODEL that produced the claim. That is not a second opinion;
81
+ * it is the same opinion asked twice. A model's mistakes are not random noise —
82
+ * they come from its training, and a second sample from the same distribution
83
+ * reproduces them. The one case a reviewer exists for, a blind spot, is exactly
84
+ * the case where an identical reviewer is guaranteed to share it.
85
+ *
86
+ * ⭐ SO THE RULE IS FAMILY DIVERSITY, NOT A HARDCODED ID. Hardcoding one model
87
+ * would be wrong the moment somebody sets `--model` to that same one — the
88
+ * check would silently become self-review again, which is the defect wearing a
89
+ * constant. The rule is "not the family that wrote the claim", so it stays true
90
+ * whatever the builder is set to.
91
+ *
92
+ * ⚠️ AND WHEN NO INDEPENDENT MODEL IS AVAILABLE IT SAYS SO RATHER THAN
93
+ * PRETENDING. A same-family refutation is still worth something — a fresh
94
+ * context with an adversarial prompt catches real mistakes — but it is WEAKER
95
+ * EVIDENCE, and a summary that presents both as "✔ could not refute it" is
96
+ * exactly the kind of quiet overclaim this package keeps finding in itself.
97
+ *
98
+ * ⭐ THE CANDIDATES ARE THE ONES THIS PACKAGE ALREADY TRUSTS: `buildChain`'s
99
+ * default fallbacks are `deepseek-chat`, `z-ai/glm-4.6` and `qwen3.7-flash`, so
100
+ * every id here is one the CLI already falls back to in production. Nothing new
101
+ * is being adopted on a hunch.
102
+ */
103
+ export const REFUTE_MODEL_ENV = 'ACUVO_REFUTE_MODEL';
104
+
105
+ /**
106
+ * ── ⭐⭐ THIS ORDER IS MEASURED, AND MY REASONING WAS WRONG ──────────────────
107
+ *
108
+ * I first put `z-ai/glm-4.6` at the front, arguing it is the strongest coder of
109
+ * the three and the reviewer's job is the harder half. That was a guess, and an
110
+ * A/B on identical correct work overturned it — all three asked to refute the
111
+ * same true claim, so the right answer was NOT REFUTED:
112
+ *
113
+ * z-ai/glm-4.6 no verdict (3 of 3 runs) $0.004348
114
+ * deepseek-v4-flash NOT REFUTED ✔ $0.000909 ← same brain
115
+ * qwen/qwen3.7-flash NOT REFUTED ✔ $0.000486
116
+ *
117
+ * ⚠️ GLM NEVER PRODUCED A PARSEABLE VERDICT. `parseRefuteVerdict` needs the last
118
+ * line to be `REFUTED:` or `NOT REFUTED:`; GLM ended three of three runs some
119
+ * other way, so its answer decided nothing while costing nine times the winner.
120
+ * A reviewer that cannot deliver a verdict is not a reviewer, however good its
121
+ * reading is — and "strongest coder" was never the relevant question.
122
+ *
123
+ * ⭐ SO QWEN LEADS: it is INDEPENDENT of the DeepSeek builder, it obeys the
124
+ * format, and at $0.000486 it is 47% CHEAPER than the same-brain reviewer it
125
+ * replaces. Independence stopped being a cost and became a discount.
126
+ *
127
+ * ⚠️ GLM IS KEPT, NOT DELETED. Three runs on one task is not grounds to retire
128
+ * a model — only to stop making it the default. It stays as the fallback for a
129
+ * qwen-family builder, where something must review it.
130
+ */
131
+ export const REFUTE_CANDIDATES = Object.freeze(['qwen/qwen3.7-flash', 'z-ai/glm-4.6', 'deepseek/deepseek-chat']);
132
+
133
+ /**
134
+ * The vendor prefix — `deepseek/deepseek-v4-pro-0813` → `deepseek`.
135
+ *
136
+ * ⚠️ FAMILY, NOT MODEL ID. `deepseek-v4-flash` and `deepseek-v4-pro` are
137
+ * different models that share a lineage, so pro reviewing flash is a weaker
138
+ * check than it looks. Comparing ids would have called that independent.
139
+ */
140
+ export function familyOf(modelId) {
141
+ const text = String(modelId ?? '').trim().toLowerCase();
142
+ if (!text) return '';
143
+ const slash = text.indexOf('/');
144
+ return slash === -1 ? text : text.slice(0, slash);
145
+ }
146
+
147
+ /**
148
+ * Choose the model that reviews a claim.
149
+ *
150
+ * @param {string} builderModel the model that produced the claim
151
+ * @param {Record<string,string|undefined>} [env]
152
+ * @returns {{ model: string, independent: boolean, why: string }}
153
+ */
154
+ export function chooseRefuteModel(builderModel, env = process.env) {
155
+ const builder = String(builderModel ?? '').trim();
156
+ const builderFamily = familyOf(builder);
157
+
158
+ /**
159
+ * ⚠️ AN EXPLICIT OVERRIDE WINS, INCLUDING A DELIBERATELY SAME-FAMILY ONE —
160
+ * but it is still reported honestly. Someone pinning both to one model has a
161
+ * reason; they should not also get a stronger-sounding verdict for it.
162
+ */
163
+ const chosen = String(env?.[REFUTE_MODEL_ENV] ?? '').trim();
164
+ if (chosen) {
165
+ const independent = familyOf(chosen) !== builderFamily;
166
+ return {
167
+ model: chosen,
168
+ independent,
169
+ why: independent
170
+ ? `${REFUTE_MODEL_ENV} names ${chosen}, a different family from the builder's ${builderFamily || 'model'}`
171
+ : `${REFUTE_MODEL_ENV} names ${chosen}, which is the same family as the builder — the review is not independent`,
172
+ };
173
+ }
174
+
175
+ const other = REFUTE_CANDIDATES.find((m) => familyOf(m) !== builderFamily && m !== builder);
176
+ if (other) {
177
+ return { model: other, independent: true, why: `reviewed by ${other}, a different family from the builder's ${builderFamily}` };
178
+ }
179
+ return {
180
+ model: builder,
181
+ independent: false,
182
+ why: `no reviewer from a family other than ${builderFamily} is configured, so the claim is being checked by its own author — a weaker check`,
183
+ };
184
+ }
185
+
186
+ /* ────────────────────────────────────────────────────────────────────────────
187
+ * THE EXTERNAL SIGNAL — the thing that decides whether a critique may happen
188
+ * ──────────────────────────────────────────────────────────────────────────── */
189
+
190
+ /**
191
+ * ⭐ THE FOUR KINDS THAT COUNT, AND WHY `program` IS NOT ONE OF THEM.
192
+ *
193
+ * The gate is "a compiler exit code, a test result, or a linter result" — a
194
+ * build is admitted alongside them because it IS a compiler exit code wearing an
195
+ * npm script's name. `node server.mjs` exiting 0 is an external fact and still
196
+ * not a check: it says a process started, not that the work is right. Admitting
197
+ * it would let the gate be satisfied by anything the command allowlist happens
198
+ * to permit, which is the gate not existing.
199
+ */
200
+ export const SIGNAL_KINDS = Object.freeze(['test', 'typecheck', 'lint', 'build']);
201
+
202
+ /**
203
+ * ⚠️ TWO, NOT "ALL OF THEM". Every candidate is a real process with real
204
+ * wall-clock time, spent BEFORE the user sees any second opinion at all. A gate
205
+ * that quietly turns into a four-command build is a gate people switch off.
206
+ */
207
+ export const MAX_SIGNAL_CANDIDATES = 2;
208
+
209
+ /** Where the durable record of every pass goes. One line, appended. */
210
+ export const REFUTE_LOG_FILE = join('.acuvo', 'refute-log.jsonl');
211
+
212
+ /** ⚠️ Bounded, because a log nobody rotates is a disk leak wearing a feature's
213
+ * name. 512KB is ~4,000 passes, far more than anyone will measure at once. */
214
+ export const REFUTE_LOG_MAX_BYTES = 512 * 1024;
215
+
216
+ /**
217
+ * What kind of check is this command, if any?
218
+ *
219
+ * ⚠️ ORDER MATTERS AND IS NOT ALPHABETICAL. `npm run test:types` is a test run
220
+ * whatever else its name contains, and `npx tsc --noEmit` is a typecheck even
221
+ * though `tsc` also builds — so the more specific intent is matched first and
222
+ * `build` is the residual. Getting this backwards would file the project's whole
223
+ * test suite under "build" and change nothing visible until someone read a
224
+ * measurement that was wrong.
225
+ */
226
+ export function classifySignal(command) {
227
+ const c = String(command ?? '').trim().toLowerCase();
228
+ if (!c) return null;
229
+ if (/(^|\s)(node\s+--test|jest|vitest|mocha|ava|tap)(\s|$)/.test(c)) return 'test';
230
+ if (/(^|\s)(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/.test(c)) return 'test';
231
+ if (/\btest\b/.test(c) && /(^|\s)(npm|pnpm|yarn|bun|npx)\b/.test(c)) return 'test';
232
+ if (/(^|\s)tsc(\s|$)|--noemit|\btypecheck\b|\btype-check\b/.test(c)) return 'typecheck';
233
+ if (/\beslint\b|\bbiome\b|\bruff\b|\blint\b/.test(c)) return 'lint';
234
+ if (/\bbuild\b|\bcompile\b/.test(c)) return 'build';
235
+ return 'program';
236
+ }
237
+
238
+ /** The command a tool record actually ran, mirroring `acceptance.mjs` so the two
239
+ * can never disagree about what "the command" was. */
240
+ function commandOfRecord(record) {
241
+ const fromResult = record?.result?.command;
242
+ if (typeof fromResult === 'string' && fromResult.trim()) return fromResult.trim();
243
+ const fromArgs = record?.args?.command;
244
+ if (typeof fromArgs === 'string' && fromArgs.trim()) return fromArgs.trim();
245
+ return null;
246
+ }
247
+
248
+ /**
249
+ * Signals the BUILDER already paid for. Free evidence — the run is over, the
250
+ * exit codes are recorded, and re-running them would buy nothing but latency.
251
+ *
252
+ * ⚠️ ONLY REAL EXECUTIONS. `result.ok !== true` is a REFUSAL, not a run, and an
253
+ * `evaluate` record is the model's own sandbox — three of the four false-✔
254
+ * probes in this package's history "passed" on exactly that. Counting either
255
+ * would let the gate be satisfied by something that never checked anything.
256
+ *
257
+ * ⭐ RED SORTS FIRST. A failing check is the informative one: handing the critic
258
+ * a green lint while the tests are red is how a grounded review still misses the
259
+ * only fact that mattered.
260
+ *
261
+ * PURE.
262
+ */
263
+ export function signalsInHand(executed) {
264
+ const records = Array.isArray(executed) ? executed : [];
265
+ const out = [];
266
+ for (const r of records) {
267
+ if (r?.name !== 'run_command' && r?.name !== 'run_program') continue;
268
+ if (r?.result?.ok !== true) continue;
269
+ const exitCode = r.result.exitCode;
270
+ if (!Number.isInteger(exitCode)) continue;
271
+ const command = commandOfRecord(r);
272
+ if (!command) continue;
273
+ const kind = classifySignal(command);
274
+ if (!SIGNAL_KINDS.includes(kind)) continue;
275
+ // ⚠️ A TIMED-OUT COMMAND DID NOT PASS whatever exit code the killer left
276
+ // behind — the same rule `checkAcceptance` applies, so the two cannot drift.
277
+ out.push({ kind, command, exitCode, passed: exitCode === 0 && r.result.timedOut !== true, source: 'builder-run' });
278
+ }
279
+ // Stable: reds keep their relative order, then greens keep theirs.
280
+ return [...out.filter((s) => !s.passed), ...out.filter((s) => s.passed)];
281
+ }
282
+
283
+ /**
284
+ * The commands we are willing to RUN to obtain a signal.
285
+ *
286
+ * ⚠️ IT NEVER INVENTS ONE. Either the run declared a criterion, or the user's
287
+ * own task text named a command — `deriveAcceptance` is the same pure extractor
288
+ * acceptance uses, and it is documented to return nothing rather than guess
289
+ * `npm test` from "run the tests". An invented command runs somebody else's
290
+ * script on their machine to satisfy a gate they did not ask for.
291
+ *
292
+ * PURE.
293
+ */
294
+ export function signalCandidates({ task, acceptance = null } = {}) {
295
+ const seen = new Set();
296
+ const out = [];
297
+ const take = (command) => {
298
+ const c = String(command ?? '').trim();
299
+ if (!c || seen.has(c)) return;
300
+ if (!SIGNAL_KINDS.includes(classifySignal(c))) return;
301
+ seen.add(c);
302
+ out.push(c);
303
+ };
304
+
305
+ for (const c of Array.isArray(acceptance) ? acceptance : []) {
306
+ if (typeof c === 'string') { take(c); continue; }
307
+ if (c && typeof c === 'object' && c.runnable !== false) take(c.command);
308
+ }
309
+ for (const c of deriveAcceptance(task, { source: 'user' })) take(c.command);
310
+ return out;
311
+ }
312
+
313
+ /**
314
+ * Get one external signal, or say why there is none.
315
+ *
316
+ * @param {object} args
317
+ * @param {string} args.task
318
+ * @param {object} args.executor needs `.root` and `.dryRun`
319
+ * @param {any[]} [args.executed] the builder's own tool records
320
+ * @param {unknown[]|null} [args.acceptance] declared criteria; read from disk when absent
321
+ * @param {(command:string)=>Promise<any>} [args.runner] the `checkAcceptance` runner contract
322
+ * @param {number} [args.commandTimeoutMs]
323
+ * @returns {Promise<{signal: object|null, reason: string|null, tried: string[]}>}
324
+ */
325
+ export async function acquireExternalSignal({
326
+ task, executor, executed = [], acceptance = null, runner = null, commandTimeoutMs,
327
+ } = {}) {
328
+ const inHand = signalsInHand(executed);
329
+ if (inHand.length > 0) return { signal: inHand[0], reason: null, tried: [] };
330
+
331
+ let declared = acceptance;
332
+ if (!Array.isArray(declared)) {
333
+ // ⚠️ A corrupt acceptance file is NOT "nothing was declared" — but it is
334
+ // also not worth failing a run over here, so it degrades to no candidates
335
+ // and the honest "no signal" message rather than to a blind critique.
336
+ const loaded = executor?.root ? loadAcceptance(executor.root) : { ok: true, criteria: [] };
337
+ declared = loaded.ok ? (loaded.criteria ?? []) : [];
338
+ }
339
+
340
+ const candidates = signalCandidates({ task, acceptance: declared });
341
+ if (candidates.length === 0) {
342
+ return {
343
+ signal: null,
344
+ tried: [],
345
+ reason: 'no test, typecheck or lint command is declared for this run or named in the task, so there is nothing to check the claim against',
346
+ };
347
+ }
348
+
349
+ /**
350
+ * ⚠️ A DRY RUN CANNOT OBTAIN ONE, and must not pretend otherwise. `--dry-run`
351
+ * promises the disk is untouched and a command is free to write, so the
352
+ * refusal belongs here rather than deep inside the runner where the reason
353
+ * would arrive as an opaque error.
354
+ */
355
+ if (executor?.dryRun === true) {
356
+ return { signal: null, tried: [], reason: 'this is a --dry-run, so no command can be executed and no external signal can be obtained' };
357
+ }
358
+
359
+ /**
360
+ * ⭐ THE SAME GATE `run_command` GOES THROUGH — allowlist, no shell, scrubbed
361
+ * environment, bounded timeout. This module does not spawn anything itself,
362
+ * for the same reason `checkAcceptance` does not: two spawners means two sets
363
+ * of rules and only one of them gets audited. The runner is injectable so the
364
+ * unit tests never start a process.
365
+ */
366
+ const run = runner ?? (async (command) => {
367
+ const { executeRunCommand } = await import('./command.mjs');
368
+ return executeRunCommand({ command, executor, ...(Number.isFinite(commandTimeoutMs) ? { timeoutMs: commandTimeoutMs } : {}) });
369
+ });
370
+
371
+ const tried = [];
372
+ for (const command of candidates.slice(0, MAX_SIGNAL_CANDIDATES)) {
373
+ tried.push(command);
374
+ let result;
375
+ try {
376
+ result = await run(command);
377
+ } catch (err) {
378
+ // A runner that throws is a wiring fault, not a failing check — blaming
379
+ // the user's code for it would be the "check that fails correct work" trap.
380
+ result = { ok: false, error: err instanceof Error ? err.message : String(err) };
381
+ }
382
+ if (result?.ok !== true || !Number.isInteger(result.exitCode)) continue;
383
+ return {
384
+ signal: {
385
+ kind: classifySignal(command),
386
+ command,
387
+ exitCode: result.exitCode,
388
+ passed: result.exitCode === 0 && result.timedOut !== true,
389
+ source: 'refuter-acquired',
390
+ },
391
+ reason: null,
392
+ tried,
393
+ };
394
+ }
395
+
396
+ return {
397
+ signal: null,
398
+ tried,
399
+ reason: `no external signal could be obtained — ${tried.map((c) => `\`${c}\``).join(', ')} could not be run here`,
400
+ };
401
+ }
402
+
403
+ /** The one sentence the critic and the human both read. */
404
+ export function formatSignal(signal) {
405
+ if (!signal) return 'none';
406
+ const who = signal.source === 'builder-run' ? 'run by the agent during the work' : 'run again just now, on the workspace as it stands';
407
+ return `\`${signal.command}\` exited ${signal.exitCode} (${signal.kind}, ${who})`;
408
+ }
409
+
410
+ /**
411
+ * The whole prompt. Written out rather than assembled, because the exact framing
412
+ * is the mechanism: "find what is wrong" produces invented findings, and "check
413
+ * the work" produces agreement.
414
+ *
415
+ * ⭐ AND THE SIGNAL IS IN IT. That is the difference between the measured-worse
416
+ * shape (a model critiquing from its own reading) and the measured-better one
417
+ * (a model reasoning from real feedback). Obtaining the signal and not showing
418
+ * it to the critic would be paying for both halves and using neither.
419
+ */
420
+ export function refutePrompt({ task, claim, signal = null }) {
421
+ return [
422
+ 'You are the SECOND opinion on work another agent has just finished. You did not do it and you have not seen how it was done.',
423
+ '',
424
+ 'THE TASK IT WAS GIVEN:',
425
+ task,
426
+ '',
427
+ 'WHAT IT CLAIMS IT DID:',
428
+ claim || '(it made no claim)',
429
+ '',
430
+ ...(signal ? [
431
+ 'THE EXTERNAL SIGNAL THAT HAS ALREADY BEEN OBTAINED — this is ground truth, not an opinion:',
432
+ ` ${formatSignal(signal)}`,
433
+ '',
434
+ 'START FROM THAT FACT. It is the only thing in this prompt that was not written by a model.',
435
+ signal.passed
436
+ ? 'It passed, so a refutation has to explain what that command does not cover — not merely doubt it.'
437
+ : 'It did NOT pass, so begin by finding out whether that failure is what the task asked to be fixed.',
438
+ '',
439
+ ] : []),
440
+ 'YOUR JOB IS TO REFUTE THAT CLAIM, not to review it and not to improve it.',
441
+ 'Look at the workspace as it is now. Run the tests. Run the thing. Grep for the callers.',
442
+ 'You are trying to find a SPECIFIC, CHECKABLE reason the claim is false — for example:',
443
+ ' · a command that fails when the claim says it passes',
444
+ ' · a caller, import or reference that no longer resolves',
445
+ ' · a requirement stated in the task that nothing in the workspace addresses',
446
+ ' · a test that was changed to make itself pass rather than the code fixed',
447
+ '',
448
+ '⚠️ YOU MAY NOT WRITE, EDIT OR DELETE ANYTHING. You have no tools that can.',
449
+ '',
450
+ '⚠️ UNCERTAINTY IS NOT A REFUTATION. "This could be fragile", "there may be edge cases",',
451
+ '"I would have done it differently" — none of those are refutations, and reporting them as',
452
+ 'though they were will fail work that is correct, which is worse than missing a bug.',
453
+ 'If you cannot find a concrete reason the claim is false, say so plainly.',
454
+ '',
455
+ 'Finish with exactly one of these two lines, on its own line, as the last line of your reply:',
456
+ ' REFUTED: <the specific reason, and the evidence you got it from>',
457
+ ' NOT REFUTED: <what you checked>',
458
+ ].join('\n');
459
+ }
460
+
461
+ /**
462
+ * ⚠️ PARSED FROM THE LAST LINE, AND ONLY THE LAST LINE. A model discussing the
463
+ * word "REFUTED" mid-answer ("I could not find anything that would be REFUTED
464
+ * by the tests") must not flip a verdict. Anchoring to the final line makes the
465
+ * verdict a thing it has to DECIDE rather than a word it happens to use.
466
+ *
467
+ * ⚠️ AND AN UNPARSEABLE REPLY IS **NOT REFUTED**, not an error. The burden is on
468
+ * the refuter; a second opinion that could not express itself has not made a
469
+ * case, and failing the run on that would be the "check that fails correct
470
+ * work" trap wearing a new hat.
471
+ */
472
+ export function parseRefuteVerdict(text) {
473
+ const lines = String(text ?? '').trim().split('\n').map((l) => l.trim()).filter(Boolean);
474
+ const last = lines[lines.length - 1] ?? '';
475
+ if (/^REFUTED\s*:/i.test(last)) {
476
+ const reason = last.replace(/^REFUTED\s*:\s*/i, '').trim();
477
+ // A refutation with no reason is an assertion, and an assertion is not evidence.
478
+ if (reason.length < 12) return { refuted: false, reason: '', unclear: true, note: `the refuter answered "${last}" with no reason, so nothing was proven` };
479
+ return { refuted: true, reason };
480
+ }
481
+ if (/^NOT\s+REFUTED\s*:/i.test(last)) {
482
+ return { refuted: false, reason: last.replace(/^NOT\s+REFUTED\s*:\s*/i, '').trim() };
483
+ }
484
+ return { refuted: false, reason: '', unclear: true, note: 'the refuter did not end with a verdict line, so its answer decides nothing' };
485
+ }
486
+
487
+ /**
488
+ * Run the second opinion.
489
+ *
490
+ * @param {object} args
491
+ * @param {string} args.task what the first agent was asked to do
492
+ * @param {string} args.claim what it says it did
493
+ * @param {object} args.executor
494
+ * @param {object} args.config
495
+ * @param {number} [args.budgetUsd] the refuter's ceiling — normally the parent's remainder
496
+ * @param {any[]} [args.executed] the BUILDER's tool records — free external signal
497
+ * @param {unknown[]|null} [args.acceptance] declared criteria; read from disk when absent
498
+ * @param {Function} [args.runner] command runner, `checkAcceptance`'s contract
499
+ * @param {Function} [args.sessionImpl] injected for tests
500
+ */
501
+ export async function refuteClaim({
502
+ task, claim, executor, config, budgetUsd = null, fleetGate = null,
503
+ maxRounds = DEFAULT_REFUTE_ROUNDS, commandTimeoutMs, onEvent, sessionImpl = null,
504
+ executed = [], acceptance = null, runner = null,
505
+ env = process.env,
506
+ } = {}) {
507
+ if (!task || !String(task).trim()) {
508
+ return { ok: false, error: 'a second opinion needs the original task — without it there is no claim to test' };
509
+ }
510
+
511
+ /**
512
+ * ── ⚠️⚠️ THE GATE, AND IT COMES FIRST — BEFORE ANY SPEND ──────────────────
513
+ *
514
+ * A critique with no external signal is measured to make answers WORSE (down
515
+ * or flat in six settings of six; −37.7 points on one benchmark in a single
516
+ * round). So the order is: get ground truth, THEN critique. If ground truth is
517
+ * not obtainable, the first answer ships untouched and this costs nothing —
518
+ * which is strictly better than paying a second model to guess.
519
+ */
520
+ const acquired = await acquireExternalSignal({ task, executor, executed, acceptance, runner, commandTimeoutMs });
521
+ const signal = acquired.signal;
522
+ if (!signal) {
523
+ const record = {
524
+ ok: true, ran: false, skipped: true, refuted: false, unclear: false,
525
+ hadSignal: false, changedAnswer: false, signal: null,
526
+ reason: acquired.reason ?? 'no external signal could be obtained',
527
+ costUsd: 0, roundsUsed: 0,
528
+ reviewerModel: null, independent: null,
529
+ };
530
+ logRefutation(executor, record);
531
+ return record;
532
+ }
533
+
534
+ const run = sessionImpl ?? (await import('./turn.mjs')).runSession;
535
+ /**
536
+ * ⭐ THE REVIEWER'S OWN MODEL. `config` used to go through untouched, which
537
+ * made the second opinion the same brain as the first — see
538
+ * `chooseRefuteModel`. The builder's config is spread so every other
539
+ * setting (key, gateway url, timeouts) is unchanged; only the model moves.
540
+ */
541
+ const reviewer = chooseRefuteModel(config?.model, env);
542
+ const reviewerConfig = { ...config, model: reviewer.model };
543
+ const rounds = Math.min(Math.max(1, Math.floor(maxRounds) || DEFAULT_REFUTE_ROUNDS), MAX_REFUTE_ROUNDS);
544
+
545
+ let outcome;
546
+ try {
547
+ outcome = await run({
548
+ task: refutePrompt({ task, claim, signal }),
549
+ executor,
550
+ config: reviewerConfig,
551
+ maxRounds: rounds,
552
+ /**
553
+ * ⚠️ `allowRun` TRUE, and it is the point. The strongest refutation
554
+ * available is a command that fails, and a reviewer that can only read is
555
+ * reduced to opinion — which this prompt explicitly refuses to accept.
556
+ */
557
+ allowRun: true,
558
+ toolNames: [...REFUTER_TOOL_NAMES],
559
+ budgetUsd: Number.isFinite(budgetUsd) ? budgetUsd : null,
560
+ fleetGate,
561
+ commandTimeoutMs,
562
+ onEvent,
563
+ });
564
+ } catch (err) {
565
+ // ⚠️ A dead refuter must not fail work that may be perfectly good.
566
+ const dead = { ok: false, ran: true, error: `the second opinion crashed: ${err?.message ?? String(err)}`, costUsd: 0, reviewerModel: reviewer.model, independent: reviewer.independent, hadSignal: true, signal, changedAnswer: false };
567
+ // ⚠️ A CRASH IS A DATA POINT TOO. Left unlogged, the measurement would read
568
+ // "grounded passes always produce a verdict" because the ones that did not
569
+ // were the only rows missing — the survivorship error, in our own numbers.
570
+ logRefutation(executor, dead);
571
+ return dead;
572
+ }
573
+
574
+ const usage = outcome?.usage ?? null;
575
+ const costUsd = Number.isFinite(usage?.cost) ? usage.cost : 0;
576
+ if (!outcome || outcome.ok !== true) {
577
+ const empty = { ok: false, ran: true, error: outcome?.error ?? 'the second opinion returned nothing', costUsd, reviewerModel: reviewer.model, independent: reviewer.independent, hadSignal: true, signal, changedAnswer: false };
578
+ logRefutation(executor, empty);
579
+ return empty;
580
+ }
581
+
582
+ const verdict = parseRefuteVerdict(outcome.content ?? outcome.note ?? '');
583
+ /**
584
+ * ── ⭐⭐ THE INSTRUMENTATION, AND WHAT `changedAnswer` ACTUALLY MEANS ───────
585
+ *
586
+ * The CLI flips the exit code on ONE outcome and one only: a concrete
587
+ * refutation. A clearance and an unparseable reply both leave the first
588
+ * verdict exactly as it was — so those cost money and changed nothing, and a
589
+ * measurement that counted them as "the refuter did something" would make a
590
+ * useless pass look busy. `changedAnswer` is therefore literally "did this
591
+ * pass alter what the user is about to act on".
592
+ *
593
+ * ⭐ Paired with `hadSignal` it answers the question this feature has to earn
594
+ * its keep on: does a grounded refutation change answers more often than a
595
+ * blind one? Today `hadSignal` is always true when `ran` is true — the gate
596
+ * guarantees it — so the comparison the log supports is grounded-vs-skipped,
597
+ * and the skipped rows carry the reason they were skipped.
598
+ */
599
+ const result = {
600
+ ok: true, ran: true, ...verdict, costUsd, roundsUsed: outcome.roundsUsed ?? 0,
601
+ reviewerModel: reviewer.model, independent: reviewer.independent, reviewerWhy: reviewer.why,
602
+ hadSignal: true, signal, changedAnswer: verdict.refuted === true,
603
+ };
604
+ logRefutation(executor, result);
605
+ return result;
606
+ }
607
+
608
+ /**
609
+ * ── ⭐ ONE JSON LINE PER PASS, SO SOMEBODY CAN ACTUALLY MEASURE THIS ─────────
610
+ *
611
+ * The whole reason this gate exists is a measurement somebody else took. The
612
+ * next person deserves one of ours: how often a pass had a signal, how often it
613
+ * changed the answer, what kind of check it was grounded in, and what it cost.
614
+ *
615
+ * ⚠️ IT MUST NEVER BREAK A RUN. A log that can fail the work it observes is a
616
+ * worse defect than the one it was added to study, so every failure here is
617
+ * swallowed and reported in the return value rather than thrown.
618
+ *
619
+ * @returns {{ok: boolean, path: string|null, reason?: string}}
620
+ */
621
+ export function recordRefutation(root, record, { file = REFUTE_LOG_FILE, maxBytes = REFUTE_LOG_MAX_BYTES } = {}) {
622
+ if (typeof root !== 'string' || !root.trim()) return { ok: false, path: null, reason: 'no workspace root' };
623
+ const path = join(root, file);
624
+ try {
625
+ if (existsSync(path) && statSync(path).size >= maxBytes) {
626
+ return { ok: false, path, reason: `${file} has reached ${maxBytes} bytes — delete it to keep recording` };
627
+ }
628
+ mkdirSync(join(root, '.acuvo'), { recursive: true });
629
+ appendFileSync(path, `${JSON.stringify({ at: new Date().toISOString(), ...record })}\n`, { encoding: 'utf8', flag: 'a' });
630
+ return { ok: true, path };
631
+ } catch (err) {
632
+ return { ok: false, path, reason: err instanceof Error ? err.message : String(err) };
633
+ }
634
+ }
635
+
636
+ /** Kept separate so the import stays static at the top and this file has one
637
+ * place that knows the record's shape. */
638
+ function logRefutation(executor, result) {
639
+ const root = executor?.root;
640
+ if (typeof root !== 'string' || !root.trim() || !existsSync(root)) return;
641
+ recordRefutation(root, {
642
+ hadSignal: result.hadSignal === true,
643
+ changedAnswer: result.changedAnswer === true,
644
+ ran: result.ran === true,
645
+ refuted: result.refuted === true,
646
+ unclear: result.unclear === true,
647
+ signalKind: result.signal?.kind ?? null,
648
+ signalSource: result.signal?.source ?? null,
649
+ exitCode: result.signal?.exitCode ?? null,
650
+ reviewerModel: result.reviewerModel ?? null,
651
+ independent: result.independent ?? null,
652
+ costUsd: typeof result.costUsd === 'number' ? result.costUsd : null,
653
+ reason: result.reason ?? result.note ?? null,
654
+ });
655
+ }
656
+
657
+ /** One line for the person watching, and it must not overclaim either way. */
658
+ export function formatRefutation(result) {
659
+ if (!result?.ok) return `second opinion unavailable: ${result?.error ?? 'unknown'}`;
660
+ /**
661
+ * ── ⭐ THE SKIP LINE, AND IT IS DELIBERATELY NOT A ✔ ───────────────────────
662
+ *
663
+ * "No second opinion" is what the user gets when no external signal was
664
+ * obtainable, and it must read as an absence rather than a pass — a blind
665
+ * critique is measured to make answers worse, so declining is the right
666
+ * outcome and still not clearance. The reason is printed in full because it is
667
+ * also the instruction: declare a command and the check becomes available.
668
+ */
669
+ if (result.skipped === true || result.ran === false) {
670
+ return `· no second opinion: ${result.reason ?? 'no external signal could be obtained'}. The first answer ships unchanged.`;
671
+ }
672
+ if (result.refuted) return `✖ SECOND OPINION REFUTES IT — ${result.reason}`;
673
+ if (result.unclear) return `· second opinion reached no verdict — ${result.note}. The first verdict stands unchanged.`;
674
+ /**
675
+ * ⚠️ THE ONE LINE THAT COULD OVERCLAIM. `✔ could not refute it` reads as
676
+ * clearance; from the model that wrote the claim it is barely a check at
677
+ * all. The weaker case says so in the same breath.
678
+ *
679
+ * ⭐ AND IT NAMES THE SIGNAL. "Could not refute it" is worth what the evidence
680
+ * under it is worth; a reader who cannot see whether that was `npm test` or
681
+ * nothing at all is being asked to trust a number they cannot check.
682
+ */
683
+ const who = result.independent === false ? ' (same model as the builder — a weaker check)' : '';
684
+ const grounded = result.signal ? ` — grounded in ${formatSignal(result.signal)}` : '';
685
+ return `✔ second opinion could not refute it${who}${grounded}${result.reason ? ` — checked: ${result.reason}` : ''}`;
686
+ }
687
+
688
+ /**
689
+ * ── ⭐⭐ WHAT THE MACHINE-READABLE DOCUMENT SAYS ABOUT THE SECOND OPINION ────
690
+ *
691
+ * `--json --refute` used to accept the flag, charge nothing, run no refutation,
692
+ * and leave NO field to say it had been skipped — because `secondOpinion` was
693
+ * called thirty lines below the `--json` early return. That is the exact
694
+ * combination CI uses: `--json` to parse, `--refute` for the trust gate. The one
695
+ * mode where nobody watches the terminal was the one that silently dropped the
696
+ * check, and a script acted on the unchecked claim.
697
+ *
698
+ * ⭐ THREE STATES, KEPT APART. Collapsing any two lets a consumer read a SKIPPED
699
+ * check as a PASSED one, which is the original defect moved one layer down:
700
+ *
701
+ * {asked:false, ran:false} `--refute` was never passed
702
+ * {asked:true, ran:false} passed, but the run had already failed, so there
703
+ * was no success to refute — with the reason
704
+ * {asked:true, ran:true} it ran, and this is what it found
705
+ *
706
+ * ⚠️ `unclear` IS ITS OWN STATE AND MUST NOT BE FLATTENED. The refuter answered
707
+ * without a parseable verdict — measured at roughly one run in three — which is
708
+ * NOT "could not refute it". A gate that treats silence as clearance is the
709
+ * failure this whole feature exists to prevent.
710
+ *
711
+ * ⚠️ `refuted` IS ONLY MEANINGFUL WHEN `ok` IS TRUE. A crashed refuter carries
712
+ * its own `ok:false` rather than being flattened into a boolean that reads as a
713
+ * clean bill of health.
714
+ *
715
+ * ⭐ `costUsd` TRAVELS because the refuter is a SECOND full agent run whose
716
+ * spend is not in the audit ledger — so a run's true cost is `costUsd +
717
+ * refutation.costUsd`. Emitting it does not close that gap; it makes it visible.
718
+ *
719
+ * @param {boolean} asked did the user pass --refute
720
+ * @param {object|null} opinion the result of `refuteClaim`, or null if it did not run
721
+ * @param {boolean} alreadyFailed was the run already failing before the refuter
722
+ * @returns {object} the `refutation` field of the --json document
723
+ */
724
+ export function refutationField(asked, opinion, alreadyFailed = false) {
725
+ if (!asked) return { asked: false, ran: false };
726
+ if (!opinion) {
727
+ return {
728
+ asked: true,
729
+ ran: false,
730
+ reason: alreadyFailed
731
+ ? 'the run had already failed, so there was no success to refute'
732
+ : 'the refuter did not run',
733
+ };
734
+ }
735
+ /**
736
+ * ── ⚠️⚠️ THE FOURTH REASON IT DID NOT RUN, AND IT IS STILL `ran: false` ────
737
+ *
738
+ * The signal gate declines a pass that had no compiler, test or lint result to
739
+ * reason from. That is `{asked:true, ran:false}` — the SAME state as "the run
740
+ * had already failed", because from a consumer's point of view they are the
741
+ * same fact: nothing checked this claim. Emitting `refuted:false` here instead
742
+ * would be the original `--json --refute` defect exactly, one door along: a
743
+ * gate reading a check that never happened as a check that passed.
744
+ *
745
+ * ⭐ `hadSignal` TRAVELS ON BOTH STATES so a CI gate can tell "declined for
746
+ * want of evidence" from "declined because the run was already red", and can
747
+ * decide for itself whether to insist on a grounded check.
748
+ */
749
+ if (opinion.skipped === true) {
750
+ return {
751
+ asked: true,
752
+ ran: false,
753
+ hadSignal: false,
754
+ reason: opinion.reason ?? 'no external signal could be obtained, so the claim was not critiqued blind',
755
+ };
756
+ }
757
+ return {
758
+ asked: true,
759
+ ran: true,
760
+ ok: opinion.ok === true,
761
+ refuted: opinion.refuted === true,
762
+ unclear: opinion.unclear === true,
763
+ reason: opinion.reason || opinion.note || opinion.error || null,
764
+ costUsd: typeof opinion.costUsd === 'number' ? opinion.costUsd : null,
765
+ roundsUsed: typeof opinion.roundsUsed === 'number' ? opinion.roundsUsed : null,
766
+ /**
767
+ * ── ⚠️⚠️ WHO CHECKED IT, AND WAS IT ACTUALLY A SECOND OPINION ────────────
768
+ *
769
+ * `refuteClaim` computed both of these and this function dropped them, so
770
+ * the machine-readable document said a check had happened and never said by
771
+ * whom. That is the same defect the header of this file is about, one layer
772
+ * down: `--json --refute` is the CI shape, and a gate cannot weigh evidence
773
+ * whose independence it cannot see.
774
+ *
775
+ * ⚠️ CAUGHT BY RUNNING IT, not by reading it. The fields existed, were
776
+ * returned, and stopped at the last line before they became visible — which
777
+ * is this package's most-repeated failure and I reproduced it inside the
778
+ * commit that adds the feature.
779
+ */
780
+ reviewerModel: typeof opinion.reviewerModel === 'string' ? opinion.reviewerModel : null,
781
+ independent: typeof opinion.independent === 'boolean' ? opinion.independent : null,
782
+ /**
783
+ * ── ⭐⭐ WHAT THE CRITIQUE WAS GROUNDED IN, AND WHETHER IT MATTERED ───────
784
+ *
785
+ * A refutation reasoned from a real exit code and one reasoned from a
786
+ * model's own reading are different quality of evidence, and the measured
787
+ * gap between them is the reason the gate exists (33.3% → 52.6% when the
788
+ * feedback became real). A CI gate that cannot see which it got is being
789
+ * asked to weigh evidence blind.
790
+ *
791
+ * `changedAnswer` is the number that makes this feature auditable: a pass
792
+ * that never changes an answer is a pass to switch off.
793
+ */
794
+ hadSignal: typeof opinion.hadSignal === 'boolean' ? opinion.hadSignal : null,
795
+ changedAnswer: typeof opinion.changedAnswer === 'boolean' ? opinion.changedAnswer : null,
796
+ signal: opinion.signal
797
+ ? {
798
+ kind: opinion.signal.kind ?? null,
799
+ command: opinion.signal.command ?? null,
800
+ exitCode: Number.isInteger(opinion.signal.exitCode) ? opinion.signal.exitCode : null,
801
+ passed: opinion.signal.passed === true,
802
+ source: opinion.signal.source ?? null,
803
+ }
804
+ : null,
805
+ };
806
+ }