skalpel 4.0.57 → 4.0.59

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 CHANGED
@@ -505,11 +505,22 @@ function findPushBranch(text) {
505
505
  return { remote, branch };
506
506
  }
507
507
 
508
- // findTag(text) → a release tag (vX.Y.Z or a `git tag <t>` / `git push origin <t>` mention). Validated.
508
+ // findTag(text) → a release tag, COMMAND-ANCHORED (2026-07-13 false-accusation incident): a git-tag
509
+ // target may ONLY be minted when the tag token appears on a line that is itself a `git tag` / `git push`
510
+ // COMMAND — NEVER scavenged from arbitrary prose/output. A bare `v26.3.1` in quoted `node --version`
511
+ // output once became a "release tag", probed `git ls-remote --tags origin v26.3.1`, and minted a false
512
+ // SHIP_FAIL on a true claim. Implementation: extract ONLY the candidate lines matching
513
+ // /\bgit\s+(?:tag|push)\b/ and run the (unchanged) tag patterns over those lines alone; no such line →
514
+ // no target, ever (→ NO_TARGET, no probe, no accusation). Validation (SHIP_SAFE_REF) unchanged.
509
515
  function findTag(text) {
510
- const near = /\bgit\s+(?:tag|push[^\n;&|]*?)\s+(v?\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/i.exec(text);
516
+ const cmdLines = String(text || "")
517
+ .split(/\r?\n/)
518
+ .filter((l) => /\bgit\s+(?:tag|push)\b/i.test(l));
519
+ if (!cmdLines.length) return null; // no git tag/push command anywhere in context → no tag target
520
+ const cmds = cmdLines.join("\n");
521
+ const near = /\bgit\s+(?:tag|push[^\n;&|]*?)\s+(v?\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/i.exec(cmds);
511
522
  if (near && SHIP_SAFE_REF.test(near[1])) return near[1];
512
- const m = /\b(v\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/.exec(text);
523
+ const m = /\b(v\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/.exec(cmds);
513
524
  return m && SHIP_SAFE_REF.test(m[1]) ? m[1] : null;
514
525
  }
515
526
 
package/eval-harness.mjs CHANGED
@@ -1127,6 +1127,7 @@ const SHIP_FIXTURES = [
1127
1127
  expectReprobe: true,
1128
1128
  },
1129
1129
  // (d) TRUE CATCH: pushed-claim + sha NOT on the real remote tip → red (it never left the machine).
1130
+ // Since the all-reds confirm-gate (2026-07-13 incident), even a hard FAIL must confirm 2/2.
1130
1131
  {
1131
1132
  id: "d",
1132
1133
  label: "pushed + sha-not-on-remote → RED",
@@ -1136,8 +1137,9 @@ const SHIP_FIXTURES = [
1136
1137
  exec: gitRemoteExec({ tip: SHA_OLD, ancestor: false }),
1137
1138
  expectOutcome: "SHIP_FAIL",
1138
1139
  expectRed: true,
1140
+ expectReprobe: true,
1139
1141
  },
1140
- // (e) TRUE CATCH: merged-claim + PR is OPEN → red.
1142
+ // (e) TRUE CATCH: merged-claim + PR is OPEN → red (confirmed 2/2).
1141
1143
  {
1142
1144
  id: "e",
1143
1145
  label: "merged + PR OPEN → RED",
@@ -1147,6 +1149,7 @@ const SHIP_FIXTURES = [
1147
1149
  exec: ghPrExec("OPEN", null),
1148
1150
  expectOutcome: "SHIP_FAIL",
1149
1151
  expectRed: true,
1152
+ expectReprobe: true,
1150
1153
  },
1151
1154
  // (f) TRUE CATCH: live-claim + confirmed 404 × 2 → red (only the double-negative fires).
1152
1155
  {
@@ -1217,7 +1220,7 @@ const SHIP_FIXTURES = [
1217
1220
  expectRed: true,
1218
1221
  expectReprobe: true,
1219
1222
  },
1220
- // (l) published + NOT on the registry → red (npm view E404).
1223
+ // (l) published + NOT on the registry → red (npm view E404, confirmed 2/2).
1221
1224
  {
1222
1225
  id: "l",
1223
1226
  label: "published + npm E404 → RED",
@@ -1227,6 +1230,7 @@ const SHIP_FIXTURES = [
1227
1230
  exec: npmExec(false, "9.9.9"),
1228
1231
  expectOutcome: "SHIP_FAIL",
1229
1232
  expectRed: true,
1233
+ expectReprobe: true,
1230
1234
  },
1231
1235
  // (m) published + IS on the registry → NO red.
1232
1236
  {
@@ -1239,7 +1243,7 @@ const SHIP_FIXTURES = [
1239
1243
  expectOutcome: "SHIP_OK",
1240
1244
  expectRed: false,
1241
1245
  },
1242
- // (n) CI green claim + checks FAILING → red.
1246
+ // (n) CI green claim + checks FAILING → red (confirmed 2/2).
1243
1247
  {
1244
1248
  id: "n",
1245
1249
  label: "ci-green + checks failing → RED",
@@ -1249,6 +1253,7 @@ const SHIP_FIXTURES = [
1249
1253
  exec: ghChecksExec([{ bucket: "pass" }, { bucket: "fail" }]),
1250
1254
  expectOutcome: "SHIP_FAIL",
1251
1255
  expectRed: true,
1256
+ expectReprobe: true,
1252
1257
  },
1253
1258
  // (o) CI green claim + checks passing → NO red.
1254
1259
  {
@@ -1261,7 +1266,7 @@ const SHIP_FIXTURES = [
1261
1266
  expectOutcome: "SHIP_OK",
1262
1267
  expectRed: false,
1263
1268
  },
1264
- // (p) released (git tag) + tag NOT on origin → red.
1269
+ // (p) released (git tag) + tag NOT on origin → red (confirmed 2/2).
1265
1270
  {
1266
1271
  id: "p",
1267
1272
  label: "released tag + missing on origin → RED",
@@ -1271,6 +1276,7 @@ const SHIP_FIXTURES = [
1271
1276
  exec: gitTagExec(false, "v2.0.0"),
1272
1277
  expectOutcome: "SHIP_FAIL",
1273
1278
  expectRed: true,
1279
+ expectReprobe: true,
1274
1280
  },
1275
1281
  // (q) released (git tag) + tag present on origin → NO red.
1276
1282
  {
package/install.mjs CHANGED
@@ -587,32 +587,27 @@ function cleanupLocalData() {
587
587
  return cleaned;
588
588
  }
589
589
 
590
- // Plain uninstall: remove staged hooks + the raw transcript snapshots + all local state/logs, but
591
- // KEEP the saved auth (that's what `--purge` is for). List every file skalpel writes under ~/.skalpel
592
- // so nothing is silently left behind; auth.json (legacy local location) is deliberately preserved.
593
- rm(join(SKALPEL_DIR, "hooks"), "staged hooks");
594
- rm(join(SKALPEL_DIR, "ingest-outbox"), "raw transcript snapshots (ingest-outbox)");
595
- for (const f of [
596
- "stats.json", // legacy "time saved" accumulator nothing writes it now, but old installs left one
597
- "session.json",
598
- "steer.json",
599
- "traj.json",
600
- "pending.json",
601
- "prefs.json",
602
- "insights.ndjson",
603
- "insights-ready.json",
604
- "metrics.ndjson",
605
- "bootstrap.json",
606
- "bootstrap.lock",
607
- "client.json",
608
- "doctor.log",
609
- "hook.log",
610
- "verify-shadow.log",
611
- "verify-last.json",
612
- "verify-shadow.lock",
613
- "verify-reveal.json",
614
- ]) {
615
- rm(join(SKALPEL_DIR, f), f);
590
+ // Plain uninstall: remove EVERYTHING under ~/.skalpel staged hooks, transcript snapshots, all
591
+ // state/logs — but KEEP the saved auth (that's what `--purge` is for). This is a SWEEP, not a
592
+ // hand-maintained filename list: the old list silently drifted from what the runtime actually
593
+ // writes (steers.ndjson, verify-verdict.json, verify-ship-last.json, auth-notice.json,
594
+ // build-stalled.json, first-run.json, verify-reveal-notified.json all survived it), breaking the
595
+ // "nothing silently left behind" promise. Everything under ~/.skalpel is ours; only auth.json
596
+ // (legacy local session location) is deliberately preserved. rm()'s PKG_DIR guard still protects
597
+ // a self-hosted layout from deleting the running installer.
598
+ const LABELS = {
599
+ hooks: "staged hooks",
600
+ "ingest-outbox": "raw transcript snapshots (ingest-outbox)",
601
+ };
602
+ let entries = [];
603
+ try {
604
+ entries = existsSync(SKALPEL_DIR) ? readdirSync(SKALPEL_DIR).sort() : [];
605
+ } catch {
606
+ /* unreadable dir — best-effort; an uninstall never fails on it */
607
+ }
608
+ for (const name of entries) {
609
+ if (name === "auth.json") continue; // kept — `--purge` is the deliberate "forget me entirely"
610
+ rm(join(SKALPEL_DIR, name), LABELS[name] || name);
616
611
  }
617
612
  if (cleaned.length)
618
613
  cleaned.push("(auth kept — run `skalpel uninstall --purge` to remove it too)");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skalpel",
3
- "version": "4.0.57",
3
+ "version": "4.0.59",
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
@@ -16,7 +16,7 @@ import {
16
16
  constants,
17
17
  } from "node:fs";
18
18
  import { homedir } from "node:os";
19
- import { join, dirname, delimiter, sep as pathSep } from "node:path";
19
+ import { basename, join, dirname, delimiter, sep as pathSep } from "node:path";
20
20
  import { createInterface, emitKeypressEvents } from "node:readline";
21
21
  import { fileURLToPath } from "node:url";
22
22
  import { spawn, spawnSync } from "node:child_process";
@@ -591,7 +591,7 @@ export async function scanHistory(days = LOOKBACK_DAYS) {
591
591
  const files = walk(join(homedir(), ".claude", "projects"))
592
592
  .concat(walk(join(homedir(), ".codex", "sessions")))
593
593
  .filter((f) => {
594
- if (f.split("/").pop().startsWith("agent-")) return false; // sub-agent transcripts, not the user
594
+ if (basename(f).startsWith("agent-")) return false; // sub-agent transcripts, not the user
595
595
  try {
596
596
  return statSync(f).mtimeMs >= cutoff;
597
597
  } catch {
@@ -611,6 +611,19 @@ export async function scanHistory(days = LOOKBACK_DAYS) {
611
611
  return out;
612
612
  }
613
613
 
614
+ // Whether the 401 self-heal below may spawn the INTERACTIVE sign-in. login.mjs opens a Google
615
+ // OAuth browser tab and blocks (~180s) on the callback — acceptable only on a real terminal where
616
+ // the user can see why a tab just popped. The detached background build (`__build`, stdio:"ignore")
617
+ // has neither TTY: spawning login from there pops a zero-context browser tab and stalls the build,
618
+ // so it must instead let the 401 fall through to the ingest error, which backgroundBuild's
619
+ // build-stalled.json marker surfaces next session. Pure decision helper, exported for tests.
620
+ export function shouldSelfHealLogin(
621
+ stdoutIsTTY = process.stdout.isTTY,
622
+ stdinIsTTY = process.stdin.isTTY,
623
+ ) {
624
+ return Boolean(stdoutIsTTY && stdinIsTTY);
625
+ }
626
+
614
627
  // HOSTED model: the client is thin — it just UPLOADS the raw transcripts; the SERVER filters
615
628
  // (real-session detection) + judges (Gemini) + builds the Amplitude dash + aggregate DAG into the
616
629
  // hosted store under the user's uid. No Python, no engine, no graph-building on the user's machine.
@@ -669,7 +682,7 @@ async function buildGraph(sp, files, id) {
669
682
  curBytes = 0;
670
683
  }
671
684
  try {
672
- cur[f.split("/").pop()] = readFileSync(f, "utf8");
685
+ cur[basename(f)] = readFileSync(f, "utf8");
673
686
  curBytes += size;
674
687
  } catch {
675
688
  /* skip unreadable */
@@ -695,8 +708,9 @@ async function buildGraph(sp, files, id) {
695
708
  const label = batches.length > 1 ? ` (batch ${bi + 1}/${batches.length})` : "";
696
709
  sp.text(`Building your graph…${label}`);
697
710
  let r = await postBatch(batches[bi], token);
698
- // SELF-HEAL on 401: refresh the Google session once, then retry this batch.
699
- if (r.status === 401 && !process.env.SKALPEL_USER) {
711
+ // SELF-HEAL on 401: refresh the Google session once, then retry this batch. INTERACTIVE ONLY
712
+ // (shouldSelfHealLogin): in the detached/non-TTY case the 401 falls through to the throw below.
713
+ if (r.status === 401 && !process.env.SKALPEL_USER && shouldSelfHealLogin()) {
700
714
  sp.text("Session expired — re-opening sign-in…");
701
715
  spawnSync("node", [join(__dir, "login.mjs")], { stdio: "inherit" });
702
716
  const fresh = await identity();
@@ -39,7 +39,16 @@ import { readFileSync, rmSync } from "node:fs";
39
39
  import { homedir } from "node:os";
40
40
  import { join } from "node:path";
41
41
  import { tailLines } from "./transcript.mjs";
42
- import { cleanText, REVEAL_PATH, VERDICT_PATH, revealEnabled, autoDarkened } from "./insights.mjs";
42
+ import {
43
+ cleanText,
44
+ REVEAL_PATH,
45
+ VERDICT_PATH,
46
+ revealEnabled,
47
+ autoDarkened,
48
+ ADJUDICATIONS_PATH,
49
+ SUPPRESS_PATH,
50
+ proofSig,
51
+ } from "./insights.mjs";
43
52
 
44
53
  const CYAN = "\x1b[36m";
45
54
  const GREEN = "\x1b[32m";
@@ -352,6 +361,38 @@ function compactMeasured(ms) {
352
361
  : `${(Math.floor(h * 10) / 10).toFixed(1).replace(/\.0$/, "")}h`;
353
362
  }
354
363
 
364
+ // adjudicatedNoKeys() → the set of `${session}|${proofSig}` keys the USER adjudicated "not a real catch"
365
+ // (`skalpel no` appends a verdict:"no" row to verify-adjudications.ndjson AND a session-scoped row to
366
+ // verify-suppressed.ndjson — both are read, either is sufficient evidence). Bounded tail reads (same
367
+ // tailLines guard as the shadow log), fail-open (any error → empty set → tally unchanged). Called LAZILY
368
+ // from shadowTally only when a mismatch row actually exists, so a log with no catches — and any install
369
+ // where the adjudication files are absent — pays zero extra cost.
370
+ const ADJ_READ_BYTES = 256 * 1024;
371
+ function adjudicatedNoKeys() {
372
+ const keys = new Set();
373
+ try {
374
+ for (const l of tailLines(ADJUDICATIONS_PATH, ADJ_READ_BYTES)) {
375
+ try {
376
+ const r = JSON.parse(l);
377
+ if (r && r.verdict === "no" && r.proof) keys.add(`${r.session ?? ""}|${proofSig(r.proof)}`);
378
+ } catch {
379
+ /* partial / garbage line — skip */
380
+ }
381
+ }
382
+ for (const l of tailLines(SUPPRESS_PATH, ADJ_READ_BYTES)) {
383
+ try {
384
+ const r = JSON.parse(l);
385
+ if (r && r.session && r.proof) keys.add(`${r.session}|${proofSig(r.proof)}`);
386
+ } catch {
387
+ /* partial / garbage line — skip */
388
+ }
389
+ }
390
+ } catch {
391
+ /* fail-open: no adjudication data → nothing excluded */
392
+ }
393
+ return keys;
394
+ }
395
+
355
396
  // measuredCaughtTotalMs() → the CUMULATIVE LIFETIME sum, in ms, of EVERY real measured red→green delta:
356
397
  // each caught false "done" (mismatch row) that has a later same-session same-proof PASS contributes that
357
398
  // one measured (t_pass − t_false) delta; a catch with no later PASS contributes 0 (never estimated). This
@@ -388,7 +429,20 @@ function shadowTally() {
388
429
  const CHECKED = new Set(["PASS", "GENUINE_FAIL", "SHIP_OK", "SHIP_FAIL"]);
389
430
  let checked = 0;
390
431
  let lies = 0;
432
+ // ADJUDICATION-AWARE (2026-07-13 false-accusation incident): a mismatch row the USER marked "not a
433
+ // real catch" (`skalpel no`) must stop counting as `false` on the bar — and an adjudicated-wrong
434
+ // check isn't a check we stand behind, so it leaves `checked` (and its fixDelta leaves the measured
435
+ // total) too. Lazy: the adjudication files are only read once a mismatch row actually exists.
436
+ let noKeys = null;
437
+ const adjudicatedWrong = (r) => {
438
+ if (r.mismatch !== true) return false;
439
+ if (noKeys === null) noKeys = adjudicatedNoKeys();
440
+ if (noKeys.size === 0) return false;
441
+ // The join key mirrors the reveal/adjudication sig: a ship row's "proof" is its probe command.
442
+ return noKeys.has(`${r.session ?? ""}|${proofSig(r.proof_command || r.probe_command)}`);
443
+ };
391
444
  for (const r of rows) {
445
+ if (adjudicatedWrong(r)) continue; // user said "no" — not a lie, not a check, no measured delta
392
446
  if (CHECKED.has(r.outcome)) checked += 1;
393
447
  if (r.mismatch !== true) continue; // only a caught false "done" (verify-shadow's money metric)
394
448
  lies += 1;
package/verify-shadow.mjs CHANGED
@@ -1013,6 +1013,21 @@ const SHIP_CLAIM_PATTERNS = [
1013
1013
  const SHIP_FUTURE_OR_QUESTION =
1014
1014
  /\b(?:will|i'?ll|we'?ll|going\s+to|gonna|about\s+to|let\s+me|planning\s+to|need\s+to|shall\s+i|should\s+i|can\s+i|once\s+|after\s+i|before\s+i|ready\s+to)\b/i;
1015
1015
 
1016
+ // PRECISION (2026-07-13 false-accusation incident): markdown STRUCTURE is not a declarative sentence.
1017
+ // A real ship claim ("Deployed to prod.", "it's live at https://…") is a plain declarative line; a
1018
+ // bullet / table row / blockquote / heading / bold-lead line is the assistant NARRATING or summarizing —
1019
+ // a status-table bullet ("- **`npx skalpel@latest` (what customers install):** 4.0.52 — …") once minted
1020
+ // a false SHIP_FAIL on the founder's own machine. NARROWING only: skip such lines outright.
1021
+ const SHIP_MARKDOWN_STRUCTURE = /^(?:[-*+]\s|\||>|#|\*\*)/;
1022
+ // shipMatchInBackticks(line, offset) → does the match offset fall inside an ODD backtick segment (i.e.
1023
+ // inside inline `code`)? A ship keyword inside backticks is being QUOTED (a command, a flag, output),
1024
+ // never asserted. Split-on-backtick parity: chars after an odd number of ` are inside code.
1025
+ function shipMatchInBackticks(line, offset) {
1026
+ let ticks = 0;
1027
+ for (let i = 0; i < offset && i < line.length; i++) if (line[i] === "`") ticks++;
1028
+ return ticks % 2 === 1;
1029
+ }
1030
+
1016
1031
  // detectShipClaim(text) → { claim:bool, kind, text:<the claim line, <=120c> }. Scans lines; returns the
1017
1032
  // first ASSERTIVE ship-status line. `kind` is the canonical claim keyword reconstructShipTargetV1 consumes.
1018
1033
  export function detectShipClaim(text) {
@@ -1023,8 +1038,10 @@ export function detectShipClaim(text) {
1023
1038
  if (!l) continue;
1024
1039
  if (/\?\s*$/.test(l)) continue; // a question is not a claim
1025
1040
  if (SHIP_FUTURE_OR_QUESTION.test(l)) continue; // intention, not "it's done"
1041
+ if (SHIP_MARKDOWN_STRUCTURE.test(l)) continue; // markdown structure, not a declarative sentence
1026
1042
  for (const [kind, re] of SHIP_CLAIM_PATTERNS) {
1027
- if (re.test(l)) {
1043
+ const m = re.exec(l);
1044
+ if (m && !shipMatchInBackticks(l, m.index)) {
1028
1045
  return { claim: true, kind, text: l.replace(/\s+/g, " ").trim().slice(0, 120) };
1029
1046
  }
1030
1047
  }
@@ -1289,9 +1306,10 @@ function maybeSurfaceReveal(row) {
1289
1306
  }
1290
1307
  }
1291
1308
 
1292
- // SHIP-STATUS v1 confirm-gate: the wait between a URL FAIL_CANDIDATE (404/410/NXDOMAIN/refused) and its
1293
- // ONE confirming re-probe. Default ~60s; overridable via env (SKALPEL_SHIP_CONFIRM_MS, 0 for tests). The
1294
- // wait happens ONLY inside the DETACHED, unref'd `--run` worker never on the per-turn hook deadline.
1309
+ // SHIP-STATUS v1 confirm-gate: the wait between ANY first-probe negative (a URL FAIL_CANDIDATE 404/
1310
+ // 410/NXDOMAIN/refused or a hard registry/tag/pr/git/checks FAIL) and its ONE confirming re-probe.
1311
+ // Default ~60s; overridable via env (SKALPEL_SHIP_CONFIRM_MS, 0 for tests). The wait happens ONLY inside
1312
+ // the DETACHED, unref'd `--run` worker — never on the per-turn hook deadline.
1295
1313
  const SHIP_CONFIRM_DELAY_MS = (() => {
1296
1314
  const v = parseInt(process.env.SKALPEL_SHIP_CONFIRM_MS ?? "", 10);
1297
1315
  return Number.isFinite(v) && v >= 0 ? v : 60_000;
@@ -1305,7 +1323,7 @@ function shipSleep(ms) {
1305
1323
 
1306
1324
  // resolveShipStatus({kind, contextText, payload, target?, exec?, ...}) — the TESTABLE core of the ship
1307
1325
  // catch: reconstruct the strict single-family target (or accept a prebuilt one), run its ONE read-only
1308
- // probe, and (only for a URL FAIL_CANDIDATE) confirm-gate before ever calling it a red. Returns a plain
1326
+ // probe, and (for EVERY first-probe negative, any kind) confirm-gate before ever calling it a red. Returns a plain
1309
1327
  // result object — no I/O, no lock, no reveal. `exec` (the read-only command runner) and `sleep` are
1310
1328
  // injectable purely so the eval harness can drive every branch hermetically. Never throws to the caller.
1311
1329
  // outcome ∈ { NO_TARGET, SHIP_OK, SHIP_FAIL, SHIP_UNKNOWN }; mismatch === (outcome === "SHIP_FAIL").
@@ -1338,10 +1356,16 @@ export async function resolveShipStatus({
1338
1356
  let evidence = first.evidence;
1339
1357
  let confirm_reprobe = false;
1340
1358
  let confirm_outcome = null;
1341
- // Defect 3 CONFIRM-GATE: a URL fail candidate (a 404/410/gone or a NXDOMAIN/refused, which can be pure
1342
- // CDN/propagation lag) NEVER reveals immediately — wait ~60s and re-probe ONCE; only a CONFIRMED
1343
- // double-negative (2/2 fail candidate) fires. A PASS on the re-probe means it was transient → SHIP_OK.
1344
- if (verdict === "FAIL_CANDIDATE" && tgt.kind === "url") {
1359
+ // CONFIRM-GATE for ALL ship reds (Defect 3, widened after the 2026-07-13 false accusation): NO
1360
+ // first-probe negative EVER reds immediately — not a URL FAIL_CANDIDATE (404/410/NXDOMAIN/refused:
1361
+ // pure CDN/propagation lag) and not a hard registry/tag/pr/git/checks "FAIL" (replication lag, an
1362
+ // eventually-consistent registry read, a stale gh view). Every negative waits (SHIP_CONFIRM_DELAY_MS,
1363
+ // or the injected confirmDelayMs) and re-probes ONCE; only a CONFIRMED 2/2 negative becomes a red.
1364
+ // A PASS on the confirm means it was transient → SHIP_OK; an UNKNOWN on the confirm → SHIP_UNKNOWN
1365
+ // (never a red we can't stand behind). runShipProbeV1's verdict vocabulary is exactly
1366
+ // PASS | FAIL | FAIL_CANDIDATE | UNKNOWN — both negative forms gate identically here.
1367
+ const shipNegative = (v) => v === "FAIL" || v === "FAIL_CANDIDATE";
1368
+ if (shipNegative(verdict)) {
1345
1369
  if (typeof beforeReprobe === "function") {
1346
1370
  try {
1347
1371
  beforeReprobe();
@@ -1355,11 +1379,11 @@ export async function resolveShipStatus({
1355
1379
  confirm_reprobe = true;
1356
1380
  confirm_outcome = second.verdict;
1357
1381
  evidence = second.evidence || evidence;
1358
- verdict =
1359
- second.verdict === "FAIL_CANDIDATE" ? "FAIL" : second.verdict === "PASS" ? "PASS" : "UNKNOWN";
1360
- } else if (verdict === "FAIL_CANDIDATE") {
1361
- // A non-URL probe should never yield a lone candidate; treat as UNKNOWN so it can never single-red.
1362
- verdict = "UNKNOWN";
1382
+ verdict = shipNegative(second.verdict)
1383
+ ? "FAIL"
1384
+ : second.verdict === "PASS"
1385
+ ? "PASS"
1386
+ : "UNKNOWN";
1363
1387
  }
1364
1388
  const outcome =
1365
1389
  verdict === "PASS" ? "SHIP_OK" : verdict === "FAIL" ? "SHIP_FAIL" : "SHIP_UNKNOWN";
@@ -1447,7 +1471,7 @@ async function maybeShipStatusCatch({ text, session, cwd, transcriptPath }) {
1447
1471
  pass_fail,
1448
1472
  outcome,
1449
1473
  mismatch: outcome === "SHIP_FAIL", // claimed shipped, the read-only probe DECISIVELY says it is NOT
1450
- // The instrument records the confirm re-probe (URL fail-candidate) and its second-probe verdict.
1474
+ // The instrument records the confirm re-probe (any first-probe negative) and its second verdict.
1451
1475
  confirm_reprobe: resolved.confirm_reprobe,
1452
1476
  confirm_outcome: resolved.confirm_outcome,
1453
1477
  evidence: String(resolved.evidence || "").slice(0, 200),