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:
garrytan committed 2026-10-27 06:39:33 +00:00
commit 624f0eedec
20 files changed
+973 -6

No files matched your search

+15
View File
@@ -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).
+17
View File
@@ -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 -1
View File
@@ -1 +1 @@
1.91.267.0
1.91.268.0
+1 -1
View File
@@ -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,
+2
View File
@@ -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).
+2
View File
@@ -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).
+172
View File
@@ -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`);
+208
View File
@@ -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
+30
View File
@@ -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.
+2
View File
@@ -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). |
+30
View File
@@ -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.
+38
View File
@@ -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.
+2
View File
@@ -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
+2
View File
@@ -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
+21
View File
@@ -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
View File
@@ -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",
+4 -3
View File
@@ -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);
});
+160
View File
@@ -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);
});
});
+2
View File
@@ -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':
+263
View File
@@ -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);
});
});