mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +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> * feat(autoplan): carry the owner's answer to a later gate that asks nothing new, gate.json eli5, stacked approvals import A later gate revision with nothing new is answered with the owner's recorded words (CARRIED); a 'do it' also covers new auto items that change no scope, cost or direction; an unreviewed post-cap delta gets one cross-family reviewer first (CARRY_CHECK, gstack-autoplan check). gate.json gains eli5 and the Captain brief requires ## ELI5 on runs that carry it. gstack-approvals import --stack writes every branch or none. The first next prints REVIEW_ESTIMATE. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry-forward, ELI5 section and stacked approvals import Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(autoplan): carry only fully owner-answered gates; void stale delta checks - a pending delta check is voided (reservation released) when the owner answers, the plan is revised, or a new gate is written; a check bound to an older delta is void - carry refuses a gate with any approval item still pending or answered in item tokens - "do it" never covers an option that changes the plan; isDoIt ignores Captain-relayed prefixes - captain brief rebuilds ELI5 from the current plan and lists items taken under "do it" - gstack-approvals import --stack locates every branch before writing any - DRIVER.md back under its 162-line budget Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs: carry-forward conditions, delta-check voiding and import --stack match the final code Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: changelog for autoplan carry-forward (unreleased; stamped at merge) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.314.0 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>
68 lines
3.0 KiB
TypeScript
68 lines
3.0 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|
|
}
|