mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
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>
This commit is contained in:
1 parent
4729f4752b
commit
dfad6cf483
2 files changed
+70
-57
No files matched your search
@@ -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 <id>` 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 <id>] <file> <phase> <decision> <why> <evidence> <result>
|
||||
gstack-decision-trail start <file> <run-id>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user