From b2ba10078146d9a797314973ff91b5b886344f58 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 3 Nov 2026 08:32:43 -0800 Subject: [PATCH 1/5] v1.94.25.0 gstack-evidence: check a lane under the label it was recorded with (TRUT-7) (#3935) * gstack-evidence: one ledger label spelling for run, record-ci and check (TRUT-7, truther#1170) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * gstack-evidence carried: list a CI row only when check's grading calls it FRESH (TRUT-7 stray ci_carried entry) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: evidence label spelling and carried's FRESH filter (l6ldocs1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- bin/gstack-evidence | 133 ++++++++++++++++++--------------- docs/pregate.md | 9 ++- docs/standalone-binaries.md | 2 +- package.json | 2 +- test/ship-ci-carry.test.ts | 26 +++++++ 8 files changed, 120 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f411c10c..f309606d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [1.94.25.0] - 2026-11-03 + +- A lane label with a space or a colon, such as `pnpm lint` or `db:test`, no longer goes MISSING right after it carries (TRUT-7, truther#1170). `gstack-evidence` writes a label to the ledger with unsafe characters turned into `_`, but `check`, which `gstack-ship-release finish` and `accept` call, looked it up under the raw label, so /ship stopped with "not FRESH at the pushed head". `run`, `record-ci` and `check` now share one label spelling. +- A carry that doesn't reach FRESH no longer leaves a `ci_carried` entry in the ship receipt (TRUT-7: truther's receipt kept `pnpm_lint:41603968341/1` from the attempt that stopped). `gstack-evidence carried` now lists a CI row only when `check`'s own grading, at the 24-hour age /ship uses, calls it FRESH. + +### Tests +- `test/ship-ci-carry.test.ts`: `accept` with the lanes `pnpm lint` and `db:test` carries both and checks them FRESH, and `gstack-evidence check` finds both. It failed before the fix (`EVIDENCE: MISSING label=pnpm lint`). +- `test/ship-ci-carry.test.ts`: a carried `pnpm lint` row aged past 24 hours grades STALE and leaves `carried` empty. It failed before the fix (the stale row was still listed). + ## [1.94.24.0] - 2026-11-03 ### Fixed diff --git a/VERSION b/VERSION index 7a11af84d..6d54e88b9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.94.24.0 +1.94.25.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index c74798cc8..849c10f13 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.94.24.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.94.25.0 — regenerate/re-copy after upgrading gstack Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed for agent hosts without a full skill install. The full skills add workflows, diff --git a/bin/gstack-evidence b/bin/gstack-evidence index e3d2bbab1..39059086c 100755 --- a/bin/gstack-evidence +++ b/bin/gstack-evidence @@ -95,7 +95,7 @@ import { isUnavailable, loadPolicyFromBase, resolveBaseBranch } from "../lib/shi import { versionAtRef } from "../lib/restamp"; import { bashCommandInvocation, runScriptSync, wtreeFingerprint } from "../lib/script-invocation"; import { findCarry, laneMapping, mappingCoverage, perFileCommand } from "../lib/ci-carry"; -import { runChanged } from "../lib/run-evidence"; +import { DEFAULT_MAX_AGE_HOURS, runChanged } from "../lib/run-evidence"; import { isConfigUnavailable, loadConfigFromBase } from "../lib/pregate/config"; import { buildRunCheck } from "../lib/pregate/run-check"; @@ -163,6 +163,9 @@ class OutputTail { } } +/** The one spelling a lane label has in the ledger: run and record-ci write it, check reads it (`pnpm lint` is `pnpm_lint`, TRUT-7). */ +const ledgerLabel = (raw: string): string => raw.replace(/[^a-zA-Z0-9._-]/g, "_"); + const ANSI = /\u001B\[[0-?]*[ -/]*[@-~]/g; const strip = (l: string) => l.replace(ANSI, "").replace(/\r$/, ""); const count = (text: string, word: string): number => Number(new RegExp(`(\\d+) ${word}\\b`).exec(text)?.[1] ?? 0); @@ -428,7 +431,7 @@ async function cmdRun(argv: string[]): Promise { const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" "); const shell = cmdArgv.length === 1 ? bashCommandInvocation(cmdArgv[0]) : null; const cmdSha = sha256(commandString); - label = label.replace(/[^a-zA-Z0-9._-]/g, "_"); + label = ledgerLabel(label); // Bookkeeping context — every piece is optional; failures only warn. let paths: ReturnType | undefined; @@ -675,7 +678,8 @@ async function cmdRecordCi(argv: string[]): Promise { const sep = argv.indexOf("--"); const opts = sep < 0 ? argv : argv.slice(0, sep); const get = (name: string) => { const i = opts.indexOf(name); return i >= 0 ? opts[i + 1] : undefined; }; - const label = get("--label")?.replace(/[^a-zA-Z0-9._-]/g, "_"); + const raw = get("--label"); + const label = raw === undefined ? undefined : ledgerLabel(raw); const base = get("--base"); const command = sep < 0 ? "" : argv.slice(sep + 1).join(" "); if (!label || !base || !command || opts.length !== 4) { @@ -754,13 +758,76 @@ function cmdCarried(argv: string[]): number { const wtree = currentWtree(); const latest = new Map(); for (const r of records) latest.set(r.label, r); - const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef)) + const carried = [...latest.values()].filter((r) => r.source === "ci" && !!wtree && r.wtree === wtree && Array.isArray(r.runs) && typeof r.policy === "string" && r.policy.startsWith(policyRef) + && gradeRecord(r, { maxAgeHours: DEFAULT_MAX_AGE_HOURS, allowPaths: [], wtreeNow: wtree }).verdict === "FRESH") .flatMap((r) => r.runs!.map((ref) => { const [run, attempt] = ref.split("/").map(Number); return { lane: r.label, run, attempt, tree: r.wtree! }; })); if (argv.includes("--json")) console.log(JSON.stringify(carried)); else for (const c of carried) console.log(`CI_CARRIED: ${c.lane} run=${c.run}/${c.attempt} tree=${c.tree.slice(0, 12)}`); return 0; } +/** check's verdict for one ledger row; `carried` lists a CI row only when it grades FRESH here, so a carry that never reached FRESH leaves no receipt entry. */ +function gradeRecord(latest: EvidenceRecord, o: { maxAgeHours?: number; expectCmd?: string; allowPaths: string[]; wtreeNow: string | undefined }): { verdict: "FRESH" | "STALE" | "ZERO-RUN"; reason: string } { + const { maxAgeHours, expectCmd, allowPaths, wtreeNow } = o; + const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown"; + const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : []; + let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH"; + let reason = ""; + if (latest.exit !== 0) { + verdict = "STALE"; + reason = "recorded run failed"; + } else if (testsRan === 0) { + // Zero-run green: exit 0 with nothing executed (all skipped, empty + // selection, "No tests found"). Graded like STALE under its own label; + // "unknown" (no recognised runner summary) stays non-blocking. + verdict = "ZERO-RUN"; + reason = "runner reported 0 executed tests"; + } else if (maxAgeHours !== undefined) { + const ageMs = Date.now() - Date.parse(latest.ts); + if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) { + verdict = "STALE"; + reason = `older than ${maxAgeHours}h`; + } + } + if (verdict === "FRESH" && expectCmd !== undefined && sha256(expectCmd) !== latest.cmd_sha256) { + verdict = "STALE"; + reason = "command changed (cmd_sha256 mismatch)"; + } + if (verdict === "FRESH") { + // Content binding: identical working-tree fingerprint, or a diff confined + // to the allow-list. Any git failure (gc'd tree, not a repo) → STALE — + // never an error into the calling flow. + if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) { + // Stored fingerprints are re-validated before reaching git argv — a + // forged/corrupt ledger line must degrade, never inject options. + verdict = "STALE"; + reason = !latest.wtree + ? changedDuringRun.length + ? `content changed during the run: ${changedDuringRun.join(", ")}` + : "record has no content fingerprint" + : !/^[0-9a-f]{40}$/.test(latest.wtree) + ? "record has malformed fingerprint" + : "current fingerprint unavailable"; + } else if (latest.wtree !== wtreeNow) { + const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]); + if (diff === undefined) { + verdict = "STALE"; + reason = "content changed (fingerprint diff unavailable)"; + } else { + const changed = diff.split("\n").map((s) => s.trim()).filter(Boolean); + const outside = changed.filter((f) => !allowPaths.some((a) => f === a || f.startsWith(a.replace(/\/$/, "") + "/"))); + if (changed.length === 0 || outside.length === 0) { + reason = changed.length ? `diff confined to allow-paths (${changed.length} file(s))` : ""; + } else { + verdict = "STALE"; + reason = `content changed: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}`; + } + } + } + } + return { verdict, reason }; +} + function cmdCheck(argv: string[]): number { // Parse: repeated --label, each optionally followed (anywhere later) by its // own --expect-cmd; pairing is positional — an --expect-cmd binds to the most @@ -771,7 +838,7 @@ function cmdCheck(argv: string[]): number { let allowPaths: string[] = []; for (let i = 0; i < argv.length; i++) { const a = argv[i]; - if (a === "--label") wanted.push({ label: argv[++i] ?? "" }); + if (a === "--label") wanted.push({ label: ledgerLabel(argv[++i] ?? "") }); else if (a === "--expect-cmd") { if (wanted.length === 0) { console.error("gstack-evidence: --expect-cmd requires a preceding --label"); @@ -820,63 +887,9 @@ function cmdCheck(argv: string[]): number { continue; } const testsRan = typeof latest.tests_ran === "number" || latest.tests_ran === "n/a" ? latest.tests_ran : "unknown"; - const changedDuringRun = Array.isArray(latest.wtree_changed) ? latest.wtree_changed.filter((p): p is string => typeof p === "string" && p !== "" && !/[\u0000-\u001f\u007f]/.test(p)).slice(0, 5) : []; const ci = latest.source === "ci" && Array.isArray(latest.runs) ? ` source=ci runs=${latest.runs.filter((s) => /^\d+\/\d+$/.test(s)).join(",")}` : ""; const detail = `label=${label} exit=${latest.exit} tests_ran=${testsRan}${latest.tests_unit === "files" ? " unit=files" : ""} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}${ci}`; - let verdict: "FRESH" | "STALE" | "ZERO-RUN" = "FRESH"; - let reason = ""; - if (latest.exit !== 0) { - verdict = "STALE"; - reason = "recorded run failed"; - } else if (testsRan === 0) { - // Zero-run green: exit 0 with nothing executed (all skipped, empty - // selection, "No tests found"). Graded like STALE under its own label; - // "unknown" (no recognised runner summary) stays non-blocking. - verdict = "ZERO-RUN"; - reason = "runner reported 0 executed tests"; - } else if (maxAgeHours !== undefined) { - const ageMs = Date.now() - Date.parse(latest.ts); - if (!(ageMs >= 0 && ageMs <= maxAgeHours * 3600 * 1000)) { - verdict = "STALE"; - reason = `older than ${maxAgeHours}h`; - } - } - if (verdict === "FRESH" && expectCmd !== undefined && sha256(expectCmd) !== latest.cmd_sha256) { - verdict = "STALE"; - reason = "command changed (cmd_sha256 mismatch)"; - } - if (verdict === "FRESH") { - // Content binding: identical working-tree fingerprint, or a diff confined - // to the allow-list. Any git failure (gc'd tree, not a repo) → STALE — - // never an error into the calling flow. - if (!latest.wtree || !/^[0-9a-f]{40}$/.test(latest.wtree) || !wtreeNow) { - // Stored fingerprints are re-validated before reaching git argv — a - // forged/corrupt ledger line must degrade, never inject options. - verdict = "STALE"; - reason = !latest.wtree - ? changedDuringRun.length - ? `content changed during the run: ${changedDuringRun.join(", ")}` - : "record has no content fingerprint" - : !/^[0-9a-f]{40}$/.test(latest.wtree) - ? "record has malformed fingerprint" - : "current fingerprint unavailable"; - } else if (latest.wtree !== wtreeNow) { - const diff = git(["diff", "--name-only", latest.wtree, wtreeNow]); - if (diff === undefined) { - verdict = "STALE"; - reason = "content changed (fingerprint diff unavailable)"; - } else { - const changed = diff.split("\n").map((s) => s.trim()).filter(Boolean); - const outside = changed.filter((f) => !allowPaths.some((a) => f === a || f.startsWith(a.replace(/\/$/, "") + "/"))); - if (changed.length === 0 || outside.length === 0) { - reason = changed.length ? `diff confined to allow-paths (${changed.length} file(s))` : ""; - } else { - verdict = "STALE"; - reason = `content changed: ${outside.slice(0, 5).join(", ")}${outside.length > 5 ? ", ..." : ""}`; - } - } - } - } + const { verdict, reason } = gradeRecord(latest, { maxAgeHours, expectCmd, allowPaths, wtreeNow }); console.log(`EVIDENCE: ${verdict} ${detail}${reason ? ` reason=${reason}` : ""}`); if (verdict !== "FRESH") allFresh = false; } diff --git a/docs/pregate.md b/docs/pregate.md index 66771f548..673887593 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -1123,6 +1123,9 @@ As a second guard, every `record-ci` row names the policy that mapped it (`origin/@:.gstack/pregate.json`), and `gstack-evidence carried`, which the ship receipt reads, lists only rows whose policy is `origin/`. A row written by a run against another base never lands in a receipt. +`carried` also lists a row only when `check`'s own grading, at the 24-hour age +limit /ship uses, calls it FRESH, so a carry that never reached FRESH (a failed +or zero-run row, or one older than 24 hours) leaves no `ci_carried` entry. A carried lane prints `LANE: