mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 14:06:50 +01:00
Merge remote-tracking branch 'origin/main' into gsta-114-autoplan-gates
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> # Conflicts: # CHANGELOG.md
This commit is contained in:
commit
624f0eedec
20 files changed
+973
-6
No files matched your search
@@ -18,6 +18,21 @@
|
||||
### Gate integrity
|
||||
- No threshold, rubric, trial count, case kind or judge prompt changed. The only prompt edit is the Eng phase's performance paragraph; the one paid autoplan case (`autoplan-dual-voice`) reads Phase 1, which is untouched.
|
||||
|
||||
## [1.91.268.0] - 2026-10-27
|
||||
|
||||
**Two read-and-write helpers absorbed from pstack: a decision trail a reviewer reads without the transcript, and a worktree audit that tells you which Conductor worktrees are safe to remove.** Both are ports of Lauren Tan's MIT scripts (header credit in each file, `licenses/MIT-pstack.txt`, NOTICE.md), approved with the rest of the pstack absorb (Garry, 2026-10-26: "Do it, send").
|
||||
|
||||
### Added
|
||||
- **`bin/gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>`**: appends one UTC-stamped row to a run's TSV decision trail, writing the header once and only ever appending; cells are flattened to one line and a cell that starts with `=`, `+`, `-` or `@` gets a leading quote so evidence lifted from a PR title or a filename cannot run as a formula in a reviewer's spreadsheet. `start <file> <run-id>` appends a run's `start` row (naming the earlier rows and `run:<id>`; the id is letters, digits and `. _ : @ / -`, so a tab or quote in it is a usage error rather than a row that breaks the idempotence check) and is a no-op while that row is the newest start row, so a rerun writes nothing twice; only a leading `-h`/`--help` is help, so a cell whose text is `-h` is appended as data; a trail without a final newline, with CRLF rows, a UTF-8 BOM, a repeated header line or no header at all is appended to correctly; `--run <id>` on an append refuses with `DECISION_TRAIL_START_REQUIRED` (exit 3) until the run has opened its stretch. The end-of-run rule, in `docs/unattended.md` ("The decision trail") and `autoplan/DRIVER.md`: audit the trail against the transcript and correct the log, not the story (a wrong row is superseded, never edited), then a reviewer on another model family reads it and the report ends with an `Attention` section (`reviewed by <model>`, the flags, or `No flags`).
|
||||
- **`bin/gstack-worktree-audit [-b <ref>] [--no-fetch] [repo]`** (bash, read-only): one TSV row per git worktree beside the main one, largest first: size, age, merged into the base, `clean`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh`'s own `--jq` (no `jq` needed), the newest Claude Code chat that ran in the worktree (`${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<slug>`) and a bucket: `prunable` (registered, directory gone), `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe`, `review`. `safe` means nothing exists only in that worktree: a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed; an unpushed commit or an untracked file on a merged branch reads `review`. A fork's PR on a same-named branch is ignored and an open PR wins over an older closed one on a reused name. A locked entry whose directory is gone and a `broken` worktree whose own `.git` link is missing (git would otherwise describe the containing repository and hide its files) read `review`; a clean tree names its gitignored entries as `clean,ignored:<n>` without changing the bucket; a branch name shared with a tag still resolves; worktree paths with leading, trailing or inner spaces are read whole. It never deletes or prunes; its one write is a best-effort fetch of the base ref that `--no-fetch` skips. `/landing-report` offers it when the sibling table shows stale worktrees; `docs/CONTRIBUTOR_REFERENCE.md` has the guide for Conductor users, `docs/standalone-binaries.md` lists both bins.
|
||||
|
||||
### Tests
|
||||
- `test/decision-trail.test.ts`: header once (an empty file gets one, a second append does not), the formula guard on every leading byte it covers, flattened tabs and line breaks, the start row's text and its idempotent rerun, a second run's start row, the `--run` refusal and admission, a `-h` cell appended as data, a missing final newline, CRLF rows and a repeated header, a blank file and a BOM, run ids with whitespace or quotes refused, usage errors.
|
||||
- `test/worktree-audit.test.ts`: a bare origin, a clone and twelve worktrees (merged, wip with an open PR, scratch with a chat today, stale, closed PR, a merged one whose path has a space, a merged one with only an untracked file, a registered one whose directory is gone, a locked one whose directory is gone, a broken one inside the clone with a precious file, one whose branch shares its name with a tag, one with only a gitignored `.env`) classified into every bucket (also under `status.showUntrackedFiles=no`, which must not hide untracked files) through a fake `gh` (which records its arguments and runs the audit's own `--jq` over canned JSON with a fork PR when `jq` is installed) and a `CLAUDE_CONFIG_DIR` transcript directory; the fixture, its worktree registry included, is byte-identical after the audit; `-b`, `--no-fetch`, an unpushed commit on a closed PR reading `review` and an open PR on the reused name winning, no `gh` on PATH (git resolved from `git --exec-path`, so a distribution's wrapper script does not matter), a failing `gh`, a missing base and a non-repo path.
|
||||
|
||||
### Gate integrity
|
||||
- No threshold, timeout, trial count or case kind changed. No pinned skill changed; `landing-report/SKILL.md` grows by one line and is unpinned. `test/autoplan-driver.test.ts`'s line cap on `autoplan/DRIVER.md` moves 160 → 162 (measured 162) for the decision-trail paragraph at the end of the coordinator contract section; the file was at the cap before.
|
||||
|
||||
## [1.91.267.0] - 2026-10-27
|
||||
|
||||
**The Captain guide says which questions never need to reach a person.** Follow-up to #3493 (pstack absorb item 4, GSTA-124).
|
||||
|
||||
@@ -124,3 +124,20 @@ design is not a defect, missing context; independent agreement as the strongest
|
||||
`docs/review-policy.md` are written in gstack's words. No sentence is copied, so no license
|
||||
text is carried; this entry is a courtesy.
|
||||
Also ideas, not text, from `pstack/skills/principle-*`, `poteto-mode` and its eval playbook: ETHOS.md principles 5 to 8, the observable-questions rule in `plan-tune/SKILL.md.tmpl` and `docs/proactive.md`, the blinding rules in `eval-plan/SKILL.md.tmpl` and `docs/TESTING_INTERNALS.md`, and the limiter sentence in AGENTS.md step 4 (GSTA-124).
|
||||
|
||||
## pstack, ported scripts — Copyright (c) 2026 Lauren Tan — MIT License
|
||||
|
||||
https://github.com/cursor/plugins (`pstack/` at 99b609a7). License text:
|
||||
`licenses/MIT-pstack.txt`. Unlike the ideas entry above, these two are code
|
||||
ports, so the license text is carried and each file's header names its source.
|
||||
|
||||
Derived, modified:
|
||||
|
||||
- `bin/gstack-decision-trail`: `pstack/skills/show-me-your-work/scripts/log.sh`
|
||||
in TypeScript: the header-once append, the one-line cells and the
|
||||
spreadsheet formula guard (a leading quote on cells starting with `=`, `+`,
|
||||
`-` or `@`). The `start` subcommand and the `--run` admission check are
|
||||
gstack's.
|
||||
- `bin/gstack-worktree-audit`: `pstack/skills/poteto-mode/scripts/worktree-audit.sh`,
|
||||
with the PR column read through `gh --jq` alone, the chat column read from
|
||||
Claude Code's per-worktree transcript directory, and a `--no-fetch` flag.
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.91.267.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.91.268.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
@@ -155,3 +155,5 @@ Spend caps. Each lane runs under the `--spend-cap` the coordinator names; a lane
|
||||
Before done. A lane pushes a WIP commit to its branch after each step and gates a pushed SHA. It is done only when `gstack-pregate run --allow-repo-commands` passes; `requires-remote` needs `--stage tests --remote --spend-cap <cap>`. Where `.gstack/pregate.json` has a `sealed` key, it runs `gstack-pregate sealed` before any push or PR creation; a refusal stops the push.
|
||||
|
||||
Handoff format. A lane reports in this order, identifiers first: PR URL (or `no PR` with branch and SHA), `GSTACK_RESULT` line, the `gstack: ok|fail <rev>` line, the `PREGATE:` line, gate summary (lanes passed/failed/skipped), behavior changes (or `none`), spend (spent/reserved/unknown), then what it could not verify. Full thread ids (`jam_…`), never short codes, in every cross-thread message. Prose after the identifiers may be truncated; nothing before them may be.
|
||||
|
||||
Decision trail. A lane keeps `<run dir>/decisions.tsv` through `gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>` (one row per fork, unit, revert or blocker; evidence a pointer that resolves) and opens it with `gstack-decision-trail start <file> <run id>` when another run wrote it first. Before done it audits the trail against the transcript, superseding a wrong row with a new one rather than editing it, has a reviewer on another model family read the trail, and ends its report with an `Attention` section (`reviewed by <model>`, then the flags, or `No flags`). The full rule is [docs/unattended.md](../docs/unattended.md#the-decision-trail).
|
||||
@@ -80,3 +80,5 @@ is the completion check. Copy the whole directory, never single files.
|
||||
{{AUTOPLAN_DRIVER_CODES}}
|
||||
|
||||
{{COORDINATOR_CONTRACT}}
|
||||
|
||||
Decision trail. A lane keeps `<run dir>/decisions.tsv` through `gstack-decision-trail <file> <phase> <decision> <why> <evidence> <result>` (one row per fork, unit, revert or blocker; evidence a pointer that resolves) and opens it with `gstack-decision-trail start <file> <run id>` when another run wrote it first. Before done it audits the trail against the transcript, superseding a wrong row with a new one rather than editing it, has a reviewer on another model family read the trail, and ends its report with an `Attention` section (`reviewed by <model>`, then the flags, or `No flags`). The full rule is [docs/unattended.md](../docs/unattended.md#the-decision-trail).
|
||||
Executable
+172
@@ -0,0 +1,172 @@
|
||||
#!/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`);
|
||||
Executable
+208
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
# gstack-worktree-audit — read-only audit of every git worktree beside the main
|
||||
# one: size, age of its last commit, whether its head is merged into the base,
|
||||
# tracked edits vs untracked scratch, remote state, the PR on its branch, the
|
||||
# newest Claude Code chat that ran in it, and a suggested bucket. One TSV row
|
||||
# per worktree, largest first. It never deletes, prunes, checks out or edits a
|
||||
# worktree or a branch; its one write is the best-effort fetch of the base ref
|
||||
# (skip it with --no-fetch). In a fork workflow, where origin is your fork and
|
||||
# gh resolves the upstream, every PR is cross-repository and the PR column is -. Removing a worktree stays a step a person takes
|
||||
# after reading the table.
|
||||
#
|
||||
# Ported from pstack's poteto-mode/scripts/worktree-audit.sh (Copyright Lauren
|
||||
# Tan, MIT; https://github.com/cursor/plugins, pstack/skills/poteto-mode at
|
||||
# 99b609a7). Changes from the source: the PR column comes from gh alone through
|
||||
# its own --jq (no jq binary) and ignores fork PRs, LAST_CHAT reads Claude
|
||||
# Code's per-worktree transcript directory instead of Cursor's, worktree paths
|
||||
# may contain spaces, a registered worktree whose directory is gone gets a
|
||||
# `prunable` row, `safe` also requires a clean tree with nothing unpushed, and
|
||||
# --no-fetch is new. License
|
||||
# text: licenses/MIT-pstack.txt; NOTICE.md carries the entry.
|
||||
#
|
||||
# Usage: gstack-worktree-audit [-b|--base <ref>] [--no-fetch] [repo-path]
|
||||
# repo-path defaults to the repository the current directory is in.
|
||||
# -b sets the ref the MERGED column checks against (origin/integration, say).
|
||||
# Without it the base is origin/HEAD, then origin/main, then origin/master,
|
||||
# and a warn line says which one was picked: origin/HEAD is the remote's
|
||||
# default branch, not necessarily the branch PRs merge into.
|
||||
# --no-fetch skips the best-effort fetch of the base before the merge check.
|
||||
#
|
||||
# Columns: SIZE AGE MERGED DIRTY REMOTE PR LAST_CHAT BUCKET WORKTREE
|
||||
# MERGED YES when the worktree's head is an ancestor of the base. A
|
||||
# squash-merged branch is never an ancestor, so read PR with it.
|
||||
# DIRTY clean | clean,ignored:<n> (only gitignored entries: .env, build
|
||||
# output; the bucket ignores them, removing the worktree would not) |
|
||||
# wip:<n> (tracked edits) | scratch:<n> (untracked only) |
|
||||
# missing (registered, directory gone) | broken (the worktree's own
|
||||
# .git link is gone, so git reads describe another repository) |
|
||||
# unknown (git status failed)
|
||||
# REMOTE pushed | ahead<n> | no-remote | detached, against the local
|
||||
# origin/<branch> tracking ref as last fetched
|
||||
# PR #<n>/<state> from gh (open, merged, closed; an open PR wins when a
|
||||
# branch name has several, fork PRs are ignored), or - without gh
|
||||
# LAST_CHAT date of the newest Claude Code transcript under
|
||||
# ${CLAUDE_CONFIG_DIR:-~/.claude}/projects/<worktree slug>/, or -
|
||||
# BUCKET prunable (directory gone and not locked) > review for a locked or
|
||||
# broken entry > hold-wip (tracked edits) >
|
||||
# hold-open-pr > verify-recent-chat (a chat in the last 4 days) >
|
||||
# safe (clean tree, and either merged into the base or its PR is
|
||||
# closed or merged with every commit pushed) > review (anything
|
||||
# else: unpushed commits, untracked files, nothing says it is done)
|
||||
set -u
|
||||
|
||||
base=""
|
||||
fetch=1
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-b|--base) [ $# -ge 2 ] || { echo "gstack-worktree-audit: $1 needs a ref" >&2; exit 2; }; base="$2"; shift 2 ;;
|
||||
--no-fetch) fetch=0; shift ;;
|
||||
-h|--help) sed -n '2,/^set -u/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;;
|
||||
-*) echo "gstack-worktree-audit: unknown option: $1" >&2; exit 2 ;;
|
||||
*) break ;;
|
||||
esac
|
||||
done
|
||||
|
||||
repo="${1:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
||||
[ -z "$repo" ] && { echo "gstack-worktree-audit: not in a git repository; pass a repo path" >&2; exit 1; }
|
||||
cd "$repo" || exit 1
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || { echo "gstack-worktree-audit: not a git repository: $repo" >&2; exit 1; }
|
||||
|
||||
# The main worktree is the first entry; every other one is a candidate.
|
||||
main_wt=$(git worktree list --porcelain | sed -n 's/^worktree //p' | head -1)
|
||||
|
||||
if [ -z "$base" ]; then
|
||||
for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do
|
||||
[ -n "$cand" ] && git rev-parse --verify --quiet "$cand^{commit}" >/dev/null && { base="$cand"; break; }
|
||||
done
|
||||
[ -z "$base" ] && { echo "gstack-worktree-audit: no base ref (origin/HEAD, origin/main, origin/master all missing); pass -b <ref>" >&2; exit 1; }
|
||||
echo "warn: no -b given; MERGED checks against $base" >&2
|
||||
fi
|
||||
|
||||
# A stale base only makes MERGED conservative, so the fetch is best-effort. The
|
||||
# explicit refspec creates the remote ref even in a single-branch clone.
|
||||
if [ "$fetch" = 1 ]; then
|
||||
case "$base" in
|
||||
origin/*) GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=true GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes}" git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \
|
||||
|| echo "warn: could not fetch $base; MERGED may be stale" >&2 ;;
|
||||
esac
|
||||
fi
|
||||
git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "gstack-worktree-audit: base ref not found: $base" >&2; exit 1; }
|
||||
|
||||
# GNU coreutils reads `stat -f` as file-system status and `date -r` as a file,
|
||||
# so pick the stat and date spellings once.
|
||||
if stat -c %Y / >/dev/null 2>&1; then
|
||||
stat_fmt=(-c '%Y %n'); fmt_day() { date -u -d "@$1" '+%Y-%m-%d'; }
|
||||
else
|
||||
stat_fmt=(-f '%m %N'); fmt_day() { date -u -r "$1" '+%Y-%m-%d'; }
|
||||
fi
|
||||
|
||||
# PR state per branch, read once as "<branch>\t<number>\t<state>" lines through
|
||||
# gh's own --jq; a fork's PR says nothing about a local branch of the same name,
|
||||
# so cross-repository PRs are dropped. Empty when gh is missing or cannot read
|
||||
# this repository.
|
||||
prs=$(mktemp)
|
||||
trap 'rm -f "$prs"' EXIT
|
||||
if command -v gh >/dev/null 2>&1; then
|
||||
gh pr list --state all --limit 1000 --json number,state,headRefName,isCrossRepository \
|
||||
--jq '.[] | select(.isCrossRepository | not) | [.headRefName, (.number|tostring), (.state|ascii_downcase)] | @tsv' > "$prs" 2>/dev/null \
|
||||
|| { : > "$prs"; echo "warn: gh could not list this repository's PRs; PR column is -" >&2; }
|
||||
else
|
||||
echo "warn: gh not on PATH; PR column is -" >&2
|
||||
fi
|
||||
|
||||
projects="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects"
|
||||
now=$(date +%s)
|
||||
|
||||
echo "# base: $base"
|
||||
printf 'SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n'
|
||||
|
||||
# One porcelain block per worktree: the path, then flag lines (locked, prunable).
|
||||
# IFS is a tab so a path keeps its leading and trailing spaces; a path with a
|
||||
# newline in it cannot be read from this format and is not supported.
|
||||
git worktree list --porcelain | awk 'BEGIN { RS=""; FS="\n" } {
|
||||
flags = "-"
|
||||
for (i = 2; i <= NF; i++) { if ($i ~ /^locked/) flags = flags "L"; if ($i ~ /^prunable/) flags = flags "P" }
|
||||
print flags "\t" substr($1, 10)
|
||||
}' | while IFS=$'\t' read -r flags wt; do
|
||||
[ "$wt" = "$main_wt" ] && continue
|
||||
# Directory gone: prunable, unless git holds the entry locked (an unmounted
|
||||
# volume, say), in which case prune would not touch it and the branch's
|
||||
# unpushed work may still exist elsewhere.
|
||||
if [ ! -d "$wt" ]; then
|
||||
case "$flags" in *L*) bucket=review ;; *) bucket=prunable ;; esac
|
||||
printf '0\t?\t?\tmissing\t?\t-\t-\t%s\t%s\n' "$bucket" "$wt"
|
||||
continue
|
||||
fi
|
||||
# A worktree whose .git file is gone (git marks it prunable) makes every
|
||||
# git -C read describe whichever repository contains the directory, so its
|
||||
# own files would be invisible: report it broken, never safe.
|
||||
case "$flags" in *P*) printf '%s\t?\t?\tbroken\t?\t-\t-\treview\t%s\n' "$(du -sh "$wt" 2>/dev/null | awk '{print $1}')" "$wt"; continue ;; esac
|
||||
# --show-cdup is empty only when the directory is itself a toplevel; a path
|
||||
# comparison would misfire where git and the shell spell one path differently.
|
||||
if [ ! -e "$wt/.git" ] || [ -n "$(git -C "$wt" rev-parse --show-cdup 2>/dev/null || echo broken)" ]; then
|
||||
printf '%s\t?\t?\tbroken\t?\t-\t-\treview\t%s\n' "$(du -sh "$wt" 2>/dev/null | awk '{print $1}')" "$wt"
|
||||
continue
|
||||
fi
|
||||
|
||||
size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}')
|
||||
head=$(git -C "$wt" rev-parse HEAD 2>/dev/null)
|
||||
head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0)
|
||||
age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?")
|
||||
|
||||
git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no
|
||||
|
||||
# Tracked edits are work in progress; untracked files alone are scratch. A
|
||||
# clean tree still names its ignored entries (.env, build output): they do
|
||||
# not change the bucket, but they are what removing the worktree would lose.
|
||||
# The flags pin what status reports: a status.showUntrackedFiles=no config
|
||||
# would otherwise hide untracked files and let a worktree holding them read clean.
|
||||
if porcelain=$(git -C "$wt" status --porcelain --untracked-files=normal --ignore-submodules=none 2>/dev/null); then
|
||||
if [ -z "$porcelain" ]; then
|
||||
ignored=$(git -C "$wt" ls-files --others --ignored --exclude-standard --directory 2>/dev/null | grep -c .)
|
||||
[ "$ignored" -gt 0 ] && dirty="clean,ignored:$ignored" || dirty=clean
|
||||
elif printf '%s\n' "$porcelain" | grep -qv '^??'; then
|
||||
dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')"
|
||||
else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi
|
||||
else dirty=unknown; fi
|
||||
|
||||
# refs/heads/ is stripped by hand: --short prints heads/<name> when a tag
|
||||
# shares the branch's name, and then nothing downstream would match.
|
||||
branch=$(git -C "$wt" symbolic-ref --quiet HEAD 2>/dev/null | sed 's#^refs/heads/##')
|
||||
if [ -z "$branch" ]; then remote=detached
|
||||
elif git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/$branch"; then
|
||||
[ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \
|
||||
&& remote=pushed \
|
||||
|| remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)"
|
||||
else remote=no-remote; fi
|
||||
|
||||
# Several PRs can share a branch name over time; an open one describes the
|
||||
# branch as it is now, so it wins over a closed or merged one.
|
||||
pr="-"
|
||||
[ -n "$branch" ] && [ -s "$prs" ] && pr=$(awk -F'\t' -v b="$branch" \
|
||||
'$1==b { if ($3=="open" || $3=="draft") { print "#" $2 "/" $3; exit } if (first=="") first="#" $2 "/" $3 } END { if (first!="") print first }' "$prs" | head -1)
|
||||
[ -z "$pr" ] && pr="-"
|
||||
|
||||
# Newest transcript in this worktree's own Claude Code project directory
|
||||
# (the path with every non-alphanumeric byte replaced by -, as Claude names it).
|
||||
last="-"; last_ts=0
|
||||
chat_dir="$projects/$(printf '%s' "$wt" | sed 's/[^A-Za-z0-9]/-/g')"
|
||||
if [ -d "$chat_dir" ]; then
|
||||
f=$(find "$chat_dir" -type f -print0 2>/dev/null | xargs -0 stat "${stat_fmt[@]}" 2>/dev/null | sort -rn | head -1)
|
||||
if [ -n "$f" ]; then last_ts=$(echo "$f" | awk '{print $1}'); last=$(fmt_day "$last_ts" 2>/dev/null); fi
|
||||
fi
|
||||
recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no)
|
||||
|
||||
# safe means nothing in the worktree exists only there: a clean tree whose
|
||||
# head is in the base, or whose PR is closed or merged with every commit
|
||||
# pushed. Unpushed commits or untracked files on a merged branch are review.
|
||||
case "$dirty" in wip:*) bucket=hold-wip ;; *)
|
||||
case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *)
|
||||
if [ "$recent" = yes ]; then bucket=verify-recent-chat
|
||||
elif [ "${dirty%%,*}" = clean ] && { [ "$merged" = YES ] || { [ "$pr" != "-" ] && [ "$remote" = pushed ]; }; }; then bucket=safe
|
||||
else bucket=review; fi ;;
|
||||
esac ;;
|
||||
esac
|
||||
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt"
|
||||
done | sort -t"$(printf '\t')" -k1,1 -rh
|
||||
@@ -172,3 +172,33 @@ Option C (this section) keeps secret-distribution scope tight.
|
||||
If the user asks you to skip the move (e.g., "just leave it as a fork PR"),
|
||||
respect that — eval CI will fail with empty-env auth, but check-freshness,
|
||||
workflow-lint, and windows-tests will still pass on the fork PR.
|
||||
|
||||
## Auditing Conductor worktrees
|
||||
|
||||
Conductor workspaces are git worktrees, so after a few weeks of parallel
|
||||
lanes the checkout carries worktrees nobody remembers. `bin/gstack-worktree-audit
|
||||
[-b origin/<base>] [--no-fetch] [repo-path]` prints one TSV row per worktree
|
||||
beside the main one, largest first: size, age of the last commit, whether its
|
||||
head is merged into the base, `clean` / `clean,ignored:<n>` (only gitignored
|
||||
entries such as `.env` or build output) / `wip:<n>` (tracked edits) /
|
||||
`scratch:<n>` (untracked only) / `missing` (registered, directory gone) /
|
||||
`broken` (below) / `unknown` (`git status` failed), `pushed` / `ahead<n>` / `no-remote` /
|
||||
`detached` (against the local `origin/<branch>` tracking ref as last fetched),
|
||||
the PR on its branch through `gh` (`#n/open|merged|closed`, or `-`; a fork's
|
||||
PR on a same-named branch is ignored, so in a fork workflow where `origin` is
|
||||
your fork the column is `-`, and an open PR wins over an older closed one on a
|
||||
reused branch name), the date of the newest Claude Code chat that ran in it,
|
||||
and a bucket: `prunable` (registered, directory gone, not locked), then
|
||||
`review` for a locked entry whose directory is gone or a `broken` worktree
|
||||
whose own `.git` link is missing, then `hold-wip`, then `hold-open-pr`, then
|
||||
`verify-recent-chat` (a chat in the last four days), then `safe`, else
|
||||
`review`. `safe` means nothing tracked or untracked exists only in that
|
||||
worktree: a clean tree whose head is merged into the base, or whose PR is
|
||||
closed or merged with every commit pushed; an unpushed commit or an untracked
|
||||
file on a merged branch is `review`. Ignored entries do not change the bucket,
|
||||
but removing the worktree removes them too, so read the `ignored:<n>` count
|
||||
before you do. A squash-merged branch is never an ancestor of the base, so read
|
||||
`MERGED` together with `PR`. The audit only reads, apart from a best-effort fetch
|
||||
of the base ref that `--no-fetch` skips; removing a worktree
|
||||
(`git worktree remove <path>`) stays your call after the table. `/landing-report` points at it when the sibling table shows
|
||||
stale worktrees.
|
||||
@@ -12,6 +12,8 @@ Beyond the slash-command skills, gstack ships standalone CLIs for workflows that
|
||||
| `gstack-code-intelligence` | **Code-intelligence provider picker** — wraps GBrain, Sourcebot, and Graphify behind one interface: `options`/`status` to see what's available, `select` to pick one, `index`/`search` to use it, `suggest` to check whether the one-time indexing offer should fire here. The offer triggers on large repos (1,000+ tracked files; a decline is persisted). Non-local providers refuse to index *or search* until you record per-repo consent (`consent <repo> yes\|no` — the query text is repo-derived content), the per-repo trust policy's deny and read-only tiers veto write-class operations regardless of consent, and every off-machine send writes an egress receipt. Fully optional — with nothing selected, gstack falls back to grep. |
|
||||
| `gstack-verify-gate` | **Verification stop hook (opt-in)** — blocks a Claude Code turn from ending until the project's declared verify command passes (after 3 blocked re-entries it yields with a loud still-RED warning instead of looping forever). Declare it on one line in CLAUDE.md: `<!-- gstack:verify: bun test -->`. Hooks bypass the permission system, so a declared command never runs until you trust it once per repo (`gstack-verify-gate --trust`); editing the command invalidates trust until re-granted, and every grant is audit-logged. `./setup` never registers it for you — opt in with `gstack-settings-hook add-event --event Stop --command ~/.claude/skills/gstack/bin/gstack-verify-gate --source verify-gate`, remove with `gstack-settings-hook remove-source --source verify-gate`. |
|
||||
| `gstack-memorable` | **Memorable recall bridge (opt-in, third party, Claude Code only)** — connects Claude Code to the external [Memorable](https://memorable.sh) CLI *through gstack* instead of the vendor's own installer, so the hook gets gstack's guarantees: an explicit consent key (`memorable_recall`, off by default, listed by `gstack-egress grants`), a fail-closed egress receipt for every prompt handed over (`gstack-egress list --sink memorable-recall`), a HIGH-tier secret pre-scan, a trust envelope and 8 KiB cap on whatever comes back, an allowlisted environment and process-group containment for the vendor process, and clean removal. `enable` registers the hook at the stable install with a 5 s timeout and never runs the vendor's own consent command; `disable` revokes the gate first and removes the entry by identity even after Claude Code strips the tag; `status` is read-only. gstack never installs Memorable, and what its binary sends is the vendor's claim, not gstack's. Not available on Windows yet. [Full guide](memorable-workflow-memory.md). |
|
||||
| `gstack-decision-trail` | **Decision trail** — append one row (ts, phase, decision, why, evidence, result) to a run's TSV trail so a reviewer reads what was chosen and why without the transcript. Header once, append-only, cells flattened to one line, and a cell starting with `=`, `+`, `-` or `@` quoted so a spreadsheet cannot run it. `start <file> <run-id>` opens a run's stretch of a shared trail (idempotent; `start` is reserved as a phase name), and `--run <id>` refuses an append until that start row is the newest. Ported from pstack's `log.sh` (MIT, NOTICE.md). [Rule](unattended.md#the-decision-trail). |
|
||||
| `gstack-worktree-audit` | **Worktree audit** — read-only table of every git worktree beside the main one, largest first: size, age, merged into the base or not, `clean`/`clean,ignored:<n>`/`wip:<n>`/`scratch:<n>`, `pushed`/`ahead<n>`/`no-remote`/`detached`, the PR on its branch through `gh` (fork PRs ignored), the newest Claude Code chat in it, and a bucket (`prunable` for an unlocked registered worktree whose directory is gone, `review` for a locked or `broken` one, `hold-wip`, `hold-open-pr`, `verify-recent-chat`, `safe` only for a clean tree that is merged into the base or whose PR is closed or merged with every commit pushed, else `review`). `-b origin/<branch>` sets the base, `--no-fetch` skips the best-effort fetch of the base ref (its one write). Never deletes. Ported from pstack's `worktree-audit.sh` (MIT, NOTICE.md). [Guide](CONTRIBUTOR_REFERENCE.md#auditing-conductor-worktrees). |
|
||||
| `gstack-wtree` | **Working-tree fingerprint** — prints a content hash of what's actually on disk (temp index seeded from the stat cache, ~40x cheaper than a full re-hash; untracked source counts, gitignored scratch doesn't). Identical content fingerprints identically through commits, rebases, amends, and squashes — it's what binds reviews and test evidence to content instead of commit SHAs. |
|
||||
| `gstack-review-log` | **Review-pass receipts** — `--start <skill>` captures the working-tree fingerprint before a diff review; `'<JSON>' --finish <token>` consumes that single-use, repository/branch/skill-scoped receipt. Binding requires matching start/end content and reviewer-reported `completed:true` and `converged:true`; it is not independent proof that a model read the source. `--check-shared-libs <token>` reads a current finding as JSON on stdin and checks prior Skip decisions against the actual branch, capture and eligible raw source blobs. It returns `reusable:false` when proof is missing or unsafe, including older records without logger-versioned coverage. Final review logging computes shared-code fingerprints and coverage rather than trusting supplied proof. |
|
||||
| `gstack-review-read` | **Review freshness** — emits review records with computed `review_freshness.status` and `reason`: CURRENT, STALE, or UNVERIFIED for diff reviews. `/ship` and `/land-and-deploy` use the same grade; a matching commit alone never certifies a diff review. [Dashboard rules](skills.md#review-readiness-dashboard). |
|
||||
|
||||
@@ -4876,3 +4876,33 @@ final summary. Rename or move yours, or switch modes (`./setup --prefix` /
|
||||
a generated gstack SKILL.md and then edited it, that file is moved to
|
||||
`~/.gstack/backups/skills/<timestamp>/<skill>/SKILL.md` before gstack's is
|
||||
linked in, never deleted.
|
||||
|
||||
## gstack-decision-trail and gstack-worktree-audit
|
||||
|
||||
<a id="decision-trail-start-required"></a>
|
||||
### `gstack-decision-trail: DECISION_TRAIL_START_REQUIRED: <file> has <n> rows and run <id> has no start row after the newest one`
|
||||
|
||||
You appended with `--run <id>` to a trail that other runs already wrote, and
|
||||
the newest `start` row in it is not this run's. A run (one agent
|
||||
conversation, with its later turns) that joins a trail opens with its own
|
||||
`start` row so a reviewer can tell whose rows are whose. Run
|
||||
`gstack-decision-trail start <file> <id>` once and append again; the start
|
||||
command is a no-op while this run's start row is still the newest, so a rerun
|
||||
of the same script is safe. Nothing was written. See
|
||||
[docs/unattended.md](unattended.md#the-decision-trail).
|
||||
|
||||
### `gstack-worktree-audit` prints `warn: no -b given; MERGED checks against origin/HEAD` or every row says `MERGED no`
|
||||
|
||||
Without `-b` the audit compares each worktree's head against the remote's
|
||||
default branch (then `origin/main`, then `origin/master`), which is not
|
||||
always the branch PRs merge into; pass `-b origin/<branch>`. A squash-merged
|
||||
branch is never an ancestor of the base, so `MERGED no` beside `PR #n/merged`
|
||||
is normal and the bucket is still `safe` while the tree is clean and every
|
||||
commit is pushed; an unpushed commit or an untracked file on that branch makes
|
||||
it `review`, because deleting the worktree would lose it. The audit only reads
|
||||
(its one write is the base fetch, which `--no-fetch` skips);
|
||||
a worktree it calls `safe` is still yours to remove, and a `prunable` row names
|
||||
a registered, unlocked worktree whose directory is already gone (`git worktree
|
||||
prune`); a locked one, or a `broken` row whose `.git` link is missing, reads
|
||||
`review` instead. `clean,ignored:<n>` means the tree is clean but holds
|
||||
gitignored entries (`.env`, build output) that go with the worktree.
|
||||
@@ -482,3 +482,41 @@ fix. `GSTACK_ALLOW_UNSUPPORTED_RUNTIME=1` admits anyway (`INSTALL_VERDICT:
|
||||
overridden`), recorded as a deviation. TypeScript entrypoints use
|
||||
`checkInstallAdmission` from `lib/install-verdict.ts`; the Bash side is
|
||||
`bin/gstack-install-verdict.sh`. See [capy-install.md](capy-install.md#the-launch-line).
|
||||
|
||||
## The decision trail
|
||||
|
||||
A long or unattended run keeps one tab-separated trail of its decisions at
|
||||
`<run dir>/decisions.tsv`, written through `gstack-decision-trail <file>
|
||||
<phase> <decision> <why> <evidence> <result>`, so the parent reads what the run
|
||||
chose and why without the transcript. The helper stamps the time, writes the
|
||||
header once, flattens each cell to one line and quotes a cell that starts
|
||||
with `=`, `+`, `-` or `@`, so evidence lifted from a PR title or a filename
|
||||
cannot run as a formula when a reviewer opens the file in a spreadsheet.
|
||||
Log the forks, not the keystrokes: an approach chosen or dropped, a unit done
|
||||
with its verification result, a revert and what triggered it, a blocker, one
|
||||
row per iteration of a loop. Evidence is a pointer that resolves (a commit, a
|
||||
PR, `file:line`, an artifact path), never prose. A run that joins a trail
|
||||
other runs wrote (a pickup after a crash, a replacement machine, a later turn
|
||||
of the same thread) first runs `gstack-decision-trail start <file> <run id>`,
|
||||
which appends a `start` row naming the earlier rows and the run; appends with
|
||||
`--run <id>` refuse (`DECISION_TRAIL_START_REQUIRED`) until that row is the
|
||||
newest start row, and `start` is a no-op when it already is, so a rerun of the
|
||||
same step writes nothing twice.
|
||||
|
||||
Before it hands back, the run audits the trail against what it did: every row
|
||||
of its own stretch (from its `start` row to the next run's) maps to a real
|
||||
decision or action, every evidence pointer resolves and shows what the row
|
||||
claims, and a fork or abandoned approach that shaped the result but is not
|
||||
logged gets a row. The audit corrects the log, never the story: no row is
|
||||
edited or removed, an invented or wrong row stays and a new row supersedes it
|
||||
with what actually happened. Then a reviewer on a different model family from
|
||||
the one that did the work (a Capy subagent on another family, or the outside
|
||||
voice) reads the trail and the transcript and flags what the owner should look
|
||||
at: decisions with weak or missing evidence, verification claimed without
|
||||
proof, choices that look risky in hindsight, gaps a skim would miss. The run's
|
||||
final report ends with an `Attention` section that opens with `reviewed by
|
||||
<model>` on its own line and lists each flag against its rows; `No flags` is a
|
||||
valid body, a missing model line is not. Self-review does not stand in for
|
||||
this. `decisions.jsonl` stays the typed record `gstack-artifact` validates; the
|
||||
trail is the plain one a person reads, and `column -s$'\t' -t decisions.tsv`
|
||||
renders it in a terminal.
|
||||
@@ -478,6 +478,8 @@ After rendering the table, suggest ONE of:
|
||||
3. **If everything looks clean:**
|
||||
"Queue is clean. Next /ship will claim a slot without conflict."
|
||||
|
||||
If the sibling table shows worktrees whose last commit is weeks old, offer `bin/gstack-worktree-audit` (read-only: size, merge state, tracked edits, remote, PR, last chat and a suggested bucket per worktree; it removes nothing).
|
||||
|
||||
---
|
||||
|
||||
## Plan Mode
|
||||
|
||||
@@ -156,6 +156,8 @@ After rendering the table, suggest ONE of:
|
||||
3. **If everything looks clean:**
|
||||
"Queue is clean. Next /ship will claim a slot without conflict."
|
||||
|
||||
If the sibling table shows worktrees whose last commit is weeks old, offer `bin/gstack-worktree-audit` (read-only: size, merge state, tracked edits, remote, PR, last chat and a suggested bucket per worktree; it removes nothing).
|
||||
|
||||
---
|
||||
|
||||
## Plan Mode
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Lauren Tan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.91.267",
|
||||
"version": "1.91.268",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* parent agent reads to drive /autoplan through `gstack-autoplan`. It is
|
||||
* generated from autoplan/DRIVER.md.tmpl by resolvers that read the code
|
||||
* (phase order, scope flags, run files, RESULT FORMAT, result codes, the
|
||||
* coordinator contract), stays under 160 lines with no harness vocabulary,
|
||||
* coordinator contract), stays at or under 162 lines (160 until the decision
|
||||
* trail paragraph joined the coordinator contract section) with no harness vocabulary,
|
||||
* is in the generator's normal, out-dir and dry-run inventories, and is
|
||||
* found from an installed root through its recorded `.source-path`.
|
||||
*/
|
||||
@@ -47,8 +48,8 @@ describe('autoplan/DRIVER.md content comes from the code', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('at most 160 lines and no harness vocabulary', () => {
|
||||
expect(doc.split('\n').length).toBeLessThanOrEqual(160);
|
||||
test('at most 162 lines and no harness vocabulary', () => {
|
||||
expect(doc.split('\n').length).toBeLessThanOrEqual(162);
|
||||
for (const term of ['PreToolUse', 'PostToolUse', 'SessionStart', 'AskUserQuestion', 'Skill tool']) expect(doc).not.toContain(term);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Subprocess tests for bin/gstack-decision-trail: the TSV decision trail
|
||||
* (header once, append-only, one-line cells, spreadsheet formula guard), the
|
||||
* start-row rule and its idempotent rerun, and the --run admission check.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BIN = path.join(ROOT, "bin", "gstack-decision-trail");
|
||||
const TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
|
||||
|
||||
let tmp: string;
|
||||
let file: string;
|
||||
beforeEach(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-decision-trail-"));
|
||||
file = path.join(tmp, "run", "decisions.tsv");
|
||||
});
|
||||
afterEach(() => fs.rmSync(tmp, { recursive: true, force: true }));
|
||||
|
||||
function run(...args: string[]) {
|
||||
const r = spawnSync(process.execPath, [BIN, ...args], { cwd: ROOT, encoding: "utf8", timeout: 20_000 });
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
|
||||
}
|
||||
const rows = () => fs.readFileSync(file, "utf8").split("\n").filter(Boolean).map(l => l.split("\t"));
|
||||
|
||||
describe("gstack-decision-trail append", () => {
|
||||
test("first append creates the parent directory, the header and one stamped row", () => {
|
||||
const r = run(file, "frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
|
||||
const [header, row] = rows();
|
||||
expect(header).toEqual(["ts", "phase", "decision", "why", "evidence", "result"]);
|
||||
expect(row!.slice(1)).toEqual(["frame", "counted the work first", "size before a long run", "commit 3a9f1c2", "5 things to sort out"]);
|
||||
expect(row![0]).toMatch(TS_RE);
|
||||
});
|
||||
|
||||
test("later appends add rows and never a second header; an empty existing file gets the header", () => {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, "");
|
||||
run(file, "a", "b", "c", "d", "e");
|
||||
run(file, "f", "g", "h", "i", "j");
|
||||
const all = rows();
|
||||
expect(all).toHaveLength(3);
|
||||
expect(all.filter(r => r[0] === "ts")).toHaveLength(1);
|
||||
expect(all[2]!.slice(1)).toEqual(["f", "g", "h", "i", "j"]);
|
||||
});
|
||||
|
||||
test("cells lose tabs and line breaks, and a leading =, +, - or @ gets a quote so a spreadsheet cannot run it", () => {
|
||||
run(file, "probe", "=HYPERLINK(\"http://evil\")", "two\nlines\there\r", "-", "@SUM(A1)");
|
||||
const row = rows()[1]!;
|
||||
expect(row).toHaveLength(6);
|
||||
expect(row.slice(1)).toEqual(["probe", "'=HYPERLINK(\"http://evil\")", "two lines here ", "'-", "'@SUM(A1)"]);
|
||||
run(file, "+1", "plain", "plain", "plain", "plain");
|
||||
expect(rows()[2]![1]).toBe("'+1");
|
||||
});
|
||||
|
||||
test("a cell equal to -h or --help is data, a trail without a final newline or with CRLF rows is repaired on append, and a repeated header is never a row", () => {
|
||||
const r = run(file, "phase", "--help", "why", "evidence", "-h");
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stdout).toBe(`appended row 1 to ${file}\n`);
|
||||
expect(rows()[1]!.slice(1)).toEqual(["phase", "'--help", "why", "evidence", "'-h"]);
|
||||
fs.appendFileSync(file, "x\ty\tz");
|
||||
run(file, "p2", "d2", "w", "e", "r");
|
||||
const all = rows();
|
||||
expect(all[2]).toEqual(["x", "y", "z"]);
|
||||
expect(all[3]!.slice(1)).toEqual(["p2", "d2", "w", "e", "r"]);
|
||||
const crlf = path.join(tmp, "crlf.tsv");
|
||||
fs.writeFileSync(crlf, "ts\tphase\tdecision\twhy\tevidence\tresult\r\n2026-01-01T00:00:00Z\ta\tb\tc\td\te\r\nts\tphase\tdecision\twhy\tevidence\tresult\r\n");
|
||||
const start = run("start", crlf, "run-1");
|
||||
expect(start.stdout).toBe(`appended start row 2 for run run-1 to ${crlf}\n`);
|
||||
expect(fs.readFileSync(crlf, "utf8").split("\n").filter(Boolean).pop()!.split("\t")[3]).toBe("1 earlier row (2026-01-01T00:00:00Z..2026-01-01T00:00:00Z) written by other runs");
|
||||
});
|
||||
|
||||
test("a blank file gets a header, and a BOM before the header does not make it a row", () => {
|
||||
const blank = path.join(tmp, "blank.tsv");
|
||||
fs.writeFileSync(blank, "\n");
|
||||
expect(run(blank, "p", "d", "w", "e", "r").status).toBe(0);
|
||||
expect(run(blank, "p2", "d", "w", "e", "r").status).toBe(0);
|
||||
const lines = fs.readFileSync(blank, "utf8").split("\n");
|
||||
expect(lines[1]).toBe("ts\tphase\tdecision\twhy\tevidence\tresult");
|
||||
expect(lines[2]!.split("\t").slice(1)).toEqual(["p", "d", "w", "e", "r"]);
|
||||
expect(lines.filter(l => l === "ts\tphase\tdecision\twhy\tevidence\tresult")).toHaveLength(1);
|
||||
const bom = path.join(tmp, "bom.tsv");
|
||||
fs.writeFileSync(bom, "\uFEFFts\tphase\tdecision\twhy\tevidence\tresult\n");
|
||||
expect(run("--run", "r1", bom, "p", "d", "w", "e", "r").status).toBe(0);
|
||||
expect(fs.readFileSync(bom, "utf8").split("\n").filter(Boolean)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("a run id with whitespace or quotes is a usage error on start and on --run", () => {
|
||||
expect(run("start", file, "a\tb").status).toBe(2);
|
||||
expect(run("start", file, "a b").status).toBe(2);
|
||||
expect(run("--run", "x'y", file, "a", "b", "c", "d", "e").status).toBe(2);
|
||||
expect(run("start", file, "jam_01ABC.turn-2:x/y@z").status).toBe(0);
|
||||
expect(fs.readFileSync(file, "utf8").split("\n")).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("wrong arity, an unknown option and a bare call are usage errors; --help exits 0", () => {
|
||||
expect(run(file, "only", "four", "cells", "here").status).toBe(2);
|
||||
expect(run("--bogus", file, "a", "b", "c", "d", "e").status).toBe(2);
|
||||
expect(run().status).toBe(2);
|
||||
expect(run(file, "start", "a", "b", "c", "d").status).toBe(2);
|
||||
const help = run("--help");
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain("gstack-decision-trail start <file> <run-id>");
|
||||
expect(fs.existsSync(file)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gstack-decision-trail start", () => {
|
||||
test("a start row names the earlier rows and the run; rerunning it for the same run is a no-op", () => {
|
||||
run(file, "a", "b", "c", "d", "e");
|
||||
run(file, "f", "g", "h", "i", "j");
|
||||
const first = run("start", file, "jam_A");
|
||||
expect(first.status).toBe(0);
|
||||
expect(first.stdout).toBe(`appended start row 3 for run jam_A to ${file}\n`);
|
||||
const start = rows()[3]!;
|
||||
expect(start[1]).toBe("start");
|
||||
expect(start[2]).toBe("run jam_A joins this trail");
|
||||
expect(start[3]).toMatch(/^2 earlier rows \(\d{4}-.*\.\.\d{4}-.*\) written by other runs$/);
|
||||
expect(start[4]).toBe("run:jam_A");
|
||||
expect(start[5]).toBe("open");
|
||||
|
||||
const again = run("start", file, "jam_A");
|
||||
expect(again.status).toBe(0);
|
||||
expect(again.stdout).toBe(`start row for run jam_A already present at ${file}:4\n`);
|
||||
expect(rows()).toHaveLength(4);
|
||||
});
|
||||
|
||||
test("a start row on an empty trail opens it, and another run's start row follows a prior run's", () => {
|
||||
expect(run("start", file, "run-1").stdout).toBe(`appended start row 1 for run run-1 to ${file}\n`);
|
||||
expect(rows()[1]!.slice(1)).toEqual(["start", "run run-1 opens this trail", "no earlier rows", "run:run-1", "open"]);
|
||||
run("--run", "run-1", file, "work", "did a thing", "because", "commit 1", "ok");
|
||||
expect(run("start", file, "run-2").status).toBe(0);
|
||||
expect(rows().map(r => r[1])).toEqual(["phase", "start", "work", "start"]);
|
||||
// run-1's row is older than run-2's start, so run-1 is a new run again and needs a new start row.
|
||||
expect(run("start", file, "run-1").stdout).toContain("appended start row 4");
|
||||
});
|
||||
|
||||
test("--run refuses an append until the run's start row is the newest one, and admits a fresh trail", () => {
|
||||
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
|
||||
run("start", file, "other");
|
||||
const refused = run("--run", "r9", file, "a", "b", "c", "d", "e");
|
||||
expect(refused.status).toBe(3);
|
||||
expect(refused.stderr).toContain("DECISION_TRAIL_START_REQUIRED");
|
||||
expect(refused.stderr).toContain(`gstack-decision-trail start ${file} r9`);
|
||||
expect(rows()).toHaveLength(3);
|
||||
run("start", file, "r9");
|
||||
expect(run("--run", "r9", file, "a", "b", "c", "d", "e").status).toBe(0);
|
||||
expect(rows()).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("start with the wrong arity is a usage error", () => {
|
||||
expect(run("start", file).status).toBe(2);
|
||||
expect(run("start", file, "r", "extra").status).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -148,6 +148,8 @@ const SCANNER_EXEMPT: Record<string, string> = {
|
||||
'fetches the user\'s own repo\'s base branch for version-claim freshness — a user-repo dev-workflow op, not gstack-state egress',
|
||||
'bin/gstack-version-bump':
|
||||
'git fetch appears only in an error-message string',
|
||||
'bin/gstack-worktree-audit':
|
||||
'read-only audit: best-effort fetch of the user\'s own repo\'s base branch so the MERGED column is current (skipped with --no-fetch) — a user-repo dev-workflow op like gstack-next-version\'s, not gstack-state egress',
|
||||
'bin/gstack-state-matrix':
|
||||
'git fetch appears only in an error-message string (the fix the user runs when origin/<base> has no merge base)',
|
||||
'bin/gstack-redact-prepush':
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* Fixture test for bin/gstack-worktree-audit: a bare origin, a main clone and
|
||||
* worktrees in every bucket the script knows, with a fake gh on PATH and a
|
||||
* Claude Code transcript directory under CLAUDE_CONFIG_DIR. The audit reads
|
||||
* only: the fixture is byte-compared before and after.
|
||||
*/
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, "..");
|
||||
const BIN = path.join(ROOT, "bin", "gstack-worktree-audit");
|
||||
|
||||
let tmp: string;
|
||||
let repo: string;
|
||||
let origin: string;
|
||||
let fakeBin: string;
|
||||
let claudeDir: string;
|
||||
const wts: Record<string, string> = {};
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
const r = spawnSync("git", args, { cwd, encoding: "utf8", timeout: 30_000, env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" } });
|
||||
if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
function commit(cwd: string, file: string, text: string, msg: string): void {
|
||||
fs.writeFileSync(path.join(cwd, file), text);
|
||||
git(cwd, "add", file);
|
||||
git(cwd, "commit", "-q", "-m", msg);
|
||||
}
|
||||
function audit(...args: string[]) {
|
||||
const r = spawnSync("bash", [BIN, ...args], {
|
||||
cwd: repo, encoding: "utf8", timeout: 60_000,
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp },
|
||||
});
|
||||
const lines = r.stdout.split("\n").filter(Boolean);
|
||||
const header = lines[1]?.split("\t") ?? [];
|
||||
const rows = lines.slice(2).map(l => Object.fromEntries(l.split("\t").map((v, i) => [header[i]!, v])) as Record<string, string>);
|
||||
return { status: r.status, stderr: r.stderr, base: lines[0], header, rows, byPath: Object.fromEntries(rows.map(r => [r.WORKTREE, r])) };
|
||||
}
|
||||
function snapshot(dir: string): string {
|
||||
const r = spawnSync("bash", ["-c", "find . -path ./.git -prune -o -type f -print0 | sort -z | xargs -0 sha256sum; git -C . status --porcelain; git -C . for-each-ref; git -C . worktree list --porcelain"], { cwd: dir, encoding: "utf8", timeout: 30_000 });
|
||||
return r.stdout;
|
||||
}
|
||||
// The fake gh records its argv and, when jq is installed, runs the audit's own --jq expression over
|
||||
// canned JSON (so the fork-PR filter is exercised); without jq it prints the TSV that expression yields.
|
||||
function fakeGh(prs: Array<{ number: number; state: string; headRefName: string; isCrossRepository: boolean }>): void {
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.json"), JSON.stringify(prs));
|
||||
fs.writeFileSync(path.join(fakeBin, "prs.tsv"), prs.filter(p => !p.isCrossRepository).map(p => `${p.headRefName}\t${p.number}\t${p.state.toLowerCase()}`).join("\n") + "\n");
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), `#!/usr/bin/env bash
|
||||
printf '%s\\n' "$@" > "${fakeBin}/gh-args"
|
||||
expr=""; while [ $# -gt 0 ]; do [ "$1" = --jq ] && expr="$2"; shift; done
|
||||
if command -v jq >/dev/null 2>&1 && [ -n "$expr" ]; then jq -r "$expr" "${fakeBin}/prs.json"; else cat "${fakeBin}/prs.tsv"; fi
|
||||
`, { mode: 0o755 });
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-wt-audit-"));
|
||||
origin = path.join(tmp, "origin.git");
|
||||
repo = path.join(tmp, "repo");
|
||||
fs.mkdirSync(origin);
|
||||
git(origin, "init", "-q", "--bare", "--initial-branch=main");
|
||||
git(tmp, "clone", "-q", origin, repo);
|
||||
git(repo, "checkout", "-q", "-b", "main");
|
||||
commit(repo, "README.md", "# fixture\n", "initial");
|
||||
git(repo, "push", "-q", "-u", "origin", "main");
|
||||
|
||||
// merged: a branch whose commit was fast-forwarded into main and pushed; clean tree, branch never pushed.
|
||||
wts.merged = path.join(tmp, "wt-merged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/merged", wts.merged);
|
||||
commit(wts.merged, "merged.txt", "done\n", "merged work");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/merged");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
|
||||
// wip: pushed branch with a tracked edit on top; its PR is open.
|
||||
wts.wip = path.join(tmp, "wt-wip");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/wip", wts.wip);
|
||||
commit(wts.wip, "wip.txt", "v1\n", "wip work");
|
||||
git(wts.wip, "push", "-q", "-u", "origin", "feat/wip");
|
||||
fs.writeFileSync(path.join(wts.wip, "wip.txt"), "v2 edited\n");
|
||||
|
||||
// scratch: an unpushed branch, two untracked files, a chat from today → verify-recent-chat.
|
||||
wts.scratch = path.join(tmp, "wt-scratch");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/scratch", wts.scratch);
|
||||
commit(wts.scratch, "scratch.txt", "s\n", "scratch work");
|
||||
fs.writeFileSync(path.join(wts.scratch, "notes.md"), "untracked\n");
|
||||
fs.writeFileSync(path.join(wts.scratch, "tmp.log"), "untracked\n");
|
||||
claudeDir = path.join(tmp, "claude");
|
||||
const chat = path.join(claudeDir, "projects", wts.scratch.replace(/[^A-Za-z0-9]/g, "-"));
|
||||
fs.mkdirSync(chat, { recursive: true });
|
||||
fs.writeFileSync(path.join(chat, "session.jsonl"), "{}\n");
|
||||
|
||||
// stale: an unpushed branch nobody touched, no chat, no PR → review.
|
||||
wts.stale = path.join(tmp, "wt-stale");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/stale", wts.stale);
|
||||
commit(wts.stale, "stale.txt", "s\n", "stale work");
|
||||
|
||||
// closed: pushed branch whose PR was closed without merging → safe.
|
||||
wts.closed = path.join(tmp, "wt-closed");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/closed", wts.closed);
|
||||
commit(wts.closed, "closed.txt", "c\n", "closed work");
|
||||
git(wts.closed, "push", "-q", "-u", "origin", "feat/closed");
|
||||
|
||||
fakeBin = path.join(tmp, "fakebin");
|
||||
fs.mkdirSync(fakeBin);
|
||||
|
||||
// spaced: a worktree whose path has a space, merged into main, clean → safe.
|
||||
wts.spaced = path.join(tmp, "wt with space");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/spaced", wts.spaced);
|
||||
commit(wts.spaced, "spaced.txt", "s\n", "spaced work");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/spaced");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
|
||||
// fresh: head is in main but an untracked file exists only here → review, not safe.
|
||||
wts.fresh = path.join(tmp, "wt-fresh");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/fresh", wts.fresh);
|
||||
fs.writeFileSync(path.join(wts.fresh, "newfeature.ts"), "export const x = 1;\n");
|
||||
|
||||
// gone: registered, directory deleted → prunable.
|
||||
wts.gone = path.join(tmp, "wt-gone");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/gone", wts.gone);
|
||||
fs.rmSync(wts.gone, { recursive: true, force: true });
|
||||
|
||||
// locked: registered and locked, directory gone → review (prune would not touch it).
|
||||
wts.locked = path.join(tmp, "wt-locked");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/locked", wts.locked);
|
||||
git(repo, "worktree", "lock", wts.locked);
|
||||
fs.rmSync(wts.locked, { recursive: true, force: true });
|
||||
|
||||
// broken: a gitignored worktree inside the clone whose .git file is gone, with a
|
||||
// precious file only there; git -C would describe the clone itself → review.
|
||||
fs.appendFileSync(path.join(repo, ".git", "info", "exclude"), ".wt\n");
|
||||
wts.broken = path.join(repo, ".wt", "broken");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/broken", wts.broken);
|
||||
fs.writeFileSync(path.join(wts.broken, "precious.txt"), "only here\n");
|
||||
fs.rmSync(path.join(wts.broken, ".git"));
|
||||
|
||||
// tagged: merged and clean, with a tag of the same name as its branch and an open PR → hold-open-pr.
|
||||
wts.tagged = path.join(tmp, "wt-tagged");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/tagged", wts.tagged);
|
||||
git(repo, "tag", "feat/tagged");
|
||||
|
||||
// ignored: merged and clean, with a gitignored .env → safe, DIRTY names the ignored entry.
|
||||
wts.ignored = path.join(tmp, "wt-ignored");
|
||||
git(repo, "worktree", "add", "-q", "-b", "feat/ignored", wts.ignored);
|
||||
fs.writeFileSync(path.join(wts.ignored, ".gitignore"), ".env\n");
|
||||
git(wts.ignored, "add", ".gitignore");
|
||||
git(wts.ignored, "commit", "-q", "-m", "ignore .env");
|
||||
git(repo, "merge", "-q", "--ff-only", "feat/ignored");
|
||||
git(repo, "push", "-q", "origin", "main");
|
||||
fs.writeFileSync(path.join(wts.ignored, ".env"), "SECRET=1\n");
|
||||
});
|
||||
// Each test starts from the same gh (one open, one closed, one fork PR on feat/stale that must be
|
||||
// ignored) and the same closed worktree, whatever an earlier test changed.
|
||||
beforeEach(() => {
|
||||
fakeGh([
|
||||
{ number: 41, state: "OPEN", headRefName: "feat/wip", isCrossRepository: false },
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 99, state: "OPEN", headRefName: "feat/stale", isCrossRepository: true },
|
||||
{ number: 43, state: "OPEN", headRefName: "feat/tagged", isCrossRepository: false },
|
||||
]);
|
||||
git(wts.closed, "reset", "-q", "--hard", "origin/feat/closed");
|
||||
});
|
||||
afterAll(() => {
|
||||
if (repo) spawnSync("git", ["worktree", "prune"], { cwd: repo, timeout: 30_000 });
|
||||
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("gstack-worktree-audit", () => {
|
||||
test("classifies every worktree, reads the PR column through gh, the chat through CLAUDE_CONFIG_DIR, and changes nothing", () => {
|
||||
const live = Object.values(wts).filter(w => fs.existsSync(w));
|
||||
const before = live.map(snapshot).join("\n") + snapshot(repo);
|
||||
const r = audit();
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("warn: no -b given; MERGED checks against origin/main");
|
||||
expect(r.base).toBe("# base: origin/main");
|
||||
expect(r.header).toEqual(["SIZE", "AGE", "MERGED", "DIRTY", "REMOTE", "PR", "LAST_CHAT", "BUCKET", "WORKTREE"]);
|
||||
expect(r.rows).toHaveLength(12);
|
||||
expect(r.byPath[repo]).toBeUndefined();
|
||||
expect(r.byPath[wts.locked]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "review" });
|
||||
expect(r.byPath[wts.broken]).toMatchObject({ DIRTY: "broken", BUCKET: "review" });
|
||||
expect(r.byPath[wts.tagged]).toMatchObject({ MERGED: "YES", DIRTY: "clean", PR: "#43/open", BUCKET: "hold-open-pr" });
|
||||
expect(r.byPath[wts.ignored]).toMatchObject({ MERGED: "YES", DIRTY: "clean,ignored:1", BUCKET: "safe" });
|
||||
const ghArgs = fs.readFileSync(path.join(fakeBin, "gh-args"), "utf8").split("\n");
|
||||
expect(ghArgs).toContain("number,state,headRefName,isCrossRepository");
|
||||
expect(ghArgs.find(a => a.startsWith(".[]"))).toContain("select(.isCrossRepository | not)");
|
||||
expect(r.byPath[wts.spaced]).toMatchObject({ MERGED: "YES", DIRTY: "clean", BUCKET: "safe" });
|
||||
expect(r.byPath[wts.fresh]).toMatchObject({ MERGED: "YES", DIRTY: "scratch:1", REMOTE: "no-remote", BUCKET: "review" });
|
||||
expect(r.byPath[wts.gone]).toMatchObject({ SIZE: "0", DIRTY: "missing", BUCKET: "prunable" });
|
||||
expect(r.rows.slice(-2).map(row => row.WORKTREE).sort()).toEqual([wts.gone, wts.locked].sort());
|
||||
|
||||
const merged = r.byPath[wts.merged]!;
|
||||
expect(merged).toMatchObject({ MERGED: "YES", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "safe", AGE: "0d" });
|
||||
expect(merged.SIZE).toMatch(/^\d+(\.\d+)?[KMG]?$/);
|
||||
|
||||
expect(r.byPath[wts.wip]).toMatchObject({ MERGED: "no", DIRTY: "wip:1", REMOTE: "pushed", PR: "#41/open", BUCKET: "hold-wip" });
|
||||
const scratch = r.byPath[wts.scratch]!;
|
||||
expect(scratch).toMatchObject({ MERGED: "no", DIRTY: "scratch:2", REMOTE: "no-remote", PR: "-", BUCKET: "verify-recent-chat" });
|
||||
expect(scratch.LAST_CHAT).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(r.byPath[wts.stale]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "no-remote", PR: "-", LAST_CHAT: "-", BUCKET: "review" });
|
||||
expect(r.byPath[wts.closed]).toMatchObject({ MERGED: "no", DIRTY: "clean", REMOTE: "pushed", PR: "#40/closed", BUCKET: "safe" });
|
||||
|
||||
const after = live.map(snapshot).join("\n") + snapshot(repo);
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
test("status.showUntrackedFiles=no in the user's config does not hide untracked files", () => {
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], {
|
||||
cwd: repo, encoding: "utf8", timeout: 60_000,
|
||||
env: { ...process.env, PATH: `${fakeBin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "status.showUntrackedFiles", GIT_CONFIG_VALUE_0: "no" },
|
||||
});
|
||||
expect(r.status).toBe(0);
|
||||
const row = r.stdout.split("\n").find(l => l.endsWith("\t" + wts.fresh))!;
|
||||
expect(row.split("\t").slice(3, 4)).toEqual(["scratch:1"]);
|
||||
expect(row.split("\t")[7]).toBe("review");
|
||||
});
|
||||
|
||||
test("an unpushed commit on a branch with a closed PR is review, an open PR on the same name wins and holds it, and -b picks the base", () => {
|
||||
git(wts.closed, "commit", "-q", "--allow-empty", "-m", "one more");
|
||||
const r1 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r1.stderr).not.toContain("warn: no -b given");
|
||||
expect(r1.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#40/closed", BUCKET: "review" });
|
||||
fakeGh([
|
||||
{ number: 40, state: "CLOSED", headRefName: "feat/closed", isCrossRepository: false },
|
||||
{ number: 42, state: "OPEN", headRefName: "feat/closed", isCrossRepository: false },
|
||||
]);
|
||||
const r2 = audit("-b", "origin/main", "--no-fetch", repo);
|
||||
expect(r2.byPath[wts.closed]).toMatchObject({ REMOTE: "ahead1", PR: "#42/open", BUCKET: "hold-open-pr" });
|
||||
});
|
||||
|
||||
test("without gh the PR column is - with a warning, and a missing base ref or repo is an error", () => {
|
||||
// A PATH with every tool the audit uses except gh. git goes in as the real binary from its
|
||||
// exec path, since a distribution's /usr/bin/git may be a wrapper script that looks git up on PATH.
|
||||
const noGh = path.join(tmp, "nogh");
|
||||
fs.mkdirSync(noGh);
|
||||
const onPath = (tool: string) => (process.env.PATH ?? "").split(path.delimiter).map(d => path.join(d, tool)).find(p => fs.existsSync(p));
|
||||
const execPath = spawnSync("git", ["--exec-path"], { encoding: "utf8", timeout: 30_000 }).stdout.trim();
|
||||
const realGit = [path.join(execPath, "git"), onPath("git")].find(p => p && fs.existsSync(p))!;
|
||||
fs.symlinkSync(fs.realpathSync(realGit), path.join(noGh, "git"));
|
||||
for (const tool of ["bash", "sh", "env", "du", "awk", "sed", "sort", "head", "find", "xargs", "stat", "date", "mktemp", "grep", "rm", "cat", "dirname", "basename", "uname", "tr", "cut", "wc"]) {
|
||||
const found = onPath(tool);
|
||||
if (found) fs.symlinkSync(fs.realpathSync(found), path.join(noGh, tool));
|
||||
}
|
||||
const r = spawnSync("bash", [BIN, "--no-fetch"], { cwd: repo, encoding: "utf8", timeout: 60_000, env: { ...process.env, PATH: noGh, CLAUDE_CONFIG_DIR: claudeDir, HOME: tmp } });
|
||||
expect(r.status).toBe(0);
|
||||
expect(r.stderr).toContain("PR column is -");
|
||||
const rows = r.stdout.split("\n").filter(Boolean).slice(2);
|
||||
expect(rows).toHaveLength(12);
|
||||
expect(rows.every(l => l.split("\t")[5] === "-")).toBe(true);
|
||||
fs.writeFileSync(path.join(fakeBin, "gh"), "#!/usr/bin/env bash\necho 'gh: not logged in' >&2; exit 4\n", { mode: 0o755 });
|
||||
const failing = audit("--no-fetch");
|
||||
expect(failing.status).toBe(0);
|
||||
expect(failing.stderr).toContain("gh could not list this repository's PRs; PR column is -");
|
||||
expect(failing.rows).toHaveLength(12);
|
||||
expect(failing.rows.every(row => row.PR === "-")).toBe(true);
|
||||
expect(audit("-b", "origin/nope", "--no-fetch").status).toBe(1);
|
||||
expect(audit("--no-fetch", path.join(tmp, "not-a-repo")).status).toBe(1);
|
||||
expect(audit("--bogus").status).toBe(2);
|
||||
expect(audit("--help").status).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user