From d6639266a13a23f7b2499c95b2143797040a26b5 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Sat, 3 Oct 2026 15:29:42 +0300 Subject: [PATCH 01/12] fix(pstack): worktree-audit never buckets unpushed or closed-unmerged work as safe Three problems in worktree-audit.sh (cursor/plugins#470): - a PR in any non-open state satisfied `$pr != "-"`, so a CLOSED (unmerged) PR made the worktree `safe`; - the computed `remote` column (ahead/no-remote/detached) was never read by the bucket logic, so unpushed commits could be bucketed `safe`; - the header called the script read-only although it runs `git fetch`. Only ancestry of origin/main or a MERGED PR now counts as proof the work exists elsewhere. Worktrees whose HEAD is ahead of / absent from the remote or detached and not on origin/main go to a new `hold-unpushed` bucket, ranked right after hold-wip and hold-open-pr. A CLOSED PR falls through to `review`. The header now states the one write the script performs, and the playbook documents the new bucket in step 1 (steps 4-5 are left untouched to avoid conflicting with #459). Verified on a synthetic repo with a stubbed gh: ahead1+CLOSED and detached off-main -> hold-unpushed; pushed+MERGED, pushed+CLOSED-on-main and detached on origin/main -> safe; pushed+CLOSED with commits not on main -> review. Fixes cursor/plugins#470 (cherry picked from commit e8e1ad16e7e95f469c62729acee461172348f977) --- .../poteto-mode/playbooks/worktree-cleanup.md | 2 +- .../poteto-mode/scripts/worktree-audit.sh | 21 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 45e1af693..2bcb7670e 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,6 +1,6 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. 4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 47e08919d..31898619f 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. +# Worktree prune audit. Classifies every git worktree by size, merge state, +# uncommitted work, remote/PR state, and the most recent chat that operated in +# it. Emits a table sorted by size with a suggested bucket. Never deletes +# anything; deletion stays a human-gated step in the playbook. The only write +# is the best-effort fetch of the base ref for the merge check. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -119,10 +120,18 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) + # Commits that exist only in this worktree (ahead of the remote, no remote + # branch, or detached) are gone with it. Only ancestry of the base or a + # MERGED PR proves the work lives elsewhere; a CLOSED PR proves nothing. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + 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 [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe else bucket=review; fi ;; esac ;; esac From e55486b6d01c6414b69d04ddb0b627993c0713a2 Mon Sep 17 00:00:00 2001 From: Helio Pinheiro Date: Mon, 12 Oct 2026 14:51:37 -0300 Subject: [PATCH 02/12] test(pstack): cover unpushed worktree audit decisions Deletion advice must distinguish preserved work from local-only commits. Exercise the audit CLI in an isolated repository with closed PRs, squash merges, a behind branch, and a detached head. (cherry picked from commit 7ec0a12084470031d048e43f6a9fda20fb998562) --- .../skills/poteto-mode/scripts/package.json | 2 +- .../scripts/worktree-audit.fixture.ts | 83 +++++++++++++++++ .../scripts/worktree-audit.test.ts | 91 +++++++++++++++++++ 3 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts create mode 100644 pstack/skills/poteto-mode/scripts/worktree-audit.test.ts diff --git a/pstack/skills/poteto-mode/scripts/package.json b/pstack/skills/poteto-mode/scripts/package.json index c8839d74d..5b1f38161 100644 --- a/pstack/skills/poteto-mode/scripts/package.json +++ b/pstack/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test watch-pr check-plan.test.ts", + "test": "bun test watch-pr check-plan.test.ts worktree-audit", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts new file mode 100644 index 000000000..19b327d07 --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.fixture.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +type PullRequest = { + number: number; + state: string; + headRefName: string; + headRefOid: string; +}; + +type AuditRow = { + merged: string; + dirty: string; + remote: string; + pr: string; + lastChat: string; + bucket: string; +}; + +export function createAuditFixture() { + const root = mkdtempSync(join(tmpdir(), "worktree-audit-test-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const origin = join(root, "origin.git"); + const repo = join(root, "repo"); + const prs = join(root, "prs.json"); + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(root, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + AUDIT_FIXTURE_PRS: prs, + }; + const cleanup = () => rmSync(root, { recursive: true, force: true }); + const run = (directory: string, command: string, args: string[]) => { + const result = spawnSync(command, args, { cwd: directory, env, encoding: "utf8" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed in ${directory}: ${result.error ?? result.stderr}`); + return result.stdout.trim(); + }; + const git = (directory: string, ...args: string[]) => run(directory, "git", args); + const setPrs = (value: PullRequest[]) => writeFileSync(prs, JSON.stringify(value)); + + try { + mkdirSync(home); + mkdirSync(bin); + setPrs([]); + writeFileSync(join(bin, "gh"), '#!/bin/sh\ncat "$AUDIT_FIXTURE_PRS"\n', { mode: 0o755 }); + git(root, "config", "--global", "user.name", "Audit Test"); + git(root, "config", "--global", "user.email", "audit@example.test"); + git(root, "config", "--global", "init.defaultBranch", "main"); + git(root, "init", "--bare", origin); + git(root, "clone", origin, repo); + writeFileSync(join(repo, "base"), "base\n"); + git(repo, "add", "base"); + git(repo, "commit", "-m", "base"); + git(repo, "push", "origin", "main"); + git(repo, "remote", "set-head", "origin", "main"); + } catch (error) { + cleanup(); + throw error; + } + + const audit = (extraEnv: NodeJS.ProcessEnv = {}) => { + const result = spawnSync("bash", [join(import.meta.dir, "worktree-audit.sh"), repo], { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + }); + const rows: Record = {}; + for (const line of result.stdout.trim().split("\n")) { + if (!line || line.startsWith("# base:") || line.startsWith("SIZE\t")) continue; + const [, , merged, dirty, remote, pr, lastChat, bucket, worktree] = line.split("\t"); + rows[basename(worktree)] = { merged, dirty, remote, pr, lastChat, bucket }; + } + return { status: result.status, stderr: result.stderr, rows }; + }; + + return { root, home, origin, repo, env, run, git, setPrs, audit, cleanup }; +} diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts new file mode 100644 index 000000000..cc8c040ef --- /dev/null +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -0,0 +1,91 @@ +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, expect, test } from "bun:test"; +import { createAuditFixture } from "./worktree-audit.fixture"; + +const fixture = createAuditFixture(); +afterAll(fixture.cleanup); +const { root, repo, origin, git } = fixture; +const worktree = (branch: string) => { + const directory = join(root, branch); + git(repo, "worktree", "add", "-b", branch, directory, "main"); + return directory; +}; +const commit = (directory: string, file: string) => { + writeFileSync(join(directory, file), `${file}\n`); + git(directory, "add", file); + git(directory, "commit", "-m", file); + return git(directory, "rev-parse", "HEAD"); +}; +const pushed = (branch: string) => { + const directory = worktree(branch); + const head = commit(directory, branch); + git(directory, "push", "origin", branch); + return { directory, head }; +}; + +const closedUnpushed = pushed("closed-unpushed"); +commit(closedUnpushed.directory, "local-only"); +const closedPushed = pushed("closed-pushed"); +const behind = pushed("behind"); +const other = join(root, "other"); +git(root, "clone", origin, other); +git(other, "checkout", "behind"); +commit(other, "remote-only"); +git(other, "push", "origin", "behind"); +git(behind.directory, "fetch", "origin"); + +const squashPruned = pushed("squash-pruned"); +const squashKept = pushed("squash-kept"); +const mergedThenMore = pushed("merged-then-more"); +for (const branch of ["squash-pruned", "squash-kept", "merged-then-more"]) { + git(repo, "merge", "--squash", branch); + git(repo, "commit", "-m", `Squash ${branch}`); +} +git(repo, "push", "origin", "main"); +git(repo, "push", "origin", "--delete", "squash-pruned", "merged-then-more"); +git(repo, "fetch", "--prune", "origin"); +commit(mergedThenMore.directory, "after-merge"); + +const detached = join(root, "detached-off-base"); +git(repo, "worktree", "add", "--detach", detached, "main"); +commit(detached, "detached-only"); + +const gone = join(root, "detached-gone"); +git(repo, "worktree", "add", "--detach", gone, "main"); +commit(gone, "gone-only"); +rmSync(gone, { recursive: true, force: true }); + +const reused = pushed("reused-name"); +git(repo, "push", "origin", "--delete", "reused-name"); +git(repo, "fetch", "--prune", "origin"); + +fixture.setPrs([ + { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, + { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, + { number: 1, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: closedUnpushed.head }, + { number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: closedPushed.head }, + { number: 4, state: "MERGED", headRefName: "squash-pruned", headRefOid: squashPruned.head }, + { number: 5, state: "MERGED", headRefName: "squash-kept", headRefOid: squashKept.head }, + { number: 6, state: "MERGED", headRefName: "merged-then-more", headRefOid: mergedThenMore.head }, +]); +const result = fixture.audit(); + +const expectations = [ + ["closed-unpushed", "ahead1", "#1/CLOSED", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/CLOSED", "review"], + ["behind", "behind", "-", "review"], + ["squash-pruned", "no-remote", "#4/MERGED", "safe"], + ["squash-kept", "pushed", "#5/MERGED", "safe"], + ["merged-then-more", "no-remote", "#6/MERGED", "hold-unpushed"], + ["detached-off-base", "detached", "-", "hold-unpushed"], + ["detached-gone", "detached", "-", "hold-unpushed"], + ["reused-name", "no-remote", "#7/MERGED", "safe"], +]; +for (const [worktree, remote, pr, bucket] of expectations) { + test(`worktree audit: ${worktree}`, () => { + expect(result.status).toBe(0); + expect(result.rows[worktree]).toMatchObject({ remote, pr, bucket }); + if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); + }); +} From 49fcb04175365eb9e4cf406e47b352b8ab470919 Mon Sep 17 00:00:00 2001 From: Helio Pinheiro Date: Mon, 12 Oct 2026 14:53:27 -0300 Subject: [PATCH 03/12] fix(pstack): recognize work preserved on remotes and PR heads A behind branch has no local-only commits. Deleted origin branches and fork branches can still have their exact head preserved by a PR. Compare that head rather than treating every missing remote as unpushed, while holding commits added after a merge. (cherry picked from commit c8fdd2d10112b0a0abb85a52f1d39ead6802367d) --- .../poteto-mode/playbooks/worktree-cleanup.md | 2 +- .../poteto-mode/scripts/worktree-audit.sh | 22 ++++++++++++------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 2bcb7670e..6212b9d39 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,6 +1,6 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. 4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 31898619f..c7bb2017b 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -63,10 +63,10 @@ prs=$(mktemp) if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef, head_oid: ""}]' > "$prs" 2>/dev/null elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' > "$prs" 2>/dev/null fi [ -s "$prs" ] || echo "[]" > "$prs" @@ -103,10 +103,15 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; [ "$(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)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' "$prs" 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -120,12 +125,13 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) - # Commits that exist only in this worktree (ahead of the remote, no remote - # branch, or detached) are gone with it. Only ancestry of the base or a - # MERGED PR proves the work lives elsewhere; a CLOSED PR proves nothing. + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no case "$dirty" in wip:*) bucket=hold-wip ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) From fce02b36b52ed4b04f9c235166c318ba720bad49 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Wed, 14 Oct 2026 13:10:16 +0300 Subject: [PATCH 04/12] test(pstack): expect lowercase PR states after the rebase onto #613 main (#613) normalises the PR list to {number, state, head} with a lowercase state and prefers the origin CLI over gh. The audit now carries head_oid through that list (gh provides it, origin's record leaves it empty) so heliowap's PR-head check still works, and the fixture expectations use the lowercase states the script prints. (cherry picked from commit 4aedd61b4a58643fecd383f6f5f6f9a7bfd7af97) --- .../poteto-mode/scripts/worktree-audit.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts index cc8c040ef..cd35a0d9f 100644 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -72,15 +72,15 @@ fixture.setPrs([ const result = fixture.audit(); const expectations = [ - ["closed-unpushed", "ahead1", "#1/CLOSED", "hold-unpushed"], - ["closed-pushed", "pushed", "#2/CLOSED", "review"], + ["closed-unpushed", "ahead1", "#1/closed", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/closed", "review"], ["behind", "behind", "-", "review"], - ["squash-pruned", "no-remote", "#4/MERGED", "safe"], - ["squash-kept", "pushed", "#5/MERGED", "safe"], - ["merged-then-more", "no-remote", "#6/MERGED", "hold-unpushed"], + ["squash-pruned", "no-remote", "#4/merged", "safe"], + ["squash-kept", "pushed", "#5/merged", "safe"], + ["merged-then-more", "no-remote", "#6/merged", "hold-unpushed"], ["detached-off-base", "detached", "-", "hold-unpushed"], ["detached-gone", "detached", "-", "hold-unpushed"], - ["reused-name", "no-remote", "#7/MERGED", "safe"], + ["reused-name", "no-remote", "#7/merged", "safe"], ]; for (const [worktree, remote, pr, bucket] of expectations) { test(`worktree audit: ${worktree}`, () => { From 5156e5993a533a8dfecf66b76ae4c1c8ab96f92b Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Wed, 28 Oct 2026 07:00:05 +0300 Subject: [PATCH 05/12] chore(pstack): regenerate the Claude Code harness tree for the worktree-audit changes (cherry picked from commit 41da1b68dceed425651f7e69bc58b2c0896ef0f3) --- .../poteto-mode/playbooks/worktree-cleanup.md | 2 +- .../skills/poteto-mode/scripts/package.json | 2 +- .../scripts/worktree-audit.fixture.ts | 83 +++++++++++++++++ .../poteto-mode/scripts/worktree-audit.sh | 37 +++++--- .../scripts/worktree-audit.test.ts | 91 +++++++++++++++++++ 5 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts create mode 100644 harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts diff --git a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md index 45e1af693..6212b9d39 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,6 +1,6 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. 4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json b/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json index c8839d74d..5b1f38161 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "bun test watch-pr check-plan.test.ts", + "test": "bun test watch-pr check-plan.test.ts worktree-audit", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict" }, "dependencies": { diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts new file mode 100644 index 000000000..19b327d07 --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.fixture.ts @@ -0,0 +1,83 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; + +type PullRequest = { + number: number; + state: string; + headRefName: string; + headRefOid: string; +}; + +type AuditRow = { + merged: string; + dirty: string; + remote: string; + pr: string; + lastChat: string; + bucket: string; +}; + +export function createAuditFixture() { + const root = mkdtempSync(join(tmpdir(), "worktree-audit-test-")); + const home = join(root, "home"); + const bin = join(root, "bin"); + const origin = join(root, "origin.git"); + const repo = join(root, "repo"); + const prs = join(root, "prs.json"); + const env = { + ...process.env, + HOME: home, + GIT_CONFIG_GLOBAL: join(root, "gitconfig"), + GIT_CONFIG_NOSYSTEM: "1", + PATH: `${bin}:${process.env.PATH}`, + AUDIT_FIXTURE_PRS: prs, + }; + const cleanup = () => rmSync(root, { recursive: true, force: true }); + const run = (directory: string, command: string, args: string[]) => { + const result = spawnSync(command, args, { cwd: directory, env, encoding: "utf8" }); + if (result.status !== 0) + throw new Error(`${command} ${args.join(" ")} failed in ${directory}: ${result.error ?? result.stderr}`); + return result.stdout.trim(); + }; + const git = (directory: string, ...args: string[]) => run(directory, "git", args); + const setPrs = (value: PullRequest[]) => writeFileSync(prs, JSON.stringify(value)); + + try { + mkdirSync(home); + mkdirSync(bin); + setPrs([]); + writeFileSync(join(bin, "gh"), '#!/bin/sh\ncat "$AUDIT_FIXTURE_PRS"\n', { mode: 0o755 }); + git(root, "config", "--global", "user.name", "Audit Test"); + git(root, "config", "--global", "user.email", "audit@example.test"); + git(root, "config", "--global", "init.defaultBranch", "main"); + git(root, "init", "--bare", origin); + git(root, "clone", origin, repo); + writeFileSync(join(repo, "base"), "base\n"); + git(repo, "add", "base"); + git(repo, "commit", "-m", "base"); + git(repo, "push", "origin", "main"); + git(repo, "remote", "set-head", "origin", "main"); + } catch (error) { + cleanup(); + throw error; + } + + const audit = (extraEnv: NodeJS.ProcessEnv = {}) => { + const result = spawnSync("bash", [join(import.meta.dir, "worktree-audit.sh"), repo], { + cwd: repo, + env: { ...env, ...extraEnv }, + encoding: "utf8", + }); + const rows: Record = {}; + for (const line of result.stdout.trim().split("\n")) { + if (!line || line.startsWith("# base:") || line.startsWith("SIZE\t")) continue; + const [, , merged, dirty, remote, pr, lastChat, bucket, worktree] = line.split("\t"); + rows[basename(worktree)] = { merged, dirty, remote, pr, lastChat, bucket }; + } + return { status: result.status, stderr: result.stderr, rows }; + }; + + return { root, home, origin, repo, env, run, git, setPrs, audit, cleanup }; +} diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh index 47e08919d..c7bb2017b 100755 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. +# Worktree prune audit. Classifies every git worktree by size, merge state, +# uncommitted work, remote/PR state, and the most recent chat that operated in +# it. Emits a table sorted by size with a suggested bucket. Never deletes +# anything; deletion stays a human-gated step in the playbook. The only write +# is the best-effort fetch of the base ref for the merge check. # # Usage: worktree-audit.sh [-b|--base ] [repo-path] # repo-path defaults to the current repo. @@ -62,10 +63,10 @@ prs=$(mktemp) if command -v origin >/dev/null 2>&1 \ && raw=$(origin pr list --author "@me" --state all --limit 1000 \ --json number,status,headRef 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef}]' > "$prs" 2>/dev/null + printf '%s' "$raw" | jq '[.[] | {number, state: .status, head: .headRef, head_oid: ""}]' > "$prs" 2>/dev/null elif raw=$(gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null); then - printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName}]' > "$prs" 2>/dev/null + --json number,state,headRefName,headRefOid 2>/dev/null); then + printf '%s' "$raw" | jq '[.[] | {number, state: (.state | ascii_downcase), head: .headRefName, head_oid: (.headRefOid // "")}]' > "$prs" 2>/dev/null fi [ -s "$prs" ] || echo "[]" > "$prs" @@ -102,10 +103,15 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; [ "$(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)" + [ "$remote" = ahead0 ] && remote=behind else remote=no-remote; fi - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.head==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) + # Several PRs can share a branch name; prefer the one whose head is HEAD. + pr_record=$([ -n "$branch" ] && jq -r --arg b "$branch" --arg h "$head" \ + '[.[] | select(.head==$b)] | (map(select(.head_oid==$h)) + .)[0] + | select(.) | "#\(.number)/\(.state)\t\(.head_oid // "")"' "$prs" 2>/dev/null) + pr=${pr_record%%$'\t'*} + pr_head=${pr_record#*$'\t'} [ -z "$pr" ] && pr="-" # Most recent chat whose transcript operated in this worktree. Match path @@ -119,10 +125,19 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; fi recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) + # An exact PR head proves the commits were pushed even if origin has no + # branch (deleted after merging, or hosted on a fork). A closed PR still + # proves nothing about whether those commits were merged. + case "$pr" in */merged) pr_merged=yes ;; *) pr_merged=no ;; esac + case "$remote" in ahead*|no-remote|detached) unpushed=yes ;; *) unpushed=no ;; esac + [ "$merged" = YES ] && unpushed=no + [ -n "$pr_head" ] && [ "$head" = "$pr_head" ] && unpushed=no + 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 [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe + if [ "$unpushed" = yes ]; then bucket=hold-unpushed + elif [ "$recent" = yes ]; then bucket=verify-recent-chat + elif [ "$merged" = YES ] || [ "$pr_merged" = yes ]; then bucket=safe else bucket=review; fi ;; esac ;; esac diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts new file mode 100644 index 000000000..cd35a0d9f --- /dev/null +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -0,0 +1,91 @@ +import { rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterAll, expect, test } from "bun:test"; +import { createAuditFixture } from "./worktree-audit.fixture"; + +const fixture = createAuditFixture(); +afterAll(fixture.cleanup); +const { root, repo, origin, git } = fixture; +const worktree = (branch: string) => { + const directory = join(root, branch); + git(repo, "worktree", "add", "-b", branch, directory, "main"); + return directory; +}; +const commit = (directory: string, file: string) => { + writeFileSync(join(directory, file), `${file}\n`); + git(directory, "add", file); + git(directory, "commit", "-m", file); + return git(directory, "rev-parse", "HEAD"); +}; +const pushed = (branch: string) => { + const directory = worktree(branch); + const head = commit(directory, branch); + git(directory, "push", "origin", branch); + return { directory, head }; +}; + +const closedUnpushed = pushed("closed-unpushed"); +commit(closedUnpushed.directory, "local-only"); +const closedPushed = pushed("closed-pushed"); +const behind = pushed("behind"); +const other = join(root, "other"); +git(root, "clone", origin, other); +git(other, "checkout", "behind"); +commit(other, "remote-only"); +git(other, "push", "origin", "behind"); +git(behind.directory, "fetch", "origin"); + +const squashPruned = pushed("squash-pruned"); +const squashKept = pushed("squash-kept"); +const mergedThenMore = pushed("merged-then-more"); +for (const branch of ["squash-pruned", "squash-kept", "merged-then-more"]) { + git(repo, "merge", "--squash", branch); + git(repo, "commit", "-m", `Squash ${branch}`); +} +git(repo, "push", "origin", "main"); +git(repo, "push", "origin", "--delete", "squash-pruned", "merged-then-more"); +git(repo, "fetch", "--prune", "origin"); +commit(mergedThenMore.directory, "after-merge"); + +const detached = join(root, "detached-off-base"); +git(repo, "worktree", "add", "--detach", detached, "main"); +commit(detached, "detached-only"); + +const gone = join(root, "detached-gone"); +git(repo, "worktree", "add", "--detach", gone, "main"); +commit(gone, "gone-only"); +rmSync(gone, { recursive: true, force: true }); + +const reused = pushed("reused-name"); +git(repo, "push", "origin", "--delete", "reused-name"); +git(repo, "fetch", "--prune", "origin"); + +fixture.setPrs([ + { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, + { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, + { number: 1, state: "CLOSED", headRefName: "closed-unpushed", headRefOid: closedUnpushed.head }, + { number: 2, state: "CLOSED", headRefName: "closed-pushed", headRefOid: closedPushed.head }, + { number: 4, state: "MERGED", headRefName: "squash-pruned", headRefOid: squashPruned.head }, + { number: 5, state: "MERGED", headRefName: "squash-kept", headRefOid: squashKept.head }, + { number: 6, state: "MERGED", headRefName: "merged-then-more", headRefOid: mergedThenMore.head }, +]); +const result = fixture.audit(); + +const expectations = [ + ["closed-unpushed", "ahead1", "#1/closed", "hold-unpushed"], + ["closed-pushed", "pushed", "#2/closed", "review"], + ["behind", "behind", "-", "review"], + ["squash-pruned", "no-remote", "#4/merged", "safe"], + ["squash-kept", "pushed", "#5/merged", "safe"], + ["merged-then-more", "no-remote", "#6/merged", "hold-unpushed"], + ["detached-off-base", "detached", "-", "hold-unpushed"], + ["detached-gone", "detached", "-", "hold-unpushed"], + ["reused-name", "no-remote", "#7/merged", "safe"], +]; +for (const [worktree, remote, pr, bucket] of expectations) { + test(`worktree audit: ${worktree}`, () => { + expect(result.status).toBe(0); + expect(result.rows[worktree]).toMatchObject({ remote, pr, bucket }); + if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); + }); +} From 69229b02d6f31725f7dfda84dc335666d38dc6a1 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 21:27:32 +0800 Subject: [PATCH 06/12] fix(pstack): preserve worktree-local files during cleanup (cherry picked from commit a20f1aa61156c2adf1decdf0b87833a19dbcbebb) (cherry picked from commit 434c19a7f8622a26259c0222183ad2b1dd8d2fc7) (cherry picked from commit 6ac426977e0d56f159d7bdae9b72b16cac8033c8) --- pstack/skills/poteto-mode/playbooks/worktree-cleanup.md | 4 ++-- pstack/skills/poteto-mode/scripts/worktree-audit.sh | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 6212b9d39..558c92a49 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -3,8 +3,8 @@ 1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. -5. Prune the confirmed set. Per path, `git worktree remove --force `. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. Show status and diffs for tracked edits; list untracked and ignored paths without opening their contents, then get an explicit decision before removing the worktree. Never treat untracked or ignored files as disposable scratch. Per Autonomy, only clean and merged worktrees that are not in use proceed. Any work or uncertainty means hold. +5. Prune the confirmed set. Per path, run `git worktree remove ` without `--force`. If Git refuses because the worktree has local files, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. This is the one playbook that deletes user state with no code review to catch a slip, so the gates above are the review. diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/pstack/skills/poteto-mode/scripts/worktree-audit.sh index c7bb2017b..814619627 100755 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -90,12 +90,10 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no - # Distinguish real WIP (tracked edits) from disposable untracked scratch. - porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) + # Treat tracked, untracked, and ignored paths as work that must be held. + porcelain=$(git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null) if [ -z "$porcelain" ]; then 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="wip:$(printf '%s\n' "$porcelain" | wc -l | tr -d '[:space:]')"; fi branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached From 91f009bae6a4738d33360b630ab5b33a5b7b07ed Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 21:32:31 +0800 Subject: [PATCH 07/12] docs(pstack): make worktree approval gate explicit (cherry picked from commit a17c46123bd9543300365de584f183989af2dc0b) (cherry picked from commit a53824bcad003efc09f905f38a77a6897f262790) (cherry picked from commit 804d9ce5c3cb99628a2da8b4f468a6c1e4c4b41c) --- pstack/skills/poteto-mode/playbooks/worktree-cleanup.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 558c92a49..cd8735495 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -3,7 +3,7 @@ 1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. Show status and diffs for tracked edits; list untracked and ignored paths without opening their contents, then get an explicit decision before removing the worktree. Never treat untracked or ignored files as disposable scratch. Per Autonomy, only clean and merged worktrees that are not in use proceed. Any work or uncertainty means hold. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. Show status and diffs for tracked edits; list untracked and ignored paths without opening their contents. Hold the candidate until the user explicitly approves removing those named paths. Never treat untracked or ignored files as disposable scratch. Per Autonomy, only clean and merged worktrees that are not in use proceed automatically; `wip` never does. 5. Prune the confirmed set. Per path, run `git worktree remove ` without `--force`. If Git refuses because the worktree has local files, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. From a9ffb8e4000a5363de353db11942aeb52435717f Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Tue, 29 Sep 2026 22:31:19 +0800 Subject: [PATCH 08/12] docs(pstack): require approval for ignored worktree paths (cherry picked from commit 2310495e5460a4c4d3fd0361b9ba61a26fe18ddd) (cherry picked from commit 51d07ca441629de089cfeab3c690487e82642624) (cherry picked from commit de3c5bc3b88e599dd9333356ff0a486f28f4807e) --- pstack/skills/poteto-mode/playbooks/worktree-cleanup.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index cd8735495..de39ae9b3 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -3,8 +3,8 @@ 1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. Show status and diffs for tracked edits; list untracked and ignored paths without opening their contents. Hold the candidate until the user explicitly approves removing those named paths. Never treat untracked or ignored files as disposable scratch. Per Autonomy, only clean and merged worktrees that are not in use proceed automatically; `wip` never does. -5. Prune the confirmed set. Per path, run `git worktree remove ` without `--force`. If Git refuses because the worktree has local files, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. For each candidate, run `git -C status --porcelain=v1 --untracked-files=all --ignored=matching`; show diffs for tracked edits and list untracked and ignored paths without opening their contents. Default to preserving every worktree with `wip`. Remove one only after the user explicitly approves every path that its removal will delete. Never treat untracked or ignored files as disposable scratch. +5. Prune only the fully approved set. `git worktree remove ` can delete ignored-only files without `--force`, so the command is not a preservation check. Re-run the step 4 status preflight immediately before removal; if any path lacks explicit approval, retain the entire worktree. If Git refuses, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. This is the one playbook that deletes user state with no code review to catch a slip, so the gates above are the review. From 8f973f2fdfbf751ca6bfef55dba42807ef693a9f Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 22 Oct 2026 09:32:18 +0300 Subject: [PATCH 09/12] test(pstack): cover untracked and ignored worktree files in the audit Adds two worktrees to the audit fixture: one with a single untracked file, one with only ignored build output (.gitignore'd build/ and run.log). With row ever reports scratch:. Also restores #459's opening paragraph of the cleanup playbook, which the stack merge had dropped, and regenerates the harness tree. (cherry picked from commit 5a3b772fc9ea88783aac0ea5c44d510f1915bff8) (cherry picked from commit 18822d2fdef47b87cf8675e6f811273fd25d258f) --- .../poteto-mode/playbooks/worktree-cleanup.md | 6 +++-- .../poteto-mode/scripts/worktree-audit.sh | 8 +++---- .../scripts/worktree-audit.test.ts | 23 ++++++++++++++++++- .../poteto-mode/playbooks/worktree-cleanup.md | 2 ++ .../scripts/worktree-audit.test.ts | 23 ++++++++++++++++++- 5 files changed, 53 insertions(+), 9 deletions(-) diff --git a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md index 6212b9d39..fb7cfad88 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/harness/pstack/claude-code/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,10 +1,12 @@ ### Worktree and simulator cleanup +**You own the disk and the safety gate.** Prune merged or abandoned git worktrees and stale iOS simulators to reclaim space. Deletion is irreversible, so every step guards against deleting something in use or holding uncommitted work. + 1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. -4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. -5. Prune the confirmed set. Per path, `git worktree remove --force `. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. +4. Pause on irreversible loss. `wip:N` counts tracked, untracked, and ignored paths. For each candidate, run `git -C status --porcelain=v1 --untracked-files=all --ignored=matching`; show diffs for tracked edits and list untracked and ignored paths without opening their contents. Default to preserving every worktree with `wip`. Remove one only after the user explicitly approves every path that its removal will delete. Never treat untracked or ignored files as disposable scratch. +5. Prune only the fully approved set. `git worktree remove ` can delete ignored-only files without `--force`, so the command is not a preservation check. Re-run the step 4 status preflight immediately before removal; if any path lacks explicit approval, retain the entire worktree. If Git refuses, stop and retain it; do not use `rm -rf` or `git worktree prune` to bypass the refusal. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, `~/Library/Application Support/Cursor` (`state.vscdb.backup`, and `snapshots/roots/` where a `` named for a folder you opened as a workspace balloons), package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. This is the one playbook that deletes user state with no code review to catch a slip, so the gates above are the review. diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh index c7bb2017b..814619627 100755 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.sh @@ -90,12 +90,10 @@ git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; # real signal; merge-base only catches fast-forward/rebase merges. git merge-base --is-ancestor "$head" "$base" 2>/dev/null && merged=YES || merged=no - # Distinguish real WIP (tracked edits) from disposable untracked scratch. - porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) + # Treat tracked, untracked, and ignored paths as work that must be held. + porcelain=$(git -C "$wt" status --porcelain --untracked-files=all --ignored=matching 2>/dev/null) if [ -z "$porcelain" ]; then 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="wip:$(printf '%s\n' "$porcelain" | wc -l | tr -d '[:space:]')"; fi branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts index cd35a0d9f..46166b4a2 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -1,4 +1,4 @@ -import { rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterAll, expect, test } from "bun:test"; import { createAuditFixture } from "./worktree-audit.fixture"; @@ -60,6 +60,19 @@ const reused = pushed("reused-name"); git(repo, "push", "origin", "--delete", "reused-name"); git(repo, "fetch", "--prune", "origin"); +// Local-only files that git status hides by default: an untracked file, and +// ignored build output. Both are work the audit must hold, never scratch. +const untrackedOnly = pushed("untracked-only"); +writeFileSync(join(untrackedOnly.directory, "notes.md"), "untracked\n"); +const ignoredOnly = pushed("ignored-only"); +writeFileSync(join(ignoredOnly.directory, ".gitignore"), "build/\nrun.log\n"); +git(ignoredOnly.directory, "add", ".gitignore"); +git(ignoredOnly.directory, "commit", "-m", "ignore build output"); +git(ignoredOnly.directory, "push", "origin", "ignored-only"); +mkdirSync(join(ignoredOnly.directory, "build")); +writeFileSync(join(ignoredOnly.directory, "build", "artifact.bin"), "bin\n"); +writeFileSync(join(ignoredOnly.directory, "run.log"), "log\n"); + fixture.setPrs([ { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, @@ -81,6 +94,8 @@ const expectations = [ ["detached-off-base", "detached", "-", "hold-unpushed"], ["detached-gone", "detached", "-", "hold-unpushed"], ["reused-name", "no-remote", "#7/merged", "safe"], + ["untracked-only", "pushed", "-", "hold-wip"], + ["ignored-only", "pushed", "-", "hold-wip"], ]; for (const [worktree, remote, pr, bucket] of expectations) { test(`worktree audit: ${worktree}`, () => { @@ -89,3 +104,9 @@ for (const [worktree, remote, pr, bucket] of expectations) { if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); }); } + +test("worktree audit: untracked and ignored paths count as wip, never scratch", () => { + expect(result.rows["untracked-only"].dirty).toBe("wip:1"); + expect(result.rows["ignored-only"].dirty).toBe("wip:2"); + for (const row of Object.values(result.rows)) expect(row.dirty).not.toMatch(/^scratch:/); +}); diff --git a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index de39ae9b3..fb7cfad88 100644 --- a/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,5 +1,7 @@ ### Worktree and simulator cleanup +**You own the disk and the safety gate.** Prune merged or abandoned git worktrees and stale iOS simulators to reclaim space. Deletion is irreversible, so every step guards against deleting something in use or holding uncommitted work. + 1. Snapshot and audit. Record `df -h /`, then run `scripts/worktree-audit.sh [-b ] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.cursor/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. `hold-unpushed` means the worktree holds commits that exist nowhere else (ahead of its remote, no remote branch, or detached and not on the base, and HEAD is not the head of one of its PRs); push them or get a decision before anything else. A closed-but-unmerged PR is not evidence the work is safe. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. Pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section. diff --git a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts index cd35a0d9f..46166b4a2 100644 --- a/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts +++ b/pstack/skills/poteto-mode/scripts/worktree-audit.test.ts @@ -1,4 +1,4 @@ -import { rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { afterAll, expect, test } from "bun:test"; import { createAuditFixture } from "./worktree-audit.fixture"; @@ -60,6 +60,19 @@ const reused = pushed("reused-name"); git(repo, "push", "origin", "--delete", "reused-name"); git(repo, "fetch", "--prune", "origin"); +// Local-only files that git status hides by default: an untracked file, and +// ignored build output. Both are work the audit must hold, never scratch. +const untrackedOnly = pushed("untracked-only"); +writeFileSync(join(untrackedOnly.directory, "notes.md"), "untracked\n"); +const ignoredOnly = pushed("ignored-only"); +writeFileSync(join(ignoredOnly.directory, ".gitignore"), "build/\nrun.log\n"); +git(ignoredOnly.directory, "add", ".gitignore"); +git(ignoredOnly.directory, "commit", "-m", "ignore build output"); +git(ignoredOnly.directory, "push", "origin", "ignored-only"); +mkdirSync(join(ignoredOnly.directory, "build")); +writeFileSync(join(ignoredOnly.directory, "build", "artifact.bin"), "bin\n"); +writeFileSync(join(ignoredOnly.directory, "run.log"), "log\n"); + fixture.setPrs([ { number: 8, state: "CLOSED", headRefName: "reused-name", headRefOid: "0".repeat(40) }, { number: 7, state: "MERGED", headRefName: "reused-name", headRefOid: reused.head }, @@ -81,6 +94,8 @@ const expectations = [ ["detached-off-base", "detached", "-", "hold-unpushed"], ["detached-gone", "detached", "-", "hold-unpushed"], ["reused-name", "no-remote", "#7/merged", "safe"], + ["untracked-only", "pushed", "-", "hold-wip"], + ["ignored-only", "pushed", "-", "hold-wip"], ]; for (const [worktree, remote, pr, bucket] of expectations) { test(`worktree audit: ${worktree}`, () => { @@ -89,3 +104,9 @@ for (const [worktree, remote, pr, bucket] of expectations) { if (worktree === "squash-pruned") expect(result.rows[worktree].merged).toBe("no"); }); } + +test("worktree audit: untracked and ignored paths count as wip, never scratch", () => { + expect(result.rows["untracked-only"].dirty).toBe("wip:1"); + expect(result.rows["ignored-only"].dirty).toBe("wip:2"); + for (const row of Object.values(result.rows)) expect(row.dirty).not.toMatch(/^scratch:/); +}); From b079b3a9472dbc40c5b22087bdf8a8c5b5832eae Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Wed, 7 Oct 2026 19:49:25 +0300 Subject: [PATCH 10/12] fix(pstack): drop agent-selection language from slash-only skill descriptions (cherry picked from commit 592f943397b68586d6e171806a459178fe91f0f6) (cherry picked from commit 60fb97db61c6bd4abeed80d4b98925e6df2a023e) --- pstack/skills/make-bot-ui/SKILL.md | 6 +++--- pstack/skills/reflect/SKILL.md | 2 +- pstack/skills/tdd/SKILL.md | 2 +- pstack/skills/typescript-best-practices/SKILL.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pstack/skills/make-bot-ui/SKILL.md b/pstack/skills/make-bot-ui/SKILL.md index 446342ca3..ba5d28b77 100644 --- a/pstack/skills/make-bot-ui/SKILL.md +++ b/pstack/skills/make-bot-ui/SKILL.md @@ -1,9 +1,9 @@ --- name: make-bot-ui description: >- - Use when building a custom UI (page, dashboard, buttons) that should wake a - Grok Bot over a webhook, when the user must provide a webhook sender key, or - when exposing that UI on Tailscale. + Build a custom UI (page, dashboard, buttons) that wakes a Grok Bot over a + webhook, keeps the webhook sender key on the server, and optionally exposes + the UI on Tailscale. Run it with /make-bot-ui. disable-model-invocation: true --- # How to make a bot UI diff --git a/pstack/skills/reflect/SKILL.md b/pstack/skills/reflect/SKILL.md index 9bb153bd2..1fd1f39bc 100644 --- a/pstack/skills/reflect/SKILL.md +++ b/pstack/skills/reflect/SKILL.md @@ -1,6 +1,6 @@ --- name: reflect -description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Use when the user says reflect. +description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Run it with /reflect. disable-model-invocation: true --- diff --git a/pstack/skills/tdd/SKILL.md b/pstack/skills/tdd/SKILL.md index ca6f24c96..6df3cb04f 100644 --- a/pstack/skills/tdd/SKILL.md +++ b/pstack/skills/tdd/SKILL.md @@ -1,6 +1,6 @@ --- name: tdd -description: "Use only when the user explicitly asks for TDD, a failing test, or a regression test, OR when the bug has an obvious cheap local test target. Skip when the test path is unclear, expensive, integration-heavy, or not requested." +description: "TDD workflow for bug fixes: write one focused failing regression test before changing production code, when the bug has a clear, cheap local test target. Skip when the test path is unclear, expensive, or integration-heavy. Run it with /tdd, or when asked for TDD, a failing test, or a regression test." disable-model-invocation: true --- diff --git a/pstack/skills/typescript-best-practices/SKILL.md b/pstack/skills/typescript-best-practices/SKILL.md index e1e5aa7c9..2aa678abf 100644 --- a/pstack/skills/typescript-best-practices/SKILL.md +++ b/pstack/skills/typescript-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-best-practices -description: TypeScript best practices. Use when reading or editing any .ts or .tsx file. +description: TypeScript best practices grounded in type-system discipline. Applies to .ts and .tsx files; run it with /typescript-best-practices. paths: ["**/*.ts", "**/*.tsx"] disable-model-invocation: true --- From 058418a46e059aa01381938ae718c25f58c75498 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Tue, 20 Oct 2026 13:42:04 +0300 Subject: [PATCH 11/12] pstack: regenerate the Claude Code harness tree for the description changes (cherry picked from commit 7dfd6c4c6ab0cb24366a6b7a252ab51f70d07b66) --- harness/pstack/claude-code/skills/make-bot-ui/SKILL.md | 6 +++--- harness/pstack/claude-code/skills/reflect/SKILL.md | 2 +- harness/pstack/claude-code/skills/tdd/SKILL.md | 2 +- .../claude-code/skills/typescript-best-practices/SKILL.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md b/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md index 7f05b0504..83ce56529 100644 --- a/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md +++ b/harness/pstack/claude-code/skills/make-bot-ui/SKILL.md @@ -1,9 +1,9 @@ --- name: make-bot-ui description: >- - Use when building a custom UI (page, dashboard, buttons) that should wake a - Grok Bot over a webhook, when the user must provide a webhook sender key, or - when exposing that UI on Tailscale. + Build a custom UI (page, dashboard, buttons) that wakes a Grok Bot over a + webhook, keeps the webhook sender key on the server, and optionally exposes + the UI on Tailscale. Run it with /make-bot-ui. disable-model-invocation: true --- # How to make a bot UI diff --git a/harness/pstack/claude-code/skills/reflect/SKILL.md b/harness/pstack/claude-code/skills/reflect/SKILL.md index dcb6c9175..99f85cf83 100644 --- a/harness/pstack/claude-code/skills/reflect/SKILL.md +++ b/harness/pstack/claude-code/skills/reflect/SKILL.md @@ -1,6 +1,6 @@ --- name: reflect -description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Use when the user says reflect. +description: Spawn three parallel review subagents over the active transcript, surface learnings, and route each to a concrete edit on an existing skill. Run it with /reflect. disable-model-invocation: true --- diff --git a/harness/pstack/claude-code/skills/tdd/SKILL.md b/harness/pstack/claude-code/skills/tdd/SKILL.md index ca6f24c96..6df3cb04f 100644 --- a/harness/pstack/claude-code/skills/tdd/SKILL.md +++ b/harness/pstack/claude-code/skills/tdd/SKILL.md @@ -1,6 +1,6 @@ --- name: tdd -description: "Use only when the user explicitly asks for TDD, a failing test, or a regression test, OR when the bug has an obvious cheap local test target. Skip when the test path is unclear, expensive, integration-heavy, or not requested." +description: "TDD workflow for bug fixes: write one focused failing regression test before changing production code, when the bug has a clear, cheap local test target. Skip when the test path is unclear, expensive, or integration-heavy. Run it with /tdd, or when asked for TDD, a failing test, or a regression test." disable-model-invocation: true --- diff --git a/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md b/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md index e1e5aa7c9..2aa678abf 100644 --- a/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md +++ b/harness/pstack/claude-code/skills/typescript-best-practices/SKILL.md @@ -1,6 +1,6 @@ --- name: typescript-best-practices -description: TypeScript best practices. Use when reading or editing any .ts or .tsx file. +description: TypeScript best practices grounded in type-system discipline. Applies to .ts and .tsx files; run it with /typescript-best-practices. paths: ["**/*.ts", "**/*.tsx"] disable-model-invocation: true --- From adf7661a4d3907362404396a0b70408cdc4b7fec Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 22 Oct 2026 09:17:06 +0300 Subject: [PATCH 12/12] fix(pstack): watch-pr --status-only reports a head with no checks instead of retrying for minutes On a PR in a repository with no CI or check apps, resolveChecks throws a retryable ChecksUnavailable when both gh pr checks and the GraphQL rollup are empty, and --status-only went through pollUntilTerminal with the full --max-query-errors budget. The result was RETRY lines at 60/120/240 s and no status at all, which stalls babysit's check mode. - readSnapshot gains noChecks: "report" | "retry". With "report" an empty check set becomes a new ci-none state (source "none", no checks, merge assessment still read); the default keeps today's retry, because a freshly pushed head has no checks yet either and polling must not call it ready. - runSimple passes "report" for --status-only and clamps maxQueryErrors to 1 for that mode, so a status read ends on the first query error instead of sleeping through the backoff schedule. - --pretty renders ci-none as "no checks reported"; --status-only help says it reads GitHub once. Tests: readSnapshot report/retry, CLI --status-only on a no-checks head (JSON and --pretty), first-error exit, and polling still emitting RETRY. bun test 74/74, tsc clean, harness tree regenerated. Fixes #750. (cherry picked from commit 0f3823c084dcac58972db5446f1fbde8a9b60303) --- .../poteto-mode/scripts/watch-pr/cli.test.ts | 75 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/cli.ts | 6 +- .../scripts/watch-pr/policy.test.ts | 48 ++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 37 ++++++++- .../poteto-mode/scripts/watch-pr/render.ts | 2 + .../poteto-mode/scripts/watch-pr/types.ts | 12 ++- .../poteto-mode/scripts/watch-pr/cli.test.ts | 75 +++++++++++++++++++ .../poteto-mode/scripts/watch-pr/cli.ts | 6 +- .../scripts/watch-pr/policy.test.ts | 48 ++++++++++++ .../poteto-mode/scripts/watch-pr/policy.ts | 37 ++++++++- .../poteto-mode/scripts/watch-pr/render.ts | 2 + .../poteto-mode/scripts/watch-pr/types.ts | 12 ++- 12 files changed, 352 insertions(+), 8 deletions(-) diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b413e..73977530e 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f27c..ea9f0895c 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 966b75367..a67521835 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -92,6 +92,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..2552538fc 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -428,6 +454,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -437,6 +469,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -518,7 +551,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..a0da9ac18 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..6f6849b5e 100644 --- a/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/harness/pstack/claude-code/skills/poteto-mode/scripts/watch-pr/types.ts @@ -142,7 +142,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed"; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts index 0656b413e..73977530e 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import { type CliRuntime, main, parseArgs } from "./cli.ts"; import { fakeReader, passingCheck } from "./fakes.test-helper.ts"; +import { WatcherQueryError } from "./github.ts"; import { renderJson, renderPretty } from "./render.ts"; import type { GitHubReader, WatcherVerdict } from "./types.ts"; import { parsePrNumber } from "./types.ts"; @@ -103,6 +104,80 @@ describe("parseArgs", () => { }); }); +describe("heads without checks", () => { + const noChecks = () => + fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + + it("--status-only reports the head once instead of retrying", async () => { + const harness = testRuntime(noChecks()); + expect(await main(["--pr", "1", "--status-only"], harness.runtime)).toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).toBe("STATUS"); + if (verdict.kind !== "STATUS") throw new Error("expected STATUS"); + const row = verdict.rows[0]; + expect(row.kind).toBe("open"); + if (row.kind !== "open") throw new Error("expected open row"); + expect(row.ci.kind).toBe("ci-none"); + }); + + it("--status-only --pretty names the missing checks", async () => { + const harness = testRuntime(noChecks()); + expect( + await main(["--pr", "1", "--status-only", "--pretty"], harness.runtime) + ).toBe(0); + expect(harness.stdout.join("")).toContain("no checks reported"); + }); + + it("--status-only ends on the first query error instead of sleeping", async () => { + const reader = noChecks(); + const failing: GitHubReader = { + ...reader, + async pullRequest() { + throw new WatcherQueryError({ + kind: "json-parse", + retryable: true, + detail: "simulated outage", + }); + }, + }; + const harness = testRuntime(failing); + const code = await main(["--pr", "1", "--status-only"], harness.runtime); + expect(code).not.toBe(0); + expect(harness.stdout).toHaveLength(1); + const verdict = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(verdict.kind).not.toBe("RETRY"); + }); + + it("polling still treats a head without checks as a retryable error", async () => { + const harness = testRuntime(noChecks()); + const slept: number[] = []; + const runtime: CliRuntime = { + ...harness.runtime, + clock: { + ...harness.runtime.clock, + async sleep(seconds) { + slept.push(seconds); + if (slept.length >= 2) throw new Error("stop test"); + }, + }, + }; + await expect( + main(["--pr", "1", "--max-query-errors", "5"], runtime) + ).rejects.toThrow("stop test"); + expect(slept.length).toBe(2); + const first = JSON.parse(harness.stdout[0] ?? "") as WatcherVerdict; + expect(first.kind).toBe("RETRY"); + }); +}); + describe("rendering", () => { const context = { owner: "owner", diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts b/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts index ec8a2f27c..ea9f0895c 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/cli.ts @@ -126,7 +126,11 @@ export function parseArgs( positiveInteger, 5 ) - .option("--status-only", "print one status table and exit 0", false) + .option( + "--status-only", + "read GitHub once, print one status table and exit 0; a head with no checks is reported, not retried", + false + ) .option("--allow-draft", "do not treat a draft as a merge gate", false) .option("--pretty", "render human text instead of JSON", false); program.parse(argv, { from: "user" }); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts index 966b75367..a67521835 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.test.ts @@ -92,6 +92,54 @@ describe("readiness truth table", () => { }); describe("snapshot query planning", () => { + it("reports a head without checks as ci-none when asked to", async () => { + const reader = fakeReader({ + fastPath: { + kind: "unusable", + exitCode: 1, + stderr: "no checks reported on the 'feature' branch", + }, + rollupPages: [{ checks: [], endCursor: null }], + }); + const snapshot = await readSnapshot({ + reader, + context: context(7), + pendingHistory: "include", + allowDraft: false, + noChecks: "report", + }); + expect(snapshot.kind).toBe("open"); + if (snapshot.kind !== "open") throw new Error("expected open snapshot"); + expect(snapshot.ci.kind).toBe("ci-none"); + expect(snapshot.ci.all).toEqual([]); + expect(snapshot.reviewAutomationRunning).toBe(false); + if (snapshot.ci.kind !== "ci-none") throw new Error("expected ci-none"); + expect(snapshot.ci.github.kind).toBe("allowed"); + expect(reader.calls).toContain("commitRollups"); + }); + + it("keeps a head without checks as a retryable query error by default", async () => { + const reader = fakeReader({ + fastPath: { kind: "checks", checks: [] }, + rollupPages: [{ checks: [], endCursor: null }], + }); + let caught: unknown = null; + try { + await readSnapshot({ + reader, + context: context(8), + pendingHistory: "include", + allowDraft: false, + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(WatcherQueryError); + if (!(caught instanceof WatcherQueryError)) throw new Error("expected query error"); + expect(caught.failure.kind).toBe("checks-unavailable"); + expect(caught.failure.retryable).toBe(true); + }); + it("does not query commit rollups while queued checks are pending", async () => { const reader = fakeReader({ fastPath: { kind: "checks", checks: [pendingCheck()] }, diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts index 8179f2d8b..2552538fc 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/policy.ts @@ -1,4 +1,5 @@ import { + ChecksUnavailable, WatcherQueryError, resolveChecks, resolveReviewThreads, @@ -61,6 +62,8 @@ export async function readSnapshot(args: { readonly context: T.PrContext; readonly pendingHistory: "include" | "omit"; readonly allowDraft: boolean; + /** `report`: a head with no checks becomes `ci-none`. `retry` (default): it stays a retryable query error, because a just-pushed head looks the same. */ + readonly noChecks?: "report" | "retry"; }): Promise { const facts = await args.reader.pullRequest(args.context); if (facts.state === "MERGED" || facts.mergedAt !== null) @@ -68,7 +71,30 @@ export async function readSnapshot(args: { if (facts.state === "CLOSED") return { kind: "closed", context: args.context, facts }; const threads = await resolveReviewThreads(args.reader, args.context); - const checks = await resolveChecks(args.reader, args.context); + let checks: T.CheckRead; + try { + checks = await resolveChecks(args.reader, args.context); + } catch (error) { + if (!(error instanceof ChecksUnavailable) || args.noChecks !== "report") + throw error; + const merge = await mergeAssessment(args.reader, facts); + return { + kind: "open", + context: args.context, + facts, + threads, + ci: { + kind: "ci-none", + source: "none", + all: [], + failed: [], + pending: [], + hadPreviousPassingCi: merge.hadPreviousPassingCi, + github: merge.github, + }, + reviewAutomationRunning: false, + }; + } const failed = nonEmpty( checks.checks.filter( (check): check is T.FailedCheck => check.kind === "failed" @@ -428,6 +454,12 @@ export async function runSimple(args: { readonly options: T.PollingOptions; }): Promise { const stamp = verdictFactory(args.dependencies.clock, args.mode); + // `--status-only` reads GitHub once: a head without checks is reported as + // such instead of being retried, and a query error ends the run on the + // first failure instead of sleeping through the backoff schedule. + const options: T.PollingOptions = args.statusOnly + ? { ...args.options, maxQueryErrors: 1 } + : args.options; const step = async (): Promise> => { const rows: T.PrSnapshot[] = []; for (const context of args.contexts) @@ -437,6 +469,7 @@ export async function runSimple(args: { context, pendingHistory: "include", allowDraft: args.options.allowDraft, + noChecks: args.statusOnly ? "report" : "retry", }) ); const complete = nonEmpty(rows); @@ -518,7 +551,7 @@ export async function runSimple(args: { }; return pollUntilTerminal({ dependencies: args.dependencies, - options: args.options, + options, stamp, step, }); diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts index 0e9358393..a0da9ac18 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/render.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/render.ts @@ -13,6 +13,8 @@ function ciCell(row: T.PrSnapshot): string { return `❌ ${row.ci.failed.length} failed${row.ci.pending.length ? `, ${row.ci.pending.length} pending` : ""}${was}`; case "ci-github-rejected": return `❌ GitHub reports failing checks${was}`; + case "ci-none": + return `no checks reported${was}`; default: { const exhaustive: never = row.ci; return exhaustive; diff --git a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts index 60b9abcac..6f6849b5e 100644 --- a/pstack/skills/poteto-mode/scripts/watch-pr/types.ts +++ b/pstack/skills/poteto-mode/scripts/watch-pr/types.ts @@ -142,7 +142,17 @@ export type CiClean = CiBase & { readonly pending: readonly []; readonly github: GitHubMergeAllowed; }; -export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean; +/** GitHub reported no checks at all for the head. Only `--status-only` produces this; polling keeps retrying, since a freshly pushed head has no checks yet either. */ +export interface CiNone { + readonly kind: "ci-none"; + readonly source: "none"; + readonly all: readonly []; + readonly failed: readonly []; + readonly pending: readonly []; + readonly hadPreviousPassingCi: boolean; + readonly github: GitHubMergeAssessment; +} +export type CiState = CiFailing | CiGithubRejected | CiPending | CiClean | CiNone; export type PrSnapshot = | { readonly kind: "merged" | "closed";