From f60c5ddef6e5caecde10aff4a263fc3632f71341 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 16 Oct 2026 17:52:22 +0200 Subject: [PATCH 1/9] test(pstack): pin the worktree audit's holds and its no-write contract The audit calls a worktree safe when removing it deletes an ignored file that exists nowhere else or orphans a commit only its HEAD reflog reaches, and it fetches and refreshes indexes while it claims to be read-only. These tests run the real script on real repositories and fail on main. They port the ignored-file and reflog cases from Michael Denyer's pstack-claude #277, and add the generator check from sergchil/jutsu da94424 that rejects a playbook which force-removes a worktree. --- tests/worktree-audit.test.mjs | 152 +++++++++++++++++++++++++++------- 1 file changed, 124 insertions(+), 28 deletions(-) diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index e0aa2053..9598c2ae 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -3,14 +3,17 @@ // the one that runs: here it gets a PATH with a fake gh and no origin at all. import { afterEach, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; +import { validatePluginLayout } from "../tools/generate.mjs"; + const audit = fileURLToPath( new URL("../plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh", import.meta.url), ); +const pluginRoot = fileURLToPath(new URL("../plugins/pstack", import.meta.url)); const TOOLS = ["git", "jq", "du", "awk", "sort", "date", "mktemp", "grep", "head", "rm", "xargs", "stat"]; const roots = []; @@ -37,7 +40,15 @@ function git(cwd, ...args) { return result.stdout; } -test("fills the PR column from gh when no origin command is on PATH", () => { +function commit(cwd, name) { + writeFileSync(join(cwd, `${name}.txt`), `${name}\n`); + git(cwd, "add", "."); + git(cwd, "commit", "-q", "-m", name); +} + +// A repo whose main is pushed to a bare origin, and a bin directory holding +// only the tools the audit runs, so no gh or origin CLI is on PATH. +function createFixture() { const root = mkdtempSync(join(tmpdir(), "worktree-audit-")); roots.push(root); const repo = join(root, "repo"); @@ -45,49 +56,134 @@ test("fills the PR column from gh when no origin command is on PATH", () => { git(root, "init", "--bare", "remote.git"); git(repo, "init"); git(repo, "remote", "add", "origin", join(root, "remote.git")); - writeFileSync(join(repo, "README.md"), "base\n"); - git(repo, "add", "."); - git(repo, "commit", "-m", "base"); + commit(repo, "base"); git(repo, "push", "-q", "origin", "main"); - for (const branch of ["shipped", "in-review", "no-pr"]) { - const tree = join(root, branch); - git(repo, "worktree", "add", "-q", "-b", branch, tree); - writeFileSync(join(tree, `${branch}.txt`), `${branch}\n`); - git(tree, "add", "."); - git(tree, "commit", "-m", branch); - } - const bin = join(root, "bin"); mkdirSync(bin); for (const tool of TOOLS) symlinkSync(Bun.which(tool), join(bin, tool)); - const calls = join(root, "gh-calls"); + return { root, repo, bin }; +} + +function addWorktree({ root, repo }, name, ...flags) { + const tree = join(root, name); + git(repo, "worktree", "add", "-q", ...(flags.length ? flags : ["-b", name]), tree); + return tree; +} + +// The audit's rows keyed by worktree directory name. +function runAudit({ root, repo, bin }, ...args) { + const result = spawnSync(Bun.which("bash"), [audit, ...args, repo], { + encoding: "utf8", + env: { ...gitEnv, PATH: bin, HOME: root }, + }); + expect(result.stderr).not.toContain("error:"); + expect(result.status).toBe(0); + return Object.fromEntries( + result.stdout + .trim() + .split("\n") + .slice(2) + .map((line) => line.split("\t")) + .map((cells) => [ + cells[8].split("/").at(-1), + { merged: cells[2], dirty: cells[3], pr: cells[5], lastChat: cells[6], bucket: cells[7] }, + ]), + ); +} + +test("fills the PR column from gh when no origin command is on PATH", () => { + const fixture = createFixture(); + for (const branch of ["shipped", "in-review", "no-pr"]) commit(addWorktree(fixture, branch), branch); + const calls = join(fixture.root, "gh-calls"); writeFileSync( - join(bin, "gh"), + join(fixture.bin, "gh"), `#!/bin/sh printf '%s\\n' "$*" >> "${calls}" printf '%s' '[{"number":7,"state":"MERGED","headRefName":"shipped"},{"number":8,"state":"OPEN","headRefName":"in-review"}]' `, ); - chmodSync(join(bin, "gh"), 0o755); + chmodSync(join(fixture.bin, "gh"), 0o755); - const env = { ...gitEnv, PATH: bin, HOME: root }; + const env = { ...gitEnv, PATH: fixture.bin, HOME: fixture.root }; expect(spawnSync(Bun.which("bash"), ["-c", "command -v origin"], { env }).status).toBe(1); - const result = spawnSync(Bun.which("bash"), [audit, repo], { encoding: "utf8", env }); - expect(result.status).toBe(0); + const rows = runAudit(fixture); expect(readFileSync(calls, "utf8")).toBe( "pr list --author @me --state all --limit 1000 --json number,state,headRefName\n", ); - const rows = Object.fromEntries( - result.stdout - .trim() - .split("\n") - .slice(2) - .map((line) => line.split("\t")) - .map((cells) => [cells[8].split("/").at(-1), { pr: cells[5], lastChat: cells[6], bucket: cells[7] }]), - ); - expect(rows).toEqual({ + const summary = ({ pr, lastChat, bucket }) => ({ pr, lastChat, bucket }); + expect(Object.fromEntries(Object.entries(rows).map(([name, row]) => [name, summary(row)]))).toEqual({ shipped: { pr: "#7/merged", lastChat: "-", bucket: "safe" }, "in-review": { pr: "#8/open", lastChat: "-", bucket: "hold-open-pr" }, "no-pr": { pr: "-", lastChat: "-", bucket: "review" }, }); }); + +test("an ignored file outside build and cache directories holds the worktree, ignored dependencies do not", () => { + const fixture = createFixture(); + writeFileSync(join(fixture.repo, ".git/info/exclude"), ".env\nnode_modules/\n"); + const secrets = addWorktree(fixture, "secrets"); + writeFileSync(join(secrets, ".env"), "TOKEN=only-here\n"); + const deps = addWorktree(fixture, "deps"); + for (const dir of ["node_modules/pkg", "packages/app/node_modules/pkg"]) { + mkdirSync(join(deps, dir), { recursive: true }); + writeFileSync(join(deps, dir, "index.js"), "module.exports = {};\n"); + } + const rows = runAudit(fixture); + expect([rows.secrets.dirty, rows.secrets.bucket]).toEqual(["ignored:1", "hold-ignored"]); + expect([rows.deps.dirty, rows.deps.bucket]).toEqual(["clean", "safe"]); +}); + +test("a commit only the worktree's HEAD reflog reaches holds it, a rebased branch's old tip does not", () => { + const fixture = createFixture(); + const detached = addWorktree(fixture, "detached", "--detach"); + commit(detached, "detached work"); + git(detached, "checkout", "-q", "--detach", "origin/main"); + const rebased = addWorktree(fixture, "rebased"); + commit(rebased, "feature"); + const landed = addWorktree(fixture, "landed"); + commit(landed, "trunk moves"); + git(landed, "push", "-q", "origin", "HEAD:main"); + git(rebased, "rebase", "-q", "origin/main"); + git(rebased, "push", "-q", "origin", "HEAD:main"); + const rows = runAudit(fixture); + expect([rows.detached.merged, rows.detached.dirty, rows.detached.bucket]).toEqual(["YES", "reflog:1", "hold-reflog"]); + expect([rows.rebased.merged, rows.rebased.dirty, rows.rebased.bucket]).toEqual(["YES", "clean", "safe"]); +}); + +test("the audit writes nothing to the repository unless --fetch is given", () => { + const fixture = createFixture(); + const idle = addWorktree(fixture, "idle"); + // A tracked file whose mtime no longer matches the index, so a plain git + // status would rewrite the index under another agent's feet. + utimesSync(join(idle, "base.txt"), new Date(0), new Date(0)); + const index = git(idle, "rev-parse", "--path-format=absolute", "--git-path", "index").trim(); + git(fixture.root, "clone", "-q", "remote.git", "other"); + const other = join(fixture.root, "other"); + commit(other, "later"); + git(other, "push", "-q", "origin", "main"); + const tracked = git(fixture.repo, "rev-parse", "origin/main"); + const indexBytes = readFileSync(index); + + runAudit(fixture); + expect(git(fixture.repo, "rev-parse", "origin/main")).toBe(tracked); + expect(readFileSync(index).equals(indexBytes)).toBe(true); + + runAudit(fixture, "--fetch"); + expect(git(fixture.repo, "rev-parse", "origin/main")).toBe(git(other, "rev-parse", "HEAD")); +}); + +test("the generator check rejects a playbook that force-removes a worktree or deletes one by hand", () => { + const root = mkdtempSync(join(tmpdir(), "worktree-playbook-")); + roots.push(root); + const playbooks = join(root, "skills/poteto-mode/playbooks"); + mkdirSync(playbooks, { recursive: true }); + writeFileSync(join(root, "skills/poteto-mode/SKILL.md"), "---\nname: poteto-mode\n---\n"); + writeFileSync( + join(playbooks, "worktree-cleanup.md"), + "5. Prune. Per path, `git worktree remove --force `.\nIf the dir survives, `rm -rf` it.\n", + ); + expect(() => validatePluginLayout(root)).toThrow( + /worktree-cleanup\.md:1: .*worktree remove --force[\s\S]*worktree-cleanup\.md:2: .*rm -rf/, + ); + expect(() => validatePluginLayout(pluginRoot)).not.toThrow(); +}); From dfe4e103822b4d7c8debc830f75769f098dce5e9 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 16 Oct 2026 17:54:34 +0200 Subject: [PATCH 2/9] fix(poteto-mode): hold worktrees with ignored files or reflog-only commits worktree-audit.sh bucketed a worktree safe when removing it would delete an ignored .env or orphan a commit only its HEAD reflog reaches. It now reads ignored paths with --ignored=matching and counts those outside a named list of build and cache directories as ignored:N (hold-ignored). It walks the worktree's HEAD reflog and counts commits no branch, tag, remote, or branch reflog reaches as reflog:N (hold-reflog). Both ports Michael Denyer's pstack-claude #277 to Lauren's bash script. The audit no longer fetches unless --fetch is given, and it sets GIT_OPTIONAL_LOCKS=0, so its git status no longer rewrites the index of a worktree an agent may be using. The read-only claim is now true by default. --- .../poteto-mode/scripts/worktree-audit.sh | 77 ++++++++++++++----- tools/forks.json | 6 ++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 2780a566..8c528bda 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,21 +1,29 @@ #!/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, read-only unless --fetch is given. Classifies every git +# worktree by size, merge state, uncommitted work, ignored files outside build +# and cache directories, commits only its HEAD reflog reaches, 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. # -# Usage: worktree-audit.sh [-b|--base ] [repo-path] +# Usage: worktree-audit.sh [-b|--base ] [--fetch] [repo-path] # repo-path defaults to the current repo. # -b sets the ref the MERGED column checks against (e.g. origin/integration). # Without it the base is origin/HEAD, then origin/main, then origin/master. # origin/HEAD is the remote's default branch, not necessarily the branch PRs # merge into; pass -b when they differ. +# --fetch refreshes an origin/* base from origin before the merge check. set -u +# git status would otherwise refresh each worktree's index, a write that can +# collide with an agent working in that worktree. +export GIT_OPTIONAL_LOCKS=0 base="" +fetch="" while [ $# -gt 0 ]; do case "$1" in -b|--base) [ $# -ge 2 ] || { echo "error: $1 needs a ref" >&2; exit 2; }; base="$2"; shift 2 ;; + --fetch) fetch=1; shift ;; -h|--help) sed -n '2,/^set -u/p' "$0" | sed '$d; s/^# \{0,1\}//'; exit 0 ;; -*) echo "error: unknown option: $1" >&2; exit 2 ;; *) break ;; @@ -38,14 +46,29 @@ if [ -z "$base" ]; then echo "warn: no -b given; MERGED checks against $base" >&2 fi -# The base drives the merge check. The fetch is best-effort (stale is fine for -# a first pass) and runs before the check, since -b may name a branch not -# fetched yet. The explicit refspec creates it even in a single-branch clone. -case "$base" in -origin/*) git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \ - || echo "warn: could not fetch $base; merged column may be stale" >&2 ;; -esac -git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 || { echo "error: base ref not found: $base" >&2; exit 1; } +# The base drives the merge check. --fetch is best-effort (stale is fine for a +# first pass, it only marks fewer worktrees merged) and runs before the check, +# since -b may name a branch not fetched yet. The explicit refspec creates it +# even in a single-branch clone. +if [ -n "$fetch" ]; then + case "$base" in + origin/*) git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \ + || echo "warn: could not fetch $base; merged column may be stale" >&2 ;; + esac +fi +git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 \ + || { echo "error: base ref not found: $base; --fetch fetches an origin/* base" >&2; exit 1; } + +# An ignored path under one of these names is a rebuildable build or cache +# artifact. Any other ignored path, such as .env, may exist nowhere else. +disposable='(\.build|\.cache|\.dart_tool|\.DS_Store|\.gradle|\.lake|\.lycheecache|\.mypy_cache|\.next|\.nuxt' +disposable+='|\.parcel-cache|\.pytest_cache|\.ruff_cache|\.svelte-kit|\.tox|\.turbo|\.venv|__pycache__|build' +disposable+='|coverage|DerivedData|dist|node_modules|target|venv)' + +# Commits a branch reflog lists, negated for rev-list. Branch reflogs live in +# the shared logs and outlive a worktree, so a rebased branch's old tip is not +# lost with it. +branch_logged=$(git rev-list --walk-reflogs --branches 2>/dev/null | awk '!seen[$0]++ { print "^" $0 }') # PR state by branch, fetched once as [{number, state, head}] with a lowercase # state. Origin when its CLI can read this repo's PRs, otherwise gh. Empty when @@ -81,12 +104,26 @@ 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) - 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 + # Distinguish real WIP (tracked edits) from untracked scratch. Ignored paths + # outside $disposable count too: `git worktree remove` deletes them without + # asking, and a .env or .claude/settings.local.json may exist nowhere else. + porcelain=$(git -C "$wt" status --porcelain --ignored=matching 2>/dev/null) + wip=$(printf '%s\n' "$porcelain" | grep -cvE '^(\?\?|!!) |^$') + scratch=$(printf '%s\n' "$porcelain" | grep -c '^?? ') + ignored=$(printf '%s\n' "$porcelain" | grep '^!! ' | grep -cvE "^!! \"?(.*/)?$disposable(/|\"?\$)") + + # Commits only this worktree's HEAD reflog reaches, such as work committed + # on a detached HEAD. Removing the worktree deletes that reflog, which + # leaves them for gc. --not before --stdin leaves stdin's revisions as given. + orphans=$({ git -C "$wt" rev-list --walk-reflogs HEAD 2>/dev/null; printf '%s\n' "$branch_logged"; } \ + | git -C "$wt" rev-list --count --not --branches --tags --remotes --stdin 2>/dev/null) + + if [ "$wip" -gt 0 ]; then dirty="wip:$wip" + elif [ "$scratch" -gt 0 ]; then dirty="scratch:$scratch" + else dirty=""; fi + [ "$ignored" -gt 0 ] && dirty="${dirty:+$dirty,}ignored:$ignored" + [ "${orphans:-0}" -gt 0 ] && dirty="${dirty:+$dirty,}reflog:$orphans" + [ -z "$dirty" ] && dirty=clean branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") if [ -z "$branch" ]; then remote=detached @@ -111,7 +148,7 @@ 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) - case "$dirty" in wip:*) bucket=hold-wip ;; *) + case "$dirty" in wip:*) bucket=hold-wip ;; *reflog:*) bucket=hold-reflog ;; *ignored:*) bucket=hold-ignored ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) if [ "$recent" = yes ]; then bucket=verify-recent-chat elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe diff --git a/tools/forks.json b/tools/forks.json index f5d6c495..d157fbab 100644 --- a/tools/forks.json +++ b/tools/forks.json @@ -72,6 +72,12 @@ "since": "2.0.0-rc.1", "upstream": "not-proposed" }, + "plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh": { + "kind": "policy", + "why": "Holds a worktree whose removal would delete an ignored file outside named build and cache directories or orphan a commit only its HEAD reflog reaches, ported from Michael Denyer's pstack-claude #277, fetches the base only under --fetch, and sets GIT_OPTIONAL_LOCKS=0 so its git status never rewrites a worktree's index.", + "since": "2.0.0-rc.7", + "upstream": "https://github.com/cursor/plugins/issues/449" + }, "plugins/pstack/skills/setup-pstack/SKILL.md": { "kind": "port-feature", "why": "Writes the T3 model sheet from orchestrator_capabilities targets and checks it with t3-sheet.mjs, in place of Cursor's always-applied .mdc rule built from model slugs.", From ded6c319268466033d44d48cf5e771a86cdf02ab Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 16 Oct 2026 17:56:12 +0200 Subject: [PATCH 3/9] fix(poteto-mode): prune worktrees with a plain git worktree remove Cleanup step 5 ran git worktree remove --force and then rm -rf on whatever survived, which deletes untracked and ignored files that exist nowhere else (cursor/plugins#449). It now runs a plain remove and treats its refusal as the stop. Step 4 names the ignored and reflog counts the audit adds, pauses on untracked scratch, and deletes only paths the user approves with a targeted git clean. The generator check fails when any playbook force-removes a worktree or runs rm -rf, as sergchil/jutsu da94424 does, so a sync cannot bring the step back silently. --- .../poteto-mode/playbooks/worktree-cleanup.md | 6 +++--- tools/forks.json | 6 ++++++ tools/generate.mjs | 16 +++++++++++++++- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 66163621..b9fc22ee 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -1,10 +1,10 @@ ### Worktree and simulator cleanup -1. Snapshot and audit. Record `df -h /`, then run `/skills/poteto-mode/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 `.claude/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 `/skills/poteto-mode/scripts/worktree-audit.sh [-b ] [--fetch] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It writes nothing to the repository unless `--fetch` refreshes that base from origin first. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.claude/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. 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, and from the T3 threads bound to each worktree (page through `t3_thread_list` with `includeSubagents: true` and read each thread's `worktreePath` with `t3_thread_read`), 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 show in the thread list. 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` 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 N untracked paths. Name them and get a decision, then delete only the ones the user approves with `git -C clean -fd -- ...`. `ignored:N` is N ignored paths outside the build and cache directories the audit names, such as `.env` or `.claude/settings.local.json`, which may exist nowhere else. List them with `git -C status --porcelain --ignored=matching`. `reflog:N` is N commits that no branch, tag, or remote reaches, kept only by the worktree's HEAD reflog, such as work committed on a detached HEAD. They are among the entries `git -C reflog` prints, and `git branch ` keeps one. Per Autonomy, clean and merged and not-in-use proceeds. `wip`, `scratch`, `ignored`, `reflog`, and in-use pause. +5. Prune the confirmed set. Per path, run a plain `git worktree remove `. It deletes every ignored file with the directory, so a worktree with `ignored:N` waits for the user's decision from step 4. It refuses a worktree with tracked edits, untracked files, or a lock, and that refusal is the stop. Leave the worktree, report it, and never add a force flag or delete the directory by hand. Branch refs survive, so commits on the worktree's branch are kept. The worktree's HEAD reflog goes with it, which is why step 4 holds `reflog`. 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`, 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/tools/forks.json b/tools/forks.json index d157fbab..ed4eddc8 100644 --- a/tools/forks.json +++ b/tools/forks.json @@ -24,6 +24,12 @@ "since": "2.0.0-rc.2", "upstream": "not-proposed" }, + "plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md": { + "kind": "policy", + "why": "Prunes with a plain git worktree remove whose refusal is the stop, never a force flag or a hand delete, as sergchil/jutsu da94424 does, pauses on untracked scratch and deletes only the paths the user approves, names the ignored and reflog counts the audit adds with the commands that list them, and documents the audit's --fetch.", + "since": "2.0.0-rc.7", + "upstream": "https://github.com/cursor/plugins/issues/449" + }, "plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts": { "kind": "port-feature", "why": "Expects the --settle default that cli.ts adds.", diff --git a/tools/generate.mjs b/tools/generate.mjs index 92667033..0ec061be 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -153,6 +153,11 @@ export function agentSkills(skillsDir) { return skills; } +// A worktree removed by force or by hand loses its untracked and ignored files +// (cursor/plugins#449), so no playbook may prescribe either. A plain +// `git worktree remove` that refuses is the stop. +const UNSAFE_REMOVAL = /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f)\b|\brm\s+-(?:[rR]f|f[rR])\b/; + // Layout invariants that live outside any one skill. export function validatePluginLayout(pluginRoot) { if (!existsSync(join(pluginRoot, "skills", ROUTED_SKILL, "SKILL.md"))) { @@ -171,17 +176,26 @@ export function validatePluginLayout(pluginRoot) { // tools/sync.mjs writes an unresolved three-way merge with git's markers and // still advances the pin, so this check is what keeps it out of a release. const markers = []; + const unsafe = []; for (const file of walk(pluginRoot)) { if (!lstatSync(file).isFile()) continue; const raw = readFileSync(file); if (raw.includes(0)) continue; + const rel = relative(pluginRoot, file); + const playbook = /(^|[\\/])playbooks[\\/][^\\/]+\.md$/.test(rel); raw.toString("utf8").split("\n").forEach((line, i) => { - if (/^(<{7}|\|{7}|={7}|>{7})( |$)/.test(line)) markers.push(`${relative(pluginRoot, file)}:${i + 1}: ${line}`); + if (/^(<{7}|\|{7}|={7}|>{7})( |$)/.test(line)) markers.push(`${rel}:${i + 1}: ${line}`); + if (playbook && UNSAFE_REMOVAL.test(line)) unsafe.push(`${rel}:${i + 1}: ${line}`); }); } if (markers.length) { throw new Error(`unresolved sync conflict markers; resolve each hunk by hand:\n${markers.join("\n")}`); } + if (unsafe.length) { + throw new Error( + `a playbook force-removes a worktree or deletes one by hand; prune with a plain git worktree remove and stop when it refuses:\n${unsafe.join("\n")}`, + ); + } } const TIERS = ["default", "strongest", "panel"]; From df4420c6e831daebe26f53409b3b73dd105e2497 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 16 Oct 2026 17:56:40 +0200 Subject: [PATCH 4/9] fix(pstack): drop the signed C2PA manifest from the plugin icons Both icon copies carried a caBX chunk with an image generator's signed C2PA manifest, whose certificate and signature text a plugin directory screen can read as a credential. The files are Michael Denyer's from pstack-claude #271: every other chunk is byte-identical, so the pixels are unchanged. His test fails on any tracked PNG with a caBX chunk. --- plugins/pstack/.claude-plugin/icon.png | Bin 977799 -> 955906 bytes plugins/pstack/assets/pstack-icon.png | Bin 977799 -> 955906 bytes tests/image-metadata.test.mjs | 28 +++++++++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 tests/image-metadata.test.mjs diff --git a/plugins/pstack/.claude-plugin/icon.png b/plugins/pstack/.claude-plugin/icon.png index 93b16adfd88b7ef54752ed0e648b8bd77836bded..b8d9c727a19a4d4be691839a3fe8f7f3338ca274 100644 GIT binary patch delta 69 zcmZp_Zr!xTYJyVp^3e9>p^QMx1jNih%mT!$K+Fcj>_E%`#GF9P1;pGy%mc){+n0y( HJ(vmr?M@tP delta 12712 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 HJ(vmr?M@tP delta 12712 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 ax_P#GdA9m_wg!2&hIzI|du@#$E&U(IU2 { + expect(pngs.length).toBeGreaterThan(0); +}); + +test.each(pngs)("%s carries no C2PA manifest chunk", (path) => { + expect(chunkTypes(readFileSync(join(repoRoot, path)))).not.toContain("caBX"); +}); From f733b72494d66ebbd3d60603b03aef072235a8a2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 16 Oct 2026 17:58:20 +0200 Subject: [PATCH 5/9] fix(poteto-mode): hold worktrees with untracked scratch The audit still bucketed a worktree with only untracked files safe, as Lauren's script treats them as throwaway. cursor/plugins#449 covers them too, and the cleanup playbook now pauses on scratch and a plain remove refuses it, so the bucket says hold-scratch and the advice matches the playbook. --- .../pstack/skills/poteto-mode/scripts/worktree-audit.sh | 9 +++++---- tests/worktree-audit.test.mjs | 8 ++++++++ tools/forks.json | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 8c528bda..79cc7dcf 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -104,9 +104,9 @@ 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 untracked scratch. Ignored paths - # outside $disposable count too: `git worktree remove` deletes them without - # asking, and a .env or .claude/settings.local.json may exist nowhere else. + # Count tracked edits (wip), untracked scratch, and ignored paths outside + # $disposable. `git worktree remove` deletes ignored files without asking, + # and a .env or .claude/settings.local.json may exist nowhere else. porcelain=$(git -C "$wt" status --porcelain --ignored=matching 2>/dev/null) wip=$(printf '%s\n' "$porcelain" | grep -cvE '^(\?\?|!!) |^$') scratch=$(printf '%s\n' "$porcelain" | grep -c '^?? ') @@ -148,7 +148,8 @@ 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) - case "$dirty" in wip:*) bucket=hold-wip ;; *reflog:*) bucket=hold-reflog ;; *ignored:*) bucket=hold-ignored ;; *) + case "$dirty" in wip:*) bucket=hold-wip ;; scratch:*) bucket=hold-scratch ;; + *reflog:*) bucket=hold-reflog ;; *ignored:*) bucket=hold-ignored ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) if [ "$recent" = yes ]; then bucket=verify-recent-chat elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index 9598c2ae..e0c7b6a5 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -133,6 +133,14 @@ test("an ignored file outside build and cache directories holds the worktree, ig expect([rows.deps.dirty, rows.deps.bucket]).toEqual(["clean", "safe"]); }); +test("untracked scratch holds the worktree, since a plain git worktree remove refuses it", () => { + const fixture = createFixture(); + const notes = addWorktree(fixture, "notes"); + writeFileSync(join(notes, "notes.md"), "run log\n"); + const rows = runAudit(fixture); + expect([rows.notes.dirty, rows.notes.bucket]).toEqual(["scratch:1", "hold-scratch"]); +}); + test("a commit only the worktree's HEAD reflog reaches holds it, a rebased branch's old tip does not", () => { const fixture = createFixture(); const detached = addWorktree(fixture, "detached", "--detach"); diff --git a/tools/forks.json b/tools/forks.json index ed4eddc8..4d9f13a9 100644 --- a/tools/forks.json +++ b/tools/forks.json @@ -80,7 +80,7 @@ }, "plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh": { "kind": "policy", - "why": "Holds a worktree whose removal would delete an ignored file outside named build and cache directories or orphan a commit only its HEAD reflog reaches, ported from Michael Denyer's pstack-claude #277, fetches the base only under --fetch, and sets GIT_OPTIONAL_LOCKS=0 so its git status never rewrites a worktree's index.", + "why": "Holds a worktree with untracked scratch, with an ignored file outside named build and cache directories, or with a commit only its HEAD reflog reaches, the last two ported from Michael Denyer's pstack-claude #277, fetches the base only under --fetch, and sets GIT_OPTIONAL_LOCKS=0 so its git status never rewrites a worktree's index.", "since": "2.0.0-rc.7", "upstream": "https://github.com/cursor/plugins/issues/449" }, From 3228f5a0d3157a96f0289ed58ee0b4558e894c75 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 16 Oct 2026 18:25:49 +0200 Subject: [PATCH 6/9] fix(poteto-mode): close the worktree audit gaps the review found Two reviewers found that a status.showUntrackedFiles=no config made the new git status call fail, which the audit read as clean, so a worktree with an ignored .env bucketed safe again. Under that config a plain git worktree remove also deletes untracked files without refusing. The status call now passes --untracked-files=normal. A status call or HEAD reflog walk that fails holds the worktree as unknown or reflog:? instead of reading clean. A detached commit HEAD still points at is held after gc expires its reflog, since HEAD itself now feeds the reflog walk. An ignored file under a tracked directory that happens to be named build or target is held, since only the last component of an ignored path can mark it disposable. wip and scratch print together. Step 4 lists ignored files with the audit's own status call, names unknown, reads a prunable worktree's HEAD and reflog from the main checkout, and deletes approved scratch with --literal-pathspecs, so notes[1].md no longer also matches notes1.md. Step 5 says what a plain remove deletes. The generator check also rejects -ff, a recursive rm with its flags in any order, git clean -x, and git worktree prune, and lets git rm -r through. --- .../poteto-mode/playbooks/worktree-cleanup.md | 4 +- .../poteto-mode/scripts/worktree-audit.sh | 64 ++++++------ tests/worktree-audit.test.mjs | 98 ++++++++++++++++--- tools/forks.json | 4 +- tools/generate.mjs | 12 ++- 5 files changed, 128 insertions(+), 54 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index b9fc22ee..32bff1b9 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md @@ -3,8 +3,8 @@ 1. Snapshot and audit. Record `df -h /`, then run `/skills/poteto-mode/scripts/worktree-audit.sh [-b ] [--fetch] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/` when PRs target another branch. It writes nothing to the repository unless `--fetch` refreshes that base from origin first. It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.claude/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. 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, and from the T3 threads bound to each worktree (page through `t3_thread_list` with `includeSubagents: true` and read each thread's `worktreePath` with `t3_thread_read`), 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 show in the thread list. 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 N untracked paths. Name them and get a decision, then delete only the ones the user approves with `git -C clean -fd -- ...`. `ignored:N` is N ignored paths outside the build and cache directories the audit names, such as `.env` or `.claude/settings.local.json`, which may exist nowhere else. List them with `git -C status --porcelain --ignored=matching`. `reflog:N` is N commits that no branch, tag, or remote reaches, kept only by the worktree's HEAD reflog, such as work committed on a detached HEAD. They are among the entries `git -C reflog` prints, and `git branch ` keeps one. Per Autonomy, clean and merged and not-in-use proceeds. `wip`, `scratch`, `ignored`, `reflog`, and in-use pause. -5. Prune the confirmed set. Per path, run a plain `git worktree remove `. It deletes every ignored file with the directory, so a worktree with `ignored:N` waits for the user's decision from step 4. It refuses a worktree with tracked edits, untracked files, or a lock, and that refusal is the stop. Leave the worktree, report it, and never add a force flag or delete the directory by hand. Branch refs survive, so commits on the worktree's branch are kept. The worktree's HEAD reflog goes with it, which is why step 4 holds `reflog`. Confirm with `df -h /` and re-list. +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 N untracked paths. Name them and get a decision, then delete only the ones the user approves with `git --literal-pathspecs -C clean -fd -- ...`, so a name such as `notes[1].md` matches only itself. `ignored:N` is N ignored paths outside the build and cache directories the audit names, such as `.env` or `.claude/settings.local.json`, which may exist nowhere else. List them with the call the audit counts from, `git -C status --porcelain --untracked-files=normal --ignored=matching`. `reflog:N` is N commits that no branch, tag, or remote reaches, kept only by the worktree's HEAD or its reflog, such as work committed on a detached HEAD. They are HEAD or among the entries `git -C reflog` prints, and `git branch ` keeps one. `unknown` means git could not read the worktree, so treat it as `wip`. When `git worktree list` marks it `prunable`, its directory is gone, a plain remove does not refuse it, and removing it drops its HEAD and HEAD reflog. Its `` is the directory under `.git/worktrees/` whose `gitdir` file names its path. From the main checkout, `git log -1 worktrees//HEAD` shows its HEAD and `git reflog show worktrees//HEAD` its reflog. Remove it per path in step 5, never with a bulk prune. Per Autonomy, clean and merged and not-in-use proceeds. `wip`, `unknown`, `scratch`, `ignored`, `reflog`, and in-use pause. +5. Prune the confirmed set. Per path, run a plain `git worktree remove `. It deletes every ignored file with the directory, and under a `status.showUntrackedFiles=no` config every untracked file too, so a worktree with `scratch` or `ignored` waits for the user's decision from step 4. It refuses a worktree with tracked edits or a lock, and that refusal is the stop. Leave the worktree, report it, and never add a force flag or delete the directory by hand. Branch refs survive, so commits on the worktree's branch are kept. The worktree's HEAD reflog goes with it, which is why step 4 holds `reflog`. 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`, 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/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 79cc7dcf..892507d5 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -1,10 +1,10 @@ #!/usr/bin/env bash # Worktree prune audit, read-only unless --fetch is given. Classifies every git # worktree by size, merge state, uncommitted work, ignored files outside build -# and cache directories, commits only its HEAD reflog reaches, 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. +# and cache directories, commits only its HEAD or HEAD reflog reaches, +# 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. # # Usage: worktree-audit.sh [-b|--base ] [--fetch] [repo-path] # repo-path defaults to the current repo. @@ -46,10 +46,9 @@ if [ -z "$base" ]; then echo "warn: no -b given; MERGED checks against $base" >&2 fi -# The base drives the merge check. --fetch is best-effort (stale is fine for a -# first pass, it only marks fewer worktrees merged) and runs before the check, -# since -b may name a branch not fetched yet. The explicit refspec creates it -# even in a single-branch clone. +# The base drives the merge check. --fetch is best-effort (stale is fine for +# a first pass) and runs before the check, since -b may name a branch not +# fetched yet. The explicit refspec creates it even in a single-branch clone. if [ -n "$fetch" ]; then case "$base" in origin/*) git fetch origin "+refs/heads/${base#origin/}:refs/remotes/$base" --quiet 2>/dev/null \ @@ -59,15 +58,12 @@ fi git rev-parse --verify --quiet "$base^{commit}" >/dev/null 2>&1 \ || { echo "error: base ref not found: $base; --fetch fetches an origin/* base" >&2; exit 1; } -# An ignored path under one of these names is a rebuildable build or cache -# artifact. Any other ignored path, such as .env, may exist nowhere else. disposable='(\.build|\.cache|\.dart_tool|\.DS_Store|\.gradle|\.lake|\.lycheecache|\.mypy_cache|\.next|\.nuxt' disposable+='|\.parcel-cache|\.pytest_cache|\.ruff_cache|\.svelte-kit|\.tox|\.turbo|\.venv|__pycache__|build' disposable+='|coverage|DerivedData|dist|node_modules|target|venv)' -# Commits a branch reflog lists, negated for rev-list. Branch reflogs live in -# the shared logs and outlive a worktree, so a rebased branch's old tip is not -# lost with it. +# Branch reflogs live in the shared logs and outlive a worktree, so a rebased +# branch's old tip is not lost with it. branch_logged=$(git rev-list --walk-reflogs --branches 2>/dev/null | awk '!seen[$0]++ { print "^" $0 }') # PR state by branch, fetched once as [{number, state, head}] with a lowercase @@ -104,25 +100,27 @@ 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 - # Count tracked edits (wip), untracked scratch, and ignored paths outside - # $disposable. `git worktree remove` deletes ignored files without asking, - # and a .env or .claude/settings.local.json may exist nowhere else. - porcelain=$(git -C "$wt" status --porcelain --ignored=matching 2>/dev/null) - wip=$(printf '%s\n' "$porcelain" | grep -cvE '^(\?\?|!!) |^$') - scratch=$(printf '%s\n' "$porcelain" | grep -c '^?? ') - ignored=$(printf '%s\n' "$porcelain" | grep '^!! ' | grep -cvE "^!! \"?(.*/)?$disposable(/|\"?\$)") - - # Commits only this worktree's HEAD reflog reaches, such as work committed - # on a detached HEAD. Removing the worktree deletes that reflog, which - # leaves them for gc. --not before --stdin leaves stdin's revisions as given. - orphans=$({ git -C "$wt" rev-list --walk-reflogs HEAD 2>/dev/null; printf '%s\n' "$branch_logged"; } \ - | git -C "$wt" rev-list --count --not --branches --tags --remotes --stdin 2>/dev/null) - - if [ "$wip" -gt 0 ]; then dirty="wip:$wip" - elif [ "$scratch" -gt 0 ]; then dirty="scratch:$scratch" - else dirty=""; fi - [ "$ignored" -gt 0 ] && dirty="${dirty:+$dirty,}ignored:$ignored" - [ "${orphans:-0}" -gt 0 ] && dirty="${dirty:+$dirty,}reflog:$orphans" + # `git worktree remove` deletes ignored files without asking, and a .env or + # .claude/settings.local.json may exist nowhere else. An explicit + # --untracked-files beats a status.showUntrackedFiles=no config, which would + # hide scratch and make --ignored fail. + dirty="" + if porcelain=$(git -C "$wt" status --porcelain --untracked-files=normal --ignored=matching 2>/dev/null); then + wip=$(printf '%s\n' "$porcelain" | grep -cvE '^(\?\?|!!) |^$') + scratch=$(printf '%s\n' "$porcelain" | grep -c '^?? ') + ignored=$(printf '%s\n' "$porcelain" | grep '^!! ' | grep -cvE "^!! \"?(.*/)?$disposable/?\"?\$") + [ "$wip" -gt 0 ] && dirty="wip:$wip" + [ "$scratch" -gt 0 ] && dirty="${dirty:+$dirty,}scratch:$scratch" + [ "$ignored" -gt 0 ] && dirty="${dirty:+$dirty,}ignored:$ignored" + else dirty=unknown; fi + + # Removing the worktree deletes its HEAD reflog, which leaves commits only + # HEAD or that reflog reaches for gc. --not before --stdin leaves stdin's + # revisions as given, and --stdin stops at the first blank line. + reflog_only=$(head_log=$(git -C "$wt" rev-list --walk-reflogs HEAD 2>/dev/null) \ + && printf '%s\n' "$head" "$head_log" "$branch_logged" | grep . \ + | git -C "$wt" rev-list --count --not --branches --tags --remotes --stdin 2>/dev/null) || reflog_only='?' + [ "$reflog_only" != 0 ] && dirty="${dirty:+$dirty,}reflog:$reflog_only" [ -z "$dirty" ] && dirty=clean branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") @@ -148,7 +146,7 @@ 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) - case "$dirty" in wip:*) bucket=hold-wip ;; scratch:*) bucket=hold-scratch ;; + case "$dirty" in wip:*|unknown*) bucket=hold-wip ;; scratch:*) bucket=hold-scratch ;; *reflog:*) bucket=hold-reflog ;; *ignored:*) bucket=hold-ignored ;; *) case "$pr" in */open|*/draft) bucket=hold-open-pr ;; *) if [ "$recent" = yes ]; then bucket=verify-recent-chat diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index e0c7b6a5..af0375d1 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -1,6 +1,3 @@ -// poteto-mode's worktree-audit.sh reads PR state from Origin's CLI when it -// can and from gh otherwise. Most hosts have no Origin CLI, so the gh path is -// the one that runs: here it gets a PATH with a fake gh and no origin at all. import { afterEach, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; @@ -46,8 +43,6 @@ function commit(cwd, name) { git(cwd, "commit", "-q", "-m", name); } -// A repo whose main is pushed to a bare origin, and a bin directory holding -// only the tools the audit runs, so no gh or origin CLI is on PATH. function createFixture() { const root = mkdtempSync(join(tmpdir(), "worktree-audit-")); roots.push(root); @@ -70,7 +65,6 @@ function addWorktree({ root, repo }, name, ...flags) { return tree; } -// The audit's rows keyed by worktree directory name. function runAudit({ root, repo, bin }, ...args) { const result = spawnSync(Bun.which("bash"), [audit, ...args, repo], { encoding: "utf8", @@ -91,6 +85,9 @@ function runAudit({ root, repo, bin }, ...args) { ); } +// The audit reads PR state from Origin's CLI when it can and from gh otherwise. +// Most hosts have no Origin CLI, so the gh path is the one that runs: here it +// gets a PATH with a fake gh and no origin at all. test("fills the PR column from gh when no origin command is on PATH", () => { const fixture = createFixture(); for (const branch of ["shipped", "in-review", "no-pr"]) commit(addWorktree(fixture, branch), branch); @@ -133,29 +130,91 @@ test("an ignored file outside build and cache directories holds the worktree, ig expect([rows.deps.dirty, rows.deps.bucket]).toEqual(["clean", "safe"]); }); -test("untracked scratch holds the worktree, since a plain git worktree remove refuses it", () => { +test("untracked scratch holds the worktree, and tracked edits beside it print both counts", () => { const fixture = createFixture(); const notes = addWorktree(fixture, "notes"); writeFileSync(join(notes, "notes.md"), "run log\n"); + const both = addWorktree(fixture, "both"); + writeFileSync(join(both, "base.txt"), "edited\n"); + writeFileSync(join(both, "notes.md"), "run log\n"); const rows = runAudit(fixture); expect([rows.notes.dirty, rows.notes.bucket]).toEqual(["scratch:1", "hold-scratch"]); + expect([rows.both.dirty, rows.both.bucket]).toEqual(["wip:1,scratch:1", "hold-wip"]); }); -test("a commit only the worktree's HEAD reflog reaches holds it, a rebased branch's old tip does not", () => { +test("an ignored file inside a tracked directory named build still holds the worktree", () => { + const fixture = createFixture(); + mkdirSync(join(fixture.repo, "build")); + commit(join(fixture.repo, "build"), "run"); + git(fixture.repo, "push", "-q", "origin", "main"); + writeFileSync(join(fixture.repo, ".git/info/exclude"), ".env\n"); + writeFileSync(join(addWorktree(fixture, "tracked-build"), "build/.env"), "TOKEN=only-here\n"); + const rows = runAudit(fixture); + expect([rows["tracked-build"].dirty, rows["tracked-build"].bucket]).toEqual(["ignored:1", "hold-ignored"]); +}); + +test("a worktree git cannot read holds instead of reading clean", () => { + const fixture = createFixture(); + const broken = addWorktree(fixture, "broken"); + writeFileSync(git(broken, "rev-parse", "--path-format=absolute", "--git-path", "index").trim(), "not an index\n"); + const rows = runAudit(fixture); + expect([rows.broken.dirty, rows.broken.bucket]).toEqual(["unknown", "hold-wip"]); +}); + +test("a status.showUntrackedFiles=no config hides neither scratch nor ignored files from the audit", () => { + const fixture = createFixture(); + writeFileSync(join(fixture.repo, ".git/info/exclude"), ".env\n"); + git(fixture.repo, "config", "status.showUntrackedFiles", "no"); + writeFileSync(join(addWorktree(fixture, "secrets"), ".env"), "TOKEN=only-here\n"); + writeFileSync(join(addWorktree(fixture, "notes"), "notes.md"), "run log\n"); + const rows = runAudit(fixture); + expect([rows.secrets.dirty, rows.secrets.bucket]).toEqual(["ignored:1", "hold-ignored"]); + expect([rows.notes.dirty, rows.notes.bucket]).toEqual(["scratch:1", "hold-scratch"]); +}); + +test("a commit only the worktree's HEAD reflog reaches holds it, a rebased branch's old tip does not, even parked at HEAD", () => { const fixture = createFixture(); const detached = addWorktree(fixture, "detached", "--detach"); commit(detached, "detached work"); git(detached, "checkout", "-q", "--detach", "origin/main"); const rebased = addWorktree(fixture, "rebased"); commit(rebased, "feature"); + const oldTip = git(rebased, "rev-parse", "HEAD").trim(); const landed = addWorktree(fixture, "landed"); commit(landed, "trunk moves"); git(landed, "push", "-q", "origin", "HEAD:main"); git(rebased, "rebase", "-q", "origin/main"); git(rebased, "push", "-q", "origin", "HEAD:main"); + const parked = join(fixture.root, "parked"); + git(fixture.repo, "worktree", "add", "-q", "--detach", parked, oldTip); + git(parked, "reflog", "expire", "--expire=now", "HEAD"); const rows = runAudit(fixture); expect([rows.detached.merged, rows.detached.dirty, rows.detached.bucket]).toEqual(["YES", "reflog:1", "hold-reflog"]); expect([rows.rebased.merged, rows.rebased.dirty, rows.rebased.bucket]).toEqual(["YES", "clean", "safe"]); + expect(rows.parked.dirty).toBe("clean"); +}); + +test("a detached commit HEAD still points at holds the worktree after its reflog expires", () => { + const fixture = createFixture(); + const old = addWorktree(fixture, "old", "--detach"); + commit(old, "old work"); + git(old, "reflog", "expire", "--expire=now", "HEAD"); + const rows = runAudit(fixture); + expect([rows.old.dirty, rows.old.bucket]).toEqual(["reflog:1", "hold-reflog"]); +}); + +test("a HEAD reflog walk that fails holds the worktree instead of reading clean", () => { + const fixture = createFixture(); + const detached = addWorktree(fixture, "detached", "--detach"); + commit(detached, "detached work"); + commit(detached, "more work"); + const tip = git(detached, "rev-parse", "HEAD").trim(); + git(detached, "checkout", "-q", "--detach", "origin/main"); + const object = join(fixture.repo, ".git/objects", tip.slice(0, 2), tip.slice(2)); + chmodSync(object, 0o644); + writeFileSync(object, "corrupt\n"); + const rows = runAudit(fixture); + expect([rows.detached.dirty, rows.detached.bucket]).toEqual(["reflog:?", "hold-reflog"]); }); test("the audit writes nothing to the repository unless --fetch is given", () => { @@ -188,10 +247,25 @@ test("the generator check rejects a playbook that force-removes a worktree or de writeFileSync(join(root, "skills/poteto-mode/SKILL.md"), "---\nname: poteto-mode\n---\n"); writeFileSync( join(playbooks, "worktree-cleanup.md"), - "5. Prune. Per path, `git worktree remove --force `.\nIf the dir survives, `rm -rf` it.\n", - ); - expect(() => validatePluginLayout(root)).toThrow( - /worktree-cleanup\.md:1: .*worktree remove --force[\s\S]*worktree-cleanup\.md:2: .*rm -rf/, + [ + "5. Prune. Per path, `git worktree remove --force `.", + "If the dir survives, `rm -rf` it.", + "A locked one takes `git worktree remove -ff `.", + "Or `rm -r -f `.", + "Then `git -C clean -fdx` drops what is left.", + "Or `rm -f -r `.", + "Or `git -C clean -e keep -fdx`.", + "Delete approved scratch with `git -C clean -fd -- `.", + "Untrack a directory with `git -C rm -r --cached `.", + "Then `git worktree prune` drops stale entries.", + ].join("\n"), ); + let message = ""; + try { + validatePluginLayout(root); + } catch (err) { + message = err.message; + } + expect([...message.matchAll(/worktree-cleanup\.md:(\d+):/g)].map((hit) => Number(hit[1]))).toEqual([1, 2, 3, 4, 5, 6, 7, 10]); expect(() => validatePluginLayout(pluginRoot)).not.toThrow(); }); diff --git a/tools/forks.json b/tools/forks.json index 4d9f13a9..086fa801 100644 --- a/tools/forks.json +++ b/tools/forks.json @@ -26,7 +26,7 @@ }, "plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md": { "kind": "policy", - "why": "Prunes with a plain git worktree remove whose refusal is the stop, never a force flag or a hand delete, as sergchil/jutsu da94424 does, pauses on untracked scratch and deletes only the paths the user approves, names the ignored and reflog counts the audit adds with the commands that list them, and documents the audit's --fetch.", + "why": "Prunes with a plain git worktree remove whose refusal is the stop, never a force flag or a hand delete, as sergchil/jutsu da94424 does, pauses on untracked scratch and deletes only the paths the user approves with a literal-pathspec git clean, names the ignored, reflog, and unknown labels the audit adds with the status call it counts from, reads a prunable worktree's HEAD and reflog from the main checkout instead of a bulk prune, and documents the audit's --fetch.", "since": "2.0.0-rc.7", "upstream": "https://github.com/cursor/plugins/issues/449" }, @@ -80,7 +80,7 @@ }, "plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh": { "kind": "policy", - "why": "Holds a worktree with untracked scratch, with an ignored file outside named build and cache directories, or with a commit only its HEAD reflog reaches, the last two ported from Michael Denyer's pstack-claude #277, fetches the base only under --fetch, and sets GIT_OPTIONAL_LOCKS=0 so its git status never rewrites a worktree's index.", + "why": "Holds a worktree with untracked scratch, with an ignored file whose last component is not a named build or cache directory, with a commit only HEAD or its reflog reaches, or that git cannot read, the ignored and reflog checks ported from Michael Denyer's pstack-claude #277, passes --untracked-files so a status.showUntrackedFiles=no config hides nothing, fetches the base only under --fetch, and sets GIT_OPTIONAL_LOCKS=0 so its git status never rewrites a worktree's index.", "since": "2.0.0-rc.7", "upstream": "https://github.com/cursor/plugins/issues/449" }, diff --git a/tools/generate.mjs b/tools/generate.mjs index 0ec061be..c000d625 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -153,10 +153,12 @@ export function agentSkills(skillsDir) { return skills; } -// A worktree removed by force or by hand loses its untracked and ignored files -// (cursor/plugins#449), so no playbook may prescribe either. A plain -// `git worktree remove` that refuses is the stop. -const UNSAFE_REMOVAL = /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f)\b|\brm\s+-(?:[rR]f|f[rR])\b/; +// A worktree removed by force or by hand, or cleaned with git clean -x, loses +// its untracked or ignored files (cursor/plugins#449), and git worktree prune +// drops every prunable worktree's HEAD reflog at once, so no playbook may +// prescribe any of them. +const UNSAFE_REMOVAL = + /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f+)\b|\bworktree\s+prune\b|(? Date: Fri, 16 Oct 2026 18:00:32 +0200 Subject: [PATCH 7/9] Release 2.0.0-rc.7: docs, notices, and manifests VERSION, the manifests, and package.json move to 2.0.0-rc.7. CHANGES.md gets the entry with its behavior-delta table. NOTICE.md and NOTICE-skills.md credit Michael Denyer's pstack-claude #277 and #271 and sergchil/jutsu da94424, CONTRIBUTING.md names the two new forks, and the README's pinned installer command names the new tag. --- .claude-plugin/marketplace.json | 2 +- CHANGES.md | 23 +++++++++++++++++++ CONTRIBUTING.md | 2 +- NOTICE-skills.md | 5 ++-- NOTICE.md | 4 +++- README.md | 2 +- VERSION | 2 +- package.json | 2 +- plugins/pstack/.claude-plugin/plugin.json | 2 +- plugins/pstack/.codex-plugin/plugin.json | 2 +- .../poteto-mode/references/licenses/NOTICE.md | 5 ++-- 11 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 87fa7659..2dead3bc 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "t3-pstack", "source": "./plugins/pstack", "description": "pstack for T3 Code: rigorous agent workflows whose panels and fan-outs run across model families through T3. Original pstack by Lauren Tan (poteto).", - "version": "2.0.0-rc.6" + "version": "2.0.0-rc.7" } ] } diff --git a/CHANGES.md b/CHANGES.md index 3c364084..572f50c3 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,29 @@ This file is the release changelog, with one `## - ` entry per release, newest first. The Cursor-to-T3 rewrite rules live in [`tools/substitutions.json`](tools/substitutions.json), and the [sync boundary](CONTRIBUTING.md#the-sync-boundary) in `CONTRIBUTING.md` defines how a change to upstream's skill content is declared. +## 2.0.0-rc.7 - Worktree cleanup keeps ignored files, scratch, and reflog-only commits + +Worktree cleanup could destroy work that existed nowhere else, as [cursor/plugins#449](https://github.com/cursor/plugins/issues/449) reports. The audit bucketed a worktree `safe` when it held only untracked scratch or an ignored file such as `.env` or `.claude/settings.local.json`. Step 5 then ran `git worktree remove --force`, and `rm -rf` on any directory that survived. A commit that only the worktree's HEAD reflog reached, such as one made on a detached HEAD, went with the worktree too. Removal deletes that reflog and leaves the commit for `git gc`. The audit also called itself read-only, yet it fetched the base ref ([cursor/plugins#470](https://github.com/cursor/plugins/issues/470)), and its `git status` rewrote the index of each worktree it read. That write can collide with an agent working in the worktree. + +The audit now asks `git status` for ignored paths with `--ignored=matching`. It counts each one whose last component is not in a named list of build and cache directories, such as `node_modules`, `.venv`, `__pycache__`, `dist`, `build`, and `target`, as `ignored:N` and holds the worktree as `hold-ignored`. An ignored `build/.env` under a tracked `build/` directory still counts. The audit walks each worktree's HEAD and HEAD reflog and counts commits that no branch, tag, remote, or branch reflog reaches as `reflog:N`, held as `hold-reflog`. HEAD itself is in the walk, so a detached commit still counts after `git gc` expires its reflog. A branch reflog lives in the shared logs and outlives the worktree, so a rebased branch's old tip does not count. Both checks port Michael Denyer's [pstack-claude #277](https://github.com/michael-denyer/pstack-claude/pull/277) from his JavaScript audit to Lauren's bash script, with his test cases. Untracked scratch now holds as `hold-scratch`, and an explicit `--untracked-files` keeps a `status.showUntrackedFiles=no` config from hiding it. A worktree git cannot read holds as `unknown` instead of reading `clean`. The audit fetches only under `--fetch` and sets `GIT_OPTIONAL_LOCKS=0`, so without `--fetch` it writes nothing to the repository. + +Step 5 now runs a plain `git worktree remove` and stops when it refuses, as sergchil's [jutsu `da94424`](https://github.com/sergchil/jutsu/commit/da9442422c72cc99442c1ce3500d2f2485234a31) does. A plain remove refuses tracked edits and a lock. It refuses untracked files unless a `status.showUntrackedFiles=no` config hides them, and it deletes ignored files without asking, so the audit holds both kinds itself. Step 4 deletes the scratch a user approves with `git --literal-pathspecs clean`, so `notes[1].md` matches only itself. `bun tools/generate.mjs --check` fails when any playbook force-removes a worktree, runs a recursive `rm`, runs `git clean -x`, or runs `git worktree prune`, which drops every prunable worktree's HEAD reflog at once. The audit and the playbook are declared forks that point at #449. + +The plugin icon, in `plugins/pstack/.claude-plugin/icon.png` and `plugins/pstack/assets/pstack-icon.png`, carried a PNG `caBX` chunk with an image generator's signed C2PA manifest. A plugin directory screen that reads image text can take its certificate and signature for a credential. Both copies are now Michael's files from [pstack-claude #271](https://github.com/michael-denyer/pstack-claude/pull/271). Every other chunk is byte-identical, so the pixels do not change. + +In a throwaway repository, rc.6's audit bucketed a worktree with an ignored `.env` and one with a detached commit `safe`. Its step 5 removed both, and the commit was gone after `git gc`. rc.7 holds both, and its step 5 removed only the clean, merged worktrees. At `99743e9` each component's dry run reports no undeclared fork, no denylist hit, and no substitution that matches nothing. Neither hooks file changes. + +| Change | 2.0.0-rc.6 | 2.0.0-rc.7 | Source | +| --- | --- | --- | --- | +| Ignored files in a worktree | Not read. A worktree whose only change was an ignored `.env` read `clean` and could bucket `safe`. | `ignored:N` counts ignored paths whose last component is not one of the audit's build and cache directories, and buckets `hold-ignored`. A worktree with only `node_modules` and the like still reads `clean`. | pstack-claude #277 | +| Commits only a worktree's HEAD reflog reaches | Not checked, so a detached commit went with the worktree. | `reflog:N` counts commits only HEAD or its reflog reaches and buckets `hold-reflog`, also after `git gc` expires the reflog. A rebased branch's old tip stays in the branch reflog and does not count. | pstack-claude #277 | +| A worktree git cannot read | Read `clean` and could bucket `safe`. | `unknown` buckets `hold-wip`, and a failed reflog walk prints `reflog:?` and holds. Step 4 reads a prunable worktree's HEAD and reflog from the main checkout. | T3 fix | +| Untracked scratch | `scratch:N`, called throwaway, could bucket `safe`. | `scratch:N` buckets `hold-scratch`, and a `status.showUntrackedFiles=no` config no longer hides it. Step 4 names the paths and deletes only the ones the user approves, with a `git clean` that reads each name literally. | T3 fix | +| Cleanup step 5 | `git worktree remove --force`, then `rm -rf` on a directory that survived, then `git worktree prune`. | A plain `git worktree remove`. Its refusal is the stop, with no force flag and no hand delete. | jutsu `da94424` | +| Playbook check | None. | `bun tools/generate.mjs --check` fails on a playbook line that force-removes a worktree, runs a recursive `rm`, runs `git clean -x`, or runs `git worktree prune`. | jutsu `da94424` | +| Audit writes | Fetched the base from origin on every run, and `git status` rewrote each worktree's index, under a read-only claim. | Writes nothing to the repository unless `--fetch` is given. `GIT_OPTIONAL_LOCKS=0` keeps `git status` off the index. | T3 fix | +| Plugin icon | Carried a `caBX` chunk with a signed C2PA manifest. | No `caBX` chunk and the same pixels. `tests/image-metadata.test.mjs` fails on any tracked PNG that has one. | pstack-claude #271 | + ## 2.0.0-rc.6 - Lauren's `99743e9`: marginal sections cut from four skills t3-pstack moves to Lauren Tan's pstack at cursor/plugins `99743e9` for all three components. Only pstack changed upstream, and its version stays 0.15.25: four skills lose sections that padded or restated their core ([pstack #634](https://github.com/cursor/plugins/pull/634)). The change only deletes text. Two rules rewrote swarm lines that upstream deleted, "Fan out N parallel cloud workers." and "not the cloud concurrency limit.", so both matched nothing and are removed. Removing them moved the derived merge base and made the three-way merge conflict on swarm, so pstack synced through `bun tools/sync.mjs --bootstrap`, which derived 120 files, kept the 13 declared forks, and changed only the four upstream touched. The port's diff to those four files is upstream's line for line, apart from the T3 form of the two deleted lines. `poteto-mode`'s principles index never quoted the dropped description clause, and upstream left it alone. At `99743e9` each component's dry run reports no undeclared fork, no denylist hit, and no substitution that matches nothing. Upstream's README change falls under a path the port excludes, and neither hooks file changes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e07dc68..743e261a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ Thanks for helping out. This repo is a **port**, not an original work: the skill Upstream owns the skill text. This port owns the rewrite of upstream's Cursor mechanics into their T3 form, and nothing else. The rewrite has two parts: - **Substitutions.** [`tools/substitutions.json`](tools/substitutions.json) rewrites each upstream sentence that names a Cursor mechanism: the `Task` tool, `subagent_type`, model slugs, the `pstack-models.mdc` rule, cloud agents, `/loop`, Cursor paths. Most T3 behavior the port adds, such as the dispatch ledger or the review loop, is an anchored insert: a rule whose pattern is an upstream sentence and whose replacement keeps that sentence and appends the T3 text. A substitution leaves upstream's later edits elsewhere in the file mechanical, so the port prefers it to a fork. Each rule's `rationale` names why it exists, and most cite the section of t3-pstack 1.0.14's `t3-tools.md` it replaces. -- **Forks.** [`tools/forks.json`](tools/forks.json) declares each file the port keeps in its own form, with a `kind`, a one-sentence `why`, the `since` release, and an `upstream` status. Today that is setup-pstack, which writes the T3 model sheet, the hook points of the watch-pr settle overlay, and the files that carry the rules and watch-pr fixes restored from Michael Denyer's pstack-claude. Every upstream change to a forked file needs a person to merge it, so a fork is the last resort. +- **Forks.** [`tools/forks.json`](tools/forks.json) declares each file the port keeps in its own form, with a `kind`, a one-sentence `why`, the `since` release, and an `upstream` status. Today that is setup-pstack, which writes the T3 model sheet, the hook points of the watch-pr settle overlay, the files that carry the rules and watch-pr fixes restored from Michael Denyer's pstack-claude, and the worktree audit and cleanup playbook, which fix [cursor/plugins#449](https://github.com/cursor/plugins/issues/449). Every upstream change to a forked file needs a person to merge it, so a fork is the last resort. The port also owns files upstream does not have: the plugin manifests, the session-start hooks and their routing text, `models.json`, the sheet checker `t3-sheet.mjs`, the watch-pr settle overlay (`settle.ts`), and the transcript lookups reflect uses. The sync lists them as port-only. diff --git a/NOTICE-skills.md b/NOTICE-skills.md index 823c4427..9a12b5a3 100644 --- a/NOTICE-skills.md +++ b/NOTICE-skills.md @@ -1,13 +1,14 @@ # NOTICE for the installed plugin -This plugin contains MIT-licensed work from five sources. The copyright notices and license terms travel beside this file. +This plugin contains MIT-licensed work from six sources. The copyright notices and license terms travel beside this file. | Included work | Upstream | Copyright | License | | --- | --- | --- | --- | | The pstack skills, principles, scripts, and the `poteto-agent` and `comment-sicko` definitions | [cursor/plugins/pstack](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/pstack) | (c) 2026 Lauren Tan | [MIT](LICENSE) | | `deslop`, `make-pr-easy-to-review`, `fix-ci`, `fix-merge-conflicts`, `get-pr-comments`, and `what-did-i-get-done` | [cursor/plugins/cursor-team-kit](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/cursor-team-kit) | (c) 2026 Cursor | [MIT](LICENSE-cursor-team-kit) | | `thermos`, `thermo-nuclear-review`, `thermo-nuclear-code-quality-review`, and the `thermo-nuclear-review-subagent` and `thermo-nuclear-code-quality-review-subagent` definitions | [cursor/plugins/thermos](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/thermos) | (c) 2026 Cursor | [MIT](LICENSE-thermos) | -| The session-start hook scripts, `read-sheet.sh`, and `reflect/scripts/find-transcript.mjs`, which t3-pstack modified, and four `poteto-mode` rules, the worktree and head-guard rules in the Feature, Opening a PR, and Shipping playbooks, and three `watch-pr` fixes, which t3-pstack reworded or adapted for T3 | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude) | (c) 2026 Michael Denyer | [MIT](LICENSE) | +| The session-start hook scripts, `read-sheet.sh`, and `reflect/scripts/find-transcript.mjs`, which t3-pstack modified, and four `poteto-mode` rules, the worktree and head-guard rules in the Feature, Opening a PR, and Shipping playbooks, three `watch-pr` fixes, and the ignored-file and reflog-only-commit holds in `poteto-mode/scripts/worktree-audit.sh`, which t3-pstack reworded or adapted for T3, and the plugin icon | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude) | (c) 2026 Michael Denyer | [MIT](LICENSE) | +| The plain-remove prune step in the worktree-cleanup playbook, which t3-pstack adapted | [sergchil/jutsu](https://github.com/sergchil/jutsu/commit/da9442422c72cc99442c1ce3500d2f2485234a31) | (c) 2026 Sergey Chilingaryan | [MIT](LICENSE) | | `poteto-mode/scripts/resolve-codex-transcript` and `resolve-codex-transcript.ts`, which t3-pstack modified | [ericlitman/open-pstack](https://github.com/ericlitman/open-pstack/tree/c5c8e237c324359a3d1929037da78fe66da3d1ed/plugins/pstack/skills/poteto-mode/scripts), [PR #151](https://github.com/ericlitman/open-pstack/pull/151) | (c) 2026 Eric A. Litman and Donncha O'Toole | [MIT](LICENSE) | t3-pstack rewrites upstream's Cursor mechanics into their T3 form and adds the files its [repository notice](https://github.com/chhoumann/t3-pstack/blob/main/NOTICE.md) lists. Those modifications are (c) 2026 Christian Bager Bach Houmann under the same [MIT license](LICENSE). The [change log](https://github.com/chhoumann/t3-pstack/blob/main/CHANGES.md) records each release. diff --git a/NOTICE.md b/NOTICE.md index a2b0f418..9985adf1 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -11,6 +11,8 @@ t3-pstack is a port of [pstack](https://github.com/cursor/plugins/tree/main/psta | `plugins/pstack/skills/{thermos,thermo-nuclear-review,thermo-nuclear-code-quality-review}/`, `plugins/pstack/agents/thermo-nuclear-review-subagent.md`, `plugins/pstack/agents/thermo-nuclear-code-quality-review-subagent.md` | [cursor/plugins/thermos @ 99743e9](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/thermos) | (c) 2026 Cursor | MIT | [LICENSE-thermos](LICENSE-thermos) | | `tools/sync.mjs`, `tools/generate.mjs`, `tools/validate-skills.mjs`, `tools/plugin.mjs`, `plugins/pstack/skills/reflect/scripts/find-transcript.mjs`, `plugins/pstack/hooks/session-start.sh`, `plugins/pstack/hooks/session-start.ps1`, `plugins/pstack/skills/setup-pstack/scripts/read-sheet.sh`, and the tests that cover them, each modified by t3-pstack | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude), as merged into t3-pstack through 1.0.14 | (c) 2026 Michael Denyer | MIT | [LICENSE](LICENSE) | | Four rules in `plugins/pstack/skills/poteto-mode/SKILL.md` (load a platform's skill before deploying, stop an abandoned agent and confirm it stopped, keep a review gate closed with no independent reviewer, the `todo.md` fallback), the per-delegate worktree rule in `playbooks/feature.md`, the stop-live-agents rule in `playbooks/opening-a-pr.md`, the `--match-head-commit` guard in `playbooks/shipping.md`, and the review-gate, stale-check, and no-checks fixes in `plugins/pstack/skills/poteto-mode/scripts/watch-pr/` with their tests in `verdicts.test.ts`, each reworded or adapted for T3 by t3-pstack | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude), pull requests [#95](https://github.com/michael-denyer/pstack-claude/pull/95), [#97](https://github.com/michael-denyer/pstack-claude/pull/97), and [#153](https://github.com/michael-denyer/pstack-claude/pull/153) for watch-pr | (c) 2026 Michael Denyer | MIT | [LICENSE](LICENSE) | +| The ignored-file and reflog-only-commit holds in `plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh`, ported from JavaScript to bash, and their wording in `playbooks/worktree-cleanup.md`, with the test cases in `tests/worktree-audit.test.mjs`; the plugin icon with its C2PA manifest removed (`plugins/pstack/.claude-plugin/icon.png`, `plugins/pstack/assets/pstack-icon.png`) and `tests/image-metadata.test.mjs` | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude), pull requests [#277](https://github.com/michael-denyer/pstack-claude/pull/277) and [#271](https://github.com/michael-denyer/pstack-claude/pull/271) | (c) 2026 Michael Denyer | MIT | [LICENSE](LICENSE) | +| The plain-remove prune step in `plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md` and the playbook check in `tools/generate.mjs`, each adapted by t3-pstack | [sergchil/jutsu @ da94424](https://github.com/sergchil/jutsu/commit/da9442422c72cc99442c1ce3500d2f2485234a31) | (c) 2026 Sergey Chilingaryan | MIT | [LICENSE](LICENSE) | | `plugins/pstack/skills/poteto-mode/scripts/resolve-codex-transcript` (verbatim) and `resolve-codex-transcript.ts` (modified, see below), `tests/resolve-codex-transcript.test.mjs` (ported from its `resolve-codex-transcript.test.ts`) | [ericlitman/open-pstack @ c5c8e23](https://github.com/ericlitman/open-pstack/tree/c5c8e237c324359a3d1929037da78fe66da3d1ed/plugins/pstack/skills/poteto-mode/scripts), [PR #151](https://github.com/ericlitman/open-pstack/pull/151) | (c) 2026 Eric A. Litman and Donncha O'Toole | MIT | [LICENSE](LICENSE) | ## What changed in the port @@ -19,7 +21,7 @@ t3-pstack is a port of [pstack](https://github.com/cursor/plugins/tree/main/psta - Plugin content lives at `plugins/pstack/`, with a Claude Code manifest (`.claude-plugin/plugin.json`) and a Codex manifest (`.codex-plugin/plugin.json`). The repository root holds both marketplaces (`.claude-plugin/marketplace.json`, `.agents/plugins/marketplace.json`) and the license, notice, readme, and changelog. - Upstream's skill text is rewritten in place into its T3 form by the substitutions: Cursor's subagent tool, model slugs, model rule, cloud agents, and `/loop` become T3's `delegate_task`, sheet entries, `schedule_task`, and related tools. The `poteto-mode` skill drops upstream's `disable-model-invocation` flag so the session-start hook can invoke it. -- `plugins/pstack/skills/setup-pstack/SKILL.md` is a declared fork that writes the T3 model sheet. The watch-pr settle overlay (`plugins/pstack/skills/poteto-mode/scripts/watch-pr/settle.ts`) is t3-pstack's own, with declared forks at its hook points in upstream's watch-pr. The files that carry Michael Denyer's rules and watch-pr fixes, listed above, are declared forks too. +- `plugins/pstack/skills/setup-pstack/SKILL.md` is a declared fork that writes the T3 model sheet. The watch-pr settle overlay (`plugins/pstack/skills/poteto-mode/scripts/watch-pr/settle.ts`) is t3-pstack's own, with declared forks at its hook points in upstream's watch-pr. The files that carry Michael Denyer's rules and watch-pr fixes, listed above, are declared forks too, as are the worktree audit and the cleanup playbook, which fix [cursor/plugins#449](https://github.com/cursor/plugins/issues/449). - `plugins/pstack/skills/poteto-mode/scripts/resolve-codex-transcript.ts` is Eric A. Litman's open-pstack resolver, which builds on Donncha O'Toole's candidate, and keeps their copyright. t3-pstack 1.0.13 modified it in seven ways. It finds the index directory as Codex 0.162.1 does, from `sqlite_home` in `config.toml`, else `CODEX_SQLITE_HOME`, else `CODEX_HOME`. It refuses a relative `CODEX_SQLITE_HOME` as `sqlite_home_relative`, since Codex reads one against a working directory the resolver cannot know. It uses the newest `state_<n>.sqlite` that opens with the `threads` schema instead of refusing more than one index or any unreadable one. It opens an index with no `-wal` file as immutable, so it reads a closed WAL index without writing beside it. It reports a live index whose schema it can see only through the `-wal` file, and cannot open that way, as `index_unreadable` instead of `index_missing`. It takes a rollout's first `session_meta` as its owner, so a forked rollout resolves. It stops at that `session_meta` and no longer prints the opening user message's metadata. Prettier formats the file. ## Port-authored files diff --git a/README.md b/README.md index f4cf6209..c78ab410 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ npx -y github:chhoumann/t3-pstack update npx -y github:chhoumann/t3-pstack doctor ``` -`npx -y github:chhoumann/t3-pstack` runs the installer from the latest commit on `main`. That is the rolling channel, the same one each home's marketplace auto-updates from. To run one release's installer, name its tag, as in `npx -y github:chhoumann/t3-pstack#v2.0.0-rc.6 doctor`. +`npx -y github:chhoumann/t3-pstack` runs the installer from the latest commit on `main`. That is the rolling channel, the same one each home's marketplace auto-updates from. To run one release's installer, name its tag, as in `npx -y github:chhoumann/t3-pstack#v2.0.0-rc.7 doctor`. `install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home <dir>` or `--codex-home <dir>`. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source <owner/repo|git URL|dir>` on the same run, and a home whose marketplace has another source is left as it is. A local directory needs a path such as `./t3-pstack`, since `owner/repo` always means GitHub. On Codex it also trusts t3-pstack's session-start hook, once its hooks file is the one this release pins. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release. diff --git a/VERSION b/VERSION index e8624285..ed6b72e3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0-rc.6 +2.0.0-rc.7 diff --git a/package.json b/package.json index 5b7c654a..24753db4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "t3-pstack", "private": true, - "version": "2.0.0-rc.6", + "version": "2.0.0-rc.7", "description": "The installer and development tooling for t3-pstack, Lauren Tan's pstack rewritten in place for T3 Code. The plugin itself lives in plugins/pstack.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/.claude-plugin/plugin.json b/plugins/pstack/.claude-plugin/plugin.json index 4c41f1be..af620242 100644 --- a/plugins/pstack/.claude-plugin/plugin.json +++ b/plugins/pstack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "t3-pstack", "displayName": "t3-pstack (pstack for T3 Code)", - "version": "2.0.0-rc.6", + "version": "2.0.0-rc.7", "description": "pstack for T3 Code: Lauren Tan's pstack with its Cursor mechanics rewritten in place for T3, so subagents, panels, schedules, and PR watches dispatch through T3's orchestration and panels mix Claude, GPT, and Grok.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/.codex-plugin/plugin.json b/plugins/pstack/.codex-plugin/plugin.json index b2f56530..5df88c6d 100644 --- a/plugins/pstack/.codex-plugin/plugin.json +++ b/plugins/pstack/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "t3-pstack", - "version": "2.0.0-rc.6", + "version": "2.0.0-rc.7", "description": "pstack for T3 Code on a Codex host: Lauren Tan's pstack with its Cursor mechanics rewritten in place for T3, so subagents, panels, schedules, and PR watches dispatch through T3's orchestration.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/skills/poteto-mode/references/licenses/NOTICE.md b/plugins/pstack/skills/poteto-mode/references/licenses/NOTICE.md index 823c4427..9a12b5a3 100644 --- a/plugins/pstack/skills/poteto-mode/references/licenses/NOTICE.md +++ b/plugins/pstack/skills/poteto-mode/references/licenses/NOTICE.md @@ -1,13 +1,14 @@ # NOTICE for the installed plugin -This plugin contains MIT-licensed work from five sources. The copyright notices and license terms travel beside this file. +This plugin contains MIT-licensed work from six sources. The copyright notices and license terms travel beside this file. | Included work | Upstream | Copyright | License | | --- | --- | --- | --- | | The pstack skills, principles, scripts, and the `poteto-agent` and `comment-sicko` definitions | [cursor/plugins/pstack](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/pstack) | (c) 2026 Lauren Tan | [MIT](LICENSE) | | `deslop`, `make-pr-easy-to-review`, `fix-ci`, `fix-merge-conflicts`, `get-pr-comments`, and `what-did-i-get-done` | [cursor/plugins/cursor-team-kit](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/cursor-team-kit) | (c) 2026 Cursor | [MIT](LICENSE-cursor-team-kit) | | `thermos`, `thermo-nuclear-review`, `thermo-nuclear-code-quality-review`, and the `thermo-nuclear-review-subagent` and `thermo-nuclear-code-quality-review-subagent` definitions | [cursor/plugins/thermos](https://github.com/cursor/plugins/tree/99743e9c151c8537cbbf1f98bb38f34ef3368453/thermos) | (c) 2026 Cursor | [MIT](LICENSE-thermos) | -| The session-start hook scripts, `read-sheet.sh`, and `reflect/scripts/find-transcript.mjs`, which t3-pstack modified, and four `poteto-mode` rules, the worktree and head-guard rules in the Feature, Opening a PR, and Shipping playbooks, and three `watch-pr` fixes, which t3-pstack reworded or adapted for T3 | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude) | (c) 2026 Michael Denyer | [MIT](LICENSE) | +| The session-start hook scripts, `read-sheet.sh`, and `reflect/scripts/find-transcript.mjs`, which t3-pstack modified, and four `poteto-mode` rules, the worktree and head-guard rules in the Feature, Opening a PR, and Shipping playbooks, three `watch-pr` fixes, and the ignored-file and reflog-only-commit holds in `poteto-mode/scripts/worktree-audit.sh`, which t3-pstack reworded or adapted for T3, and the plugin icon | [michael-denyer/pstack-claude](https://github.com/michael-denyer/pstack-claude) | (c) 2026 Michael Denyer | [MIT](LICENSE) | +| The plain-remove prune step in the worktree-cleanup playbook, which t3-pstack adapted | [sergchil/jutsu](https://github.com/sergchil/jutsu/commit/da9442422c72cc99442c1ce3500d2f2485234a31) | (c) 2026 Sergey Chilingaryan | [MIT](LICENSE) | | `poteto-mode/scripts/resolve-codex-transcript` and `resolve-codex-transcript.ts`, which t3-pstack modified | [ericlitman/open-pstack](https://github.com/ericlitman/open-pstack/tree/c5c8e237c324359a3d1929037da78fe66da3d1ed/plugins/pstack/skills/poteto-mode/scripts), [PR #151](https://github.com/ericlitman/open-pstack/pull/151) | (c) 2026 Eric A. Litman and Donncha O'Toole | [MIT](LICENSE) | t3-pstack rewrites upstream's Cursor mechanics into their T3 form and adds the files its [repository notice](https://github.com/chhoumann/t3-pstack/blob/main/NOTICE.md) lists. Those modifications are (c) 2026 Christian Bager Bach Houmann under the same [MIT license](LICENSE). The [change log](https://github.com/chhoumann/t3-pstack/blob/main/CHANGES.md) records each release. From 65680c336b641a6c6da62e55c171074310f70a70 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Fri, 16 Oct 2026 19:13:15 +0200 Subject: [PATCH 8/9] fix(pstack): narrow the playbook check's git rm exception The exception that let `git -C <path> rm -r --cached` through excused a recursive rm after any earlier git on the same line, so a line such as "If git refuses, rm -rf the directory." passed the check. It now excuses only `git rm` and `git -C <path> rm`. Cursor Bugbot found it on #26. --- tests/worktree-audit.test.mjs | 3 ++- tools/generate.mjs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index af0375d1..51d0be01 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -258,6 +258,7 @@ test("the generator check rejects a playbook that force-removes a worktree or de "Delete approved scratch with `git -C <path> clean -fd -- <file>`.", "Untrack a directory with `git -C <path> rm -r --cached <dir>`.", "Then `git worktree prune` drops stale entries.", + "If git refuses, rm -rf the directory.", ].join("\n"), ); let message = ""; @@ -266,6 +267,6 @@ test("the generator check rejects a playbook that force-removes a worktree or de } catch (err) { message = err.message; } - expect([...message.matchAll(/worktree-cleanup\.md:(\d+):/g)].map((hit) => Number(hit[1]))).toEqual([1, 2, 3, 4, 5, 6, 7, 10]); + expect([...message.matchAll(/worktree-cleanup\.md:(\d+):/g)].map((hit) => Number(hit[1]))).toEqual([1, 2, 3, 4, 5, 6, 7, 10, 11]); expect(() => validatePluginLayout(pluginRoot)).not.toThrow(); }); diff --git a/tools/generate.mjs b/tools/generate.mjs index c000d625..e7231bf8 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -158,7 +158,7 @@ export function agentSkills(skillsDir) { // drops every prunable worktree's HEAD reflog at once, so no playbook may // prescribe any of them. const UNSAFE_REMOVAL = - /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f+)\b|\bworktree\s+prune\b|(?<!\bgit\b[^`\n]*\s)\brm\b[^`\n]*\s(?:-[a-zA-Z]*[rR]|--recursive)|\bclean\b[^`\n]*\s-[a-zA-Z]*[xX]/; + /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f+)\b|\bworktree\s+prune\b|(?<!\bgit\s|\bgit\s+-C\s+\S+\s)\brm\b[^`\n]*\s(?:-[a-zA-Z]*[rR]|--recursive)|\bclean\b[^`\n]*\s-[a-zA-Z]*[xX]/; // Layout invariants that live outside any one skill. export function validatePluginLayout(pluginRoot) { From d53e5a2dac69759a9f08809aa6f12bb321b2d0d5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann <christian@bagerbach.com> Date: Fri, 16 Oct 2026 19:25:26 +0200 Subject: [PATCH 9/9] fix(pstack): stop the git -C exception at a backtick or separator The path in the `git -C <path> rm` exception matched any non-space run, so a closing backtick or a glued semicolon became part of the path and a recursive rm after it passed the check. The path now stops at a backtick, `;`, `&`, or `|`. Cursor Bugbot found it on #26. --- tests/worktree-audit.test.mjs | 4 +++- tools/generate.mjs | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index 51d0be01..83a149bf 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -259,6 +259,8 @@ test("the generator check rejects a playbook that force-removes a worktree or de "Untrack a directory with `git -C <path> rm -r --cached <dir>`.", "Then `git worktree prune` drops stale entries.", "If git refuses, rm -rf the directory.", + "Run `git -C <path>` rm -rf it.", + "Or `git -C x; rm -rf y`.", ].join("\n"), ); let message = ""; @@ -267,6 +269,6 @@ test("the generator check rejects a playbook that force-removes a worktree or de } catch (err) { message = err.message; } - expect([...message.matchAll(/worktree-cleanup\.md:(\d+):/g)].map((hit) => Number(hit[1]))).toEqual([1, 2, 3, 4, 5, 6, 7, 10, 11]); + expect([...message.matchAll(/worktree-cleanup\.md:(\d+):/g)].map((hit) => Number(hit[1]))).toEqual([1, 2, 3, 4, 5, 6, 7, 10, 11, 12, 13]); expect(() => validatePluginLayout(pluginRoot)).not.toThrow(); }); diff --git a/tools/generate.mjs b/tools/generate.mjs index e7231bf8..0d62ffd7 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -158,7 +158,7 @@ export function agentSkills(skillsDir) { // drops every prunable worktree's HEAD reflog at once, so no playbook may // prescribe any of them. const UNSAFE_REMOVAL = - /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f+)\b|\bworktree\s+prune\b|(?<!\bgit\s|\bgit\s+-C\s+\S+\s)\brm\b[^`\n]*\s(?:-[a-zA-Z]*[rR]|--recursive)|\bclean\b[^`\n]*\s-[a-zA-Z]*[xX]/; + /\bworktree\s+remove\b[^`\n]*\s(?:--force|-f+)\b|\bworktree\s+prune\b|(?<!\bgit\s|\bgit\s+-C\s+[^\s`;&|]+\s)\brm\b[^`\n]*\s(?:-[a-zA-Z]*[rR]|--recursive)|\bclean\b[^`\n]*\s-[a-zA-Z]*[xX]/; // Layout invariants that live outside any one skill. export function validatePluginLayout(pluginRoot) {