skalpel 4.0.30 → 4.0.31

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 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.30",
3
+ "version": "4.0.31",
4
4
  "description": "Behavioral graph for AI-assisted coding — learns how you work and steers Claude Code + Codex in real time.",
5
5
  "type": "module",
6
6
  "bin": {
package/skalpel-setup.mjs CHANGED
@@ -62,6 +62,7 @@ const KNOWN_SUBS = new Set([
62
62
  "autopsy",
63
63
  "xray",
64
64
  "catches",
65
+ "card",
65
66
  "__build",
66
67
  "__verify-report",
67
68
  ]);
@@ -77,6 +78,7 @@ usage:
77
78
  skalpel autopsy local, read-only receipt of your verified patterns
78
79
  skalpel xray re-run your last session's own proof — did "done" hold up?
79
80
  skalpel catches scan your own history for "done" claims over failing tests
81
+ skalpel card [--last|--id <id>] [--md] share one verified catch: the claim + its own failing proof
80
82
  skalpel uninstall [--purge] remove the hooks + local data from this machine
81
83
 
82
84
  options:
@@ -112,6 +114,11 @@ function validateArgv() {
112
114
  process.exit(0);
113
115
  }
114
116
  if (BOOL_FLAGS.has(a)) continue; // valueless flag (e.g. `uninstall --purge`)
117
+ // `skalpel card` owns its own flags (--last / --id <id> / --md / --plain) and forwards them to
118
+ // card.mjs. They are NOT setup options, so setup must not reject them as unknown — pass any token
119
+ // after the `card` subcommand straight through (card.mjs validates its own flags). Global -v/-h
120
+ // above still win for a bare `skalpel -v`. Scoped to `card` so no other subcommand's contract shifts.
121
+ if (i > 0 && argv[0] === "card") continue;
115
122
  if (a.startsWith("-")) {
116
123
  console.error(`skalpel: unknown option \`${a}\`\n\n${USAGE}`);
117
124
  process.exit(2);
@@ -878,6 +885,17 @@ async function main() {
878
885
  spawnSync("node", [join(__dir, "catches.mjs"), ...argv.slice(1)], { stdio: "inherit" });
879
886
  return;
880
887
  }
888
+ // `skalpel card` — THE CATCH CARD: a LOCAL, READ-ONLY, ZERO-NETWORK render of ONE already-verified
889
+ // catch that the live shadow (verify-shadow) wrote to ~/.skalpel/verify-shadow.log — the agent's
890
+ // verbatim "done" claim, the exact proof command skalpel re-ran, the real FAIL verdict, and the
891
+ // verbatim output tail — as a clean, copy-pasteable card for Slack / a PR / X. It never re-runs a
892
+ // proof, never touches the network; renders only a GENUINE_FAIL row (never harness noise) and, if
893
+ // there's no catch to show, says so honestly. `--md`/`--plain` pick the export; `--id <session>`
894
+ // targets a specific session; `--last` (default) is the newest catch.
895
+ if (sub === "card") {
896
+ spawnSync("node", [join(__dir, "card.mjs"), ...argv.slice(1)], { stdio: "inherit" });
897
+ return;
898
+ }
881
899
  // `skalpel __verify-report` — the claim-verification SHADOW instrument: reads the local
882
900
  // verify-shadow.log and prints the three numbers (claim fire-rate, proof-reconstruction rate, and the
883
901
  // headline MISMATCH rate = how often the agent claimed done but its OWN proof, re-run, failed).