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", 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/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/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" }, 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);