mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* feat(bin): gstack-decision-trail and gstack-worktree-audit, ported from pstack (GSTA-123)
Two MIT ports from Lauren Tan's pstack (licenses/MIT-pstack.txt, NOTICE.md):
a TSV decision trail with the header-once append, the formula guard, a start
row per run and a --run admission check; and a read-only worktree audit with
size, merge state, dirty, remote, PR, last chat and a suggested bucket.
Docs: docs/unattended.md (the decision trail and its end-of-run audit),
autoplan/DRIVER.md, docs/CONTRIBUTOR_REFERENCE.md, docs/standalone-binaries.md,
docs/troubleshooting.md, one line in landing-report.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test(egress): reasoned scanner exemption for gstack-worktree-audit's base fetch
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review of the pstack ports
bin/gstack-worktree-audit: a worktree path with a space is read whole (sed
on the porcelain 'worktree ' line, not awk's second field); a registered
worktree whose directory is gone prints a 'prunable' row instead of being
skipped; 'safe' now requires a clean tree and either merged into the base
or a closed/merged PR with every commit pushed, so unpushed commits or
untracked files on a merged branch read 'review' (the port's rule called
them safe); fork PRs are dropped and an open PR wins over an older closed
one on a reused branch name; GIT_TERMINAL_PROMPT=0 on the base fetch.
bin/gstack-decision-trail: only a leading -h/--help is help, so a '-h'
cell is appended as data; a trail without a final newline, with CRLF rows
or with a repeated header line is appended to correctly; a run id is
limited to [A-Za-z0-9._:@/-] (exit 2) so a tab or quote cannot break the
start-row idempotence check.
Tests cover each case; the 'without gh' test resolves git from
'git --exec-path' instead of a PATH rebuilt from a tool list. Docs,
CHANGELOG and --help text describe the stricter buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, pass 2
bin/gstack-worktree-audit: a worktree whose own .git link is gone (git
marks it prunable, or its toplevel is another repository) prints a 'broken'
row in 'review' instead of describing the containing repository as clean
and safe; a locked entry whose directory is gone reads 'review', not
'prunable', since prune would not touch it; a clean tree names its
gitignored entries as 'clean,ignored:<n>' without changing the bucket; the
branch name is read from refs/heads/ by hand so a tag of the same name
cannot hide the PR; porcelain blocks are read with IFS=tab so paths with
leading or trailing spaces survive; a failed git status reads 'unknown',
never 'clean'; the base fetch also sets GIT_ASKPASS=true and ssh BatchMode.
bin/gstack-decision-trail: a file whose first line is not the header (a
blank file, rows written by hand) gets the header appended, and a UTF-8
BOM before the header no longer turns it into a data row.
Tests: the fake gh records its argv and runs the audit's own --jq over
canned JSON with a fork PR when jq is installed; row counts are asserted
before the every() checks; each test starts from the same gh and closed
worktree; the snapshot includes the worktree registry; new fixtures for
locked, broken, tag-shadowed and ignored-only worktrees, a blank trail and
a BOM. Docs and CHANGELOG describe the new DIRTY values and buckets.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes, pass 3
bin/gstack-decision-trail: hasHeader looks for a header line anywhere in
the file, so a blank or hand-written trail gets exactly one header rather
than one per append. bin/gstack-worktree-audit: the broken-worktree check
uses rev-parse --show-cdup and the presence of /.git instead of
comparing two spellings of the same path, which differ on Git for Windows.
Test: the blank-trail case appends twice and asserts a single header.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: adversarial review, pass 5 (rule-23 grant, one finding)
bin/gstack-worktree-audit: git status runs with --untracked-files=normal
--ignore-submodules=none, so a user's status.showUntrackedFiles=no config
cannot hide untracked files and let a worktree holding them read clean and
safe. Test: the fresh worktree under that config via GIT_CONFIG_* still
reads scratch:1 / review (red before the fix).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs: documentation audit corrections for the pstack ports
docs/standalone-binaries.md, docs/CONTRIBUTOR_REFERENCE.md,
docs/troubleshooting.md: the audit's safe condition is stated in full
(clean and merged, or PR closed/merged with every commit pushed), the
DIRTY list names missing, broken and unknown, start is a reserved phase
name, and 'read-only' names the base fetch as the one write --no-fetch
skips. CHANGELOG.md says the same (the audit's one decision item).
Audit id gsta123-docs-20261026T221657Z, status updated, no blockers.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* Stamp 1.91.268.0 for #3494 (train slot 1, base d9b0d600)
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>
173 lines
7.7 KiB
TypeScript
Executable File
173 lines
7.7 KiB
TypeScript
Executable File
#!/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 <id>] <file> <phase> <decision> <why> <evidence> <result>
|
|
* gstack-decision-trail start <file> <run-id>
|
|
* 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:<id>`). `start` is idempotent for the same
|
|
* 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";
|
|
|
|
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>
|
|
gstack-decision-trail --help
|
|
|
|
Appends one tab-separated row (ts is stamped in UTC) to <file>, 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 <file> <run-id> appends this run's start row (phase 'start', evidence
|
|
'run:<run-id>') 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 <id> 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 <file> <run-id>\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 <file> 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 <file> <run-id>", 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`);
|