Merge remote-tracking branch 'origin/main' into garrytan/fix-wave-issues-prs

This commit is contained in:
Garry Tan
2026-08-16 12:42:57 -07:00
51 changed files with 3029 additions and 180 deletions
+445
View File
@@ -0,0 +1,445 @@
#!/usr/bin/env bun
/**
* gstack-evidence — verification-evidence ledger: the mechanical arm of /ship's
* IRON LAW ("no completion claims without fresh verification evidence").
*
* gstack-evidence run --label <L> -- <cmd...>
* gstack-evidence check [--label <L> [--expect-cmd <exact string>]]... | --all
* [--max-age <hours>] [--allow-paths <csv>]
*
* `run` is a TRANSPARENT wrapper: it streams the child's output through
* unchanged, tees it to a 0600 log (2MB cap with a truncation marker), and
* appends {ts, label, command, cmd_sha256, exit, duration_s, commit, tree,
* dirty, wtree, log_path} to ~/.gstack/projects/<slug>/<branch>-evidence.jsonl.
*
* TRANSPARENCY INVARIANT (load-bearing): the child's exit code is ALWAYS the
* wrapper's exit code. Every bookkeeping failure — ledger append, log dir,
* non-git context, redact scan — is a stderr warning, never a failure. The
* wrapper must never turn green tests red.
*
* Freshness binds to `wtree`, the working-tree content fingerprint from
* bin/gstack-wtree: evidence recorded on uncommitted code stays FRESH after
* the exact tested content is committed, and an untracked new source file
* invalidates it. `cmd_sha256` = sha256 of the exact command string, no
* normalization — the same convention as bin/gstack-verify-gate (which hashes
* for TRUST; this ledger hashes for FRESHNESS).
*
* MACHINE-LOCAL by design: neither the ledger nor the logs are brain-synced.
* A synced record citing an unsynced log would grade FRESH on a machine where
* the log doesn't exist.
*
* `check` is read-only and never throws into the calling skill flow: any git
* failure (gc'd stored tree, not a repo) degrades to STALE/MISSING. Call sites
* must name expected labels explicitly — `--all` checks only labels that exist
* in the ledger; it cannot prove that an expected lane ever ran.
*/
import { mkdirSync, openSync, writeSync, closeSync, readdirSync, statSync, unlinkSync, chmodSync } from "fs";
import { join, dirname } from "path";
import { spawnSync } from "child_process";
import { appendJsonl, readJsonl } from "../lib/jsonl-store";
import { scan, applyRedactions } from "../lib/redact-engine";
const BIN_DIR = dirname(Bun.fileURLToPath(import.meta.url));
const LOG_MAX_BYTES = 2 * 1024 * 1024;
const LOG_PRUNE_DAYS = 30;
interface EvidenceRecord {
ts: string;
label: string;
command: string;
cmd_sha256: string;
exit: number;
duration_s: number;
commit?: string;
tree?: string;
dirty?: boolean;
wtree?: string;
log_path?: string;
redacted?: boolean;
}
function warn(msg: string): void {
console.error(`gstack-evidence: warning: ${msg}`);
}
function sha256(text: string): string {
const h = new Bun.CryptoHasher("sha256");
h.update(text);
return h.digest("hex");
}
function git(args: string[]): string | undefined {
try {
const r = spawnSync("git", args, { encoding: "utf-8", timeout: 15000 });
if (r.status !== 0) return undefined;
const out = (r.stdout || "").trim();
return out || undefined;
} catch {
return undefined;
}
}
function currentWtree(): string | undefined {
try {
const r = spawnSync(join(BIN_DIR, "gstack-wtree"), { encoding: "utf-8", timeout: 30000 });
if (r.status !== 0) return undefined;
const out = (r.stdout || "").trim();
return /^[0-9a-f]{40}$/.test(out) ? out : undefined;
} catch {
return undefined;
}
}
function ledgerPath(): { dir: string; file: string; logsDir: string } {
const home = process.env.GSTACK_HOME || (process.env.HOME ? join(process.env.HOME, ".gstack") : undefined);
// No resolvable home: skip bookkeeping (a literal "~" dir in cwd would land
// inside the repo and perturb the fingerprint it exists to compute).
if (!home) throw new Error("no GSTACK_HOME/HOME — bookkeeping skipped");
// ONE gstack-slug spawn: its output carries both SLUG= and BRANCH= lines
// (same branch→filename sanitization as reviews.jsonl).
const slugOut = spawnSync(join(BIN_DIR, "gstack-slug"), { encoding: "utf-8" });
const sm = (slugOut.stdout || "").match(/^SLUG=(.+)$/m);
const bm = (slugOut.stdout || "").match(/^BRANCH=(.+)$/m);
const slug = sm ? sm[1].trim() : "unknown";
const branch = bm ? bm[1].trim() : "no-branch";
const dir = join(home, "projects", slug);
return { dir, file: join(dir, `${branch}-evidence.jsonl`), logsDir: join(dir, "logs") };
}
/** Redact-engine pass over the command string. HIGH finding → store redacted. */
function safeCommandForRecord(command: string): { command: string; redacted: boolean } {
try {
const { findings } = scan(command);
const high = findings.filter((f) => f.tier === "HIGH");
if (high.length === 0) return { command, redacted: false };
const redactedBody = applyRedactions(command, findings.map((f) => f.id)).body;
const still = scan(redactedBody).findings.some((f) => f.tier === "HIGH");
return { command: still ? "<redacted: HIGH credential in command>" : redactedBody, redacted: true };
} catch {
return { command, redacted: false };
}
}
/** Opportunistic prune of logs older than LOG_PRUNE_DAYS. Best-effort. */
function pruneOldLogs(logsDir: string): void {
try {
const cutoff = Date.now() - LOG_PRUNE_DAYS * 24 * 3600 * 1000;
for (const name of readdirSync(logsDir)) {
const p = join(logsDir, name);
try {
if (statSync(p).mtimeMs < cutoff) unlinkSync(p);
} catch {}
}
} catch {}
}
/** Exclusive-open a collision-safe log file. Returns undefined on failure. */
function openLog(logsDir: string, label: string, cmdSha: string): { fd: number; path: string } | undefined {
try {
mkdirSync(logsDir, { recursive: true });
pruneOldLogs(logsDir);
const ts = new Date().toISOString().replace(/[:.]/g, "-");
const base = `${ts}-${label}-${process.pid}-${cmdSha.slice(0, 8)}`;
for (let i = 0; i < 3; i++) {
const p = join(logsDir, i === 0 ? `${base}.log` : `${base}-${i}.log`);
try {
const fd = openSync(p, "ax", 0o600);
return { fd, path: p };
} catch {}
}
} catch (e: any) {
warn(`log setup failed (${e?.message ?? e}) — running unlogged`);
}
return undefined;
}
async function cmdRun(argv: string[]): Promise<number> {
let label = "default";
const li = argv.indexOf("--label");
const sep = argv.indexOf("--");
if (li >= 0 && li + 1 < argv.length && (sep < 0 || li < sep)) label = argv[li + 1];
if (sep < 0 || sep + 1 >= argv.length) {
console.error("usage: gstack-evidence run --label <L> -- <cmd...>");
return 2;
}
const cmdArgv = argv.slice(sep + 1);
// Compound/piped commands pass as ONE string via bash -c; a multi-token argv
// runs directly. The hashed command string is exact, no normalization.
const commandString = cmdArgv.length === 1 ? cmdArgv[0] : cmdArgv.join(" ");
const spawnArgv = cmdArgv.length === 1 ? ["bash", "-c", cmdArgv[0]] : cmdArgv;
const cmdSha = sha256(commandString);
label = label.replace(/[^a-zA-Z0-9._-]/g, "_");
// Bookkeeping context — every piece is optional; failures only warn.
let paths: ReturnType<typeof ledgerPath> | undefined;
try {
paths = ledgerPath();
mkdirSync(paths.dir, { recursive: true });
} catch (e: any) {
warn(`ledger setup failed (${e?.message ?? e}) — result will not be recorded`);
}
const log = paths ? openLog(paths.logsDir, label, cmdSha) : undefined;
// Fingerprint the content BEFORE the child runs: a working-tree edit made
// DURING a long suite must not be certified as "the tested content".
const wtreeBefore = currentWtree();
const started = Date.now();
let exitCode: number;
let proc: ReturnType<typeof Bun.spawn> | undefined;
try {
proc = Bun.spawn(spawnArgv, { stdin: "inherit", stdout: "pipe", stderr: "pipe" });
} catch (e: any) {
// Spawn failure (ENOENT on argv-direct form): record exit 127, propagate 127.
exitCode = 127;
warn(`spawn failed: ${e?.message ?? e}`);
record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore);
return exitCode;
}
// Stream-tee: forward chunks as they arrive (never buffer — E2E logs are MBs).
let logBytes = 0;
let truncated = false;
const teeToLog = (chunk: Uint8Array) => {
if (!log || truncated) return;
try {
if (logBytes + chunk.byteLength > LOG_MAX_BYTES) {
const room = LOG_MAX_BYTES - logBytes;
if (room > 0) writeSync(log.fd, chunk.subarray(0, room));
writeSync(log.fd, Buffer.from("\n\n[gstack-evidence: log truncated at 2MB — output continued on console]\n"));
truncated = true;
} else {
writeSync(log.fd, chunk);
logBytes += chunk.byteLength;
}
} catch {
truncated = true; // stop teeing on any write failure; console stream continues
try {
writeSync(log.fd, Buffer.from("\n\n[gstack-evidence: log ended early (write failure) — output continued on console]\n"));
} catch {}
}
};
const pump = async (stream: ReadableStream<Uint8Array> | undefined, out: NodeJS.WriteStream) => {
if (!stream) return;
for await (const chunk of stream) {
// Honor backpressure: when the console consumer is slower than the child
// (piped into a pager/log collector), wait for drain instead of queueing
// unbounded chunks in the WriteStream buffer.
if (!out.write(chunk)) {
// Race drain against error: a dying consumer (EPIPE from `| head`)
// never drains — resolve either way and stop forwarding on error.
await new Promise<void>((r) => {
const done = () => {
out.off("drain", done);
out.off("error", done);
r();
};
out.once("drain", done);
out.once("error", done);
});
}
teeToLog(chunk);
}
};
try {
await Promise.all([pump(proc.stdout as any, process.stdout), pump(proc.stderr as any, process.stderr)]);
exitCode = await proc.exited;
if (exitCode === null || exitCode === undefined) exitCode = 1;
} catch (e: any) {
warn(`stream error: ${e?.message ?? e}`);
try {
exitCode = await proc.exited;
} catch {
exitCode = 1;
}
} finally {
if (log) {
try {
closeSync(log.fd);
} catch {}
}
}
record(paths, log?.path, label, commandString, cmdSha, exitCode, started, wtreeBefore);
return exitCode;
}
function record(
paths: { dir: string; file: string } | undefined,
logPath: string | undefined,
label: string,
commandString: string,
cmdSha: string,
exitCode: number,
startedMs: number,
wtreeBefore: string | undefined,
): void {
if (!paths) return;
try {
const { command, redacted } = safeCommandForRecord(commandString);
const rec: EvidenceRecord = {
ts: new Date().toISOString(),
label,
command,
cmd_sha256: cmdSha,
exit: exitCode,
duration_s: Math.round((Date.now() - startedMs) / 100) / 10,
};
if (redacted) rec.redacted = true;
const commit = git(["rev-parse", "HEAD"]);
if (commit) {
rec.commit = commit;
rec.tree = git(["rev-parse", "HEAD^{tree}"]);
rec.dirty = (git(["status", "--porcelain", "-uno"]) ?? "") !== "";
// TOCTOU guard: the fingerprint is only trustworthy when the content was
// IDENTICAL before and after the run. A mid-run edit omits wtree, so
// check grades STALE instead of certifying content the suite never ran.
const wtreeAfter = currentWtree();
if (wtreeBefore && wtreeAfter && wtreeBefore === wtreeAfter) {
rec.wtree = wtreeAfter;
} else if (wtreeBefore || wtreeAfter) {
warn("working-tree content changed during the run — evidence recorded without a content fingerprint (will grade STALE)");
}
}
if (logPath) rec.log_path = logPath;
appendJsonl(paths.file, rec, { mode: 0o600 });
try {
chmodSync(paths.file, 0o600);
} catch {}
// Summary line on stderr so calling agents get the exit + log path even
// when the lane ran backgrounded. Never on stdout (stays transparent).
console.error(`gstack-evidence: recorded label=${label} exit=${exitCode} log=${logPath ?? "-"}`);
} catch (e: any) {
warn(`ledger append failed (${e?.message ?? e}) — the command result stands`);
}
}
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
// recent --label before it.
const wanted: { label: string; expectCmd?: string }[] = [];
let all = false;
let maxAgeHours: number | undefined;
let allowPaths: string[] = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--label") wanted.push({ label: argv[++i] ?? "" });
else if (a === "--expect-cmd") {
if (wanted.length === 0) {
console.error("gstack-evidence: --expect-cmd requires a preceding --label");
return 2;
}
wanted[wanted.length - 1].expectCmd = argv[++i] ?? "";
} else if (a === "--all") all = true;
else if (a === "--max-age") {
maxAgeHours = Number(argv[++i]);
if (!Number.isFinite(maxAgeHours) || maxAgeHours <= 0) {
// A typo must never silently drop the age gate (fail open) on a
// freshness checker: it is a usage error.
console.error(`gstack-evidence: --max-age must be a positive number of hours, got: ${JSON.stringify(argv[i])}`);
return 2;
}
}
else if (a === "--allow-paths") allowPaths = (argv[++i] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
}
if (!all && wanted.length === 0) {
console.error("usage: gstack-evidence check [--label <L> [--expect-cmd <s>]]... | --all [--max-age <hrs>] [--allow-paths <csv>]");
return 2;
}
let records: EvidenceRecord[] = [];
try {
records = readJsonl<EvidenceRecord>(ledgerPath().file);
} catch {
records = [];
}
const labels = all
? [...new Set(records.map((r) => r.label))].map((label) => ({ label, expectCmd: undefined as string | undefined }))
: wanted;
if (all && labels.length === 0) {
console.log("EVIDENCE: MISSING (ledger empty — no labels recorded)");
return 1;
}
const wtreeNow = currentWtree();
let allFresh = true;
for (const { label, expectCmd } of labels) {
const latest = records.findLast((r) => r.label === label);
if (!latest) {
console.log(`EVIDENCE: MISSING label=${label}`);
allFresh = false;
continue;
}
const detail = `label=${label} exit=${latest.exit} ts=${latest.ts}${latest.log_path ? ` log=${latest.log_path}` : ""}`;
let verdict: "FRESH" | "STALE" = "FRESH";
let reason = "";
if (latest.exit !== 0) {
verdict = "STALE";
reason = "recorded run failed";
} 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
? "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 ? ", ..." : ""}`;
}
}
}
}
console.log(`EVIDENCE: ${verdict} ${detail}${reason ? ` reason=${reason}` : ""}`);
if (verdict !== "FRESH") allFresh = false;
}
return allFresh ? 0 : 1;
}
const [, , sub, ...rest] = process.argv;
try {
if (sub === "run") {
process.exit(await cmdRun(rest));
} else if (sub === "check") {
process.exit(cmdCheck(rest));
} else {
console.error("usage: gstack-evidence run|check ...");
process.exit(2);
}
} catch (e: any) {
// Never let the wrapper's own failure look like a command failure in a way
// that breaks a skill flow: `run` propagates the child's code from inside
// cmdRun; reaching here means bookkeeping blew up outside it.
warn(`unexpected error: ${e?.message ?? e}`);
process.exit(1);
}
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bun
/**
* gstack-issue-guard — fetch tracker text and emit it inside the untrusted
* trust envelope (lib/tracker-guard.ts). The ONLY sanctioned path for reading
* PR/issue body text into an agent's context — the wiring scanner
* (test/tracker-guard-wiring.test.ts) fails CI on raw reads outside it.
*
* gstack-issue-guard issue <n> # gh issue: title + body + comments
* gstack-issue-guard pr-body # gh: current PR body
* gstack-issue-guard pr-comments # gh: current PR issue-comments
* gstack-issue-guard --stdin [--source <label>] # envelope stdin (works for glab too)
*
* Failure polarity: a gh/glab fetch failure exits NON-ZERO with NO envelope on
* stdout — never emit a fake-trusted empty envelope. Callers own their error
* contract (greptile-triage skips silently; others surface the error).
* Empty content IS enveloped (with a note): "empty" is data, "failed" is not.
*
* gh is spawned via an argv array — never string concatenation — and the
* issue number is validated before use.
*/
import { spawnSync } from "child_process";
import { wrapUntrustedTrackerContent } from "../lib/tracker-guard";
import { flagValue } from "../lib/bin-context";
function gh(args: string[]): { ok: boolean; out: string; err: string } {
try {
const r = spawnSync("gh", args, { encoding: "utf-8", timeout: 30000, maxBuffer: 16 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "" };
} catch (e: any) {
return { ok: false, out: "", err: String(e?.message ?? e) };
}
}
function fail(msg: string): never {
console.error(`gstack-issue-guard: ${msg}`);
process.exit(1);
}
const [, , mode, ...rest] = process.argv;
if (mode === "--stdin") {
const source = flagValue(rest, "--source");
const text = await Bun.stdin.text();
console.log(wrapUntrustedTrackerContent(text, source ?? "stdin"));
process.exit(0);
}
if (mode === "issue") {
const n = rest[0] ?? "";
if (!/^[0-9]+$/.test(n)) fail(`issue number must be numeric, got: ${JSON.stringify(n)}`);
const r = gh(["issue", "view", n, "--json", "title,body,comments"]);
if (!r.ok) fail(`gh issue view failed: ${r.err.trim() || "unknown error"}`);
let title = "";
let body = "";
let comments: { author?: { login?: string }; body?: string }[] = [];
try {
const j = JSON.parse(r.out);
title = typeof j.title === "string" ? j.title : "";
body = typeof j.body === "string" ? j.body : "";
comments = Array.isArray(j.comments) ? j.comments : [];
} catch {
fail("gh returned unparseable JSON");
}
const parts = [`TITLE: ${title}`, "", body];
for (const c of comments) {
parts.push("", `--- comment by ${c?.author?.login ?? "unknown"} ---`, c?.body ?? "");
}
console.log(wrapUntrustedTrackerContent(parts.join("\n"), `issue #${n}`));
process.exit(0);
}
if (mode === "pr-body") {
const r = gh(["pr", "view", "--json", "body", "--jq", ".body"]);
if (!r.ok) fail(`gh pr view failed: ${r.err.trim() || "unknown error"}`);
console.log(wrapUntrustedTrackerContent(r.out, "pr body"));
process.exit(0);
}
if (mode === "pr-comments") {
const r = gh(["pr", "view", "--json", "comments"]);
if (!r.ok) fail(`gh pr view failed: ${r.err.trim() || "unknown error"}`);
let comments: { author?: { login?: string }; body?: string }[] = [];
try {
const j = JSON.parse(r.out);
comments = Array.isArray(j.comments) ? j.comments : [];
} catch {
fail("gh returned unparseable JSON");
}
const parts: string[] = [];
for (const c of comments) {
parts.push(`--- comment by ${c?.author?.login ?? "unknown"} ---`, c?.body ?? "", "");
}
console.log(wrapUntrustedTrackerContent(parts.join("\n"), "pr comments"));
process.exit(0);
}
fail("usage: gstack-issue-guard issue <n> | pr-body | pr-comments | --stdin [--source <label>]");
+45 -4
View File
@@ -1,21 +1,62 @@
#!/usr/bin/env bash
# gstack-review-log — atomically log a review result
# Usage: gstack-review-log '{"skill":"...","timestamp":"...","status":"..."}'
#
# Binding fields (content-addressed staleness): every appended record is
# stamped with commit_full, tree, dirty (informational) and wtree (the GATING
# working-tree fingerprint from bin/gstack-wtree). These are computed
# AUTHORITATIVELY here — caller-supplied values for the four keys are ignored,
# so a stale rendered template (or a forged field) cannot bind a record to
# content it wasn't made on. All other caller fields pass through untouched.
# Outside a git repo the fields are simply omitted (legacy consumers fall back
# to their heuristics).
#
# Known limitation: binding happens at LOG time, not review-START time — edits
# made between finishing a review and logging it (including fixes the review
# itself applied) are certified by the stamped fingerprint. gstack-evidence
# closes this window for test runs (before/after capture); review flows log
# immediately after reviewing, which keeps the window small but nonzero.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
mkdir -p "$GSTACK_HOME/projects/$SLUG"
# Validate: input must be parseable JSON (reject malformed or injection attempts)
INPUT="$1"
if ! printf '%s' "$INPUT" | bun -e "JSON.parse(await Bun.stdin.text())" 2>/dev/null; then
# Compute binding fields (best-effort; empty outside a git repo).
COMMIT_FULL=$(git rev-parse HEAD 2>/dev/null || true)
TREE=""
WTREE=""
DIRTY=""
if [ -n "$COMMIT_FULL" ]; then
TREE=$(git rev-parse 'HEAD^{tree}' 2>/dev/null || true)
WTREE=$("$SCRIPT_DIR/gstack-wtree" 2>/dev/null || true)
if [ -n "$(git status --porcelain -uno 2>/dev/null | head -1)" ]; then
DIRTY="true"
else
DIRTY="false"
fi
fi
# Validate (reject malformed or injection attempts) AND stamp in one pass.
# Caller values for the binding keys are dropped before stamping.
STAMPED=$(printf '%s' "$INPUT" | GSTACK_STAMP_COMMIT_FULL="$COMMIT_FULL" GSTACK_STAMP_TREE="$TREE" GSTACK_STAMP_WTREE="$WTREE" GSTACK_STAMP_DIRTY="$DIRTY" bun -e "
const rec = JSON.parse(await Bun.stdin.text());
for (const k of ['commit_full', 'tree', 'wtree', 'dirty']) delete rec[k];
const env = process.env;
if (env.GSTACK_STAMP_COMMIT_FULL) rec.commit_full = env.GSTACK_STAMP_COMMIT_FULL;
if (env.GSTACK_STAMP_TREE) rec.tree = env.GSTACK_STAMP_TREE;
if (env.GSTACK_STAMP_WTREE) rec.wtree = env.GSTACK_STAMP_WTREE;
if (env.GSTACK_STAMP_DIRTY) rec.dirty = env.GSTACK_STAMP_DIRTY === 'true';
console.log(JSON.stringify(rec));
" 2>/dev/null) || {
# Not valid JSON — refuse to append
echo "gstack-review-log: invalid JSON, skipping" >&2
exit 1
fi
}
echo "$INPUT" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl"
echo "$STAMPED" >> "$GSTACK_HOME/projects/$SLUG/$BRANCH-reviews.jsonl"
# gbrain-sync: enqueue for cross-machine sync (no-op if sync is off).
"$SCRIPT_DIR/gstack-brain-enqueue" "projects/$SLUG/$BRANCH-reviews.jsonl" 2>/dev/null &
+13
View File
@@ -1,6 +1,13 @@
#!/usr/bin/env bash
# gstack-review-read — read review log and config for dashboard
# Usage: gstack-review-read
#
# Emits, in order: the raw reviews JSONL, ---CONFIG--- (skip_eng_review),
# ---HEAD--- (short sha), ---WTREE--- (current working-tree fingerprint from
# bin/gstack-wtree, or "unknown"), ---TREE--- (HEAD tree, informational) and
# ---DIRTY--- (tracked-file dirty flag). Consumers grade diff-scoped review
# rows CURRENT when a record's `wtree` equals ---WTREE---; everything needed
# for that rule ships in this one output so graders run no extra commands.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
@@ -10,3 +17,9 @@ echo "---CONFIG---"
"$SCRIPT_DIR/gstack-config" get skip_eng_review 2>/dev/null || echo "false"
echo "---HEAD---"
git rev-parse --short HEAD 2>/dev/null || echo "unknown"
echo "---WTREE---"
"$SCRIPT_DIR/gstack-wtree" 2>/dev/null || echo "unknown"
echo "---TREE---"
git rev-parse 'HEAD^{tree}' 2>/dev/null || echo "unknown"
echo "---DIRTY---"
if [ -n "$(git status --porcelain -uno 2>/dev/null | head -1)" ]; then echo "true"; else echo "false"; fi
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# gstack-wtree — print a working-tree CONTENT fingerprint (a git tree hash).
#
# Builds a temp index, stages the full working tree into it (`git add -A`, so
# .gitignore'd scratch stays out and UNTRACKED source is included), and prints
# `git write-tree` of that index. Properties that make this the right
# staleness fingerprint, vs `git rev-parse HEAD^{tree}`:
#
# - Committing identical content does NOT change the fingerprint, so a
# record made on a dirty tree stays valid after the exact same content is
# committed (the /ship Step 5 -> Step 16 case).
# - Untracked new source files DO change the fingerprint, so "tests passed"
# can't stay FRESH after a new file appears.
# - Rebase/amend/squash that preserve content do not change it.
#
# Performance: the temp index is seeded by COPYING the real index (git writes
# it atomically via rename, so the copy is a consistent snapshot). That
# preserves the stat cache, so `git add -A` only re-hashes files whose stat
# changed — measured 40x faster than a `read-tree HEAD` seed, which zeroes
# stat data and forces a full re-hash of every tracked file. Both seeds
# produce the identical write-tree hash. Fallback: `read-tree HEAD` when the
# index copy is unavailable (fresh repo, exotic index).
#
# The real repo index is never touched. Staged blobs land in the object store
# as unreachable objects and get gc'd like stash churn (note: this means the
# CONTENT of untracked, non-ignored files enters .git/objects until gc — the
# same property `git stash -u` has). Exit 1 outside a git repo or in a repo
# with no commits — callers treat that as "no fingerprint".
set -euo pipefail
TOP=$(git rev-parse --show-toplevel 2>/dev/null) || exit 1
# Resolve the REAL index path BEFORE exporting GIT_INDEX_FILE — with the env
# var set, `git rev-parse --git-path index` returns the temp index itself and
# the stat-cache seed silently self-copies into a dead fast path.
REAL_INDEX=$(git -C "$TOP" rev-parse --git-path index 2>/dev/null || true)
TMPIDX=$(mktemp "${TMPDIR:-/tmp}/gstack-wtree-XXXXXX")
trap 'rm -f "$TMPIDX"' EXIT
export GIT_INDEX_FILE="$TMPIDX"
# Resolve relative --git-path output against the repo root.
case "$REAL_INDEX" in
""|/*) ;;
*) REAL_INDEX="$TOP/$REAL_INDEX" ;;
esac
if [ -n "$REAL_INDEX" ] && [ -f "$REAL_INDEX" ] && cp "$REAL_INDEX" "$TMPIDX" 2>/dev/null; then
: # stat-cache-preserving seed
else
git -C "$TOP" read-tree HEAD 2>/dev/null || exit 1
fi
git -C "$TOP" add -A 2>/dev/null || exit 1
git -C "$TOP" write-tree 2>/dev/null