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/icon.png b/plugins/pstack/.claude-plugin/icon.png index 93b16adf..b8d9c727 100644 Binary files a/plugins/pstack/.claude-plugin/icon.png and b/plugins/pstack/.claude-plugin/icon.png differ 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/assets/pstack-icon.png b/plugins/pstack/assets/pstack-icon.png index 93b16adf..b8d9c727 100644 Binary files a/plugins/pstack/assets/pstack-icon.png and b/plugins/pstack/assets/pstack-icon.png differ diff --git a/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md b/plugins/pstack/skills/poteto-mode/playbooks/worktree-cleanup.md index 66163621..32bff1b9 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 `<plugin root>/skills/poteto-mode/scripts/worktree-audit.sh [-b <pr-base-ref>] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/<branch>` 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 `<plugin root>/skills/poteto-mode/scripts/worktree-audit.sh [-b <pr-base-ref>] [--fetch] [repo-path]` (principle-build-the-lever). Without `-b` it checks `MERGED` against `origin/HEAD`, so pass `-b origin/<branch>` 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 <path>`. 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 --literal-pathspecs -C <path> clean -fd -- <file>...`, 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 <path> 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 <path> reflog` prints, and `git branch <name> <sha>` 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 `<id>` is the directory under `.git/worktrees/` whose `gitdir` file names its path. From the main checkout, `git log -1 worktrees/<id>/HEAD` shows its HEAD and `git reflog show worktrees/<id>/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 <path>`. 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 <id>` 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/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. diff --git a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh index 2780a566..892507d5 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 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 <ref>] [repo-path] +# Usage: worktree-audit.sh [-b|--base <ref>] [--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,25 @@ 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 +# 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. -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; } +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; } + +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)' + +# 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 +100,28 @@ 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 + # `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 "") if [ -z "$branch" ]; then remote=detached @@ -111,7 +146,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 ;; *) + 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 elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe diff --git a/tests/image-metadata.test.mjs b/tests/image-metadata.test.mjs new file mode 100644 index 00000000..8ea8837d --- /dev/null +++ b/tests/image-metadata.test.mjs @@ -0,0 +1,28 @@ +// Image generators embed a signed C2PA manifest in a PNG `caBX` chunk. Its +// printable text is a certificate chain and a signature block, which the plugin +// directory's text screen reads as a credential, so no shipped PNG may carry one. +import { expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); + +function chunkTypes(bytes) { + const types = []; + for (let i = 8; i < bytes.length; i += 12 + bytes.readUInt32BE(i)) { + types.push(bytes.toString("latin1", i + 4, i + 8)); + } + return types; +} + +const pngs = execFileSync("git", ["ls-files", "*.png"], { cwd: repoRoot, encoding: "utf8" }).split("\n").filter(Boolean); + +test("the repository tracks PNGs to check", () => { + expect(pngs.length).toBeGreaterThan(0); +}); + +test.each(pngs)("%s carries no C2PA manifest chunk", (path) => { + expect(chunkTypes(readFileSync(join(repoRoot, path)))).not.toContain("caBX"); +}); diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index e0aa2053..83a149bf 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -1,16 +1,16 @@ -// 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, 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 +37,13 @@ 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); +} + +function createFixture() { const root = mkdtempSync(join(tmpdir(), "worktree-audit-")); roots.push(root); const repo = join(root, "repo"); @@ -45,49 +51,224 @@ 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; +} + +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] }, + ]), + ); +} + +// 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); + 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("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("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", () => { + 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 <path>`.", + "If the dir survives, `rm -rf` it.", + "A locked one takes `git worktree remove -ff <path>`.", + "Or `rm -r -f <path>`.", + "Then `git -C <path> clean -fdx` drops what is left.", + "Or `rm -f -r <path>`.", + "Or `git -C <path> clean -e keep -fdx`.", + "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.", + "Run `git -C <path>` rm -rf it.", + "Or `git -C x; rm -rf y`.", + ].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, 11, 12, 13]); + expect(() => validatePluginLayout(pluginRoot)).not.toThrow(); +}); diff --git a/tools/forks.json b/tools/forks.json index f5d6c495..086fa801 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 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" + }, "plugins/pstack/skills/poteto-mode/scripts/watch-pr/cli.test.ts": { "kind": "port-feature", "why": "Expects the --settle default that cli.ts adds.", @@ -72,6 +78,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 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" + }, "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.", diff --git a/tools/generate.mjs b/tools/generate.mjs index 92667033..0d62ffd7 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -153,6 +153,13 @@ export function agentSkills(skillsDir) { return skills; } +// 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|(?<!\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) { if (!existsSync(join(pluginRoot, "skills", ROUTED_SKILL, "SKILL.md"))) { @@ -171,17 +178,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, deletes one by hand, cleans ignored files, or prunes worktrees in bulk; prune with a plain git worktree remove and stop when it refuses:\n${unsafe.join("\n")}`, + ); + } } const TIERS = ["default", "strongest", "panel"];