acuvo-code 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/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
|
+
}
|