From dfad6cf483ed0fc58d29493a847d7dfebdfe6124 Mon Sep 17 00:00:00 2001 From: garrytan Date: Thu, 29 Oct 2026 04:22:12 +0000 Subject: [PATCH] refactor: share the decision-trail append rules (lib/decision-trail.ts) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-decision-trail | 60 ++--------------------------------- lib/decision-trail.ts | 67 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 57 deletions(-) create mode 100644 lib/decision-trail.ts diff --git a/bin/gstack-decision-trail b/bin/gstack-decision-trail index 297c6aee4..750b00da8 100755 --- a/bin/gstack-decision-trail +++ b/bin/gstack-decision-trail @@ -17,7 +17,8 @@ * 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. + * licenses/MIT-pstack.txt; NOTICE.md carries the entry. The append rules live in + * lib/decision-trail.ts, shared with the autoplan runner. * * 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 @@ -26,11 +27,8 @@ * 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"; +import { appendTrailRow as appendRow, readTrailRows as readRows, type TrailRow as Row } from "../lib/decision-trail"; -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 @@ -56,58 +54,6 @@ letters, digits and . _ : @ / - (a Capy jam_ id, a session id, a PR number). 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); diff --git a/lib/decision-trail.ts b/lib/decision-trail.ts new file mode 100644 index 000000000..dc3597b89 --- /dev/null +++ b/lib/decision-trail.ts @@ -0,0 +1,67 @@ +/** + * decision-trail — the append rules of a run's decision trail (docs/unattended.md#the-decision-trail): + * a TSV with one line per decision (ts, phase, decision, why, evidence, result). 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. Shared by + * bin/gstack-decision-trail and the autoplan runner (lib/autoplan-carry.ts records its carry verdicts). + * The rules are a port of pstack's show-me-your-work/scripts/log.sh (Copyright Lauren Tan, MIT); + * licenses/MIT-pstack.txt, NOTICE.md. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; + +export const TRAIL_COLUMNS = ["ts", "phase", "decision", "why", "evidence", "result"] as const; +export const TRAIL_HEADER = TRAIL_COLUMNS.join("\t") + "\n"; + +export type TrailRow = { line: number; cells: string[] }; + +function clean(cell: string): string { + const flat = cell.replace(/[\t\n\r]/g, " "); + return /^[=+\-@]/.test(flat) ? `'${flat}` : flat; +} + +export function readTrailRows(file: string): TrailRow[] { + if (!fs.existsSync(file)) return []; + const lines = fs.readFileSync(file, "utf8").split("\n"); + const rows: TrailRow[] = []; + 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 === TRAIL_HEADER.trimEnd()) return; + rows.push({ line: i + 1, cells: text.split("\t") }); + }); + return rows; +} + +/** Append one row (ts stamped in UTC) and return the row count. */ +export function appendTrailRow(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; readTrailRows 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, TRAIL_HEADER); + const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + fs.appendFileSync(file, [ts, ...cells.map(clean)].join("\t") + "\n"); + return readTrailRows(file).length; +} + +function hasHeader(file: string): boolean { + return fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "").split("\n") + .some(line => line.replace(/\r$/, "") === TRAIL_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); + } +}