skalpel 4.0.44 → 4.0.46

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
@@ -438,6 +438,434 @@ export async function runProbe(rawTarget) {
438
438
  return { pass: null, evidence: "unknown target kind" };
439
439
  }
440
440
 
441
+ // ===================================================================================================
442
+ // SHIP-STATUS v1 ("Remote-Truth"): STRICT kind↔probe reconstruction + read-only probes.
443
+ //
444
+ // Distinct from the doubt-spiral reconstructTarget/runProbe above (which allow claim-FAMILY fallbacks
445
+ // to resolve a user's re-verify spiral): the ship-status CATCH must be precision-first. So here EACH
446
+ // ship claim kind maps to EXACTLY ONE probe family, with NO cross-kind fallback — a `pushed` claim can
447
+ // NEVER fall back to a PR probe (the cross-kind false-gotcha that fires "PR #n is OPEN" against a TRUE
448
+ // push). No target of the RIGHT kind → null → the caller stays honestly silent (NO_TARGET, never a red).
449
+ //
450
+ // Every probe is READ-ONLY, execFile arg-array (never a shell), args re-validated against strict
451
+ // whitelists, bounded timeout, fail-open (any error → UNKNOWN, never a lie). The `pushed` probe is the
452
+ // new REMOTE-TRUTH read: one `git ls-remote` (the ONLY network read) resolves the REAL remote tip, then
453
+ // a purely-local `git merge-base --is-ancestor` decides reachability — so a push that never left the
454
+ // machine is caught, while a legitimate feature-branch push is never false-accused.
455
+ // ===================================================================================================
456
+
457
+ // The 1:1 map the caller asserts: each ship claim kind → its single allowed probe FAMILY. `live` &
458
+ // `deployed` share the URL probe; `released` & `published` share the registry/tag family; the rest are
459
+ // unique. reconstructShipTargetV1 ALWAYS returns a target whose family is SHIP_KIND_PROBE[claim], or null.
460
+ export const SHIP_KIND_PROBE = {
461
+ pushed: "git-remote",
462
+ merged: "pr",
463
+ live: "url",
464
+ deployed: "url",
465
+ released: "registry",
466
+ published: "registry",
467
+ ci: "gh-checks",
468
+ };
469
+
470
+ // shipProbeFamily(targetKind) → the probe FAMILY a concrete target belongs to (npm & git-tag are both
471
+ // the registry/tag family). Used to assert reconstruction never crosses kinds.
472
+ export function shipProbeFamily(kind) {
473
+ if (kind === "npm" || kind === "git-tag") return "registry";
474
+ return kind; // git-remote | pr | url | gh-checks
475
+ }
476
+
477
+ // Strict arg whitelists (defense in depth — execFile already means no shell).
478
+ const SHIP_SAFE_REF = /^[A-Za-z0-9._@/+-]{1,120}$/; // git remote/branch/tag name
479
+ const SHIP_SHA_RE = /^[0-9a-f]{7,40}$/;
480
+ const SHIP_SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
481
+ const SHIP_NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
482
+ const SHIP_NET_TIMEOUT_MS = 10000; // network probes (ls-remote / npm view / gh) get a touch more than 5s
483
+
484
+ // findPushBranch(text) → the LAST `git push [<remote>] [<branch>]` seen in context, as {remote, branch}
485
+ // (either may be null). Used ONLY to name the branch/remote whose remote tip the `pushed` probe reads —
486
+ // never executed. A refspec `local:remote` keeps the remote-side ref. Both fields strictly validated.
487
+ function findPushBranch(text) {
488
+ const re = /\bgit\s+push\b([^\n;&|]*)/gi;
489
+ let last = null;
490
+ let m;
491
+ while ((m = re.exec(text))) {
492
+ const pos = m[1]
493
+ .trim()
494
+ .split(/\s+/)
495
+ .filter((t) => t && !t.startsWith("-"));
496
+ if (pos.length >= 2) last = { remote: pos[0], branch: pos[1] };
497
+ else if (pos.length === 1) last = { remote: pos[0], branch: null };
498
+ else last = { remote: null, branch: null };
499
+ }
500
+ if (!last) return null;
501
+ let branch = last.branch;
502
+ if (branch && branch.includes(":")) branch = branch.split(":").pop(); // refspec local:remote → remote
503
+ const remote = last.remote && SHIP_SAFE_REF.test(last.remote) ? last.remote : null;
504
+ branch = branch && SHIP_SAFE_REF.test(branch) ? branch : null;
505
+ return { remote, branch };
506
+ }
507
+
508
+ // findTag(text) → a release tag (vX.Y.Z or a `git tag <t>` / `git push origin <t>` mention). Validated.
509
+ function findTag(text) {
510
+ const near = /\bgit\s+(?:tag|push[^\n;&|]*?)\s+(v?\d+\.\d+\.\d+[0-9A-Za-z.\-+]*)\b/i.exec(text);
511
+ 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);
513
+ return m && SHIP_SAFE_REF.test(m[1]) ? m[1] : null;
514
+ }
515
+
516
+ // findNpmVersion(text) → a semver version string near a publish/version mention (strict semver). The
517
+ // npm target's NAME is read from the session's own package.json — never from claim/prompt content.
518
+ function findNpmVersion(text) {
519
+ const near =
520
+ /\b(?:publish|version|v)\s*@?\s*(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/i.exec(
521
+ text,
522
+ ) || /@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
523
+ if (near && SHIP_SEMVER_RE.test(near[1])) return near[1];
524
+ const m = /\b(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)\b/.exec(text);
525
+ return m && SHIP_SEMVER_RE.test(m[1]) ? m[1] : null;
526
+ }
527
+
528
+ // readPkgName(repoDir) → the "name" field of the session's own package.json, validated against npm's
529
+ // package-name rules, or null. This is the ONLY source of the published package name (never the claim).
530
+ function readPkgName(repoDir) {
531
+ try {
532
+ if (!repoDir) return null;
533
+ const name = JSON.parse(readFileSync(join(repoDir, "package.json"), "utf8"))?.name;
534
+ return typeof name === "string" && SHIP_NPM_NAME_RE.test(name) ? name : null;
535
+ } catch {
536
+ return null;
537
+ }
538
+ }
539
+
540
+ // reconstructShipTargetV1(claim, contextText, payload) → the STRICT, single-family target for a ship
541
+ // claim, or null. NO cross-kind fallback: a kind that can't reconstruct ITS OWN target returns null.
542
+ export function reconstructShipTargetV1(claim, contextText, payload) {
543
+ const text = String(contextText || "");
544
+ const repoDir = repoDirOf(payload);
545
+ const family = SHIP_KIND_PROBE[claim];
546
+ if (!family) return null;
547
+ switch (family) {
548
+ case "git-remote": {
549
+ // pushed → REMOTE-TRUTH. Needs the claimed sha + a repo. The branch (from the session's own
550
+ // `git push`, else resolved at probe time from the upstream) is optional here; ambiguity that
551
+ // can't be resolved becomes UNKNOWN in the probe, never a false red.
552
+ const sha = findSha(text);
553
+ if (!sha || !repoDir) return null;
554
+ const pb = findPushBranch(text) || {};
555
+ return {
556
+ kind: "git-remote",
557
+ sha,
558
+ remote: pb.remote || null,
559
+ branch: pb.branch || null,
560
+ repoDir,
561
+ };
562
+ }
563
+ case "pr": {
564
+ // merged → the PR probe ONLY. No PR number in context → null (never a git/url fallback).
565
+ const n = findPr(text);
566
+ return n != null ? { kind: "pr", n, repoDir } : null;
567
+ }
568
+ case "url": {
569
+ // live/deployed → the URL probe ONLY.
570
+ const url = findUrl(text);
571
+ return url ? { kind: "url", url } : null;
572
+ }
573
+ case "registry": {
574
+ // released/published → the registry/tag family ONLY. Prefer npm when the session actually ran
575
+ // `npm publish` AND both the package name (from package.json) and a semver version reconstruct;
576
+ // else a git tag if one is present. Neither → null.
577
+ if (/\bnpm\s+publish\b/i.test(text)) {
578
+ const name = readPkgName(repoDir);
579
+ const version = findNpmVersion(text);
580
+ if (name && version) return { kind: "npm", name, version };
581
+ }
582
+ const tag = findTag(text);
583
+ if (tag && repoDir) {
584
+ const pb = findPushBranch(text) || {};
585
+ return { kind: "git-tag", tag, remote: pb.remote || "origin", repoDir };
586
+ }
587
+ return null;
588
+ }
589
+ case "gh-checks": {
590
+ // "CI green/passing" → gh checks ONLY (a PR's checks, else a commit's runs).
591
+ const n = findPr(text);
592
+ if (n != null) return { kind: "gh-checks", n, repoDir };
593
+ const sha = findSha(text);
594
+ if (sha) return { kind: "gh-checks", sha, repoDir };
595
+ return null;
596
+ }
597
+ default:
598
+ return null;
599
+ }
600
+ }
601
+
602
+ // validateShipTargetV1(t) → re-validate every field of a ship-v1 target before it is passed to execFile
603
+ // (defense in depth). Returns a normalized target or null. Never throws.
604
+ function validateShipTargetV1(t) {
605
+ if (!t || typeof t !== "object") return null;
606
+ switch (t.kind) {
607
+ case "url":
608
+ return isSafeUrl(t.url) ? { kind: "url", url: t.url } : null;
609
+ case "pr": {
610
+ const n = Number(t.n);
611
+ if (!Number.isInteger(n) || n <= 0 || n >= 1e7) return null;
612
+ return { kind: "pr", n, repoDir: validDir(t.repoDir) };
613
+ }
614
+ case "git-remote": {
615
+ if (!SHIP_SHA_RE.test(String(t.sha || ""))) return null;
616
+ const repoDir = validDir(t.repoDir);
617
+ if (!repoDir) return null;
618
+ const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
619
+ const branch = t.branch && SHIP_SAFE_REF.test(t.branch) ? t.branch : null;
620
+ return { kind: "git-remote", sha: String(t.sha).toLowerCase(), remote, branch, repoDir };
621
+ }
622
+ case "npm": {
623
+ if (
624
+ !SHIP_NPM_NAME_RE.test(String(t.name || "")) ||
625
+ !SHIP_SEMVER_RE.test(String(t.version || ""))
626
+ )
627
+ return null;
628
+ return { kind: "npm", name: t.name, version: t.version };
629
+ }
630
+ case "git-tag": {
631
+ const repoDir = validDir(t.repoDir);
632
+ if (!repoDir || !SHIP_SAFE_REF.test(String(t.tag || ""))) return null;
633
+ const remote = t.remote && SHIP_SAFE_REF.test(t.remote) ? t.remote : "origin";
634
+ return { kind: "git-tag", tag: t.tag, remote, repoDir };
635
+ }
636
+ case "gh-checks": {
637
+ const repoDir = validDir(t.repoDir);
638
+ const n = t.n != null ? Number(t.n) : null;
639
+ const sha = t.sha ? String(t.sha).toLowerCase() : null;
640
+ if (n != null && Number.isInteger(n) && n > 0 && n < 1e7)
641
+ return { kind: "gh-checks", n, repoDir };
642
+ if (sha && SHIP_SHA_RE.test(sha)) return { kind: "gh-checks", sha, repoDir };
643
+ return null;
644
+ }
645
+ default:
646
+ return null;
647
+ }
648
+ }
649
+
650
+ // classifyHttpVerdict(code) → the Defect-3 HTTP precision map. A responding server is REACHABLE; only a
651
+ // genuine "gone" (404/410) is a FAIL CANDIDATE (never an immediate red — the caller confirm-reprobes).
652
+ // 401/403/405 (auth-walled), 429 (rate-limit), 5xx (transient), and any other response → UNKNOWN.
653
+ export function classifyHttpVerdict(code) {
654
+ if (!Number.isFinite(code) || code === 0) return "UNKNOWN";
655
+ if (code >= 200 && code < 400) return "PASS";
656
+ if (code === 404 || code === 410) return "FAIL_CANDIDATE";
657
+ return "UNKNOWN";
658
+ }
659
+
660
+ // parseLsRemoteTip(stdout, branch) → the 40-hex tip sha for refs/heads/<branch> (or the sole ref line),
661
+ // else null. `git ls-remote <remote> <branch>` prints `<sha>\t<ref>` lines.
662
+ function parseLsRemoteTip(stdout, branch) {
663
+ const lines = String(stdout || "").split("\n");
664
+ for (const line of lines) {
665
+ const mm = line.match(/^([0-9a-f]{40})\s+(\S+)/i);
666
+ if (mm && (mm[2] === `refs/heads/${branch}` || mm[2] === "HEAD")) return mm[1].toLowerCase();
667
+ }
668
+ const first = String(stdout || "").match(/^([0-9a-f]{40})\b/im);
669
+ return first ? first[1].toLowerCase() : null;
670
+ }
671
+
672
+ // runShipProbeV1(target, exec) → ONE read-only probe for the strict target. Returns
673
+ // { verdict, evidence } where verdict ∈ PASS | FAIL | FAIL_CANDIDATE | UNKNOWN. `exec` is injectable
674
+ // (defaults to the real read-only `run`) purely so the eval harness can drive it hermetically. Never throws.
675
+ export async function runShipProbeV1(rawTarget, exec = run) {
676
+ const t = validateShipTargetV1(rawTarget);
677
+ if (!t) return { verdict: "UNKNOWN", evidence: "rejected: target failed validation" };
678
+ try {
679
+ if (t.kind === "url") {
680
+ // curl -sS -m 5 -o /dev/null -w "%{http_code}" <url> (READ-ONLY). curl exits 0 on ANY HTTP
681
+ // status; a non-zero exit is a transport error (6=NXDOMAIN, 7=refused → fail candidate; else unknown).
682
+ const { err, stdout } = await exec(
683
+ "curl",
684
+ ["-sS", "-m", "5", "-o", "/dev/null", "-w", "%{http_code}", t.url],
685
+ { timeout: SHIP_NET_TIMEOUT_MS },
686
+ );
687
+ if (err) {
688
+ const c = err.code;
689
+ if (c === 6)
690
+ return { verdict: "FAIL_CANDIDATE", evidence: "curl: could not resolve host (NXDOMAIN)" };
691
+ if (c === 7) return { verdict: "FAIL_CANDIDATE", evidence: "curl: connection refused" };
692
+ return {
693
+ verdict: "UNKNOWN",
694
+ evidence: clip(`curl error: ${err.killed ? "timeout" : err.message || c}`),
695
+ };
696
+ }
697
+ const code = parseInt(stdout, 10);
698
+ const verdict = classifyHttpVerdict(code);
699
+ const evidence =
700
+ Number.isFinite(code) && code !== 0 ? `http_code=${code}` : clip(`no http code: ${stdout}`);
701
+ return { verdict, evidence };
702
+ }
703
+ if (t.kind === "pr") {
704
+ // gh pr view <n> --json state,mergedAt (READ-ONLY, DECISIVE)
705
+ const opts = t.repoDir ? { cwd: t.repoDir } : {};
706
+ const { err, stdout } = await exec(
707
+ "gh",
708
+ ["pr", "view", String(t.n), "--json", "state,mergedAt"],
709
+ opts,
710
+ );
711
+ if (err)
712
+ return {
713
+ verdict: "UNKNOWN",
714
+ evidence: clip(`gh error: ${err.killed ? "timeout" : err.message}`),
715
+ };
716
+ let j;
717
+ try {
718
+ j = JSON.parse(stdout);
719
+ } catch {
720
+ return { verdict: "UNKNOWN", evidence: clip(`gh non-json: ${stdout}`) };
721
+ }
722
+ const merged = j.state === "MERGED" || !!j.mergedAt;
723
+ return {
724
+ verdict: merged ? "PASS" : "FAIL",
725
+ evidence: clip(`state=${j.state} mergedAt=${j.mergedAt || "null"}`),
726
+ };
727
+ }
728
+ if (t.kind === "git-remote") {
729
+ // REMOTE-TRUTH. Resolve the branch (from the push, else the upstream / current branch), read the
730
+ // REAL remote tip with ONE `git ls-remote`, then decide reachability locally with merge-base.
731
+ const remote = t.remote || "origin";
732
+ let branch = t.branch;
733
+ if (!branch) {
734
+ const up = await exec(
735
+ "git",
736
+ ["-C", t.repoDir, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
737
+ { timeout: SHIP_NET_TIMEOUT_MS },
738
+ );
739
+ if (!up.err) {
740
+ const ref = String(up.stdout || "").trim(); // e.g. origin/feat-x
741
+ const idx = ref.indexOf("/");
742
+ if (idx > 0) branch = ref.slice(idx + 1);
743
+ }
744
+ if (!branch) {
745
+ const cur = await exec("git", ["-C", t.repoDir, "symbolic-ref", "--short", "HEAD"], {
746
+ timeout: SHIP_NET_TIMEOUT_MS,
747
+ });
748
+ if (!cur.err) branch = String(cur.stdout || "").trim() || null;
749
+ }
750
+ if (!branch || !SHIP_SAFE_REF.test(branch))
751
+ return { verdict: "UNKNOWN", evidence: "git-remote: branch unresolved (ambiguous)" };
752
+ }
753
+ const ls = await exec("git", ["-C", t.repoDir, "ls-remote", remote, branch], {
754
+ timeout: SHIP_NET_TIMEOUT_MS,
755
+ });
756
+ if (ls.err)
757
+ return {
758
+ verdict: "UNKNOWN",
759
+ evidence: clip(`ls-remote error: ${ls.err.killed ? "timeout" : ls.err.message}`),
760
+ };
761
+ const tip = parseLsRemoteTip(ls.stdout, branch);
762
+ if (!tip)
763
+ return { verdict: "UNKNOWN", evidence: clip(`no remote tip for ${remote}/${branch}`) };
764
+ const mb = await exec("git", ["-C", t.repoDir, "merge-base", "--is-ancestor", t.sha, tip], {
765
+ timeout: SHIP_NET_TIMEOUT_MS,
766
+ });
767
+ const evid = `sha=${t.sha.slice(0, 12)} branch=${remote}/${branch} remoteTip=${tip.slice(0, 12)}`;
768
+ // exit 0 → reachable (PASS). exit 1 → both objects known AND not reachable (DECISIVE FAIL — it
769
+ // never left the machine). Any other exit → a commit is unknown locally → UNKNOWN (never a lie).
770
+ if (!mb.err) return { verdict: "PASS", evidence: clip(`${evid} ancestor`) };
771
+ if (mb.err.code === 1) return { verdict: "FAIL", evidence: clip(`${evid} not-ancestor`) };
772
+ return {
773
+ verdict: "UNKNOWN",
774
+ evidence: clip(`${evid} merge-base indeterminate (tip/sha unknown locally)`),
775
+ };
776
+ }
777
+ if (t.kind === "npm") {
778
+ // npm view <name>@<version> version (READ-ONLY registry read, DECISIVE)
779
+ const { err, stdout, stderr } = await exec(
780
+ "npm",
781
+ ["view", `${t.name}@${t.version}`, "version"],
782
+ {
783
+ timeout: SHIP_NET_TIMEOUT_MS,
784
+ },
785
+ );
786
+ if (!err) {
787
+ return String(stdout || "").trim()
788
+ ? {
789
+ verdict: "PASS",
790
+ evidence: clip(`npm ${t.name}@${t.version} = ${String(stdout).trim()}`),
791
+ }
792
+ : { verdict: "UNKNOWN", evidence: clip(`npm empty for ${t.name}@${t.version}`) };
793
+ }
794
+ const combined = `${stderr || ""} ${stdout || ""}`;
795
+ if (/E404|No match found|is not in (?:this|the)/i.test(combined))
796
+ return { verdict: "FAIL", evidence: clip(`npm: ${t.name}@${t.version} not on registry`) };
797
+ return {
798
+ verdict: "UNKNOWN",
799
+ evidence: clip(`npm error: ${err.killed ? "timeout" : err.message}`),
800
+ };
801
+ }
802
+ if (t.kind === "git-tag") {
803
+ // git -C <repo> ls-remote --tags <remote> <tag> (READ-ONLY, DECISIVE)
804
+ const { err, stdout } = await exec(
805
+ "git",
806
+ ["-C", t.repoDir, "ls-remote", "--tags", t.remote, t.tag],
807
+ {
808
+ timeout: SHIP_NET_TIMEOUT_MS,
809
+ },
810
+ );
811
+ if (err)
812
+ return {
813
+ verdict: "UNKNOWN",
814
+ evidence: clip(`ls-remote --tags error: ${err.killed ? "timeout" : err.message}`),
815
+ };
816
+ const esc = t.tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
817
+ const has = new RegExp(`\\brefs/tags/${esc}(?:\\^\\{\\})?$`, "m").test(String(stdout || ""));
818
+ return has
819
+ ? { verdict: "PASS", evidence: clip(`tag ${t.remote}/${t.tag} present`) }
820
+ : { verdict: "FAIL", evidence: clip(`tag ${t.tag} not on ${t.remote}`) };
821
+ }
822
+ if (t.kind === "gh-checks") {
823
+ // gh pr checks <n> --json ... / gh run list --commit <sha> --json ... (READ-ONLY). gh prints
824
+ // JSON even on a non-zero exit (failing/pending), so we parse stdout regardless of err.
825
+ const opts = t.repoDir ? { cwd: t.repoDir } : {};
826
+ let err, stdout;
827
+ if (t.n != null) {
828
+ ({ err, stdout } = await exec(
829
+ "gh",
830
+ ["pr", "checks", String(t.n), "--json", "state,bucket"],
831
+ opts,
832
+ ));
833
+ } else {
834
+ ({ err, stdout } = await exec(
835
+ "gh",
836
+ ["run", "list", "--commit", t.sha, "--json", "conclusion,status"],
837
+ opts,
838
+ ));
839
+ }
840
+ const out = String(stdout || "");
841
+ if (err && !out.trim())
842
+ return {
843
+ verdict: "UNKNOWN",
844
+ evidence: clip(`gh checks error: ${err.killed ? "timeout" : err.message}`),
845
+ };
846
+ let arr;
847
+ try {
848
+ arr = JSON.parse(out);
849
+ } catch {
850
+ return { verdict: "UNKNOWN", evidence: clip(`gh checks non-json: ${out}`) };
851
+ }
852
+ if (!Array.isArray(arr) || !arr.length)
853
+ return { verdict: "UNKNOWN", evidence: "gh checks: none reported" };
854
+ const norm = arr.map((c) =>
855
+ String((c && (c.bucket || c.conclusion || c.state || c.status)) || "").toLowerCase(),
856
+ );
857
+ if (norm.some((s) => /fail|error|cancel|timed_out|action_required/.test(s)))
858
+ return { verdict: "FAIL", evidence: clip(`checks: ${norm.join(",")}`) };
859
+ if (norm.some((s) => s === "" || /pending|queued|in_progress|waiting|requested/.test(s)))
860
+ return { verdict: "UNKNOWN", evidence: clip(`checks pending: ${norm.join(",")}`) };
861
+ return { verdict: "PASS", evidence: clip(`checks: ${norm.join(",")}`) };
862
+ }
863
+ } catch (e) {
864
+ return { verdict: "UNKNOWN", evidence: clip(`probe threw: ${e}`) };
865
+ }
866
+ return { verdict: "UNKNOWN", evidence: "unknown ship target kind" };
867
+ }
868
+
441
869
  // ---------------------------------------------------------------------------------------------------
442
870
  // Logging — the ONLY side effect. Append-only NDJSON at ~/.skalpel/anchor-shadow.log. Never throws.
443
871
  // ---------------------------------------------------------------------------------------------------
package/card.mjs CHANGED
@@ -69,7 +69,20 @@ export function isGenuineCatch(row) {
69
69
  // Ship-status catches (category:"ship", outcome SHIP_FAIL) record the check under probe_command, not
70
70
  // proof_command — accept either so a genuine "you said deployed, it's 404" catch is not invisible.
71
71
  const command = row.proof_command || row.probe_command;
72
- return failed && !!command && !!row.claim_text;
72
+ if (!failed || !command || !row.claim_text) return false;
73
+ // RETRO catches (category:"retro") are RECORDED at claim time — never re-run. The whole indictment rests
74
+ // on the exit code the transcript already recorded, so a retro row is a genuine catch ONLY when it carries
75
+ // that recorded non-zero exit code; without it we can't render the "already failed" provenance truthfully.
76
+ if (row.category === "retro") {
77
+ return Number.isInteger(row.recorded_exit_code) && row.recorded_exit_code !== 0;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ // A retro row is RECORDED at claim time (from the user's own transcript), never re-run — so its wording
83
+ // must never borrow the live card's "skalpel re-ran its own proof" language.
84
+ function isRetro(row) {
85
+ return row && row.category === "retro";
73
86
  }
74
87
 
75
88
  // readCatches() → all genuine-catch rows in the shadow log, oldest→newest as written. Fail-open: any
@@ -133,9 +146,17 @@ export function extractCode(evidence) {
133
146
 
134
147
  // verdictPhrase(row) → the honest one-line verdict. Always truthful for a catch row.
135
148
  function verdictPhrase(row) {
136
- const code = extractCode(row.evidence);
137
149
  const failN = extractFailCount(row.evidence);
138
150
  const count = failN != null ? ` · ${failN} failing` : "";
151
+ // RETRO: the exit code was RECORDED in the transcript at claim time — we never re-ran it, so the verdict
152
+ // must say "had already failed", never "re-ran". recorded_exit_code is a required field for a retro catch.
153
+ if (isRetro(row)) {
154
+ const n = Number.isInteger(row.recorded_exit_code)
155
+ ? ` (recorded exit ${row.recorded_exit_code})`
156
+ : "";
157
+ return `FAIL — its own test had already failed when it said this${n}${count}`;
158
+ }
159
+ const code = extractCode(row.evidence);
139
160
  if (code && code.kind === "http") return `FAIL — its own proof got HTTP ${code.value}${count}`;
140
161
  if (code && code.kind === "exit") return `FAIL — its own proof exited ${code.value}${count}`;
141
162
  return `FAIL — its own proof re-ran and exited non-zero${count}`;
@@ -152,13 +173,18 @@ export function renderPlainCard(row) {
152
173
  const claim = clean(row.claim_text, 200) || "it's done";
153
174
  const proof = clean(row.proof_command || row.probe_command, 200) || "its own proof";
154
175
  const evidence = clean(row.evidence, 200);
176
+ const retro = isRetro(row);
155
177
  const L = [];
156
- L.push("🔬 skalpel — verified catch");
178
+ L.push(retro ? "🔬 skalpel — recorded catch" : "🔬 skalpel — verified catch");
157
179
  L.push("");
158
180
  L.push("The agent claimed:");
159
181
  L.push(` "${claim}"`);
160
182
  L.push("");
161
- L.push("skalpel re-ran the agent's OWN proof, out-of-band:");
183
+ L.push(
184
+ retro
185
+ ? "Its own test had already failed when it said this — recorded in your transcript at claim time:"
186
+ : "skalpel re-ran the agent's OWN proof, out-of-band:",
187
+ );
162
188
  L.push(` $ ${proof}`);
163
189
  L.push(` → ${verdictPhrase(row)}`);
164
190
  if (evidence) {
@@ -167,8 +193,16 @@ export function renderPlainCard(row) {
167
193
  L.push(` ${evidence}`);
168
194
  }
169
195
  L.push("");
170
- L.push(`caught ${stamp(row.ts)}`);
171
- L.push("The agent said done. Its own proof, re-run, disagreed.");
196
+ L.push(
197
+ retro
198
+ ? `recorded ${stamp(row.ts)} (your transcript, at claim time)`
199
+ : `caught ${stamp(row.ts)}`,
200
+ );
201
+ L.push(
202
+ retro
203
+ ? "The agent said done. Its own test, recorded at that moment, had already failed."
204
+ : "The agent said done. Its own proof, re-run, disagreed.",
205
+ );
172
206
  return L.join("\n");
173
207
  }
174
208
 
@@ -177,13 +211,18 @@ export function renderMarkdownCard(row) {
177
211
  const claim = clean(row.claim_text, 200) || "it's done";
178
212
  const proof = clean(row.proof_command || row.probe_command, 200) || "its own proof";
179
213
  const evidence = clean(row.evidence, 200);
214
+ const retro = isRetro(row);
180
215
  const FENCE = "```";
181
216
  const L = [];
182
- L.push("**🔬 skalpel — verified catch**");
217
+ L.push(retro ? "**🔬 skalpel — recorded catch**" : "**🔬 skalpel — verified catch**");
183
218
  L.push("");
184
219
  L.push(`> The agent claimed: _"${claim}"_`);
185
220
  L.push("");
186
- L.push("skalpel re-ran the agent's own proof, out-of-band:");
221
+ L.push(
222
+ retro
223
+ ? "Its own test had already failed when it said this — recorded in your transcript at claim time:"
224
+ : "skalpel re-ran the agent's own proof, out-of-band:",
225
+ );
187
226
  L.push(FENCE);
188
227
  L.push(`$ ${proof}`);
189
228
  L.push(`→ ${verdictPhrase(row)}`);
@@ -194,7 +233,11 @@ export function renderMarkdownCard(row) {
194
233
  L.push(evidence);
195
234
  L.push(FENCE);
196
235
  }
197
- L.push(`_Caught ${stamp(row.ts)}. The agent said done; its own proof, re-run, disagreed._`);
236
+ L.push(
237
+ retro
238
+ ? `_Recorded ${stamp(row.ts)} in your transcript, at claim time. The agent said done; its own test had already failed._`
239
+ : `_Caught ${stamp(row.ts)}. The agent said done; its own proof, re-run, disagreed._`,
240
+ );
198
241
  return L.join("\n");
199
242
  }
200
243
 
@@ -203,14 +246,21 @@ function renderTerminalCard(row) {
203
246
  const claim = clean(row.claim_text, 200) || "it's done";
204
247
  const proof = clean(row.proof_command || row.probe_command, 200) || "its own proof";
205
248
  const evidence = clean(row.evidence, 180);
249
+ const retro = isRetro(row);
206
250
  const L = [];
207
251
  L.push("");
208
- L.push(` ${B}🔬 skalpel${X} ${D}·${X} ${B}${R}verified catch${X}`);
252
+ L.push(
253
+ ` ${B}🔬 skalpel${X} ${D}·${X} ${B}${R}${retro ? "recorded catch" : "verified catch"}${X}`,
254
+ );
209
255
  L.push(` ${D}${"─".repeat(70)}${X}`);
210
256
  L.push(` ${D}The agent claimed:${X}`);
211
257
  L.push(` ${B}"${claim}"${X}`);
212
258
  L.push("");
213
- L.push(` ${D}skalpel re-ran the agent's OWN proof, out-of-band:${X}`);
259
+ L.push(
260
+ retro
261
+ ? ` ${D}Its own test had already failed when it said this — recorded at claim time:${X}`
262
+ : ` ${D}skalpel re-ran the agent's OWN proof, out-of-band:${X}`,
263
+ );
214
264
  L.push(` ${D}$${X} ${proof}`);
215
265
  L.push(` ${R}${B}→ ${verdictPhrase(row)}${X}`);
216
266
  if (evidence) {
@@ -219,7 +269,9 @@ function renderTerminalCard(row) {
219
269
  }
220
270
  L.push(` ${D}${"─".repeat(70)}${X}`);
221
271
  L.push(
222
- ` ${D}caught ${stamp(row.ts)} — the agent said done; its own proof, re-run, disagreed.${X}`,
272
+ retro
273
+ ? ` ${D}recorded ${stamp(row.ts)} — the agent said done; its own test had already failed.${X}`
274
+ : ` ${D}caught ${stamp(row.ts)} — the agent said done; its own proof, re-run, disagreed.${X}`,
223
275
  );
224
276
  L.push("");
225
277
  return L.join("\n");