#!/usr/bin/env -S bun --no-env-file --config=/dev/null /** * gstack-decision-trail — append one row to a run's decision trail: a TSV with * one line per decision (ts, phase, decision, why, evidence, result) that a * reviewer reads top to bottom after the run. Append-only: a wrong call gets a * new row that supersedes it, never an edit. * * Usage: * gstack-decision-trail [--run ] * gstack-decision-trail start * gstack-decision-trail --help * * The write rules are a port of pstack's show-me-your-work/scripts/log.sh * (Copyright Lauren Tan, MIT; https://github.com/cursor/plugins, * pstack/skills/show-me-your-work at 99b609a7), in TypeScript: the header is * written once and only ever appended, cells lose their tabs and line breaks, * and a cell whose first byte a spreadsheet would run as a formula (=, +, -, @) * gets a leading apostrophe, so evidence taken from PR titles, filenames or * generated text cannot execute when a reviewer opens the file. License text: * licenses/MIT-pstack.txt; NOTICE.md carries the entry. * * The start-row rule is gstack's mechanisation of the same skill's prose: a run * (one agent conversation, with its later turns) that writes to a trail already * holding rows opens with a `start` row naming the rows it did not write, and * its evidence names the run (`run:`). `start` is idempotent for the same * id, and an append with `--run ` refuses until that run's start row is the * newest start row in the file (DECISION_TRAIL_START_REQUIRED, exit 3). */ import * as fs from "node:fs"; import * as path from "node:path"; const COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const; const HEADER = COLUMNS.join("\t") + "\n"; const USAGE = `Usage: gstack-decision-trail [--run ] gstack-decision-trail start gstack-decision-trail --help Appends one tab-separated row (ts is stamped in UTC) to , writing the header line first when the file is missing or empty. Cells are flattened to one line, and a cell starting with =, +, - or @ is prefixed with a quote so a spreadsheet cannot run it as a formula. Nothing is ever rewritten or removed. phase the phase or workstream the decision belongs to; 'start' is reserved decision what was chosen or done, one line, in plain words why the reason, as you would tell a teammate evidence a pointer that resolves: commit, PR, file:line, artifact path result the outcome: tests green, reverted, INCONCLUSIVE, open, ... start appends this run's start row (phase 'start', evidence 'run:') naming the rows earlier runs wrote. Running it again for the same run is a no-op while that start row is still the newest one. A run id is letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number). --run on an append refuses with DECISION_TRAIL_START_REQUIRED (exit 3) when the trail has rows and the newest start row is another run's. `; type Row = { line: number; cells: string[] }; function clean(cell: string): string { const flat = cell.replace(/[\t\n\r]/g, " "); return /^[=+\-@]/.test(flat) ? `'${flat}` : flat; } function readRows(file: string): Row[] { if (!fs.existsSync(file)) return []; const lines = fs.readFileSync(file, "utf8").split("\n"); const rows: Row[] = []; lines.forEach((raw, i) => { let text = raw.endsWith("\r") ? raw.slice(0, -1) : raw; if (i === 0 && text.startsWith("\uFEFF")) text = text.slice(1); // A repeated header (the append-on-flaky-mount case) is never a row. if (text === "" || text === HEADER.trimEnd()) return; rows.push({ line: i + 1, cells: text.split("\t") }); }); return rows; } function appendRow(file: string, cells: readonly string[]): number { const dir = path.dirname(file); if (dir && dir !== "." && !fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); // Append the header too, never truncate: on a flaky mount the worst case is // one stray header line, not lost rows. // A file with no header line anywhere (empty, blank, or rows written by hand) // gets one appended once; readRows skips a header line wherever it sits. const size = fs.existsSync(file) ? fs.statSync(file).size : 0; if (size > 0 && !endsWithNewline(file, size)) fs.appendFileSync(file, "\n"); if (size === 0 || !hasHeader(file)) fs.appendFileSync(file, HEADER); const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n"); return readRows(file).length; } function hasHeader(file: string): boolean { return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n") .some(line => line.replace(/\r$/, "") === HEADER.trimEnd()); } function endsWithNewline(file: string, size: number): boolean { const fd = fs.openSync(file, "r"); try { const last = Buffer.alloc(1); fs.readSync(fd, last, 0, 1, size - 1); return last[0] === 0x0a; } finally { fs.closeSync(fd); } } const RUN_ID = /^[A-Za-z0-9._:@/-]+$/; function checkRunId(runId: string): void { if (!RUN_ID.test(runId)) fail(`run id must match ${RUN_ID} (letters, digits, . _ : @ / -), got ${JSON.stringify(runId)}`, 2); } function newestStart(rows: Row[]): Row | undefined { for (let i = rows.length - 1; i >= 0; i--) if (rows[i]!.cells[1] === "start") return rows[i]; return undefined; } function fail(message: string, code: number): never { process.stderr.write(`gstack-decision-trail: ${message}\n`); process.exit(code); } const argv = process.argv.slice(2); // Only a leading --help asks for help: a cell whose text is "-h" is data and gets appended (quoted). if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { process.stdout.write(USAGE); process.exit(argv.length === 0 ? 2 : 0); } if (argv[0] === "start") { const [, file, runId, ...extra] = argv; if (!file || !runId || extra.length > 0) fail(`start takes \n${USAGE}`, 2); checkRunId(runId); const rows = readRows(file); const newest = newestStart(rows); if (newest && newest.cells[4] === `run:${runId}`) { process.stdout.write(`start row for run ${runId} already present at ${file}:${newest.line}\n`); process.exit(0); } const earlier = rows.length === 0 ? "no earlier rows" : `${rows.length} earlier row${rows.length === 1 ? "" : "s"} (${rows[0]!.cells[0]}..${rows[rows.length - 1]!.cells[0]}) written by other runs`; const line = appendRow(file, ["start", rows.length === 0 ? `run ${runId} opens this trail` : `run ${runId} joins this trail`, earlier, `run:${runId}`, "open"]); process.stdout.write(`appended start row ${line} for run ${runId} to ${file}\n`); process.exit(0); } let runId: string | undefined; const positional = [...argv]; while (positional[0]?.startsWith("--")) { const flag = positional.shift()!; if (flag === "--run") { runId = positional.shift(); if (!runId) fail("--run needs an id", 2); checkRunId(runId); } else fail(`unknown option ${flag}\n${USAGE}`, 2); } if (positional.length !== 6) fail(`expected and five cells, got ${positional.length} argument${positional.length === 1 ? "" : "s"}\n${USAGE}`, 2); const [file, ...cells] = positional as [string, string, string, string, string, string]; if (cells[0] === "start") fail("phase 'start' is reserved; use: gstack-decision-trail start ", 2); if (runId) { const rows = readRows(file); if (rows.length > 0 && newestStart(rows)?.cells[4] !== `run:${runId}`) { fail(`DECISION_TRAIL_START_REQUIRED: ${file} has ${rows.length} rows and run ${runId} has no start row after the newest one; run: gstack-decision-trail start ${file} ${runId}`, 3); } } const line = appendRow(file, cells); process.stdout.write(`appended row ${line} to ${file}\n`);