Files
gstack/lib/decision-trail.ts
Garry Tanandcapy-ai[bot] 0cab97447c v1.91.314.0 feat(autoplan): carry the owner's answer forward, gate ELI5 field, stacked approvals import (#3583)
* 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>
2026-10-29 09:58:34 +00:00

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);
}
}