skalpel 4.0.29 → 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/anchor-shadow.mjs +627 -0
- package/card.mjs +306 -0
- package/package.json +1 -1
- package/skalpel-setup.mjs +18 -0
- package/verify-shadow.mjs +326 -34
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// anchor-shadow.mjs — ship-status "Auto-Run-the-Anchor", SHADOW mode, READ-ONLY.
|
|
3
|
+
//
|
|
4
|
+
// WHAT: when the user falls into the cycle-12 signature (>=2 consecutive terse re-verify/doubt turns
|
|
5
|
+
// about the SAME claim — "did it merge?", "you sure it's live?", "did that actually push?") AND that
|
|
6
|
+
// doubt is about SHIP STATUS (merged / pushed / live / deployed / price / trial / CI / passing /
|
|
7
|
+
// released), reconstruct the concrete anchor from recent session + repo context (a PR number, a URL,
|
|
8
|
+
// a commit sha, a Stripe price), run ONE read-only allowlisted probe to resolve the doubt, and LOG
|
|
9
|
+
// what we WOULD have injected. It NEVER injects into the live model and NEVER mutates anything.
|
|
10
|
+
//
|
|
11
|
+
// RED LINES honored by construction:
|
|
12
|
+
// - LOCAL-ONLY: the only side effect is appending to ~/.skalpel/anchor-shadow.log. No network exfil.
|
|
13
|
+
// - FAIL-OPEN: the per-turn wiring only spawns a DETACHED, unref'd child to run the probe, so the
|
|
14
|
+
// hook returns immediately — a probe can never block or slow a turn. Every probe has a hard 5s cap.
|
|
15
|
+
// - REVERSIBLE / SHADOW: log only, never inject, never throw out to the caller.
|
|
16
|
+
// - PRECISION OVER RECALL: fire only on a tight, consecutive doubt streak about a ship-status claim.
|
|
17
|
+
// - NO FABRICATED NUMBERS: probe evidence is the REAL stdout of a read-only command (execFile arg
|
|
18
|
+
// arrays, never a shell string), or an honest "unknown" on error/timeout.
|
|
19
|
+
//
|
|
20
|
+
// INJECTION SAFETY: every probe is `execFile(bin, [args...])` — arguments are an array, so there is NO
|
|
21
|
+
// shell and NO string interpolation. On top of that (defense in depth) every reconstructed value is
|
|
22
|
+
// re-validated against a strict whitelist before it is ever passed as an argument: PR = integer, sha =
|
|
23
|
+
// /^[0-9a-f]{7,40}$/, url = parseable http(s) URL with no whitespace/control chars, repo dir = an
|
|
24
|
+
// existing absolute directory. A crafted target such as url = "; rm -rf x" fails `new URL()` and is
|
|
25
|
+
// rejected; even if it weren't, execFile would hand it to curl as one literal argv, not to a shell.
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
appendFileSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
existsSync,
|
|
31
|
+
statSync,
|
|
32
|
+
writeFileSync,
|
|
33
|
+
readFileSync,
|
|
34
|
+
realpathSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import { homedir } from "node:os";
|
|
37
|
+
import { join, dirname, isAbsolute } from "node:path";
|
|
38
|
+
import { fileURLToPath } from "node:url";
|
|
39
|
+
import { execFile, spawn } from "node:child_process";
|
|
40
|
+
import { tailLines } from "./transcript.mjs";
|
|
41
|
+
|
|
42
|
+
const SKALPEL_DIR = join(homedir(), ".skalpel");
|
|
43
|
+
const LOG_PATH = join(SKALPEL_DIR, "anchor-shadow.log");
|
|
44
|
+
const PENDING_PATH = join(SKALPEL_DIR, "anchor-pending.json");
|
|
45
|
+
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
46
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
47
|
+
|
|
48
|
+
// The claim classifier: the doubt only counts as SHIP-STATUS if one of these tokens is present.
|
|
49
|
+
// Word-boundary matched, case-insensitive. Kept tight (precision over recall).
|
|
50
|
+
const SHIP_LEXICON =
|
|
51
|
+
/\b(merged?|push(?:ed)?|live|deployed?|deploy|prod(?:uction)?|price|pricing|trial|ci|passing|pass(?:ed)?|released?|release|shipp?ed|ship)\b/i;
|
|
52
|
+
|
|
53
|
+
// The re-verify / doubt signature: a TERSE turn that questions a prior claim without taking a new
|
|
54
|
+
// action. Either it hits one of these doubt phrases, or it is a short bare question ("live?", "did it?").
|
|
55
|
+
const DOUBT_LEXICON =
|
|
56
|
+
/\b(did (?:it|that|you|they|we)|are you sure|you sure|is it (?:really|actually|live|up|merged|deployed|passing)|are they|has it|have you|really|actually|for real|for sure|double[- ]?check|recheck|re-?verify|verify|confirm|check again|wait\b|hmm+|sure\?)\b/i;
|
|
57
|
+
|
|
58
|
+
// A NEW ACTION (imperative work) disqualifies a turn from being a pure re-verify. If a "doubt" turn
|
|
59
|
+
// also issues fresh work, it isn't the passive cycle-12 loop we target.
|
|
60
|
+
const NEW_ACTION =
|
|
61
|
+
/\b(add|fix|change|write|implement|refactor|make|create|update|remove|delete|build|run|try|use|set|rename)\b/i;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------------------------------
|
|
64
|
+
// Transcript helpers (bounded tail read — reuses the same helper the hook uses, so no unbounded slurp).
|
|
65
|
+
// ---------------------------------------------------------------------------------------------------
|
|
66
|
+
function entryText(e) {
|
|
67
|
+
const raw = e?.content ?? e?.message?.content;
|
|
68
|
+
if (typeof raw === "string") return raw;
|
|
69
|
+
if (Array.isArray(raw))
|
|
70
|
+
return raw
|
|
71
|
+
.map((b) => {
|
|
72
|
+
if (typeof b === "string") return b;
|
|
73
|
+
if (b?.type === "text") return b.text || "";
|
|
74
|
+
// tool_result content can be a string or nested blocks — flatten best-effort for anchor mining.
|
|
75
|
+
if (b?.type === "tool_result") {
|
|
76
|
+
const c = b.content;
|
|
77
|
+
if (typeof c === "string") return c;
|
|
78
|
+
if (Array.isArray(c)) return c.map((x) => x?.text || "").join(" ");
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
})
|
|
82
|
+
.join(" ");
|
|
83
|
+
return "";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The last `n` USER turns, newest first, as trimmed strings. Bounded tail read; never throws.
|
|
87
|
+
export function recentUserTurns(payload, n = 5) {
|
|
88
|
+
const out = [];
|
|
89
|
+
try {
|
|
90
|
+
if (payload?.prompt) out.push(String(payload.prompt).trim());
|
|
91
|
+
if (!payload?.transcript_path) return out.slice(0, n);
|
|
92
|
+
const lines = tailLines(payload.transcript_path, TRANSCRIPT_TAIL_BYTES);
|
|
93
|
+
for (let i = lines.length - 1; i >= 0 && out.length < n; i--) {
|
|
94
|
+
let e;
|
|
95
|
+
try {
|
|
96
|
+
e = JSON.parse(lines[i]);
|
|
97
|
+
} catch {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (e.type === "user" || e.role === "user") {
|
|
101
|
+
const t = entryText(e).trim();
|
|
102
|
+
if (t) out.push(t);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
/* fail-open */
|
|
107
|
+
}
|
|
108
|
+
return out.slice(0, n);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// A broad recent-context string (user + assistant + tool text) for anchor mining — this is where a PR
|
|
112
|
+
// number, URL, or sha most often lives (in the assistant's prior `gh`/`git` output, not the user turn).
|
|
113
|
+
export function recentContextText(payload, entries = 60) {
|
|
114
|
+
try {
|
|
115
|
+
if (!payload?.transcript_path) return String(payload?.prompt || "");
|
|
116
|
+
const lines = tailLines(payload.transcript_path, TRANSCRIPT_TAIL_BYTES);
|
|
117
|
+
const slice = lines.slice(-entries);
|
|
118
|
+
const parts = [];
|
|
119
|
+
for (const ln of slice) {
|
|
120
|
+
try {
|
|
121
|
+
parts.push(entryText(JSON.parse(ln)));
|
|
122
|
+
} catch {
|
|
123
|
+
/* skip */
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (payload?.prompt) parts.push(String(payload.prompt));
|
|
127
|
+
return parts.join("\n");
|
|
128
|
+
} catch {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------------------------------
|
|
134
|
+
// (1) detectShipStatusSpiral — the cycle-12 re-verify signature + ship-status classification.
|
|
135
|
+
// `turns` = user turns newest-first (as returned by recentUserTurns). Returns {fire, claim}.
|
|
136
|
+
// ---------------------------------------------------------------------------------------------------
|
|
137
|
+
function isTerse(t) {
|
|
138
|
+
return t.split(/\s+/).filter(Boolean).length <= 10;
|
|
139
|
+
}
|
|
140
|
+
function isDoubtTurn(t) {
|
|
141
|
+
if (!isTerse(t)) return false;
|
|
142
|
+
if (NEW_ACTION.test(t)) return false; // a fresh instruction is not a passive re-verify
|
|
143
|
+
return DOUBT_LEXICON.test(t) || /\?\s*$/.test(t); // doubt phrase OR a short bare question
|
|
144
|
+
}
|
|
145
|
+
function shipToken(t) {
|
|
146
|
+
const m = SHIP_LEXICON.exec(t);
|
|
147
|
+
return m ? m[1].toLowerCase() : null;
|
|
148
|
+
}
|
|
149
|
+
// Normalize surface variants to a canonical claim keyword for stable grouping/probing.
|
|
150
|
+
function canonicalClaim(tok) {
|
|
151
|
+
if (!tok) return null;
|
|
152
|
+
if (/^merg/.test(tok)) return "merged";
|
|
153
|
+
if (/^push/.test(tok)) return "pushed";
|
|
154
|
+
if (/^deploy/.test(tok)) return "deployed";
|
|
155
|
+
if (/^prod/.test(tok)) return "deployed";
|
|
156
|
+
if (tok === "live") return "live";
|
|
157
|
+
if (/^releas/.test(tok)) return "released";
|
|
158
|
+
if (/^ship/.test(tok)) return "released";
|
|
159
|
+
if (/^pric/.test(tok)) return "price";
|
|
160
|
+
if (tok === "trial") return "trial";
|
|
161
|
+
if (tok === "ci" || /^pass/.test(tok)) return "ci";
|
|
162
|
+
return tok;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function detectShipStatusSpiral(turns) {
|
|
166
|
+
const none = { fire: false, claim: null };
|
|
167
|
+
if (!Array.isArray(turns) || turns.length < 2) return none;
|
|
168
|
+
// Walk consecutive doubt turns from the newest. Stop at the first non-doubt turn.
|
|
169
|
+
const streak = [];
|
|
170
|
+
for (const t of turns) {
|
|
171
|
+
if (isDoubtTurn(t)) streak.push(t);
|
|
172
|
+
else break;
|
|
173
|
+
}
|
|
174
|
+
if (streak.length < 2) return none; // need >=2 consecutive terse re-verify/doubt turns
|
|
175
|
+
// The doubt must classify as SHIP-STATUS: a ship-status token present across the streak. "SAME claim"
|
|
176
|
+
// = the streak shares one canonical ship claim (no conflicting ship subject dominates the streak).
|
|
177
|
+
const claims = streak.map((t) => canonicalClaim(shipToken(t))).filter(Boolean);
|
|
178
|
+
if (claims.length === 0) return none; // doubt, but not about ship status → do not fire
|
|
179
|
+
// pick the most-recent ship claim; require the streak not to mix two different ship subjects.
|
|
180
|
+
const claim = claims[0];
|
|
181
|
+
const distinct = new Set(claims);
|
|
182
|
+
if (distinct.size > 1) return none; // ambiguous — different claims → precision guard, do not fire
|
|
183
|
+
return { fire: true, claim };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------------------------------
|
|
187
|
+
// (2) reconstructTarget — pull the concrete anchor from recent turns + repo context. Returns a STRUCTURED
|
|
188
|
+
// target or null (null = "not confidently reconstructable" → the caller logs a miss and does NOT fire).
|
|
189
|
+
// ---------------------------------------------------------------------------------------------------
|
|
190
|
+
function findPr(text) {
|
|
191
|
+
// "#123", "PR 123", "PR#123", "pull/123", "pull request 123"
|
|
192
|
+
const m = /(?:\bpr\s*#?\s*|\bpull(?:\s*request)?\s*#?\s*|\/pull\/|#)(\d{1,6})\b/i.exec(text);
|
|
193
|
+
if (!m) return null;
|
|
194
|
+
const n = Number(m[1]);
|
|
195
|
+
return Number.isInteger(n) && n > 0 && n < 1e7 ? n : null;
|
|
196
|
+
}
|
|
197
|
+
function findUrl(text) {
|
|
198
|
+
const m = /\bhttps?:\/\/[^\s'")<>`]+/i.exec(text);
|
|
199
|
+
if (!m) return null;
|
|
200
|
+
let url = m[0].replace(/[.,);]+$/, ""); // trim trailing punctuation from prose
|
|
201
|
+
return isSafeUrl(url) ? url : null;
|
|
202
|
+
}
|
|
203
|
+
function findSha(text) {
|
|
204
|
+
// Prefer a sha that appears near commit-ish words; otherwise the longest lone hex token in [7,40].
|
|
205
|
+
const near = /\b(?:commit|sha|rev(?:ision)?|HEAD|at)\s+([0-9a-f]{7,40})\b/i.exec(text);
|
|
206
|
+
if (near && /^[0-9a-f]{7,40}$/.test(near[1])) return near[1].toLowerCase();
|
|
207
|
+
let best = null;
|
|
208
|
+
const re = /\b([0-9a-f]{7,40})\b/gi;
|
|
209
|
+
let m;
|
|
210
|
+
while ((m = re.exec(text))) {
|
|
211
|
+
const tok = m[1].toLowerCase();
|
|
212
|
+
if (/^\d+$/.test(tok)) continue; // pure decimal → not a sha (avoids matching long numbers)
|
|
213
|
+
if (!best || tok.length > best.length) best = tok;
|
|
214
|
+
}
|
|
215
|
+
return best;
|
|
216
|
+
}
|
|
217
|
+
function repoDirOf(payload) {
|
|
218
|
+
const cwd = payload?.cwd && isAbsolute(payload.cwd) ? payload.cwd : process.cwd();
|
|
219
|
+
try {
|
|
220
|
+
return existsSync(cwd) && statSync(cwd).isDirectory() ? cwd : null;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function reconstructTarget(claim, contextText, payload) {
|
|
227
|
+
const text = String(contextText || "");
|
|
228
|
+
const repoDir = repoDirOf(payload);
|
|
229
|
+
const pr = findPr(text);
|
|
230
|
+
const url = findUrl(text);
|
|
231
|
+
const sha = findSha(text);
|
|
232
|
+
|
|
233
|
+
// Claim-driven preference, each with fallbacks. Return the first that reconstructs; null otherwise.
|
|
234
|
+
const tryPr = () => (pr != null ? { kind: "pr", n: pr, repoDir } : null);
|
|
235
|
+
const tryUrl = () => (url ? { kind: "url", url } : null);
|
|
236
|
+
const tryGit = () => (sha && repoDir ? { kind: "git", sha, repoDir } : null);
|
|
237
|
+
const tryStripe = () => ({ kind: "stripe-price" });
|
|
238
|
+
|
|
239
|
+
let order;
|
|
240
|
+
switch (claim) {
|
|
241
|
+
case "merged":
|
|
242
|
+
case "pushed":
|
|
243
|
+
case "ci":
|
|
244
|
+
case "released":
|
|
245
|
+
order = [tryPr, tryGit, tryUrl];
|
|
246
|
+
break;
|
|
247
|
+
case "live":
|
|
248
|
+
case "deployed":
|
|
249
|
+
order = [tryUrl, tryPr, tryGit];
|
|
250
|
+
break;
|
|
251
|
+
case "price":
|
|
252
|
+
case "trial":
|
|
253
|
+
// Only claim a stripe-price target if the context actually talks price/product — otherwise a
|
|
254
|
+
// bare "stripe prices list" is not a confident reconstruction of THIS doubt.
|
|
255
|
+
order = /\b(price|pricing|trial|checkout|stripe|plan|subscription|\$\d)/i.test(text)
|
|
256
|
+
? [tryStripe, tryUrl]
|
|
257
|
+
: [tryUrl];
|
|
258
|
+
break;
|
|
259
|
+
default:
|
|
260
|
+
order = [tryPr, tryUrl, tryGit];
|
|
261
|
+
}
|
|
262
|
+
for (const f of order) {
|
|
263
|
+
const t = f();
|
|
264
|
+
if (t) return t;
|
|
265
|
+
}
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------------------------------
|
|
270
|
+
// Strict validators (defense in depth — execFile already means no shell).
|
|
271
|
+
// ---------------------------------------------------------------------------------------------------
|
|
272
|
+
function isSafeUrl(u) {
|
|
273
|
+
if (typeof u !== "string" || u.length === 0 || u.length > 2048) return false;
|
|
274
|
+
if (/[\s-<>`"'\\]/.test(u)) return false; // no whitespace/control/quote/backtick/backslash
|
|
275
|
+
try {
|
|
276
|
+
const p = new URL(u);
|
|
277
|
+
return p.protocol === "http:" || p.protocol === "https:";
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
function validateTarget(t) {
|
|
283
|
+
if (!t || typeof t !== "object") return null;
|
|
284
|
+
switch (t.kind) {
|
|
285
|
+
case "pr": {
|
|
286
|
+
const n = Number(t.n);
|
|
287
|
+
if (!Number.isInteger(n) || n <= 0 || n >= 1e7) return null;
|
|
288
|
+
const repoDir = validDir(t.repoDir);
|
|
289
|
+
return { kind: "pr", n, repoDir };
|
|
290
|
+
}
|
|
291
|
+
case "url":
|
|
292
|
+
return isSafeUrl(t.url) ? { kind: "url", url: t.url } : null;
|
|
293
|
+
case "git": {
|
|
294
|
+
if (!/^[0-9a-f]{7,40}$/.test(String(t.sha || ""))) return null;
|
|
295
|
+
const repoDir = validDir(t.repoDir);
|
|
296
|
+
if (!repoDir) return null;
|
|
297
|
+
return { kind: "git", sha: String(t.sha).toLowerCase(), repoDir };
|
|
298
|
+
}
|
|
299
|
+
case "stripe-price":
|
|
300
|
+
return { kind: "stripe-price" };
|
|
301
|
+
default:
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function validDir(d) {
|
|
306
|
+
try {
|
|
307
|
+
return d && isAbsolute(d) && existsSync(d) && statSync(d).isDirectory() ? d : null;
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------------------------------
|
|
314
|
+
// (3) runProbe — ONE read-only allowlisted command via execFile (arg ARRAYS, never a shell string),
|
|
315
|
+
// hard 5s timeout, catch-all. Returns {pass, evidence}. pass ∈ {true,false,null(unknown)}.
|
|
316
|
+
// ---------------------------------------------------------------------------------------------------
|
|
317
|
+
function run(bin, args, opts = {}) {
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
let done = false;
|
|
320
|
+
const finish = (v) => {
|
|
321
|
+
if (!done) {
|
|
322
|
+
done = true;
|
|
323
|
+
resolve(v);
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
const child = execFile(
|
|
328
|
+
bin,
|
|
329
|
+
args,
|
|
330
|
+
{ timeout: PROBE_TIMEOUT_MS, killSignal: "SIGKILL", maxBuffer: 1 << 20, ...opts },
|
|
331
|
+
(err, stdout, stderr) => {
|
|
332
|
+
finish({ err, stdout: String(stdout || ""), stderr: String(stderr || "") });
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
child.on("error", (err) => finish({ err, stdout: "", stderr: String(err) }));
|
|
336
|
+
} catch (err) {
|
|
337
|
+
finish({ err, stdout: "", stderr: String(err) });
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
const clip = (s) =>
|
|
342
|
+
String(s || "")
|
|
343
|
+
.replace(/\s+/g, " ")
|
|
344
|
+
.trim()
|
|
345
|
+
.slice(0, 200);
|
|
346
|
+
|
|
347
|
+
export async function runProbe(rawTarget) {
|
|
348
|
+
const t = validateTarget(rawTarget);
|
|
349
|
+
if (!t) return { pass: null, evidence: "rejected: target failed validation" };
|
|
350
|
+
try {
|
|
351
|
+
if (t.kind === "pr") {
|
|
352
|
+
// gh pr view <n> --json state,mergedAt (READ-ONLY)
|
|
353
|
+
const opts = t.repoDir ? { cwd: t.repoDir } : {};
|
|
354
|
+
const { err, stdout } = await run(
|
|
355
|
+
"gh",
|
|
356
|
+
["pr", "view", String(t.n), "--json", "state,mergedAt"],
|
|
357
|
+
opts,
|
|
358
|
+
);
|
|
359
|
+
if (err)
|
|
360
|
+
return { pass: null, evidence: clip(`gh error: ${err.killed ? "timeout" : err.message}`) };
|
|
361
|
+
let j;
|
|
362
|
+
try {
|
|
363
|
+
j = JSON.parse(stdout);
|
|
364
|
+
} catch {
|
|
365
|
+
return { pass: null, evidence: clip(`gh non-json: ${stdout}`) };
|
|
366
|
+
}
|
|
367
|
+
const merged = j.state === "MERGED" || !!j.mergedAt;
|
|
368
|
+
return { pass: merged, evidence: clip(`state=${j.state} mergedAt=${j.mergedAt || "null"}`) };
|
|
369
|
+
}
|
|
370
|
+
if (t.kind === "url") {
|
|
371
|
+
// curl -sS -m 5 -o /dev/null -w "%{http_code}" <url> (READ-ONLY reachability)
|
|
372
|
+
const { err, stdout } = await run("curl", [
|
|
373
|
+
"-sS",
|
|
374
|
+
"-m",
|
|
375
|
+
"5",
|
|
376
|
+
"-o",
|
|
377
|
+
"/dev/null",
|
|
378
|
+
"-w",
|
|
379
|
+
"%{http_code}",
|
|
380
|
+
t.url,
|
|
381
|
+
]);
|
|
382
|
+
if (err)
|
|
383
|
+
return {
|
|
384
|
+
pass: null,
|
|
385
|
+
evidence: clip(`curl error: ${err.killed ? "timeout" : err.message}`),
|
|
386
|
+
};
|
|
387
|
+
const code = parseInt(stdout, 10);
|
|
388
|
+
if (!Number.isFinite(code) || code === 0)
|
|
389
|
+
return { pass: null, evidence: clip(`no http code: ${stdout}`) };
|
|
390
|
+
return { pass: code >= 200 && code < 400, evidence: `http_code=${code}` };
|
|
391
|
+
}
|
|
392
|
+
if (t.kind === "git") {
|
|
393
|
+
// git -C <repo> branch --contains <sha> -a (READ-ONLY: is the commit on main?)
|
|
394
|
+
const { err, stdout } = await run("git", [
|
|
395
|
+
"-C",
|
|
396
|
+
t.repoDir,
|
|
397
|
+
"branch",
|
|
398
|
+
"--contains",
|
|
399
|
+
t.sha,
|
|
400
|
+
"-a",
|
|
401
|
+
"--format=%(refname:short)",
|
|
402
|
+
]);
|
|
403
|
+
if (err)
|
|
404
|
+
return { pass: null, evidence: clip(`git error: ${err.killed ? "timeout" : err.message}`) };
|
|
405
|
+
const branches = stdout
|
|
406
|
+
.split("\n")
|
|
407
|
+
.map((s) => s.trim())
|
|
408
|
+
.filter(Boolean);
|
|
409
|
+
const onMain = branches.some((b) => /(^|\/)main$|(^|\/)master$/.test(b));
|
|
410
|
+
return { pass: onMain, evidence: clip(`contains: ${branches.join(",") || "(none)"}`) };
|
|
411
|
+
}
|
|
412
|
+
if (t.kind === "stripe-price") {
|
|
413
|
+
// stripe prices list --limit 3 (READ-ONLY)
|
|
414
|
+
const { err, stdout } = await run("stripe", ["prices", "list", "--limit", "3"]);
|
|
415
|
+
if (err)
|
|
416
|
+
return {
|
|
417
|
+
pass: null,
|
|
418
|
+
evidence: clip(`stripe error: ${err.killed ? "timeout" : err.message}`),
|
|
419
|
+
};
|
|
420
|
+
const has = /"id"\s*:/.test(stdout) || /price_/.test(stdout);
|
|
421
|
+
return { pass: has, evidence: clip(has ? "prices listed" : `no prices: ${stdout}`) };
|
|
422
|
+
}
|
|
423
|
+
} catch (e) {
|
|
424
|
+
return { pass: null, evidence: clip(`probe threw: ${e}`) };
|
|
425
|
+
}
|
|
426
|
+
return { pass: null, evidence: "unknown target kind" };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ---------------------------------------------------------------------------------------------------
|
|
430
|
+
// Logging — the ONLY side effect. Append-only NDJSON at ~/.skalpel/anchor-shadow.log. Never throws.
|
|
431
|
+
// ---------------------------------------------------------------------------------------------------
|
|
432
|
+
function logRow(row) {
|
|
433
|
+
try {
|
|
434
|
+
mkdirSync(SKALPEL_DIR, { recursive: true });
|
|
435
|
+
appendFileSync(LOG_PATH, JSON.stringify(row) + "\n");
|
|
436
|
+
} catch {
|
|
437
|
+
/* logging is best-effort; never block or throw */
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
function readPending() {
|
|
441
|
+
try {
|
|
442
|
+
return JSON.parse(readFileSync(PENDING_PATH, "utf8"));
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function writePending(obj) {
|
|
448
|
+
try {
|
|
449
|
+
mkdirSync(SKALPEL_DIR, { recursive: true });
|
|
450
|
+
writeFileSync(PENDING_PATH, JSON.stringify(obj || {}));
|
|
451
|
+
} catch {
|
|
452
|
+
/* best-effort */
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------------------------------
|
|
457
|
+
// (4) recordAnchorShadow — the per-turn wiring. SHADOW: log only, never inject, never throw. Cheap on
|
|
458
|
+
// the common path (no spiral → returns after a bounded tail read). On a fire with a reconstructable
|
|
459
|
+
// target it spawns a DETACHED, unref'd child to run the probe so the hook is NEVER slowed by it.
|
|
460
|
+
// ---------------------------------------------------------------------------------------------------
|
|
461
|
+
export function recordAnchorShadow(payload) {
|
|
462
|
+
try {
|
|
463
|
+
const session = payload?.session_id ?? null;
|
|
464
|
+
const turns = recentUserTurns(payload, 5);
|
|
465
|
+
|
|
466
|
+
// n+1 instrument (3): resolve the PREVIOUS fire against THIS turn — did the spiral end? (the user's
|
|
467
|
+
// newest turn no longer doubts the same claim). Race-free: uses only doubt-cessation, not the probe.
|
|
468
|
+
const pending = readPending();
|
|
469
|
+
if (pending && pending.claim) {
|
|
470
|
+
const newest = turns[0] || "";
|
|
471
|
+
const stillDoubting =
|
|
472
|
+
isDoubtTurn(newest) && canonicalClaim(shipToken(newest)) === pending.claim;
|
|
473
|
+
logRow({
|
|
474
|
+
ts: new Date().toISOString(),
|
|
475
|
+
session,
|
|
476
|
+
event: "resolve",
|
|
477
|
+
ref_ts: pending.ts,
|
|
478
|
+
claim: pending.claim,
|
|
479
|
+
spiral_ended: !stillDoubting,
|
|
480
|
+
});
|
|
481
|
+
writePending({});
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const { fire, claim } = detectShipStatusSpiral(turns);
|
|
485
|
+
if (!fire) return;
|
|
486
|
+
|
|
487
|
+
const target = reconstructTarget(claim, recentContextText(payload), payload);
|
|
488
|
+
if (!target) {
|
|
489
|
+
// Fire but no concrete anchor → DO NOT fire the probe; log a miss (instrument 2 denominator).
|
|
490
|
+
logRow({ ts: new Date().toISOString(), session, event: "miss", claim, reason: "no-target" });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Arm the n+1 resolution for next turn, then hand the read-only probe to a detached child.
|
|
495
|
+
writePending({ ts: new Date().toISOString(), claim });
|
|
496
|
+
spawnProbeChild({ session, claim, target });
|
|
497
|
+
} catch {
|
|
498
|
+
/* SHADOW: swallow everything — this can NEVER affect the hook or its injected output. */
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Spawn a detached, unref'd child that runs ONE read-only probe and appends the fire row, then exits.
|
|
503
|
+
// The parent returns immediately → zero added per-turn latency. Job passed via env as JSON (no shell,
|
|
504
|
+
// no argv interpolation); the child re-validates every field before executing anything.
|
|
505
|
+
function spawnProbeChild(job) {
|
|
506
|
+
try {
|
|
507
|
+
const self = fileURLToPath(import.meta.url);
|
|
508
|
+
const child = spawn(process.execPath, [self, "--run-probe"], {
|
|
509
|
+
detached: true,
|
|
510
|
+
stdio: "ignore",
|
|
511
|
+
env: { ...process.env, SKALPEL_ANCHOR_JOB: JSON.stringify(job) },
|
|
512
|
+
});
|
|
513
|
+
child.on("error", () => {});
|
|
514
|
+
child.unref();
|
|
515
|
+
} catch {
|
|
516
|
+
/* if we can't spawn, log a miss so the fire is still accounted for */
|
|
517
|
+
logRow({
|
|
518
|
+
ts: new Date().toISOString(),
|
|
519
|
+
session: job?.session ?? null,
|
|
520
|
+
event: "miss",
|
|
521
|
+
claim: job?.claim ?? null,
|
|
522
|
+
reason: "spawn-failed",
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async function runDetachedProbe() {
|
|
528
|
+
let job;
|
|
529
|
+
try {
|
|
530
|
+
job = JSON.parse(process.env.SKALPEL_ANCHOR_JOB || "{}");
|
|
531
|
+
} catch {
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const { pass, evidence } = await runProbe(job.target);
|
|
535
|
+
logRow({
|
|
536
|
+
ts: new Date().toISOString(),
|
|
537
|
+
session: job.session ?? null,
|
|
538
|
+
event: "probe",
|
|
539
|
+
claim: job.claim ?? null,
|
|
540
|
+
target: safeTarget(job.target),
|
|
541
|
+
would_inject_pass_fail: pass === null ? "UNKNOWN" : pass ? "PASS" : "FAIL",
|
|
542
|
+
evidence: clip(evidence),
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
// A compact, non-sensitive view of the target for the log.
|
|
546
|
+
function safeTarget(t) {
|
|
547
|
+
if (!t || typeof t !== "object") return null;
|
|
548
|
+
if (t.kind === "pr") return { kind: "pr", n: t.n };
|
|
549
|
+
if (t.kind === "url") return { kind: "url", url: clip(t.url) };
|
|
550
|
+
if (t.kind === "git") return { kind: "git", sha: t.sha };
|
|
551
|
+
if (t.kind === "stripe-price") return { kind: "stripe-price" };
|
|
552
|
+
return null;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---------------------------------------------------------------------------------------------------
|
|
556
|
+
// The tiny analyzer — `skalpel __anchor-report`. Prints the three instrument numbers from the log.
|
|
557
|
+
// ---------------------------------------------------------------------------------------------------
|
|
558
|
+
export function report() {
|
|
559
|
+
let lines = [];
|
|
560
|
+
try {
|
|
561
|
+
lines = readFileSync(LOG_PATH, "utf8").trim().split("\n").filter(Boolean);
|
|
562
|
+
} catch {
|
|
563
|
+
console.log(`no anchor-shadow log yet (${LOG_PATH})`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const rows = [];
|
|
567
|
+
for (const ln of lines) {
|
|
568
|
+
try {
|
|
569
|
+
rows.push(JSON.parse(ln));
|
|
570
|
+
} catch {
|
|
571
|
+
/* skip */
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const fires = rows.filter((r) => r.event === "probe" || r.event === "miss");
|
|
575
|
+
const probes = rows.filter((r) => r.event === "probe");
|
|
576
|
+
const resolves = rows.filter((r) => r.event === "resolve");
|
|
577
|
+
|
|
578
|
+
const withTarget = probes.length;
|
|
579
|
+
const decisive = probes.filter((r) => r.would_inject_pass_fail !== "UNKNOWN").length;
|
|
580
|
+
const spiralEnded = resolves.filter((r) => r.spiral_ended).length;
|
|
581
|
+
|
|
582
|
+
const pct = (a, b) => (b > 0 ? `${((100 * a) / b).toFixed(0)}%` : "n/a");
|
|
583
|
+
|
|
584
|
+
console.log(`\nskalpel anchor-shadow report — ${rows.length} rows — ${LOG_PATH}\n`);
|
|
585
|
+
console.log(` 1. fires (detected ship-status spirals): ${fires.length}`);
|
|
586
|
+
console.log(
|
|
587
|
+
` 2. target-reconstruction success rate: ${pct(withTarget, fires.length)} (${withTarget}/${fires.length})`,
|
|
588
|
+
);
|
|
589
|
+
console.log(
|
|
590
|
+
` 3. probe would-resolve the doubt (decisive): ${pct(decisive, withTarget)} (${decisive}/${withTarget})`,
|
|
591
|
+
);
|
|
592
|
+
console.log(
|
|
593
|
+
` └ spiral ended on the next human turn: ${pct(spiralEnded, resolves.length)} (${spiralEnded}/${resolves.length})`,
|
|
594
|
+
);
|
|
595
|
+
// Pass/fail split of what we WOULD have surfaced — real command output, never fabricated.
|
|
596
|
+
const p = probes.filter((r) => r.would_inject_pass_fail === "PASS").length;
|
|
597
|
+
const f = probes.filter((r) => r.would_inject_pass_fail === "FAIL").length;
|
|
598
|
+
const u = probes.filter((r) => r.would_inject_pass_fail === "UNKNOWN").length;
|
|
599
|
+
console.log(`\n would-inject verdicts: PASS ${p} · FAIL ${f} · UNKNOWN ${u}\n`);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---------------------------------------------------------------------------------------------------
|
|
603
|
+
// CLI entry (only when run directly). `--run-probe` = the detached child; `--report` = the analyzer.
|
|
604
|
+
// When imported (from the hook or tests) none of this runs — only the exports above are used.
|
|
605
|
+
// ---------------------------------------------------------------------------------------------------
|
|
606
|
+
const isMain = (() => {
|
|
607
|
+
try {
|
|
608
|
+
if (!process.argv[1]) return false;
|
|
609
|
+
const self = fileURLToPath(import.meta.url);
|
|
610
|
+
if (self === process.argv[1]) return true;
|
|
611
|
+
// Tolerate symlinked staging paths (~/.skalpel/hooks may be reached via a symlink).
|
|
612
|
+
return realpathSync(self) === realpathSync(process.argv[1]);
|
|
613
|
+
} catch {
|
|
614
|
+
return false;
|
|
615
|
+
}
|
|
616
|
+
})();
|
|
617
|
+
if (isMain) {
|
|
618
|
+
if (process.argv.includes("--run-probe")) {
|
|
619
|
+
runDetachedProbe().finally(() => process.exit(0));
|
|
620
|
+
} else if (process.argv.includes("--report")) {
|
|
621
|
+
report();
|
|
622
|
+
process.exit(0);
|
|
623
|
+
} else {
|
|
624
|
+
console.log("anchor-shadow.mjs — internal skalpel module. Use `skalpel __anchor-report`.");
|
|
625
|
+
process.exit(0);
|
|
626
|
+
}
|
|
627
|
+
}
|