From c3b1ddf230a6095c46fbfa4962fd884ecdfd5e19 Mon Sep 17 00:00:00 2001 From: J0UH <217747670+J0UH@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:46:42 +0200 Subject: [PATCH 1/3] Read whole worktree paths in worktree-audit.sh The audit took each worktree path from `git worktree list --porcelain` with awk's $2, which stops at the first space. With the checkout and its worktrees under one parent such as "/work/My Project/", the main worktree and every other one all read as "/work/My", so the loop skipped each of them as the main worktree and printed an empty table. The audit now prints everything after "worktree " and reads it with IFS= read -r. The new test puts the fixture under a temp directory whose name has a space: before the fix it found no worktrees, now it finds both. The forks.json entry for the audit names the change. --- .../skills/poteto-mode/scripts/worktree-audit.sh | 4 ++-- tests/worktree-audit.test.mjs | 13 +++++++++++-- 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 892507d5..2d252504 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh +++ b/plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh @@ -36,7 +36,7 @@ cd "$repo" || exit 1 git rev-parse --git-dir >/dev/null 2>&1 || { echo "error: not a git repo: $repo" >&2; exit 1; } # Main worktree is the first entry; everything else is a candidate. -main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') +main_wt=$(git worktree list --porcelain | awk '/^worktree /{print substr($0, 10); exit}') if [ -z "$base" ]; then for cand in "$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null)" origin/main origin/master; do @@ -88,7 +88,7 @@ now=$(date +%s) echo "# base: $base" printf "SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" -git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do +git worktree list --porcelain | awk '/^worktree /{print substr($0, 10)}' | while IFS= read -r wt; do [ "$wt" = "$main_wt" ] && continue size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}') diff --git a/tests/worktree-audit.test.mjs b/tests/worktree-audit.test.mjs index 83a149bf..a94b54d5 100644 --- a/tests/worktree-audit.test.mjs +++ b/tests/worktree-audit.test.mjs @@ -43,8 +43,8 @@ function commit(cwd, name) { git(cwd, "commit", "-q", "-m", name); } -function createFixture() { - const root = mkdtempSync(join(tmpdir(), "worktree-audit-")); +function createFixture(prefix = "worktree-audit-") { + const root = mkdtempSync(join(tmpdir(), prefix)); roots.push(root); const repo = join(root, "repo"); mkdirSync(repo); @@ -115,6 +115,15 @@ printf '%s' '[{"number":7,"state":"MERGED","headRefName":"shipped"},{"number":8, }); }); +test("lists every worktree when the checkout and its worktrees sit under a path with spaces", () => { + const fixture = createFixture("worktree audit "); + writeFileSync(join(addWorktree(fixture, "notes"), "notes.md"), "run log\n"); + addWorktree(fixture, "idle"); + const rows = runAudit(fixture); + expect(Object.keys(rows).sort()).toEqual(["idle", "notes"]); + expect([rows.notes.dirty, rows.notes.bucket]).toEqual(["scratch:1", "hold-scratch"]); +}); + 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"); diff --git a/tools/forks.json b/tools/forks.json index 086fa801..359eecbb 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 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.", + "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, sets GIT_OPTIONAL_LOCKS=0 so its git status never rewrites a worktree's index, and reads each worktree path whole, so a path with a space is not cut at the space.", "since": "2.0.0-rc.7", "upstream": "https://github.com/cursor/plugins/issues/449" }, From d8466adfe83d8b44511369f94b2cd127b0bcbd43 Mon Sep 17 00:00:00 2001 From: J0UH <217747670+J0UH@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:46:42 +0200 Subject: [PATCH 2/3] Stop bootstrap writing through a symlinked local directory bootstrapComponent checked only the final path component with lstat. With plugins/pstack/skills/arena a symlink to a directory outside the checkout, arena/SKILL.md looked like a regular file and --bootstrap overwrote the file the link points at. The normal sync already reports a local link at a directory above an upstream path and writes nothing under it. Bootstrap now does the same: before any write it reports the first symlinked ancestor of each carried path under symlinks, which fails the run and blocks every write. dirsAbove moves to module scope so both paths share it. --- tests/sync.test.mjs | 17 +++++++++++++++++ tools/sync.mjs | 11 +++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/tests/sync.test.mjs b/tests/sync.test.mjs index 45803765..cd54e623 100644 --- a/tests/sync.test.mjs +++ b/tests/sync.test.mjs @@ -20,6 +20,7 @@ import { deriveT3 } from "../tools/derive-t3.mjs"; import { loadModels } from "../tools/generate.mjs"; import { applySubstitutions, + bootstrapComponent, changedLines, classify, denylistHits, @@ -1415,6 +1416,22 @@ describe("syncComponent", () => { }); }); +describe("bootstrapComponent", () => { + test("a local symlink at a directory above upstream paths is reported at the link, and nothing is written through it", () => { + const outside = tree({ "arena/SKILL.md": "outside\n" }); + const newDir = tree({ "skills/arena/SKILL.md": "upstream\n", "skills/arena/ref.md": "ref\n", "skills/how/SKILL.md": "how\n" }); + const localDir = tree({ "skills/how/SKILL.md": "old\n" }); + symlinkSync(join(outside, "arena"), join(localDir, "skills/arena")); + + const report = bootstrapComponent({ newDir, localDir, rules: [] }); + + expect(report.symlinks).toEqual(["skills/arena"]); + expect(readdirSync(join(outside, "arena"))).toEqual(["SKILL.md"]); + expect(readFileSync(join(outside, "arena/SKILL.md"), "utf8")).toBe("outside\n"); + expect(readFileSync(join(localDir, "skills/how/SKILL.md"), "utf8")).toBe("old\n"); + }); +}); + describe("sync CLI", () => { const declareS = (kind) => ({ "plugins/pstack/skills/s.md": { kind, why: "Fixture fork.", since: "0.9.48", upstream: "not-proposed" }, diff --git a/tools/sync.mjs b/tools/sync.mjs index c4d88aab..62573fcf 100644 --- a/tools/sync.mjs +++ b/tools/sync.mjs @@ -264,6 +264,8 @@ export function isExcluded(rel, exclude) { const gitMode = (mode) => (mode & 0o100 ? 0o755 : 0o644); +const dirsAbove = (rel) => rel.split("/").slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join("/")); + const lazyFile = (mode, load) => { let loaded; return { @@ -391,7 +393,6 @@ export function syncComponent({ const elsewhere = new Set(carriedElsewhere); // A port link where upstream has a directory is reported at the link, and // nothing under it is read or written, since each write would land in its target. - const dirsAbove = (rel) => rel.split("/").slice(0, -1).map((_, i, parts) => parts.slice(0, i + 1).join("/")); const upstreamDirs = new Set([...oldPaths, ...newPaths].filter((rel) => !isExcluded(rel, exclude)).flatMap(dirsAbove)); const isLink = (rel) => localPaths.has(rel) && lstatSync(join(localDir, rel)).isSymbolicLink(); const outcomeOf = (rel) => { @@ -515,13 +516,19 @@ export function unusedRules(rules, trees) { // Regenerate one component from upstream at a SHA: every carried file in its // derived form, except a declared fork that the port already holds. Returns // the files it writes, the forks it keeps, and denylist hits, which block -// every write, as does a symlink on either side. +// every write, as does a symlink on either side or at a local directory above +// a carried path. export function bootstrapComponent({ newDir, localDir, rules, denylist = [], exclude = [], derive = (_, t) => t, forks = new Map(), dryRun = false }) { const report = { written: [], kept: [], symlinks: [], hits: [] }; const writes = []; for (const file of walk(newDir)) { const rel = relative(newDir, file); if (isExcluded(rel, exclude)) continue; + const link = dirsAbove(rel).find((dir) => lstatIn(localDir, dir)?.isSymbolicLink()); + if (link) { + if (!report.symlinks.includes(link)) report.symlinks.push(link); + continue; + } const local = lstatSync(join(localDir, rel), { throwIfNoEntry: false }); if (lstatSync(file).isSymbolicLink() || local?.isSymbolicLink()) { report.symlinks.push(rel); From 59fa0fd533be4cea6505f6b55bf802eff2531443 Mon Sep 17 00:00:00 2001 From: J0UH <217747670+J0UH@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:47:58 +0200 Subject: [PATCH 3/3] Release 2.0.0-rc.16: docs and manifests VERSION, the manifests, and package.json move to 2.0.0-rc.16. CHANGES.md gets the entry with its behavior-delta table, and the README's pinned installer command names the new tag. The pins stay at 88d4751, so NOTICE.md, NOTICE-skills.md, and poteto-mode's copy of the notice do not change. The skill count stays 64. --- .claude-plugin/marketplace.json | 2 +- CHANGES.md | 9 +++++++++ README.md | 2 +- VERSION | 2 +- package.json | 2 +- plugins/pstack/.claude-plugin/plugin.json | 2 +- plugins/pstack/.codex-plugin/plugin.json | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index ba59546d..a4bff169 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.15" + "version": "2.0.0-rc.16" } ] } diff --git a/CHANGES.md b/CHANGES.md index 1910d2e9..ee440339 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,15 @@ 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.16 - The worktree audit reads paths with spaces, and `--bootstrap` never writes through a symlinked directory + +Two fixes in the port's own code, not from Lauren. `worktree-audit.sh` took each worktree path from `git worktree list --porcelain` with awk's `$2`, which stops at the first space. Under a parent such as `/work/My Project/`, the main worktree and every other one read as `/work/My`, so the audit skipped them all as the main worktree and printed an empty table. It now reads the whole path, and its declared fork in `tools/forks.json` says so. `bun tools/sync.mjs --bootstrap` checked only the last part of each path for a symlink, so with `plugins/pstack/skills/arena` linked to a directory outside the checkout it overwrote the `SKILL.md` behind the link. It now reports the link the way the normal sync does, as `FAIL: symlink at plugins/pstack/skills/arena`, and writes nothing. The pins stay at Lauren's `88d4751`, where 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.15 | 2.0.0-rc.16 | Source | +| --- | --- | --- | --- | +| Worktree audit under a path with a space | Lists no worktrees. | Lists every worktree. | T3 fix | +| `--bootstrap` with a symlinked local directory | Writes through the link to its target. | Fails naming the link and writes nothing. | T3 fix | + ## 2.0.0-rc.15 - Lauren's `88d4751`: sharpen rewrites the prompt and no longer does the task t3-pstack moves to Lauren Tan's pstack at cursor/plugins `88d4751` for all three components. Only pstack changed upstream, and its version moves to 0.15.29. `/sharpen` goes back to Lauren's original prompt, written in the user's voice ([pstack #729](https://github.com/cursor/plugins/pull/729)): "I want a deeper version of this task, not a bigger one." Before it rewrites the task, the agent does four things, now a numbered list. It says what a top professional in the field would do that the user isn't asking for, names the rawest material they would start from, names the specific methods and tools they would use, and says what, other than the agent or the user, would show the result is right. It then rewrites the user's prompt to ask for exactly that and does not do the task. Of the last version it keeps only the line that leaves the user's choices open, and its description now reads "Make a task deeper, not bigger, then rewrite the prompt." It keeps Lauren's `disable-model-invocation`, so on Claude Code it runs when the user types `/t3-pstack:sharpen`. On Codex it is `$t3-pstack:sharpen`, and Codex ignores the flag. poteto-help's row for `/sharpen` now says it returns a rewritten prompt and doesn't do the task, and its recipe reads `/sharpen <the prompt you're about to send>`. `bun tools/sync.mjs` updated sharpen, poteto-help, and the recipes cleanly through existing substitutions. The changed lines name no Cursor tool, path, model, or command, so no rule was added, and the 15 declared forks are untouched. #729's version bump, upstream's README row, its two guide pages under `docs/guide/`, and its copies under `harness/pstack/claude-code/` sit outside every synced component. At `88d4751` each component's dry run reports no undeclared fork, no denylist hit, and no substitution that matches nothing. Neither hooks file changes. diff --git a/README.md b/README.md index 0073f77e..d9120bb6 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.15 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.16 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 0cf96fc5..447ff16d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0-rc.15 +2.0.0-rc.16 diff --git a/package.json b/package.json index ab8da27c..61111fe0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "t3-pstack", "private": true, - "version": "2.0.0-rc.15", + "version": "2.0.0-rc.16", "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 3334c1a5..228a2378 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.15", + "version": "2.0.0-rc.16", "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 14522823..a3a32f67 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.15", + "version": "2.0.0-rc.16", "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",