skalpel 4.0.30 → 4.0.32
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/card.mjs +306 -0
- package/ledger.mjs +271 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +28 -0
package/card.mjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// card.mjs — THE CATCH CARD: `skalpel card`.
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM (same root as verify-shadow / catches): your agent is both the executor AND the reporter
|
|
5
|
+
// of its own work, so when it says "done / tests pass" nothing independent re-checks. verify-shadow
|
|
6
|
+
// already closes that loop LIVE and logs the real verdict. THIS turns a single, already-verified catch
|
|
7
|
+
// into a clean, copy-pasteable artifact you can drop into Slack / a PR / X — so the moment the agent
|
|
8
|
+
// gets caught is shareable, and it's the AGENT that's on the hook, never you.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT IS: a one-command render of the MOST RECENT genuine catch (a GENUINE_FAIL row) that
|
|
11
|
+
// verify-shadow already wrote to ~/.skalpel/verify-shadow.log. The card carries ONLY what the real row
|
|
12
|
+
// holds: the agent's verbatim completion claim, the exact proof/probe command skalpel re-ran, the real
|
|
13
|
+
// verdict (FAIL — the proof exited non-zero; plus a real exit code / HTTP status / failing-count ONLY
|
|
14
|
+
// when the captured output literally contains one), the verbatim tail of the real output, and the real
|
|
15
|
+
// timestamp. Nothing else — no summaries, no aggregates, no cross-model claims, no minutes.
|
|
16
|
+
//
|
|
17
|
+
// HARD RED LINES (why this is safe + honest):
|
|
18
|
+
// • READ-ONLY over verify-shadow's OWN output. It only READS ~/.skalpel/verify-shadow.log. It NEVER
|
|
19
|
+
// re-runs a proof, spawns, builds, writes, deploys, or mutates anything, and opens no socket.
|
|
20
|
+
// • OPT-IN. User-invoked subcommand; prints and exits. The per-turn hook is untouched (byte-identical
|
|
21
|
+
// when the shadow is off — this file is never imported by the hook path).
|
|
22
|
+
// • VERIFIED CATCHES ONLY. It renders a row ONLY if that row is a GENUINE_FAIL (the agent's own
|
|
23
|
+
// test/build/lint really ran and exited non-zero). A HARNESS_ERROR (timeout / missing-script /
|
|
24
|
+
// wrong-cwd) is proof-machinery noise, never a catch, and is never rendered — we never cry wolf.
|
|
25
|
+
// • NO FABRICATED NUMBERS. Every value on the card traces to a real field of the real row. The banned
|
|
26
|
+
// "minutes saved / lost" metric never appears. An exit code / HTTP status / failing count is shown
|
|
27
|
+
// ONLY when the row's own captured output literally contains it. If there is no catch to show, it
|
|
28
|
+
// SAYS so honestly — it never invents one.
|
|
29
|
+
import { readFileSync } from "node:fs";
|
|
30
|
+
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { realpathSync } from "node:fs";
|
|
32
|
+
import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
|
|
33
|
+
|
|
34
|
+
// Bound the read so a pathological log can never OOM us (verify-shadow already trims to ~1MB/500 lines).
|
|
35
|
+
const MAX_LOG_BYTES = 4 * 1024 * 1024;
|
|
36
|
+
|
|
37
|
+
// ---------- palette (matches the statusline / setup) ----------
|
|
38
|
+
const R = "\x1b[38;2;233;38;31m"; // skalpel red
|
|
39
|
+
const D = "\x1b[2m";
|
|
40
|
+
const B = "\x1b[1m";
|
|
41
|
+
const X = "\x1b[0m";
|
|
42
|
+
|
|
43
|
+
// ---------- sanitize (values come from real captured output — strip any control/ANSI bytes) ----------
|
|
44
|
+
// The claim / proof / evidence are captured from the agent's transcript + test output; they must never
|
|
45
|
+
// be able to inject their own escape codes into the terminal or a pasted card. Strip ESC + other C0
|
|
46
|
+
// control chars (keep normal whitespace which we then collapse), and cap length.
|
|
47
|
+
function clean(s, n) {
|
|
48
|
+
const t = String(s == null ? "" : s)
|
|
49
|
+
// eslint-disable-next-line no-control-regex
|
|
50
|
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // ANSI CSI sequences
|
|
51
|
+
// eslint-disable-next-line no-control-regex
|
|
52
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ") // other control chars → space
|
|
53
|
+
.replace(/\s+/g, " ")
|
|
54
|
+
.trim();
|
|
55
|
+
if (n && t.length > n) return t.slice(0, n - 1) + "…";
|
|
56
|
+
return t;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------- (1) read + select the catch ----------
|
|
60
|
+
// A row is a genuine catch iff the agent's OWN proof really ran and exited non-zero. verify-shadow
|
|
61
|
+
// records that as outcome==="GENUINE_FAIL" (pass_fail==="FAIL"). HARNESS_ERROR / PASS / no-proof rows
|
|
62
|
+
// are never catches. We tolerate a future "ship-FAIL" label defensively but still exclude harness noise.
|
|
63
|
+
export function isGenuineCatch(row) {
|
|
64
|
+
if (!row || typeof row !== "object") return false;
|
|
65
|
+
if (row.outcome === "HARNESS_ERROR" || row.pass_fail === "HARNESS_ERROR") return false;
|
|
66
|
+
const failed = row.outcome === "GENUINE_FAIL" || row.pass_fail === "FAIL";
|
|
67
|
+
// A real catch must carry the two things the card indicts the agent with: what it claimed, and the
|
|
68
|
+
// proof that contradicts it. A row missing either can't be rendered honestly, so it isn't a catch.
|
|
69
|
+
return failed && !!row.proof_command && !!row.claim_text;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// readCatches() → all genuine-catch rows in the shadow log, oldest→newest as written. Fail-open: any
|
|
73
|
+
// read/parse error yields [] (rendered as the honest "no catches yet" state).
|
|
74
|
+
export function readCatches(logPath = VERIFY_LOG_PATH) {
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = readFileSync(logPath, "utf8");
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
if (raw.length > MAX_LOG_BYTES) raw = raw.slice(-MAX_LOG_BYTES);
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const line of raw.split("\n")) {
|
|
84
|
+
if (!line.trim()) continue;
|
|
85
|
+
let row;
|
|
86
|
+
try {
|
|
87
|
+
row = JSON.parse(line);
|
|
88
|
+
} catch {
|
|
89
|
+
continue; // partial/garbage line — skip
|
|
90
|
+
}
|
|
91
|
+
if (isGenuineCatch(row)) out.push(row);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// selectCatch({ id }) → the catch to render: the newest overall, or (with --id) the newest for that
|
|
97
|
+
// session id. Returns null when there is nothing genuine to show. `catches` is injectable for tests.
|
|
98
|
+
export function selectCatch({ id = null, catches = null, logPath = VERIFY_LOG_PATH } = {}) {
|
|
99
|
+
const rows = catches || readCatches(logPath);
|
|
100
|
+
if (!rows.length) return null;
|
|
101
|
+
const pool = id ? rows.filter((r) => r.session === id) : rows;
|
|
102
|
+
if (!pool.length) return null;
|
|
103
|
+
return pool[pool.length - 1]; // rows are append-ordered → last is newest
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------- (2) honest verdict extraction ----------
|
|
107
|
+
// A GENUINE_FAIL means, by verify-shadow's own classifyOutcome, that the proof PROCESS exited non-zero
|
|
108
|
+
// (not a timeout, not command-not-found, not a missing script — those are HARNESS_ERROR). So
|
|
109
|
+
// "exited non-zero" is always provably true for a catch row. Beyond that, we surface a SPECIFIC code
|
|
110
|
+
// ONLY when the real captured output literally printed one — never invented.
|
|
111
|
+
export function extractCode(evidence) {
|
|
112
|
+
const s = String(evidence || "");
|
|
113
|
+
// HTTP status first (auth/proxy/API proofs surface these): "HTTP/1.1 500", "status: 503",
|
|
114
|
+
// "responded with 429", "got 401". Only 1xx–5xx are treated as a status.
|
|
115
|
+
const http = s.match(
|
|
116
|
+
/\b(?:HTTP\/\d(?:\.\d)?\s+|status(?:\s+code)?[:\s]+|responded with\s+|returned\s+|got\s+)(\d{3})\b/i,
|
|
117
|
+
);
|
|
118
|
+
if (http) {
|
|
119
|
+
const n = Number.parseInt(http[1], 10);
|
|
120
|
+
if (n >= 100 && n < 600) return { kind: "http", value: n };
|
|
121
|
+
}
|
|
122
|
+
// Process exit code: "exit code 1", "exited with code 2", "Process exited with code 1".
|
|
123
|
+
const exit = s.match(/exit(?:ed)?(?:\s+with)?\s+(?:code\s+)?(\d{1,3})\b/i);
|
|
124
|
+
if (exit) {
|
|
125
|
+
const n = Number.parseInt(exit[1], 10);
|
|
126
|
+
if (Number.isFinite(n)) return { kind: "exit", value: n };
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// verdictPhrase(row) → the honest one-line verdict. Always truthful for a catch row.
|
|
132
|
+
function verdictPhrase(row) {
|
|
133
|
+
const code = extractCode(row.evidence);
|
|
134
|
+
const failN = extractFailCount(row.evidence);
|
|
135
|
+
const count = failN != null ? ` · ${failN} failing` : "";
|
|
136
|
+
if (code && code.kind === "http") return `FAIL — its own proof got HTTP ${code.value}${count}`;
|
|
137
|
+
if (code && code.kind === "exit") return `FAIL — its own proof exited ${code.value}${count}`;
|
|
138
|
+
return `FAIL — its own proof re-ran and exited non-zero${count}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Human timestamp: keep the real ISO instant (never fabricated); show it verbatim.
|
|
142
|
+
function stamp(ts) {
|
|
143
|
+
const s = clean(ts, 40);
|
|
144
|
+
return s || "unknown time";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------- (3a) plain-text card (no ANSI — the shareable artifact) ----------
|
|
148
|
+
export function renderPlainCard(row) {
|
|
149
|
+
const claim = clean(row.claim_text, 200) || "it's done";
|
|
150
|
+
const proof = clean(row.proof_command, 200) || "its own proof";
|
|
151
|
+
const evidence = clean(row.evidence, 200);
|
|
152
|
+
const L = [];
|
|
153
|
+
L.push("🔬 skalpel — verified catch");
|
|
154
|
+
L.push("");
|
|
155
|
+
L.push("The agent claimed:");
|
|
156
|
+
L.push(` "${claim}"`);
|
|
157
|
+
L.push("");
|
|
158
|
+
L.push("skalpel re-ran the agent's OWN proof, out-of-band:");
|
|
159
|
+
L.push(` $ ${proof}`);
|
|
160
|
+
L.push(` → ${verdictPhrase(row)}`);
|
|
161
|
+
if (evidence) {
|
|
162
|
+
L.push("");
|
|
163
|
+
L.push("real output (verbatim):");
|
|
164
|
+
L.push(` ${evidence}`);
|
|
165
|
+
}
|
|
166
|
+
L.push("");
|
|
167
|
+
L.push(`caught ${stamp(row.ts)}`);
|
|
168
|
+
L.push("The agent said done. Its own proof, re-run, disagreed.");
|
|
169
|
+
return L.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- (3b) markdown card (Slack / PR / X) ----------
|
|
173
|
+
export function renderMarkdownCard(row) {
|
|
174
|
+
const claim = clean(row.claim_text, 200) || "it's done";
|
|
175
|
+
const proof = clean(row.proof_command, 200) || "its own proof";
|
|
176
|
+
const evidence = clean(row.evidence, 200);
|
|
177
|
+
const FENCE = "```";
|
|
178
|
+
const L = [];
|
|
179
|
+
L.push("**🔬 skalpel — verified catch**");
|
|
180
|
+
L.push("");
|
|
181
|
+
L.push(`> The agent claimed: _"${claim}"_`);
|
|
182
|
+
L.push("");
|
|
183
|
+
L.push("skalpel re-ran the agent's own proof, out-of-band:");
|
|
184
|
+
L.push(FENCE);
|
|
185
|
+
L.push(`$ ${proof}`);
|
|
186
|
+
L.push(`→ ${verdictPhrase(row)}`);
|
|
187
|
+
L.push(FENCE);
|
|
188
|
+
if (evidence) {
|
|
189
|
+
L.push("real output (verbatim):");
|
|
190
|
+
L.push(FENCE);
|
|
191
|
+
L.push(evidence);
|
|
192
|
+
L.push(FENCE);
|
|
193
|
+
}
|
|
194
|
+
L.push(`_Caught ${stamp(row.ts)}. The agent said done; its own proof, re-run, disagreed._`);
|
|
195
|
+
return L.join("\n");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---------- (3c) styled terminal card ----------
|
|
199
|
+
function renderTerminalCard(row) {
|
|
200
|
+
const claim = clean(row.claim_text, 200) || "it's done";
|
|
201
|
+
const proof = clean(row.proof_command, 200) || "its own proof";
|
|
202
|
+
const evidence = clean(row.evidence, 180);
|
|
203
|
+
const L = [];
|
|
204
|
+
L.push("");
|
|
205
|
+
L.push(` ${B}🔬 skalpel${X} ${D}·${X} ${B}${R}verified catch${X}`);
|
|
206
|
+
L.push(` ${D}${"─".repeat(70)}${X}`);
|
|
207
|
+
L.push(` ${D}The agent claimed:${X}`);
|
|
208
|
+
L.push(` ${B}"${claim}"${X}`);
|
|
209
|
+
L.push("");
|
|
210
|
+
L.push(` ${D}skalpel re-ran the agent's OWN proof, out-of-band:${X}`);
|
|
211
|
+
L.push(` ${D}$${X} ${proof}`);
|
|
212
|
+
L.push(` ${R}${B}→ ${verdictPhrase(row)}${X}`);
|
|
213
|
+
if (evidence) {
|
|
214
|
+
L.push("");
|
|
215
|
+
L.push(` ${D}real output:${X} ${evidence}`);
|
|
216
|
+
}
|
|
217
|
+
L.push(` ${D}${"─".repeat(70)}${X}`);
|
|
218
|
+
L.push(
|
|
219
|
+
` ${D}caught ${stamp(row.ts)} — the agent said done; its own proof, re-run, disagreed.${X}`,
|
|
220
|
+
);
|
|
221
|
+
L.push("");
|
|
222
|
+
return L.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ---------- (4) the honest empty state ----------
|
|
226
|
+
function renderNoCatch({ id }) {
|
|
227
|
+
const L = [];
|
|
228
|
+
L.push("");
|
|
229
|
+
L.push(` ${B}🔬 skalpel card${X}`);
|
|
230
|
+
L.push(` ${D}${"─".repeat(70)}${X}`);
|
|
231
|
+
if (id) {
|
|
232
|
+
L.push(` ${D}No verified catch found for session ${clean(id, 60)}.${X}`);
|
|
233
|
+
} else {
|
|
234
|
+
L.push(` ${D}No verified catch to show yet.${X}`);
|
|
235
|
+
}
|
|
236
|
+
L.push("");
|
|
237
|
+
L.push(
|
|
238
|
+
` ${D}A card is minted only from a REAL catch — a turn where your agent claimed "done"${X}`,
|
|
239
|
+
);
|
|
240
|
+
L.push(
|
|
241
|
+
` ${D}and skalpel re-ran its OWN proof and it genuinely FAILED. Nothing is invented.${X}`,
|
|
242
|
+
);
|
|
243
|
+
L.push("");
|
|
244
|
+
L.push(
|
|
245
|
+
` ${D}Turn the live shadow on with ${X}${B}SKALPEL_VERIFY_SHADOW=1${X}${D}, keep coding, and${X}`,
|
|
246
|
+
);
|
|
247
|
+
L.push(` ${D}run ${X}${B}skalpel card${X}${D} again the next time a claim doesn't hold up.${X}`);
|
|
248
|
+
L.push("");
|
|
249
|
+
return L.join("\n");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---------- (5) top-level render (used by CLI + tests) ----------
|
|
253
|
+
// format ∈ { "terminal" (default), "md", "plain" }. Returns the string to print. `catches` injectable.
|
|
254
|
+
export function renderCard({ id = null, format = "terminal", catches = null } = {}) {
|
|
255
|
+
const row = selectCatch({ id, catches });
|
|
256
|
+
if (!row) return renderNoCatch({ id });
|
|
257
|
+
if (format === "md") return renderMarkdownCard(row);
|
|
258
|
+
if (format === "plain") return renderPlainCard(row);
|
|
259
|
+
// Default terminal view = the styled card, followed by a copy-pasteable markdown block for sharing.
|
|
260
|
+
return (
|
|
261
|
+
renderTerminalCard(row) +
|
|
262
|
+
"\n" +
|
|
263
|
+
` ${D}copy for Slack / a PR / X ${X}${D}(or: ${X}${B}skalpel card --md | pbcopy${X}${D}):${X}\n\n` +
|
|
264
|
+
renderMarkdownCard(row) +
|
|
265
|
+
"\n"
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------- CLI ----------
|
|
270
|
+
function parseArgs(argv) {
|
|
271
|
+
let id = null;
|
|
272
|
+
let format = "terminal";
|
|
273
|
+
for (let i = 0; i < argv.length; i++) {
|
|
274
|
+
const a = argv[i];
|
|
275
|
+
if (a === "--last")
|
|
276
|
+
continue; // newest catch (the default) — accepted for explicitness
|
|
277
|
+
else if (a === "--id") {
|
|
278
|
+
id = argv[i + 1] && !argv[i + 1].startsWith("-") ? argv[++i] : null;
|
|
279
|
+
} else if (a.startsWith("--id=")) id = a.slice("--id=".length);
|
|
280
|
+
else if (a === "--md" || a === "--markdown") format = "md";
|
|
281
|
+
else if (a === "--plain" || a === "--text") format = "plain";
|
|
282
|
+
}
|
|
283
|
+
return { id, format };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const isMain = (() => {
|
|
287
|
+
try {
|
|
288
|
+
return (
|
|
289
|
+
Boolean(process.argv[1]) &&
|
|
290
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
291
|
+
);
|
|
292
|
+
} catch {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
})();
|
|
296
|
+
|
|
297
|
+
if (isMain) {
|
|
298
|
+
try {
|
|
299
|
+
const { id, format } = parseArgs(process.argv.slice(2));
|
|
300
|
+
process.stdout.write(renderCard({ id, format }) + "\n");
|
|
301
|
+
} catch {
|
|
302
|
+
// READ-ONLY + fail-open: never throw at the user.
|
|
303
|
+
process.stdout.write("\n 🔬 skalpel — could not read your catch log on this machine.\n\n");
|
|
304
|
+
}
|
|
305
|
+
process.exit(0);
|
|
306
|
+
}
|
package/ledger.mjs
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ledger.mjs — the PERSONAL TRUTH LEDGER: `skalpel ledger`.
|
|
3
|
+
//
|
|
4
|
+
// THE PROBLEM: your agent is both the executor AND the reporter of its own work. Every "done / tests
|
|
5
|
+
// pass / fixed" it emits is a CLAIM. The claim-verification SHADOW (verify-shadow.mjs) already re-runs
|
|
6
|
+
// the session's OWN proof on each such claim and appends one honest row per claim to
|
|
7
|
+
// ~/.skalpel/verify-shadow.log. That file is already an APPEND-ONLY, LOCAL history of every
|
|
8
|
+
// claim -> verdict. What was missing was a VIEW: the running personal tally of how many "done"s were
|
|
9
|
+
// checked and how many were caught FALSE, with the receipts.
|
|
10
|
+
//
|
|
11
|
+
// THIS is that view. It DERIVES entirely from verify-shadow.log — it NEVER re-runs a proof, NEVER writes
|
|
12
|
+
// a second copy of the history (no double-write), NEVER touches the network, and NEVER touches the
|
|
13
|
+
// per-turn hook. It is a user-invoked, read-only subcommand: opt-in by construction, dark by default,
|
|
14
|
+
// byte-identical to before when not invoked.
|
|
15
|
+
//
|
|
16
|
+
// HARD RED LINES (why this is safe + honest):
|
|
17
|
+
// • DERIVED, NOT DOUBLE-WRITTEN. The append-only ledger IS verify-shadow.log (written by the shadow).
|
|
18
|
+
// This module only READS it and renders a view. It writes no ledger file of its own.
|
|
19
|
+
// • READ-ONLY + LOCAL. Imports only node: core + local sibling modules. Opens no socket. Re-runs nothing
|
|
20
|
+
// (the pass/fail is the verdict the shadow already recorded).
|
|
21
|
+
// • NO FABRICATED / ESTIMATED NUMBERS. Every count is a real tally over real logged rows. The ONLY time
|
|
22
|
+
// figure shown is a MEASURED wall-clock delta between two REAL logged timestamps — the moment a "done"
|
|
23
|
+
// was caught FALSE (mismatch row) and the moment that SAME proof, in that SAME session, was later
|
|
24
|
+
// logged PASS. If no such later PASS exists, we show the count ONLY — never an estimate, never a
|
|
25
|
+
// "minutes saved / lost" tally (that metric stays DEAD).
|
|
26
|
+
// • FAIL-OPEN. Any read/parse error yields an honest zero-state, never a throw at the user.
|
|
27
|
+
import { readFileSync } from "node:fs";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { realpathSync } from "node:fs";
|
|
30
|
+
// READ-ONLY reuse of the shadow's single-sourced log path + its honest fail-count extractor. Importing
|
|
31
|
+
// verify-shadow.mjs runs no side effects (its CLI block is guarded on isMain) and modifies nothing there.
|
|
32
|
+
import { VERIFY_LOG_PATH, extractFailCount } from "./verify-shadow.mjs";
|
|
33
|
+
|
|
34
|
+
// ---------- (1) read the append-only history (verify-shadow.log) ----------
|
|
35
|
+
// One NDJSON row per logged claim:
|
|
36
|
+
// { ts, session, claim_text, proof_command|null, pass_fail: "PASS"|"FAIL"|"HARNESS_ERROR"|null,
|
|
37
|
+
// outcome: "PASS"|"GENUINE_FAIL"|"HARNESS_ERROR", mismatch: boolean, evidence }
|
|
38
|
+
// The file is size-capped by the shadow (newest rows kept), so a plain read is safe.
|
|
39
|
+
export function readLedgerRows(path = VERIFY_LOG_PATH) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = readFileSync(path, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return null; // no shadow log yet → honest zero-state (distinct from "log exists but empty")
|
|
45
|
+
}
|
|
46
|
+
const rows = [];
|
|
47
|
+
for (const line of raw.split("\n")) {
|
|
48
|
+
const l = line.trim();
|
|
49
|
+
if (!l) continue;
|
|
50
|
+
try {
|
|
51
|
+
const r = JSON.parse(l);
|
|
52
|
+
if (r && typeof r === "object") rows.push(r);
|
|
53
|
+
} catch {
|
|
54
|
+
/* a partial/garbage line — skip it (fail-open) */
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return rows;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------- (2) the MEASURED wall-clock delta (real timestamps only) ----------
|
|
61
|
+
// Parse an ISO timestamp to epoch ms, or null if it isn't a real parseable time.
|
|
62
|
+
function tsMs(ts) {
|
|
63
|
+
if (typeof ts !== "string") return null;
|
|
64
|
+
const n = Date.parse(ts);
|
|
65
|
+
return Number.isFinite(n) ? n : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// measureFixDelta(falseRow, rows) → { ms, atTs } for the EARLIEST later row that is, in the SAME session,
|
|
69
|
+
// the SAME proof_command, and a genuine PASS whose timestamp is strictly after the caught-false claim.
|
|
70
|
+
// That is the real moment the false "done" became a true "done": the exact proof that failed later passed.
|
|
71
|
+
// Returns null when no such row exists (then the ledger shows the count only — never an invented minute).
|
|
72
|
+
export function measureFixDelta(falseRow, rows) {
|
|
73
|
+
const t0 = tsMs(falseRow.ts);
|
|
74
|
+
if (t0 == null) return null;
|
|
75
|
+
const session = falseRow.session ?? null;
|
|
76
|
+
const proof = falseRow.proof_command || null;
|
|
77
|
+
if (!proof) return null; // can't tie a fix to a claim with no proof — count only
|
|
78
|
+
let best = null;
|
|
79
|
+
for (const r of rows) {
|
|
80
|
+
if (r === falseRow) continue;
|
|
81
|
+
if ((r.session ?? null) !== session) continue;
|
|
82
|
+
if ((r.proof_command || null) !== proof) continue;
|
|
83
|
+
if (r.outcome !== "PASS") continue; // the SAME proof genuinely passing = the real done
|
|
84
|
+
const t1 = tsMs(r.ts);
|
|
85
|
+
if (t1 == null || t1 <= t0) continue; // must be strictly LATER than the false claim
|
|
86
|
+
if (best == null || t1 < best) best = t1;
|
|
87
|
+
}
|
|
88
|
+
return best == null ? null : { ms: best - t0, atTs: new Date(best).toISOString() };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------- (3) build the ledger view from the rows ----------
|
|
92
|
+
// verdict per row:
|
|
93
|
+
// "FALSE" — mismatch true: claimed done, its OWN proof re-run GENUINELY failed (a caught lie)
|
|
94
|
+
// "verified" — pass_fail PASS: claimed done, its OWN proof re-run really passed (a true done)
|
|
95
|
+
// "unverifiable" — no re-runnable proof, or HARNESS_ERROR (timeout/missing-script/wrong-cwd) — NEVER a lie
|
|
96
|
+
export function buildLedger(rows) {
|
|
97
|
+
const entries = (rows || []).map((r) => {
|
|
98
|
+
let verdict;
|
|
99
|
+
if (r.mismatch === true) verdict = "FALSE";
|
|
100
|
+
else if (r.pass_fail === "PASS") verdict = "verified";
|
|
101
|
+
else verdict = "unverifiable";
|
|
102
|
+
return {
|
|
103
|
+
ts: r.ts || null,
|
|
104
|
+
session: r.session ?? null,
|
|
105
|
+
claim_text: r.claim_text || "",
|
|
106
|
+
// Ship-status rows (category:"ship") record the check under probe_command, not proof_command —
|
|
107
|
+
// fall back so a caught ship-lie shows its real probe command, not an empty one.
|
|
108
|
+
proof_command: r.proof_command || r.probe_command || null,
|
|
109
|
+
evidence: r.evidence || "",
|
|
110
|
+
fail_count: extractFailCount(r.evidence),
|
|
111
|
+
verdict,
|
|
112
|
+
_row: r,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// "Checked" = claims we got a genuine re-run verdict for (a true PASS or a caught FALSE). Unverifiable
|
|
117
|
+
// rows (no proof / harness noise) are logged but NOT counted as checked — we can't stand behind them.
|
|
118
|
+
const checked = entries.filter((e) => e.verdict === "FALSE" || e.verdict === "verified").length;
|
|
119
|
+
const falseEntries = entries.filter((e) => e.verdict === "FALSE");
|
|
120
|
+
// Attach the MEASURED fix delta where — and only where — a real later same-proof PASS exists.
|
|
121
|
+
for (const e of falseEntries) {
|
|
122
|
+
const d = measureFixDelta(e._row, rows || []);
|
|
123
|
+
e.fix_delta_ms = d ? d.ms : null;
|
|
124
|
+
e.fixed_at = d ? d.atTs : null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
total: entries.length,
|
|
128
|
+
checked,
|
|
129
|
+
falseCount: falseEntries.length,
|
|
130
|
+
verifiedCount: entries.filter((e) => e.verdict === "verified").length,
|
|
131
|
+
unverifiableCount: entries.filter((e) => e.verdict === "unverifiable").length,
|
|
132
|
+
entries,
|
|
133
|
+
falseEntries,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ---------- (4) render ----------
|
|
138
|
+
function clip(s, n) {
|
|
139
|
+
const t = String(s || "")
|
|
140
|
+
.replace(/\s+/g, " ")
|
|
141
|
+
.trim();
|
|
142
|
+
return t.length > n ? t.slice(0, n - 1) + "…" : t;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Format a MEASURED millisecond delta as a human duration. Input is a real (t_pass - t_false) delta; this
|
|
146
|
+
// only formats it — it never rounds up into an invented number. Sub-second deltas read as "<1s".
|
|
147
|
+
export function formatDuration(ms) {
|
|
148
|
+
if (!Number.isFinite(ms) || ms < 0) return null;
|
|
149
|
+
const s = Math.floor(ms / 1000);
|
|
150
|
+
if (s < 1) return "<1s";
|
|
151
|
+
const h = Math.floor(s / 3600);
|
|
152
|
+
const m = Math.floor((s % 3600) / 60);
|
|
153
|
+
const sec = s % 60;
|
|
154
|
+
if (h > 0) return `${h}h ${m}m`;
|
|
155
|
+
if (m > 0) return `${m}m ${sec}s`;
|
|
156
|
+
return `${sec}s`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const MAX_RECEIPTS = 5;
|
|
160
|
+
|
|
161
|
+
export function renderLedger(ledger, { logPath = VERIFY_LOG_PATH } = {}) {
|
|
162
|
+
const L = [];
|
|
163
|
+
L.push("");
|
|
164
|
+
L.push(
|
|
165
|
+
' 🔬 skalpel — personal truth ledger (every "done" your agent claimed, checked by its own proof)',
|
|
166
|
+
);
|
|
167
|
+
L.push(" " + "─".repeat(74));
|
|
168
|
+
|
|
169
|
+
if (ledger == null) {
|
|
170
|
+
L.push("");
|
|
171
|
+
L.push(" No claim-verification history yet on this machine.");
|
|
172
|
+
L.push(
|
|
173
|
+
" Enable the shadow with SKALPEL_VERIFY_SHADOW=1, then keep coding — every turn your agent",
|
|
174
|
+
);
|
|
175
|
+
L.push(' claims "done / tests pass" gets its own proof re-run and appended to the ledger.');
|
|
176
|
+
L.push("");
|
|
177
|
+
return L.join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { checked, falseCount } = ledger;
|
|
181
|
+
L.push("");
|
|
182
|
+
// THE HEADLINE — both numbers are real counts over the real append-only log.
|
|
183
|
+
L.push(
|
|
184
|
+
` ${checked} done-claim${checked === 1 ? "" : "s"} checked, ${falseCount} verified FALSE.`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
if (ledger.total === 0) {
|
|
188
|
+
L.push("");
|
|
189
|
+
L.push(" The ledger is empty — no completion claims logged yet. I'll append every one live.");
|
|
190
|
+
L.push("");
|
|
191
|
+
return L.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (falseCount === 0) {
|
|
195
|
+
L.push("");
|
|
196
|
+
L.push(' No caught lies — every checked "done" held up against its own proof. Clean ledger.');
|
|
197
|
+
if (ledger.unverifiableCount > 0) {
|
|
198
|
+
L.push(
|
|
199
|
+
` (${ledger.unverifiableCount} claim(s) logged but unverifiable — no re-runnable proof or harness noise; not counted.)`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
L.push("");
|
|
203
|
+
return L.join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
L.push("");
|
|
207
|
+
L.push(
|
|
208
|
+
" Every FALSE below is real — the agent's own proof, re-run, did NOT pass at claim time:",
|
|
209
|
+
);
|
|
210
|
+
L.push("");
|
|
211
|
+
|
|
212
|
+
const top = ledger.falseEntries.slice(-MAX_RECEIPTS).reverse();
|
|
213
|
+
top.forEach((e, i) => {
|
|
214
|
+
const failN = e.fail_count != null ? ` (${e.fail_count} failing)` : "";
|
|
215
|
+
L.push(` ${i + 1}. your agent said:`);
|
|
216
|
+
L.push(` "${clip(e.claim_text, 72)}"`);
|
|
217
|
+
L.push(` but its own \`${clip(e.proof_command, 64)}\` had just FAILED${failN}:`);
|
|
218
|
+
if (e.evidence) L.push(` ${clip(e.evidence, 72)}`);
|
|
219
|
+
// MEASURED time — shown ONLY when a real later same-proof PASS exists in the log. Never estimated.
|
|
220
|
+
if (e.fix_delta_ms != null) {
|
|
221
|
+
const dur = formatDuration(e.fix_delta_ms);
|
|
222
|
+
if (dur)
|
|
223
|
+
L.push(
|
|
224
|
+
` ⏱ ${dur} later, that same proof was logged PASS (measured, real timestamps).`,
|
|
225
|
+
);
|
|
226
|
+
} else {
|
|
227
|
+
L.push(" ⏱ no later PASS logged for this proof yet — count only, no time estimated.");
|
|
228
|
+
}
|
|
229
|
+
L.push("");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
if (falseCount > top.length)
|
|
233
|
+
L.push(` …and ${falseCount - top.length} more FALSE in the ledger.`);
|
|
234
|
+
if (ledger.unverifiableCount > 0) {
|
|
235
|
+
L.push(
|
|
236
|
+
` (${ledger.unverifiableCount} claim(s) logged but unverifiable — no re-runnable proof or harness noise; not counted as checked.)`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
L.push(" " + "─".repeat(74));
|
|
240
|
+
L.push(` Append-only local history: ${logPath}`);
|
|
241
|
+
L.push("");
|
|
242
|
+
return L.join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---------- CLI ----------
|
|
246
|
+
const isMain = (() => {
|
|
247
|
+
try {
|
|
248
|
+
return (
|
|
249
|
+
Boolean(process.argv[1]) &&
|
|
250
|
+
realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
251
|
+
);
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
|
|
257
|
+
if (isMain) {
|
|
258
|
+
try {
|
|
259
|
+
// Optional override so the verifier can point the ledger at a crafted log (tests / self-check).
|
|
260
|
+
const logPath = process.env.SKALPEL_LEDGER_LOG || VERIFY_LOG_PATH;
|
|
261
|
+
const rows = readLedgerRows(logPath);
|
|
262
|
+
const ledger = rows == null ? null : buildLedger(rows);
|
|
263
|
+
process.stdout.write(renderLedger(ledger, { logPath }) + "\n");
|
|
264
|
+
} catch {
|
|
265
|
+
// READ-ONLY + fail-open: never throw at the user.
|
|
266
|
+
process.stdout.write(
|
|
267
|
+
"\n 🔬 skalpel — could not read your claim-verification ledger on this machine.\n\n",
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
package/package.json
CHANGED
package/skalpel-setup.mjs
CHANGED
|
@@ -62,6 +62,8 @@ const KNOWN_SUBS = new Set([
|
|
|
62
62
|
"autopsy",
|
|
63
63
|
"xray",
|
|
64
64
|
"catches",
|
|
65
|
+
"card",
|
|
66
|
+
"ledger",
|
|
65
67
|
"__build",
|
|
66
68
|
"__verify-report",
|
|
67
69
|
]);
|
|
@@ -77,6 +79,8 @@ usage:
|
|
|
77
79
|
skalpel autopsy local, read-only receipt of your verified patterns
|
|
78
80
|
skalpel xray re-run your last session's own proof — did "done" hold up?
|
|
79
81
|
skalpel catches scan your own history for "done" claims over failing tests
|
|
82
|
+
skalpel card [--last|--id <id>] [--md] share one verified catch: the claim + its own failing proof
|
|
83
|
+
skalpel ledger your running tally: N "done"s checked, M verified FALSE
|
|
80
84
|
skalpel uninstall [--purge] remove the hooks + local data from this machine
|
|
81
85
|
|
|
82
86
|
options:
|
|
@@ -112,6 +116,11 @@ function validateArgv() {
|
|
|
112
116
|
process.exit(0);
|
|
113
117
|
}
|
|
114
118
|
if (BOOL_FLAGS.has(a)) continue; // valueless flag (e.g. `uninstall --purge`)
|
|
119
|
+
// `skalpel card` owns its own flags (--last / --id <id> / --md / --plain) and forwards them to
|
|
120
|
+
// card.mjs. They are NOT setup options, so setup must not reject them as unknown — pass any token
|
|
121
|
+
// after the `card` subcommand straight through (card.mjs validates its own flags). Global -v/-h
|
|
122
|
+
// above still win for a bare `skalpel -v`. Scoped to `card` so no other subcommand's contract shifts.
|
|
123
|
+
if (i > 0 && argv[0] === "card") continue;
|
|
115
124
|
if (a.startsWith("-")) {
|
|
116
125
|
console.error(`skalpel: unknown option \`${a}\`\n\n${USAGE}`);
|
|
117
126
|
process.exit(2);
|
|
@@ -878,6 +887,25 @@ async function main() {
|
|
|
878
887
|
spawnSync("node", [join(__dir, "catches.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
879
888
|
return;
|
|
880
889
|
}
|
|
890
|
+
// `skalpel card` — THE CATCH CARD: a LOCAL, READ-ONLY, ZERO-NETWORK render of ONE already-verified
|
|
891
|
+
// catch that the live shadow (verify-shadow) wrote to ~/.skalpel/verify-shadow.log — the agent's
|
|
892
|
+
// verbatim "done" claim, the exact proof command skalpel re-ran, the real FAIL verdict, and the
|
|
893
|
+
// verbatim output tail — as a clean, copy-pasteable card for Slack / a PR / X. It never re-runs a
|
|
894
|
+
// proof, never touches the network; renders only a GENUINE_FAIL row (never harness noise) and, if
|
|
895
|
+
// there's no catch to show, says so honestly. `--md`/`--plain` pick the export; `--id <session>`
|
|
896
|
+
// targets a specific session; `--last` (default) is the newest catch.
|
|
897
|
+
if (sub === "card") {
|
|
898
|
+
spawnSync("node", [join(__dir, "card.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
// `skalpel ledger` — the PERSONAL TRUTH LEDGER: a LOCAL, READ-ONLY view DERIVED from the append-only
|
|
902
|
+
// verify-shadow.log — your running tally of "done" claims checked and how many were verified FALSE,
|
|
903
|
+
// with the receipts and (only where a real red->green timestamp delta exists) the measured time.
|
|
904
|
+
// Never re-runs a proof, never touches the network, never estimates a number.
|
|
905
|
+
if (sub === "ledger") {
|
|
906
|
+
spawnSync("node", [join(__dir, "ledger.mjs"), ...argv.slice(1)], { stdio: "inherit" });
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
881
909
|
// `skalpel __verify-report` — the claim-verification SHADOW instrument: reads the local
|
|
882
910
|
// verify-shadow.log and prints the three numbers (claim fire-rate, proof-reconstruction rate, and the
|
|
883
911
|
// headline MISMATCH rate = how often the agent claimed done but its OWN proof, re-run, failed).
|