skalpel 4.0.40 → 4.0.42
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/_test-home.mjs +18 -0
- package/insights.mjs +184 -3
- package/install.mjs +96 -28
- package/mutation-check.mjs +158 -0
- package/package.json +1 -1
- package/postinstall.mjs +33 -6
- package/skalpel-hook.mjs +6 -8
- package/skalpel-setup.mjs +148 -0
- package/skalpel-statusline.mjs +16 -2
- package/verify-core.test.mjs +186 -0
- package/verify-integration.test.mjs +194 -0
- package/verify-safeguards.test.mjs +178 -0
- package/verify-shadow.mjs +287 -16
package/skalpel-setup.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import { fileURLToPath } from "node:url";
|
|
|
22
22
|
import { spawn, spawnSync } from "node:child_process";
|
|
23
23
|
import { identity as authIdentity, cliAuthPath } from "./auth.mjs";
|
|
24
24
|
import { isGenuineUserTurn } from "./session-turns.mjs";
|
|
25
|
+
import { appendAdjudication, suppressProof, lastReveal, parseArmToken } from "./insights.mjs";
|
|
25
26
|
|
|
26
27
|
// CLI (the `skalpel` bin): `skalpel setup [--api https://graph.skalpel.ai]
|
|
27
28
|
// [--user <id>]`, plus `skalpel uninstall` and `skalpel login`. A bare invocation
|
|
@@ -65,6 +66,9 @@ const KNOWN_SUBS = new Set([
|
|
|
65
66
|
"card",
|
|
66
67
|
"ledger",
|
|
67
68
|
"analytics",
|
|
69
|
+
"verify",
|
|
70
|
+
"yes",
|
|
71
|
+
"no",
|
|
68
72
|
"__build",
|
|
69
73
|
"__verify-report",
|
|
70
74
|
]);
|
|
@@ -83,6 +87,8 @@ usage:
|
|
|
83
87
|
skalpel card [--last|--id <id>] [--md] share one verified catch: the claim + its own failing proof
|
|
84
88
|
skalpel ledger your running tally: N "done"s checked, M verified FALSE
|
|
85
89
|
skalpel analytics interactive local analytics — caught lies, measured cost, steers, re-asks
|
|
90
|
+
skalpel verify on|off|status arm/disarm the live catch (re-runs your own proof on a "done" claim)
|
|
91
|
+
skalpel yes | skalpel no was the last catch right? (feeds precision; "no" stops that proof this session)
|
|
86
92
|
skalpel uninstall [--purge] remove the hooks + local data from this machine
|
|
87
93
|
|
|
88
94
|
options:
|
|
@@ -157,6 +163,84 @@ function fileCfg() {
|
|
|
157
163
|
}
|
|
158
164
|
const API = process.env.SKALPEL_API || apiFlag || fileCfg().api || DEFAULT_API;
|
|
159
165
|
|
|
166
|
+
// writeVerifyConfig(state) — persist the CONSENTED arming for the live catch to ~/.skalpel/client.json.
|
|
167
|
+
// state ∈ { "on", "off" }. On "on" we stamp verify_armed_at (a REAL timestamp) only if not already armed,
|
|
168
|
+
// so re-confirming never resets the honest "armed N days" clock; on "off" we clear it. This is the ONLY
|
|
169
|
+
// writer of the `verify` key; insights.mjs's resolver is the only reader — one key, no drift. Never throws.
|
|
170
|
+
function writeVerifyConfig(state) {
|
|
171
|
+
const cfgDir = join(homedir(), ".skalpel");
|
|
172
|
+
const cfgPath = join(cfgDir, "client.json");
|
|
173
|
+
let cfg = {};
|
|
174
|
+
try {
|
|
175
|
+
cfg = JSON.parse(readFileSync(cfgPath, "utf8")) || {};
|
|
176
|
+
} catch {
|
|
177
|
+
/* fresh config */
|
|
178
|
+
}
|
|
179
|
+
const wasOn = parseArmToken(cfg.verify) === true;
|
|
180
|
+
cfg.verify = state === "on" ? "on" : "off";
|
|
181
|
+
if (state === "on") {
|
|
182
|
+
if (!wasOn || !cfg.verify_armed_at) cfg.verify_armed_at = new Date().toISOString();
|
|
183
|
+
} else {
|
|
184
|
+
delete cfg.verify_armed_at;
|
|
185
|
+
}
|
|
186
|
+
mkdirSync(cfgDir, { recursive: true });
|
|
187
|
+
writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// promptVerifyConsent() — the ONE honest consent line at setup. The prompt DEFAULTS TO THE EXISTING
|
|
191
|
+
// SETTING so a setup re-run never silently flips a deliberate choice: a user who ran `skalpel verify off`
|
|
192
|
+
// and re-runs setup + Enter STAYS OFF; unset/on defaults to on (the activation intent). Non-interactive /
|
|
193
|
+
// CI must NOT hang: no TTY → leave the config untouched and return. The copy is accurate, not hype: the
|
|
194
|
+
// re-run is only the session's OWN already-executed command (reconstructed from its Bash tool calls) —
|
|
195
|
+
// never a new class of side effect. Best-effort: never breaks setup.
|
|
196
|
+
// resolveConsentChoice(current, answerRaw) → "on" | "off". The prompt DEFAULTS TO THE EXISTING SETTING so
|
|
197
|
+
// an empty Enter never silently flips a deliberate choice: current "off" → stays off unless explicitly
|
|
198
|
+
// re-enabled (y/yes/on); current "on"/unset → the activation default, on unless declined (n/no/off).
|
|
199
|
+
export function resolveConsentChoice(current, answerRaw) {
|
|
200
|
+
const defaultOn = parseArmToken(current) !== false; // off → default off; on/unset → default on
|
|
201
|
+
const t = String(answerRaw || "")
|
|
202
|
+
.trim()
|
|
203
|
+
.toLowerCase();
|
|
204
|
+
if (defaultOn) return t === "n" || t === "no" || t === "off" ? "off" : "on";
|
|
205
|
+
return t === "y" || t === "yes" || t === "on" ? "on" : "off"; // stays off unless explicitly turned on
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function promptVerifyConsent() {
|
|
209
|
+
try {
|
|
210
|
+
const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI;
|
|
211
|
+
if (!interactive || process.env.SKALPEL_DEMO || process.env.SKALPEL_SKIP_VERIFY_CONSENT === "1")
|
|
212
|
+
return; // CI / piped / demo → leave config untouched, never block
|
|
213
|
+
// Default the prompt to the EXISTING setting: off → Enter keeps off; on/unset → Enter accepts.
|
|
214
|
+
const current = fileCfg().verify; // "on" | "off" | undefined
|
|
215
|
+
const defaultOn = parseArmToken(current) !== false;
|
|
216
|
+
console.log(
|
|
217
|
+
` ${B}The live catch${X}\n` +
|
|
218
|
+
` ${D}When your agent says "done / tests pass", skalpel re-runs the SAME test/build command\n` +
|
|
219
|
+
` it just ran, in the same directory, and shows you the real result. It only ever re-runs\n` +
|
|
220
|
+
` your session's own command — never anything new.${X}`,
|
|
221
|
+
);
|
|
222
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
223
|
+
const promptLine = defaultOn ? `[Enter = on · n = off]` : `[Enter = keep off · y = on]`;
|
|
224
|
+
const ans = await new Promise((res) =>
|
|
225
|
+
rl.question(` ${B}${promptLine}${X} `, (a) => {
|
|
226
|
+
rl.close();
|
|
227
|
+
res(a);
|
|
228
|
+
}),
|
|
229
|
+
);
|
|
230
|
+
// Resolve against the existing setting so an empty Enter preserves the current choice.
|
|
231
|
+
const choice = resolveConsentChoice(current, ans);
|
|
232
|
+
const on = choice === "on";
|
|
233
|
+
writeVerifyConfig(choice);
|
|
234
|
+
console.log(
|
|
235
|
+
on
|
|
236
|
+
? ` ${G}✓${X} ${D}Live catch on — it'll re-run your own proof and flag a false "done". ${X}skalpel verify off${D} anytime.${X}\n`
|
|
237
|
+
: ` ${D}Live catch off. Turn it on anytime with ${X}skalpel verify on${D}.${X}\n`,
|
|
238
|
+
);
|
|
239
|
+
} catch {
|
|
240
|
+
/* consent is best-effort — never break setup */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
160
244
|
// who's signed in — the session saved by the login flow (~/.skalpel/auth.json). Its `uid` keys the
|
|
161
245
|
// hosted graph; `access_token` authenticates the upload. SKALPEL_USER overrides for dev (no token).
|
|
162
246
|
// Uses auth.mjs's REFRESH-CAPABLE identity() so a token that expired since login (Supabase tokens
|
|
@@ -933,6 +1017,66 @@ async function main() {
|
|
|
933
1017
|
spawnSync("node", [join(__dir, "verify-shadow.mjs"), "--xray"], { stdio: "inherit" });
|
|
934
1018
|
return;
|
|
935
1019
|
}
|
|
1020
|
+
// `skalpel verify on|off|status` — the one-command reverse for the live catch. Local, no network, no
|
|
1021
|
+
// sign-in. `on`/`off` persist the consented arming to client.json (the single writer); `status` (and a
|
|
1022
|
+
// bare `skalpel verify`) render the arming + FIELD-PROOF (real fires, adjudicated precision, armed-days,
|
|
1023
|
+
// auto-darken state) — all from real local rows.
|
|
1024
|
+
if (sub === "verify") {
|
|
1025
|
+
const action = (argv[1] || "status").toLowerCase();
|
|
1026
|
+
if (action === "on" || action === "off") {
|
|
1027
|
+
writeVerifyConfig(action);
|
|
1028
|
+
if (action === "on") {
|
|
1029
|
+
console.log(
|
|
1030
|
+
`\n ${G}✓${X} Live catch ${B}armed${X}. On a "done" claim, skalpel re-runs your session's own\n` +
|
|
1031
|
+
` test/build once (twice to confirm before any red) and shows the real result.\n` +
|
|
1032
|
+
` ${D}Reverse anytime: ${X}skalpel verify off${D} · see the field proof: ${X}skalpel verify status\n`,
|
|
1033
|
+
);
|
|
1034
|
+
} else {
|
|
1035
|
+
console.log(
|
|
1036
|
+
`\n ${G}✓${X} Live catch ${B}off${X}. ${D}Re-arm anytime: ${X}skalpel verify on\n`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
} else if (action === "status") {
|
|
1040
|
+
spawnSync("node", [join(__dir, "verify-shadow.mjs"), "--status"], { stdio: "inherit" });
|
|
1041
|
+
} else {
|
|
1042
|
+
console.error(`skalpel verify: unknown action \`${argv[1]}\` (use: on | off | status)`);
|
|
1043
|
+
process.exitCode = 2;
|
|
1044
|
+
}
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
// `skalpel yes` / `skalpel no` — ONE-KEY ADJUDICATION of the most-recent catch (the flight recorder).
|
|
1048
|
+
// Local, no network, no sign-in. Appends one NDJSON row keyed to the reveal you just saw; a "no" also
|
|
1049
|
+
// suppresses that exact proof for the rest of THAT session and counts against precision (feeding the
|
|
1050
|
+
// auto-darken kill-switch). Fail-open: nothing to adjudicate → an honest note, never an error.
|
|
1051
|
+
if (sub === "yes" || sub === "no") {
|
|
1052
|
+
const r = lastReveal();
|
|
1053
|
+
if (!r || !r.proof_command) {
|
|
1054
|
+
console.log(
|
|
1055
|
+
`\n ${D}No recent skalpel catch to adjudicate — nothing has surfaced yet.${X}\n` +
|
|
1056
|
+
` ${D}When skalpel flags a false "done", it'll say: was this right? ${X}skalpel yes / skalpel no\n`,
|
|
1057
|
+
);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
const proofShort = String(r.proof_command).slice(0, 60);
|
|
1061
|
+
appendAdjudication({
|
|
1062
|
+
verdict: sub, // "yes" | "no"
|
|
1063
|
+
reveal_ts: r.ts || null,
|
|
1064
|
+
session: r.session || null,
|
|
1065
|
+
proof: r.proof_command,
|
|
1066
|
+
});
|
|
1067
|
+
if (sub === "no") {
|
|
1068
|
+
suppressProof(r.session || null, r.proof_command);
|
|
1069
|
+
console.log(
|
|
1070
|
+
`\n ${G}✓${X} Marked ${B}not a real catch${X}. ${D}skalpel won't re-accuse ${X}\`${proofShort}\`${D}\n` +
|
|
1071
|
+
` for the rest of this session, and it counts against precision.${X}\n`,
|
|
1072
|
+
);
|
|
1073
|
+
} else {
|
|
1074
|
+
console.log(
|
|
1075
|
+
`\n ${G}✓${X} Marked ${B}a real catch${X}. ${D}Thanks — this counts toward precision.${X}\n`,
|
|
1076
|
+
);
|
|
1077
|
+
}
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
936
1080
|
// Persist --api/--user to ~/.skalpel/client.json so the hooks can read them without shell-env
|
|
937
1081
|
// plumbing (env vars SKALPEL_API / SKALPEL_USER still win at hook runtime).
|
|
938
1082
|
if (apiFlag || userFlag) {
|
|
@@ -971,6 +1115,10 @@ async function main() {
|
|
|
971
1115
|
` ${G}✓${X} Signed in as ${D}${id.name ? `${id.name} · ${id.email}` : id.email || id.uid}${X}\n`,
|
|
972
1116
|
);
|
|
973
1117
|
|
|
1118
|
+
// CONSENT for the live catch (default-accept). One honest line; persisted to client.json. Non-TTY/CI
|
|
1119
|
+
// never blocks (stays default OFF). Reversible anytime with `skalpel verify off`.
|
|
1120
|
+
await promptVerifyConsent();
|
|
1121
|
+
|
|
974
1122
|
// 2) find the real coding sessions in the last 30 days (the client reads; the server builds)
|
|
975
1123
|
const s1 = spinner(`Looking through your last ${LOOKBACK_DAYS} days of Claude Code + Codex…`);
|
|
976
1124
|
await sleep(500);
|
package/skalpel-statusline.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { readFileSync, rmSync } from "node:fs";
|
|
|
30
30
|
import { homedir } from "node:os";
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { tailLines } from "./transcript.mjs";
|
|
33
|
-
import { cleanText, REVEAL_PATH, VERDICT_PATH, revealEnabled } from "./insights.mjs";
|
|
33
|
+
import { cleanText, REVEAL_PATH, VERDICT_PATH, revealEnabled, autoDarkened } from "./insights.mjs";
|
|
34
34
|
|
|
35
35
|
const CYAN = "\x1b[36m";
|
|
36
36
|
const GREEN = "\x1b[32m";
|
|
@@ -368,9 +368,23 @@ function main() {
|
|
|
368
368
|
// SKALPEL_VERIFY_REVEAL — when off (the default) this branch never runs and the bar is byte-identical to
|
|
369
369
|
// the always-on baseline below.
|
|
370
370
|
if (revealEnabled(process.env)) {
|
|
371
|
+
// AUTO-DARKEN kill-switch: once adjudicated precision drops below 90% over >= 10 `skalpel yes|no`
|
|
372
|
+
// rows, the LIVE red reveal self-pauses and the bar points at the retro `skalpel catches`
|
|
373
|
+
// (transcript-grounded — it can't flake) until the user re-arms. autoDarkened() is DERIVED from the
|
|
374
|
+
// real adjudication rows on every render (never a stored flag), so writer + reader can't drift.
|
|
375
|
+
if (autoDarkened()) {
|
|
376
|
+
process.stdout.write(
|
|
377
|
+
`${BOLD}skalpel${RESET} · ${DIM}live catch paused (precision <90%) · run: skalpel catches${RESET}`,
|
|
378
|
+
);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
371
381
|
const reveal = revealNote(payload.session_id);
|
|
372
382
|
if (reveal) {
|
|
373
|
-
|
|
383
|
+
// The red carries its one-key adjudication pointer — the flight recorder that feeds precision +
|
|
384
|
+
// the auto-darken kill-switch. A `skalpel no` also suppresses this exact proof for the session.
|
|
385
|
+
process.stdout.write(
|
|
386
|
+
`${BOLD}${reveal}${RESET} ${DIM}· was this right? skalpel yes / skalpel no${RESET}`,
|
|
387
|
+
);
|
|
374
388
|
return;
|
|
375
389
|
}
|
|
376
390
|
const verdict = verdictNote();
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// verify-core.test.mjs — pure-function unit tests for the three CRITICAL-LOGIC functions the mutation
|
|
2
|
+
// harness (mutation-check.mjs) targets: detectCompletionClaim, classifyOutcome, classifyConfirm. These
|
|
3
|
+
// are pure (no fs / no network), so they need no HOME isolation. Every assertion here must be tight enough
|
|
4
|
+
// that flipping a predicate/branch in the function under test makes at least one case fail (survivors = 0).
|
|
5
|
+
import { test } from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import {
|
|
8
|
+
detectCompletionClaim,
|
|
9
|
+
classifyOutcome,
|
|
10
|
+
classifyConfirm,
|
|
11
|
+
hasMutatingFlag,
|
|
12
|
+
urlResponded,
|
|
13
|
+
shipUrlPass,
|
|
14
|
+
} from "./verify-shadow.mjs";
|
|
15
|
+
|
|
16
|
+
test("detectCompletionClaim — fires on real completion claims", () => {
|
|
17
|
+
for (const s of [
|
|
18
|
+
"All tests pass now.",
|
|
19
|
+
"I've fixed the bug.",
|
|
20
|
+
"Done — build is green.",
|
|
21
|
+
"lgtm",
|
|
22
|
+
"everything should work now",
|
|
23
|
+
"ready to merge",
|
|
24
|
+
]) {
|
|
25
|
+
assert.equal(detectCompletionClaim(s).claim, true, `should claim: ${s}`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("detectCompletionClaim — does NOT fire on non-claims", () => {
|
|
30
|
+
for (const s of [
|
|
31
|
+
"Let me run the tests and see.",
|
|
32
|
+
"This might be broken.",
|
|
33
|
+
"Can you check whether it works?",
|
|
34
|
+
"",
|
|
35
|
+
" ",
|
|
36
|
+
]) {
|
|
37
|
+
assert.equal(detectCompletionClaim(s).claim, false, `should NOT claim: ${s}`);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("detectCompletionClaim — returns the specific claim line, bounded to 120 chars", () => {
|
|
42
|
+
const r = detectCompletionClaim("Here is a summary.\nAll tests pass.\nMore prose.");
|
|
43
|
+
assert.equal(r.claim, true);
|
|
44
|
+
assert.equal(r.text, "All tests pass.");
|
|
45
|
+
const long = "x ".repeat(200) + "all tests pass";
|
|
46
|
+
assert.ok(detectCompletionClaim(long).text.length <= 120);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("classifyOutcome — no error is a PASS", () => {
|
|
50
|
+
assert.equal(classifyOutcome(null, ""), "PASS");
|
|
51
|
+
assert.equal(classifyOutcome(undefined, "anything"), "PASS");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("classifyOutcome — a real non-zero exit is a GENUINE_FAIL", () => {
|
|
55
|
+
assert.equal(classifyOutcome({ code: 1 }, "2 failed, 3 passed"), "GENUINE_FAIL");
|
|
56
|
+
assert.equal(
|
|
57
|
+
classifyOutcome({ code: 2 }, "AssertionError: expected 1 to equal 2"),
|
|
58
|
+
"GENUINE_FAIL",
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("classifyOutcome — broken harness is NEVER a lie (HARNESS_ERROR)", () => {
|
|
63
|
+
assert.equal(classifyOutcome({ killed: true, signal: "SIGTERM" }, "..."), "HARNESS_ERROR"); // timeout
|
|
64
|
+
assert.equal(classifyOutcome({ code: "ENOENT" }, "..."), "HARNESS_ERROR"); // missing binary/cwd
|
|
65
|
+
assert.equal(classifyOutcome({ code: 127 }, "..."), "HARNESS_ERROR"); // command not found
|
|
66
|
+
assert.equal(classifyOutcome({ code: 126 }, "..."), "HARNESS_ERROR"); // not executable
|
|
67
|
+
assert.equal(classifyOutcome({ code: 1 }, 'npm error Missing script: "test"'), "HARNESS_ERROR");
|
|
68
|
+
assert.equal(classifyOutcome({ code: 1 }, "bash: pytest: command not found"), "HARNESS_ERROR");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("classifyConfirm — 2/2 GENUINE_FAIL is the ONLY confirmed catch", () => {
|
|
72
|
+
const two = classifyConfirm("GENUINE_FAIL", "GENUINE_FAIL");
|
|
73
|
+
assert.deepEqual(two, { needsConfirm: true, confirmedFail: true, unconfirmedFail: false });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("classifyConfirm — a single un-reproduced fail is UNCONFIRMED (never a red)", () => {
|
|
77
|
+
for (const second of ["PASS", "HARNESS_ERROR", "REFUSED", null, undefined]) {
|
|
78
|
+
const r = classifyConfirm("GENUINE_FAIL", second);
|
|
79
|
+
assert.equal(r.needsConfirm, true, `needsConfirm for second=${second}`);
|
|
80
|
+
assert.equal(r.confirmedFail, false, `NOT confirmed for second=${second}`);
|
|
81
|
+
assert.equal(r.unconfirmedFail, true, `unconfirmed for second=${second}`);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("classifyConfirm — a non-fail first run needs no confirm and is never a catch", () => {
|
|
86
|
+
for (const first of ["PASS", "HARNESS_ERROR", "REFUSED"]) {
|
|
87
|
+
const r = classifyConfirm(first, "GENUINE_FAIL");
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
r,
|
|
90
|
+
{ needsConfirm: false, confirmedFail: false, unconfirmedFail: false },
|
|
91
|
+
`first=${first}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ---- Fix 2: negation / hedge guard — a negated claim is NOT a completion claim ----
|
|
97
|
+
test("detectCompletionClaim — NEGATED / hedged lines are NOT completion claims (no self-contradicting red)", () => {
|
|
98
|
+
for (const s of [
|
|
99
|
+
"not done yet, still working on it",
|
|
100
|
+
"it isn't done",
|
|
101
|
+
"this isn't fixed",
|
|
102
|
+
"won't be fixed in this PR",
|
|
103
|
+
"not fixed yet",
|
|
104
|
+
"the tests are still failing",
|
|
105
|
+
"still broken, needs another pass",
|
|
106
|
+
"tests are still red",
|
|
107
|
+
"TODO: implement the parser",
|
|
108
|
+
"to-do: wire the handler",
|
|
109
|
+
"almost done",
|
|
110
|
+
"not quite passing yet",
|
|
111
|
+
"the build does not pass",
|
|
112
|
+
]) {
|
|
113
|
+
assert.equal(detectCompletionClaim(s).claim, false, `should NOT claim (negated): ${s}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("detectCompletionClaim — a genuine claim still fires even alongside a negated line", () => {
|
|
118
|
+
assert.equal(detectCompletionClaim("I've fixed the bug and it works now.").claim, true);
|
|
119
|
+
const r = detectCompletionClaim("The refactor is done.\nThe auth part is not done yet.");
|
|
120
|
+
assert.equal(r.claim, true);
|
|
121
|
+
assert.equal(r.text, "The refactor is done.", "picks the non-negated positive line");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ---- Fix 3: mutating-flag guard — a proof that rewrites files is refused ----
|
|
125
|
+
test("hasMutatingFlag — flags that REWRITE files are caught", () => {
|
|
126
|
+
for (const [cmd, args] of [
|
|
127
|
+
["eslint", ["--fix"]],
|
|
128
|
+
["eslint", ["src", "--fix"]],
|
|
129
|
+
["ruff", ["check", "--fix"]],
|
|
130
|
+
["ruff", ["format"]],
|
|
131
|
+
["ruff", ["format", "app.py"]],
|
|
132
|
+
["jest", ["-u"]],
|
|
133
|
+
["jest", ["--updateSnapshot"]],
|
|
134
|
+
["vitest", ["--update"]],
|
|
135
|
+
["vitest", ["-u"]],
|
|
136
|
+
["biome", ["check", "--write"]],
|
|
137
|
+
["prettier", ["--write", "."]],
|
|
138
|
+
]) {
|
|
139
|
+
assert.equal(hasMutatingFlag(cmd, args), true, `mutating: ${cmd} ${args.join(" ")}`);
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("hasMutatingFlag — pure verify runs (and pnpm -w) are NOT mutating", () => {
|
|
144
|
+
for (const [cmd, args] of [
|
|
145
|
+
["eslint", ["."]],
|
|
146
|
+
["ruff", ["check"]],
|
|
147
|
+
["ruff", ["format", "--check"]],
|
|
148
|
+
["ruff", ["format", "--diff"]],
|
|
149
|
+
["jest", []],
|
|
150
|
+
["vitest", ["run"]],
|
|
151
|
+
["biome", ["check"]],
|
|
152
|
+
["pnpm", ["-w", "test"]], // -w = workspace-root, NOT write
|
|
153
|
+
["npm", ["test"]],
|
|
154
|
+
]) {
|
|
155
|
+
assert.equal(hasMutatingFlag(cmd, args), false, `NOT mutating: ${cmd} ${args.join(" ")}`);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// ---- Fix 1: SHIP-STATUS URL reachability — any HTTP status = live (never a red) ----
|
|
160
|
+
test("shipUrlPass — any HTTP status means REACHABLE/live → NO red", () => {
|
|
161
|
+
for (const code of [200, 301, 302, 401, 403, 404, 429, 500, 502, 503]) {
|
|
162
|
+
assert.equal(shipUrlPass(`http_code=${code}`, null), true, `HTTP ${code} → reachable (no red)`);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("shipUrlPass — a red requires 2/2 NO-RESPONSE; a single transient blip does not", () => {
|
|
167
|
+
assert.equal(
|
|
168
|
+
shipUrlPass("curl error: timeout", "curl error: Could not resolve host"),
|
|
169
|
+
false,
|
|
170
|
+
"2/2 no-response → decisive unreachable (red)",
|
|
171
|
+
);
|
|
172
|
+
assert.equal(
|
|
173
|
+
shipUrlPass("curl error: timeout", "http_code=200"),
|
|
174
|
+
true,
|
|
175
|
+
"no-response then response → live (no red)",
|
|
176
|
+
);
|
|
177
|
+
assert.equal(shipUrlPass("no http code: 000", "no http code: 000"), false, "000 twice → red");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("urlResponded — http_code=<n> means the server answered; curl errors do not", () => {
|
|
181
|
+
assert.equal(urlResponded("http_code=503"), true);
|
|
182
|
+
assert.equal(urlResponded("http_code=200"), true);
|
|
183
|
+
assert.equal(urlResponded("curl error: timeout"), false);
|
|
184
|
+
assert.equal(urlResponded("no http code: 000"), false);
|
|
185
|
+
assert.equal(urlResponded(""), false);
|
|
186
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// verify-integration.test.mjs — END-TO-END proofs that drive the REAL engine: recordVerifyShadow
|
|
2
|
+
// reconstructs the session's own `npm test`, re-runs it for real (execFile, allowlist, 60s cap), and the
|
|
3
|
+
// false-accusation safeguards gate the REAL reveal state file. Also exercises the real `skalpel yes|no`
|
|
4
|
+
// CLI. HOME-isolated (import "./_test-home.mjs" FIRST). Requires `npm` on PATH (present in node:20/22).
|
|
5
|
+
import "./_test-home.mjs";
|
|
6
|
+
import { test, beforeEach } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { tmpdir, homedir } from "node:os";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { recordVerifyShadow } from "./verify-shadow.mjs";
|
|
14
|
+
import { appendAdjudication } from "./insights.mjs";
|
|
15
|
+
|
|
16
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
|
+
const DIR = join(homedir(), ".skalpel");
|
|
18
|
+
const REVEAL = join(DIR, "verify-reveal.json");
|
|
19
|
+
const LOG = join(DIR, "verify-shadow.log");
|
|
20
|
+
const INSIGHTS = join(DIR, "insights.ndjson");
|
|
21
|
+
const ADJ = join(DIR, "verify-adjudications.ndjson");
|
|
22
|
+
const SUPPRESS = join(DIR, "verify-suppressed.ndjson");
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
rmSync(DIR, { recursive: true, force: true });
|
|
26
|
+
mkdirSync(DIR, { recursive: true });
|
|
27
|
+
process.env.SKALPEL_VERIFY_REVEAL = "1"; // ARMED for these end-to-end tests
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// A temp project whose `npm test` outcome we control via a `mode` file + a per-run counter.
|
|
31
|
+
function makeProject(mode) {
|
|
32
|
+
const p = mkdtempSync(join(tmpdir(), "skalpel-proj-"));
|
|
33
|
+
writeFileSync(
|
|
34
|
+
join(p, "package.json"),
|
|
35
|
+
JSON.stringify({ name: "t", version: "1.0.0", scripts: { test: "node run-test.js" } }),
|
|
36
|
+
);
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(p, "run-test.js"),
|
|
39
|
+
`const fs=require("fs");const mode=fs.readFileSync("mode","utf8").trim();
|
|
40
|
+
let n=0;try{n=parseInt(fs.readFileSync("n","utf8"),10)||0;}catch{}
|
|
41
|
+
n++;fs.writeFileSync("n",String(n));
|
|
42
|
+
if(mode==="always_fail"){console.log("assertion failed on run "+n);process.exit(1);}
|
|
43
|
+
if(mode==="flake"){if(n===1){console.log("assertion failed on run "+n);process.exit(1);}console.log("ok "+n);process.exit(0);}
|
|
44
|
+
console.log("ok "+n);process.exit(0);`,
|
|
45
|
+
);
|
|
46
|
+
writeFileSync(join(p, "mode"), mode);
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A minimal Claude transcript: a Bash tool_use that ran `npm test`, then an assistant completion claim.
|
|
51
|
+
function writeTranscript(proj, claimText) {
|
|
52
|
+
const t = join(proj, "transcript.jsonl");
|
|
53
|
+
const rows = [
|
|
54
|
+
{
|
|
55
|
+
type: "assistant",
|
|
56
|
+
cwd: proj,
|
|
57
|
+
message: {
|
|
58
|
+
role: "assistant",
|
|
59
|
+
content: [{ type: "tool_use", name: "Bash", id: "tool_1", input: { command: "npm test" } }],
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: "user",
|
|
64
|
+
message: {
|
|
65
|
+
role: "user",
|
|
66
|
+
content: [{ type: "tool_result", tool_use_id: "tool_1", content: "ran" }],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
type: "assistant",
|
|
71
|
+
message: { role: "assistant", content: [{ type: "text", text: claimText }] },
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
writeFileSync(t, rows.map((r) => JSON.stringify(r)).join("\n") + "\n");
|
|
75
|
+
return t;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const lastRow = () => {
|
|
79
|
+
const ls = readFileSync(LOG, "utf8").trim().split("\n");
|
|
80
|
+
return JSON.parse(ls[ls.length - 1]);
|
|
81
|
+
};
|
|
82
|
+
const insightKinds = () => {
|
|
83
|
+
try {
|
|
84
|
+
return readFileSync(INSIGHTS, "utf8")
|
|
85
|
+
.trim()
|
|
86
|
+
.split("\n")
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.map((l) => JSON.parse(l));
|
|
89
|
+
} catch {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
async function drive(mode, { session, claim }) {
|
|
95
|
+
rmSync(REVEAL, { force: true });
|
|
96
|
+
const proj = makeProject(mode);
|
|
97
|
+
const tp = writeTranscript(proj, claim);
|
|
98
|
+
await recordVerifyShadow({ transcriptPath: tp, session, cwd: proj });
|
|
99
|
+
return { revealed: existsSync(REVEAL), row: lastRow() };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
test("2/2 flake-confirm — a confirmed fail SURFACES a red", async () => {
|
|
103
|
+
const r = await drive("always_fail", { session: "s-fail", claim: "All tests pass. Done." });
|
|
104
|
+
assert.equal(r.revealed, true, "confirmed 2/2 fail → red reveal written");
|
|
105
|
+
assert.equal(r.row.mismatch, true);
|
|
106
|
+
assert.equal(r.row.outcome, "GENUINE_FAIL");
|
|
107
|
+
assert.equal(r.row.confirm_outcome, "GENUINE_FAIL");
|
|
108
|
+
assert.equal(r.row.unconfirmed_fail, false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("2/2 flake-confirm — a single un-reproduced fail NEVER surfaces (flake, logged UNCONFIRMED)", async () => {
|
|
112
|
+
const r = await drive("flake", { session: "s-flake", claim: "All tests pass now." });
|
|
113
|
+
assert.equal(r.revealed, false, "1-fail-then-pass → NO red");
|
|
114
|
+
assert.equal(r.row.mismatch, false);
|
|
115
|
+
assert.equal(r.row.outcome, "GENUINE_FAIL");
|
|
116
|
+
assert.equal(r.row.confirm_outcome, "PASS");
|
|
117
|
+
assert.equal(r.row.unconfirmed_fail, true, "recorded for the instrument, never shown");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("2/2 flake-confirm — a clean PASS surfaces no red (green verdict path)", async () => {
|
|
121
|
+
const r = await drive("always_pass", { session: "s-pass", claim: "Done — all green." });
|
|
122
|
+
assert.equal(r.revealed, false);
|
|
123
|
+
assert.equal(r.row.outcome, "PASS");
|
|
124
|
+
assert.equal(r.row.confirm_outcome, null, "no confirm run needed on a pass");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("suppression — `skalpel no` stops that exact proof re-accusing for the session", async () => {
|
|
128
|
+
// Run 1: a confirmed fail surfaces the red and logs a shown-reveal row for the CLI to adjudicate.
|
|
129
|
+
const first = await drive("always_fail", { session: "s1", claim: "All tests pass." });
|
|
130
|
+
assert.equal(first.revealed, true);
|
|
131
|
+
|
|
132
|
+
// The user hits `skalpel no` (the REAL CLI) — appends the adjudication + suppresses (s1, npm test).
|
|
133
|
+
const no = spawnSync("node", [join(HERE, "skalpel-setup.mjs"), "no"], {
|
|
134
|
+
env: process.env,
|
|
135
|
+
encoding: "utf8",
|
|
136
|
+
});
|
|
137
|
+
assert.equal(no.status, 0, no.stderr);
|
|
138
|
+
const adj = readFileSync(ADJ, "utf8").trim();
|
|
139
|
+
assert.match(adj, /"verdict":"no"/);
|
|
140
|
+
assert.ok(existsSync(SUPPRESS), "suppress file written");
|
|
141
|
+
assert.match(readFileSync(SUPPRESS, "utf8"), /npm test/);
|
|
142
|
+
|
|
143
|
+
// Run 2: SAME session, a DIFFERENT claim (so it isn't deduped) — still a confirmed 2/2 fail, but the
|
|
144
|
+
// proof is suppressed → NO new red, and a reveal_suppressed insight explains why.
|
|
145
|
+
const second = await drive("always_fail", { session: "s1", claim: "The build is green now." });
|
|
146
|
+
assert.equal(second.row.mismatch, true, "still a real confirmed catch in the instrument");
|
|
147
|
+
assert.equal(second.revealed, false, "…but suppressed → no red shown");
|
|
148
|
+
const supp = insightKinds().filter(
|
|
149
|
+
(i) => i.kind === "reveal_suppressed" && i.reason === "adjudicated_no",
|
|
150
|
+
);
|
|
151
|
+
assert.ok(supp.length >= 1, "reveal_suppressed(adjudicated_no) recorded");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("auto-darken — <90% over >=10 adjudications pauses the LIVE red (falls back to retro)", async () => {
|
|
155
|
+
// 8 right / 2 wrong = 80% over 10 rows → derived auto-darken = true.
|
|
156
|
+
for (let i = 0; i < 8; i++) appendAdjudication({ verdict: "yes" });
|
|
157
|
+
for (let i = 0; i < 2; i++) appendAdjudication({ verdict: "no" });
|
|
158
|
+
|
|
159
|
+
const r = await drive("always_fail", { session: "s-dark", claim: "All tests pass." });
|
|
160
|
+
assert.equal(r.row.mismatch, true, "still a real confirmed catch in the instrument");
|
|
161
|
+
assert.equal(r.revealed, false, "auto-darkened → no live red");
|
|
162
|
+
const supp = insightKinds().filter(
|
|
163
|
+
(i) => i.kind === "reveal_suppressed" && i.reason === "auto_darken",
|
|
164
|
+
);
|
|
165
|
+
assert.ok(supp.length >= 1, "reveal_suppressed(auto_darken) recorded");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("`skalpel yes` — records a real catch toward precision (no suppression)", async () => {
|
|
169
|
+
await drive("always_fail", { session: "s-yes", claim: "All tests pass." });
|
|
170
|
+
const yes = spawnSync("node", [join(HERE, "skalpel-setup.mjs"), "yes"], {
|
|
171
|
+
env: process.env,
|
|
172
|
+
encoding: "utf8",
|
|
173
|
+
});
|
|
174
|
+
assert.equal(yes.status, 0, yes.stderr);
|
|
175
|
+
assert.match(readFileSync(ADJ, "utf8"), /"verdict":"yes"/);
|
|
176
|
+
assert.equal(existsSync(SUPPRESS), false, "a `yes` never suppresses");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("`skalpel no` with nothing to adjudicate is honest, not an error", () => {
|
|
180
|
+
const no = spawnSync("node", [join(HERE, "skalpel-setup.mjs"), "no"], {
|
|
181
|
+
env: process.env,
|
|
182
|
+
encoding: "utf8",
|
|
183
|
+
});
|
|
184
|
+
assert.equal(no.status, 0);
|
|
185
|
+
assert.match(no.stdout, /No recent skalpel catch/i);
|
|
186
|
+
assert.equal(existsSync(ADJ), false);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("fail-open — the detached worker never throws on a broken transcript, surfaces nothing", async () => {
|
|
190
|
+
await assert.doesNotReject(
|
|
191
|
+
recordVerifyShadow({ transcriptPath: "/no/such/transcript.jsonl", session: "x", cwd: "/tmp" }),
|
|
192
|
+
);
|
|
193
|
+
assert.equal(existsSync(REVEAL), false, "no red on a broken input");
|
|
194
|
+
});
|