Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
9 changes: 9 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

This file is the release changelog, with one `## <version> - <title>` 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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.0.0-rc.15
2.0.0-rc.16
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/pstack/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion plugins/pstack/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions plugins/pstack/skills/poteto-mode/scripts/worktree-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}')
Expand Down
17 changes: 17 additions & 0 deletions tests/sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { deriveT3 } from "../tools/derive-t3.mjs";
import { loadModels } from "../tools/generate.mjs";
import {
applySubstitutions,
bootstrapComponent,
changedLines,
classify,
denylistHits,
Expand Down Expand Up @@ -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" },
Expand Down
13 changes: 11 additions & 2 deletions tests/worktree-audit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 1 addition & 1 deletion tools/forks.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
11 changes: 9 additions & 2 deletions tools/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down