diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77eb885..c6f485e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,24 @@ jobs: run: | git diff --check git diff --exit-code -- upstream/ + verify-macos: + # Case-insensitive filesystem: catches tracked paths that differ only by case. + runs-on: macos-15 + steps: + - uses: actions/checkout@v7.0.1 + - name: Check the checkout is complete + run: test -z "$(git status --porcelain)" + - uses: actions/setup-python@v7.0.0 + with: + python-version: "3.13" + - name: Test runtime diagnostics + run: python3 -m unittest discover -s tests -v + - name: Build skills and verify license notices + run: | + python3 tools/pstack_opencodex.py build + cmp LICENSE dist/LICENSE + cmp upstream/LICENSE dist/LICENSE.pstack + - name: Check whitespace and upstream preservation + run: | + git diff --check + git diff --exit-code -- upstream/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdb23a9..4a26e1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Issues and pull requests are welcome. Include the command or workflow you used, ## Make a change -- Keep `upstream/` identical to the source pinned in `UPSTREAM`. Use the sync command to update it. +- Keep `upstream/` identical to the source pinned in `UPSTREAM.lock`. Use the sync command to update it. - Put harness instructions and patches in `overlay/`, and Python tooling in `tools/`. - Keep `dist/` out of Git. It is generated and contains local absolute paths. - Use English for project documentation and Conventional Commits for commit messages. diff --git a/README.md b/README.md index d5fef57..714f99a 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This is an independent community adaptation. It is not an official Cursor, OpenA - Python 3.11 or later; the Python tools use only the standard library. - Git and `patch` on your `PATH`. - Codex configured through opencodex, with an available model catalog. -- Additional tools required by the workflow you choose: GitHub CLI (`gh`) for GitHub operations, Bun for pstack's TypeScript scripts, and Graphite (`gt`) for Orchestrate. Orchestrate also requires `gt auth` and `gt init`. +- Additional tools required by the workflow you choose: GitHub CLI (`gh`) for GitHub operations and Bun for pstack's TypeScript scripts, including Orchestrate's `orch`. When the Origin CLI (`origin`) is installed and can resolve the repository, pstack uses it instead of `gh`. Graphite (`gt`) is no longer required. ## Quick start @@ -79,7 +79,9 @@ A native ChatGPT V2 parent can produce encrypted tasks that routed children cann - Adds a pointer to `dist/HARNESS.md` at the start of each skill, except `principle-*` skills. - Adds Codex's `allow_implicit_invocation: false` policy for upstream skills marked `disable-model-invocation: true`. - Normalizes skill names to directory names such as `poteto-mode`. +- Copies upstream's `README.md` and `docs/` into `dist/` unmodified, so links from `poteto-help` and the guide resolve. `docs/harness/claude-code.md` there is upstream's Claude Code adapter; `dist/HARNESS.md` replaces it for Codex. Upstream's `plugin.json` and `.cursor-plugin/` manifests are not copied. - Applies the patches in `overlay/patches/`, stopping if a patch no longer applies. +- Rewrites `poteto-help` for Codex: install steps that keep an existing skills folder, `$skill` invocation, no Custom Modes, `~/.codex/pstack-models.md`, and no `make-bot-ui`. - Excludes `make-bot-ui`, which depends on Cursor automations and Grok Bots. - Includes both this project's and upstream pstack's MIT license notices in `dist/`. @@ -89,11 +91,11 @@ The harness maps Cursor cloud-agent steps to local workers. Available tools and | Path | Purpose | | --- | --- | -| `UPSTREAM` | Source repository, subtree, and pinned commit. | +| `UPSTREAM.lock` | Source repository, subtree, and pinned commit. | | `upstream/` | Unmodified copy of `cursor/plugins/pstack`. | | `overlay/` | Harness instructions, patches, exclusions, and model example. | | `tools/` | Build, install, synchronization, and diagnostic tools. | -| `tests/` | Runtime diagnostic tests using temporary configurations and catalogs. | +| `tests/` | Build, patch, and runtime diagnostic tests using temporary directories, configurations, and catalogs. | | `dist/` | Generated skills, harness, build metadata, and license notices; ignored by Git. | ## Update upstream @@ -125,7 +127,7 @@ python3 -m unittest discover -s tests -v python3 tools/pstack_opencodex.py build ``` -Tests cover V1/V2 compatibility, disabled models, configuration/session mismatches, experimental mitigations, CLI exit codes, and preservation of input settings. They use temporary files and do not contact providers. A passing suite does not establish live delegation compatibility. +Tests cover the build (shipped skills, exact patch application, `poteto-help` links, the patched worktree audit), upstream default models against a synthetic catalog, V1/V2 compatibility, disabled models, configuration/session mismatches, experimental mitigations, CLI exit codes, and preservation of input settings. They use temporary files, do not install skills, and do not contact providers. A passing suite does not establish live delegation compatibility. See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines. diff --git a/UPSTREAM b/UPSTREAM.lock similarity index 55% rename from UPSTREAM rename to UPSTREAM.lock index 1773278..ea1c234 100644 --- a/UPSTREAM +++ b/UPSTREAM.lock @@ -1,3 +1,3 @@ repo=https://github.com/cursor/plugins.git path=pstack -ref=12d587dfb20741cafc376c42c696c5f6e2a64487 +ref=88d47514b82e7c44f00f2b2a72ff6dd12158038c diff --git a/overlay/HARNESS.md b/overlay/HARNESS.md index 51434ec..7564582 100644 --- a/overlay/HARNESS.md +++ b/overlay/HARNESS.md @@ -9,16 +9,19 @@ pstack was written for Cursor. This file maps its tools, paths, and model names - Subagent definitions: `{{PSTACK_DIST}}/agents/poteto-agent.md` and `{{PSTACK_DIST}}/agents/comment-sicko.md`. - Skill folders: `.agents/skills/` in the repository for project skills such as a generated `verify-`, and `~/.agents/skills/` for user skills. - Cursor paths in pstack text map as follows. `.cursor/skills/` is `.agents/skills/`. `~/.cursor/skills/` is `~/.agents/skills/`, and Codex also reads `~/.codex/skills/`. `~/.cursor/plugins/` is `~/.codex/plugins/`. Project rules under `.cursor/rules/` are the repository's `AGENTS.md` files. `~/.cursor/projects//agent-transcripts/` is covered under Transcripts. For worktrees under `.cursor/worktrees/`, use `git worktree list`, which finds worktrees wherever they live. +- This build is not installed as a plugin. Where pstack text searches `~/.cursor/plugins/` for a pstack file, or picks the copy whose `.cursor-plugin/plugin.json` has the highest `version`, read the file under `{{PSTACK_DIST}}` instead. +- `{{PSTACK_DIST}}/README.md` and `{{PSTACK_DIST}}/docs/` are unmodified copies of upstream's README and docs, so they describe Cursor. `{{PSTACK_DIST}}/docs/harness/claude-code.md` is upstream's Claude Code adapter. It does not apply to Codex; this file takes its place. ## Subagents - `Task` means Codex's `spawn_agent`. Use the collaboration tools actually exposed by the session. In the desktop surface, `wait_agent` waits for mailbox updates; read the delivered messages and final answers to collect results. `send_message` sends a message without starting an idle agent, `followup_task` assigns more work and starts an idle agent, and `interrupt_agent` stops its current turn while keeping it available. Interruption is not closure. Use `send_input`, `wait`, or `close_agent` only on hosts that expose those tools; do not invent absent tools. - Pass the prompt as `message`, and set `model` and `reasoning_effort` as described under Models. Drop `subagent_type`, `run_in_background`, and any other parameter Codex does not have. Codex subagents already run in the background. +- Where pstack says to pick each subagent's reasoning level and fast or standard speed for the job, per the Subagents section of **poteto-mode**, adjust only `reasoning_effort`. Start from the role's value, move the effort the way poteto-mode says, then clamp it as described under Models. Codex has no per-spawn speed choice, so ignore the speed half and never add `-fast`. If the spawn rejects the adjusted effort, spawn with the role's value and say so. - When this host only accepts model overrides with a partial or empty fork, use `fork_turns: "none"` for independent tasks and include their context explicitly. This controls history inheritance, not V1/V2 transport or encryption. - `readonly: true` means the prompt must say the agent may read files and run commands but must not edit files, commit, or push. - `environment: "cloud"` means a local subagent that works in its own git worktree and output directory. - `subagent_type: "poteto-agent"` means a subagent whose message starts with "Read {{PSTACK_DIST}}/skills/poteto-mode/SKILL.md in full, including its Principles section, before any work." Then give the task. -- `subagent_type: "Comment Sicko"` means a read-only subagent whose message starts with "Read {{PSTACK_DIST}}/agents/comment-sicko.md and follow it as your instructions." +- `subagent_type: "comment-sicko"` means a read-only subagent whose message starts with "Read {{PSTACK_DIST}}/agents/comment-sicko.md and follow it as your instructions." - A pstack step that tells you to spawn subagents is the skill instruction that authorizes spawning. A model chosen in the pstack settings file is the user's explicit model choice. - Codex can stop a subagent from spawning its own subagents (`agents.max_depth`). When a spawn fails for that reason, do those roles yourself, one after another, and say so in the report. @@ -70,12 +73,25 @@ Codex stores sessions at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, subagen - `/goal` means Codex's `create_goal` when this session has it. Otherwise keep the objective and its exit condition in the plan file. - `/loop` means a timed wait between checks: a shell `sleep` of at most 60 seconds per call, repeated. In the Codex desktop app, a thread heartbeat automation can wake the thread for longer intervals. +## Skill notes + +Invoke every skill as `$name`. These skills need more than the mappings above: + +- `ketchup`: the user's last message is the last one they typed in this session. `wait_agent` results, subagent messages, and notifications are not their messages. Give each screenshot or video the work produced as a Markdown image or link to its absolute local path, and say in the same line what it shows, since not every Codex client renders media inline. Write diagrams as fenced `mermaid` blocks. For charts, see `/visualize` under Tools pstack does not ship. +- `poteto-help`: the build rewrites its install, settings, and mode guidance for Codex (overlay patch `0002`). Its links to the README and guide pages open the unmodified upstream copies, which describe Cursor's install, Custom Modes, and settings. For this build, install with `tools/pstack_opencodex.py build` and `install`, invoke skills as `$name`, start each task with `$poteto-mode` because Codex has no Custom Modes, resume a task in a new session through the Session pickup playbook, keep model settings in `~/.codex/pstack-models.md`, and say that `make-bot-ui` is not installed. +- `create-formal-verification-skill`, `maintain-formal-verification-skill`, and `create-verification-skill`: write a generated project-local skill to the repository's `.agents/skills//SKILL.md`, with frontmatter whose `name` equals the folder name and a `description`. A session that started before the file existed may not list it, so open it by path or start a new session. +- `correct`: the agent instruction file is the repository's `AGENTS.md`. Keep the rule table there. +- `benchmark-checklist`, `principle-explain-the-number`, `in-your-own-words`, and `sharpen` need nothing beyond this file. + +## Help and guide pages + +`$poteto-help` links to `{{PSTACK_DIST}}/README.md` and `{{PSTACK_DIST}}/docs/guide/`. The build copies them unmodified from upstream, so they describe Cursor's install, Custom Modes, and settings. For this build, install with `tools/pstack_opencodex.py build` and `install`, and run skills as `$name`. Codex has no Custom Modes, so start each task with `$poteto-mode`; a new session does not remember the last one, so resume through the Session pickup playbook. Settings live in `~/.codex/pstack-models.md`, and `make-bot-ui` is not installed. + ## Cursor features Codex does not have - Cursor cloud agents and the Cursor dashboard. Codex subagents run on this machine and stop when Codex exits. Their state lives in `wait_agent` results, their worktrees, pushed branches, and the program's ledger files. Where pstack describes a Cursor restart, apply the same rule to a Codex restart: treat every subagent as gone and resume from those records. Where pstack offers a local root or a cloud root, follow the local root instructions. - Cursor's built-in skills, such as its babysit skill. Codex has none of them, so follow the pstack playbook pstack names. - Cursor automations, webhook routines, and Grok Bots. The `make-bot-ui` skill depends on them and is not installed. -- Graphite. Orchestrate's stack tools (`orch frontier`) run `gt` and read PR numbers through Graphite, which needs `gt auth` and `gt init` in the repository. Before starting Orchestrate, check `command -v gt` and check that `~/.config/graphite/user_config` contains an `authToken` key without printing its value, for example with `rg -q authToken ~/.config/graphite/user_config`. If `gt` is missing or not authenticated, tell the user before any work. The other playbooks use `gh` and never require `gt`. ## Tools pstack does not ship @@ -85,3 +101,4 @@ Codex stores sessions at `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`, subagen - `control-cli`: a tmux session or a PTY command for CLIs and TUIs. - Bugbot and Cursor's agentic security review: apply their triage rules to whatever review bots the repository uses. If none comment, skip those steps. - MCP discovery in the **why** skill: Cursor's `mcps/` directory means the MCP tools in your own tool list, named `mcp____`. `codex mcp list` shows the configured servers. +- `/visualize`: Cursor's chart tool. Draw a fenced `mermaid` chart instead, such as `xychart-beta` for numbers, or give the numbers in one sentence. Never invent a number. diff --git a/overlay/patches/0001-worktree-audit-linux-and-codex.patch b/overlay/patches/0001-worktree-audit-linux-and-codex.patch index 262f1d8..854de08 100644 --- a/overlay/patches/0001-worktree-audit-linux-and-codex.patch +++ b/overlay/patches/0001-worktree-audit-linux-and-codex.patch @@ -1,8 +1,8 @@ --- a/skills/poteto-mode/scripts/worktree-audit.sh +++ b/skills/poteto-mode/scripts/worktree-audit.sh -@@ -22,11 +22,19 @@ - gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null > "$prs" || echo "[]" > "$prs" +@@ -61,11 +61,19 @@ + fi + [ -s "$prs" ] || echo "[]" > "$prs" -# Transcripts dir: ~/.cursor/projects//agent-transcripts. +# Transcripts: Cursor keeps them per project under @@ -18,10 +18,10 @@ +mtimes() { if stat -c %Y / >/dev/null 2>&1; then xargs stat -c '%Y %n'; else xargs stat -f '%m %N'; fi; } +day() { date -d "@$1" '+%Y-%m-%d' 2>/dev/null || date -r "$1" '+%Y-%m-%d'; } + + 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 -@@ -63,12 +71,11 @@ +@@ -103,12 +111,11 @@ # Most recent chat whose transcript operated in this worktree. Match path # followed by "/" or a quote so glint-482 does not match glint-482-r37. last="-"; last_ts=0 @@ -39,7 +39,7 @@ 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 ;; *) -@@ -83,4 +90,4 @@ +@@ -123,4 +130,4 @@ "$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt" done | sort -t$'\t' -k1,1 -rh diff --git a/overlay/patches/0002-poteto-help-codex.patch b/overlay/patches/0002-poteto-help-codex.patch new file mode 100644 index 0000000..d4eb47e --- /dev/null +++ b/overlay/patches/0002-poteto-help-codex.patch @@ -0,0 +1,103 @@ +--- a/skills/poteto-help/SKILL.md ++++ b/skills/poteto-help/SKILL.md +@@ -6,11 +6,11 @@ + + # Poteto help + +-Answer the user's question about pstack, hand them a prompt they can send, and link the file the answer came from. For a help question, don't start the work. The user asked how, and a pstack run spends real tokens, so let them send the prompt. ++Answer the user's question about pstack, hand them a prompt they can send, and link the file the answer came from. For a help question, don't start the work. The user asked how, and a pstack run spends real tokens, so let them send the prompt. In Codex, skills are invoked as `$name`, so write every prompt you hand over that way, such as `$poteto-mode`. + +-A message that asks for work, such as "use pstack to fix this bug", is not a help question. Read [`poteto-mode`](../poteto-mode/SKILL.md), do the work under it, and mention once that a Custom Mode keeps it on. ++A message that asks for work, such as "use pstack to fix this bug", is not a help question. Read [`poteto-mode`](../poteto-mode/SKILL.md), do the work under it, and mention once that Codex has no Custom Modes, so each new task starts with `$poteto-mode`. + +-This file maps questions to the skills and guide pages that hold the answers. Those files own the details. Read the file you route to before you quote it, and trust it when it disagrees with this map. The links here point into the installed plugin, which the user may not be able to open, so give the user the file's public copy: `https://github.com/cursor/plugins/blob/main/pstack/` followed by its path. ++This file maps questions to the skills and guide pages that hold the answers. Those files own the details. Read the file you route to before you quote it, and trust it when it disagrees with this map. The links here point into the installed plugin, which the user may not be able to open, so give the user the file's public copy: `https://github.com/cursor/plugins/blob/main/pstack/` followed by its path. That is upstream's Cursor edition. In this Codex build, the local file is the one that applies, and the local README and guide pages are upstream's Cursor-oriented text, so where they differ from this map or from `HARNESS.md`, follow this map and `HARNESS.md`. + + ## Find out what they need + +@@ -24,40 +24,41 @@ + + Check the state that changes the answer, and mention it only when it does: + +-- No `~/.cursor/rules/pstack-models.mdc` means `/setup-pstack` hasn't run for this user, so every role uses its default model. +-- No `verify-*` skill or other app harness in the project means agents have no scripted way to drive the app. Mention `/create-verification-skill` when the question is about proving a change works. +-- No formal verification skill in the project means no property is checked past what tests reach. Mention `/create-formal-verification-skill` when the question is about a race, an invariant, or another case tests rarely hit. When one exists and the code it covers changed after the commit it records as last in sync, mention `/maintain-formal-verification-skill` if the question touches that code. ++- No `~/.codex/pstack-models.md` means `$setup-pstack` hasn't run for this user, so every role uses its default model. This build reads that file instead of Cursor's `pstack-models.mdc` rule. ++- No `verify-*` skill or other app harness in the project means agents have no scripted way to drive the app. Mention `$create-verification-skill` when the question is about proving a change works. ++- No formal verification skill in the project means no property is checked past what tests reach. Mention `$create-formal-verification-skill` when the question is about a race, an invariant, or another case tests rarely hit. When one exists and the code it covers changed after the commit it records as last in sync, mention `$maintain-formal-verification-skill` if the question touches that code. + +-When the model rule is missing and it matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per chat. If the need is also unclear, ask both questions together. Offer two choices: ++When `~/.codex/pstack-models.md` is missing and it matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per session. If the need is also unclear, ask both questions together. Offer two choices: + +-- Now: give them `/setup-pstack` to type, and answer their question too. +-- Later: answer their question, and add one line saying every role keeps its default model until they run `/setup-pstack`. ++- Now: give them `$setup-pstack` to type, and answer their question too. ++- Later: answer their question, and add one line saying every role keeps its default model until they run `$setup-pstack`. + + ## Get set up + +-1. Install with `/add-plugin pstack` in chat, or from Customize in the sidebar. +-2. Run [`/setup-pstack`](../setup-pstack/SKILL.md). It asks for a reasoning budget, maps a model to each role, and writes a rule. The rule applies to new chats. Then it offers once each to generate a verification skill and a formal verification skill for the project, unless the project already has one. +-3. Start a real task with `/poteto-mode`, a goal, and a check that can pass or fail. ++1. Install the Codex build of pstack. There is no `/add-plugin` or Customize sidebar in Codex. Clone `https://github.com/foxytanuki/pstack-opencodex`, run `python3 tools/pstack_opencodex.py build`, then `python3 tools/pstack_opencodex.py install --target ~/.agents/skills` for every project, or `--target /.agents/skills` for one project. If the user already keeps pstack skills in another skills folder, such as `~/.codex/skills`, keep that folder as the target instead of guessing a path. The installer symlinks into the checkout's `dist/skills/`, so keep the checkout in place. Start a new Codex session afterward. Codex must run through opencodex with a model catalog. ++2. Run [`$setup-pstack`](../setup-pstack/SKILL.md). It asks for a reasoning budget, maps a model from the catalog to each role, and writes `~/.codex/pstack-models.md`. The settings apply to new sessions. `python3 tools/pstack_opencodex.py check-models` validates the file, and `check-runtime --parent-model ` is the read-only preflight before delegating to a routed child such as Claude or Grok. Then it offers once each to generate a verification skill and a formal verification skill for the project, unless the project already has one. ++3. Start a real task with `$poteto-mode`, a goal, and a check that can pass or fail. + +-Installing changes nothing until the user invokes a skill. Only `/setup-pstack` loads from the user's words. The [README](../../README.md) and [guide page 1](../../docs/guide/01-setup.md) have the details. Offer to word their first prompt with them, per [`references/prompting.md`](references/prompting.md). ++Installing changes nothing until the user invokes a skill. Only `$setup-pstack` loads from the user's words; every other pstack skill runs only when the user invokes it or a playbook step runs it. The upstream [README](../../README.md) and [guide page 1](../../docs/guide/01-setup.md) describe the Cursor install, so use them for the concepts and the install steps above for the commands. Offer to word their first prompt with them, per [`references/prompting.md`](references/prompting.md). + +-If cost is the worry, say where the tokens go and how to spend fewer. pstack spends extra tokens on subagents and review panels. Rerun `/setup-pstack` and pick a smaller budget or cheaper models. A role set to `auto` or `inherit-parent` runs on the chat's model, which saves tokens when the chat runs on Auto or a cheaper model. A shorter panel list runs fewer subagents, one for each entry. Save `/poteto-mode` for work that needs rigor. ++If cost is the worry, say where the tokens go and how to spend fewer. pstack spends extra tokens on subagents and review panels. Rerun `$setup-pstack` and pick a smaller budget or cheaper models. A role set to `auto` or `inherit-parent` runs on the chat's model, which saves tokens when the session runs on a cheaper model. A shorter panel list runs fewer subagents, one for each entry. Save `$poteto-mode` for work that needs rigor. + +-pstack is built for Cursor, and it also installs in Claude Code with `/plugin marketplace add cursor/plugins`, then `/plugin install pstack@cursor-plugins`. There, each command takes a `pstack:` prefix, such as `/pstack:poteto-mode`. The [Claude Code adapter](../../docs/harness/claude-code.md) maps the Cursor names, and a step that needs a Cursor-only feature, such as Bugbot or Automations, is skipped with a one-line note. ++pstack was written for Cursor, and this build adapts it to Codex through opencodex. `HARNESS.md` maps Cursor tools, paths, and model names: `Task` becomes Codex's `spawn_agent`, and per-role models come from `~/.codex/pstack-models.md`. Routed children such as Claude need a compatible session. If delegation fails, run `check-runtime` and read its status instead of retrying. Cursor-only features, such as Custom Modes, cloud agents, and automations, are not available. + +-Other tools can read the skills, because they use the Agent Skills format. But most workflow skills, including `/poteto-mode`, `/how`, `/why`, and `/teach`, spawn Cursor subagents with per-role models, and Custom Modes and `/loop` are Cursor features, so those parts may not work there. ++Upstream pstack also installs in Claude Code through its own adapter. Those install steps and the `pstack:` command prefix do not apply to this Codex build. + + ## Start a task with `/poteto-mode` + + `/poteto-mode` matches the task to a playbook, copies the playbook's steps into the todo list, and runs the other skills as the steps need them. A step it skips stays in the list as `skip: `. A good prompt states the goal and how to tell it's done. It doesn't list skills, because a hand-written sequence tends to drop or reorder steps the playbook would keep. Read [`references/prompting.md`](references/prompting.md) before you help word one. [Guide page 2](../../docs/guide/02-poteto-mode.md) has examples. + +-Whether `/poteto-mode` stays on depends on how the user starts it: ++Codex has no Custom Modes and no Option+Enter shortcut. Whether `$poteto-mode` stays on depends on what the user says: + +-- Enter on `/poteto-mode` attaches the skill to one message. It fades as the chat moves on. +-- Option+Enter on Mac or Alt+Enter on Windows, or Use as Mode from the skill entry, makes it a Custom Mode. It stays in context every turn until the user exits the mode, and it stays out of casual turns. +-- Cursor's docs list Custom Modes in the Agents Window and the CLI. Elsewhere, start each new task with `/poteto-mode`. ++- Invoking `$poteto-mode` applies the skill to that task. It fades as the conversation moves on, so invoke it again for the next task. ++- A follow-up in the same session continues the same task. Say "new task" to make it match a fresh playbook. ++- A new Codex session starts with no memory of the last one. To continue a task, invoke `$poteto-mode` and say what to resume, or use the Session pickup playbook, which reads the branch, the worktree, and Codex's rollout files under `~/.codex/sessions/`. ++- Subagents run on this machine and stop when Codex exits. After a restart, treat them as gone and resume from their worktrees, branches, and ledger files. + +-Link [Cursor's skills docs](https://cursor.com/docs/skills) when this comes up. Mid-chat, "new task" makes the mode match a fresh playbook. `/poteto-mode` already uses `poteto-agent` for the subagents its playbook steps spawn. To get the same style from a subagent of your own, spawn it with `subagent_type: "poteto-agent"`. ++`$poteto-mode` already uses `poteto-agent` for the subagents its playbook steps spawn. To get the same style from a subagent of your own, follow `HARNESS.md`: start its message with an instruction to read `poteto-mode/SKILL.md` in full. + + ## Pick a skill + +@@ -95,7 +96,7 @@ + | Turn their own working habits into a personal mode skill | [`/automate-me`](../automate-me/SKILL.md) | + | Turn what a finished task taught into skill edits | [`/reflect`](../reflect/SKILL.md) | + | Stop agents from repeating the same mistakes in this repo | [`/correct`](../correct/SKILL.md) | +-| Build a page whose buttons wake a Grok Bot over a webhook | [`/make-bot-ui`](../make-bot-ui/SKILL.md) | ++| Build a page whose buttons wake a Grok Bot over a webhook | `/make-bot-ui` is not installed in the Codex build because it needs Cursor automations and Grok Bots. Say so instead of linking it. | + | Find their way around pstack | `/poteto-help` | + + If a skill directory next to this one is missing from the table, read its frontmatter and route by its description. The `principle-*` directories are covered under principles below. +@@ -136,12 +137,12 @@ + + | Symptom | Fix | + |---|---| +-| The mode stopped applying after a few turns | It was started with Enter. Start it as a Custom Mode, or start each task with `/poteto-mode`. | ++| The mode stopped applying after a few turns | Codex has no Custom Mode. Start each task with `$poteto-mode`. | + | A question got treated as the next step of the last task | Say "new task", or say the turn doesn't need the mode. | +-| A new model choice had no effect | The rule from `/setup-pstack` applies to new chats. Start one. | ++| A new model choice had no effect | The settings from `$setup-pstack` apply to new sessions. Start one. | + | Runs cost more than expected | See the cost paragraph under Get set up. | +-| A skill didn't load on its own | Only `/setup-pstack` loads from the user's words. The others load when the user types them or when `/poteto-mode` runs them, and it doesn't run every skill. | +-| Parallel agents overwrote each other | Give each agent its own worktree, or run them as cloud agents, which each get their own machine. | ++| A skill didn't load on its own | Only `$setup-pstack` loads from the user's words. The others load when the user types `$name` or when `$poteto-mode` runs them, and it doesn't run every skill. | ++| Parallel agents overwrote each other | Give each agent its own worktree. Codex subagents run locally and have no cloud machines. | + | An overnight run moved but finished nothing | `/loop` needs a check that can pass or fail, not a duration. See [guide page 7](../../docs/guide/07-overnight.md). | + | The reply claims success from a green build | Ask for the real command, flow, stored value, or profile. That's the prove-it-works principle. | + diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..8727c06 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,182 @@ +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "tools" / "pstack_opencodex.py" +sys.path.insert(0, str(ROOT / "tools")) +import pstack_opencodex # noqa: E402 + +# Skill folders cursor/plugins pstack added between 12d587d and 88d4751 (0.15.29). +ADDED_IN_0_15_29 = ( + "benchmark-checklist", + "correct", + "create-formal-verification-skill", + "in-your-own-words", + "ketchup", + "maintain-formal-verification-skill", + "poteto-help", + "principle-explain-the-number", + "sharpen", +) +EXPLICIT_ONLY_POLICY = "policy:\n allow_implicit_invocation: false\n" + + +def excluded_skills(): + lines = (ROOT / "overlay" / "exclude").read_text().splitlines() + return {line.strip() for line in lines if line.strip() and not line.startswith("#")} + + +def upstream_skills(): + return {p.name for p in (ROOT / "upstream" / "skills").iterdir() if (p / "SKILL.md").exists()} + + +def frontmatter(path): + front, _ = pstack_opencodex.split_frontmatter(path.read_text()) + return front + + +def build(out): + result = subprocess.run([sys.executable, str(TOOL), "build", "--out", str(out)], capture_output=True, text=True, timeout=120) + if result.returncode != 0: + raise AssertionError(f"build failed:\n{result.stdout}{result.stderr}") + return result + + +class BuiltSkills(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory(prefix="pstack-build-test-") + # Resolve symlinked temp roots (macOS /var -> /private/var) so paths match the build output. + cls.dist = Path(cls.tmp.name).resolve() / "dist" + try: + cls.result = build(cls.dist) + except AssertionError: + cls.tmp.cleanup() + raise + cls.info = json.loads((cls.dist / "BUILD-INFO.json").read_text()) + cls.built = {p.name for p in (cls.dist / "skills").iterdir() if (p / "SKILL.md").exists()} + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def test_build_ships_every_upstream_skill_except_the_exclusions(self): + self.assertEqual(self.built, upstream_skills() - excluded_skills()) + self.assertEqual(set(self.info["excluded"]), excluded_skills()) + self.assertNotIn("make-bot-ui", self.built) + + def test_build_reports_how_many_skills_it_ships(self): + count = len(upstream_skills() - excluded_skills()) + self.assertEqual(self.info["skills"], count) + self.assertIn(f"built {count} skills into {self.dist}", self.result.stdout) + + def test_skills_added_in_0_15_29_are_built_with_the_harness_pointer(self): + pointer = f"read {self.dist}/HARNESS.md" + for name in ADDED_IN_0_15_29: + with self.subTest(skill=name): + skill_md = self.dist / "skills" / name / "SKILL.md" + self.assertTrue(skill_md.exists(), f"{name} was not built") + if name.startswith("principle-"): + self.assertNotIn(pointer, skill_md.read_text()) + else: + self.assertIn(pointer, skill_md.read_text()) + + def test_each_built_skill_is_named_after_its_folder(self): + for name in sorted(self.built): + with self.subTest(skill=name): + front = frontmatter(self.dist / "skills" / name / "SKILL.md") + names = [line.split(":", 1)[1].strip() for line in front if line.startswith("name:")] + self.assertEqual(names, [name]) + + def test_explicit_only_policy_follows_upstream_frontmatter(self): + explicit = 0 + for name in sorted(self.built): + with self.subTest(skill=name): + upstream_front = frontmatter(ROOT / "upstream" / "skills" / name / "SKILL.md") + flagged = "disable-model-invocation: true" in [line.strip() for line in upstream_front] + meta = self.dist / "skills" / name / "agents" / "openai.yaml" + self.assertEqual(meta.exists(), flagged) + if flagged: + self.assertEqual(meta.read_text(), EXPLICIT_ONLY_POLICY) + explicit += flagged + self.assertEqual(self.info["explicit_only"], explicit) + + def test_upstream_plugin_manifests_are_not_shipped(self): + self.assertFalse((self.dist / "plugin.json").exists()) + self.assertFalse((self.dist / ".cursor-plugin").exists()) + + def test_both_license_notices_are_shipped(self): + self.assertEqual((self.dist / "LICENSE").read_bytes(), (ROOT / "LICENSE").read_bytes()) + self.assertEqual((self.dist / "LICENSE.pstack").read_bytes(), (ROOT / "upstream" / "LICENSE").read_bytes()) + + +class SkillNameNormalization(unittest.TestCase): + """Upstream names every skill after its folder since #702. The build keeps its existing + normalization as a safety net: a no-op for matching names, a rewrite for a future mismatch.""" + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="pstack-name-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name).resolve() + + def skill(self, folder, name): + skill_dir = self.root / "skills" / folder + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: test skill\ndisable-model-invocation: true\n---\n\n# Test\n" + ) + return skill_dir + + def test_a_name_that_differs_from_the_folder_is_rewritten_to_the_folder(self): + skill_dir = self.skill("poteto-mode", "Poteto Mode") + self.assertTrue(pstack_opencodex.adapt_skill(skill_dir, self.root)) + front = frontmatter(skill_dir / "SKILL.md") + self.assertEqual([line for line in front if line.startswith("name:")], ["name: poteto-mode"]) + + def test_a_matching_name_is_kept_and_adapted(self): + skill_dir = self.skill("ketchup", "ketchup") + self.assertTrue(pstack_opencodex.adapt_skill(skill_dir, self.root)) + text = (skill_dir / "SKILL.md").read_text() + self.assertTrue(text.startswith("---\nname: ketchup\n")) + self.assertIn(f"read {self.root}/HARNESS.md", text) + self.assertEqual((skill_dir / "agents" / "openai.yaml").read_text(), EXPLICIT_ONLY_POLICY) + + +class OverlayPatches(unittest.TestCase): + def test_every_patch_applies_to_upstream_without_offset_or_fuzz(self): + if shutil.which("patch") is None: + self.skipTest("patch is not available") + # GNU patch translates its diagnostics. The C locale keeps "offset" and "fuzz" in English, + # and LANGUAGE is dropped so gettext cannot pick another catalog. + env = {key: value for key, value in os.environ.items() if key != "LANGUAGE"} + env["LC_ALL"] = "C" + version = subprocess.run(["patch", "--version"], capture_output=True, text=True, env=env) + if "GNU patch" not in version.stdout: + self.skipTest("offset and fuzz reporting is checked with GNU patch") + patches = sorted((ROOT / "overlay" / "patches").glob("*.patch")) + self.assertTrue(patches) + for patch in patches: + with self.subTest(patch=patch.name), tempfile.TemporaryDirectory() as tmp: + targets = re.findall(r"^\+\+\+ b/(\S+)", patch.read_text(), re.M) + self.assertTrue(targets, f"{patch.name} names no target file") + for rel in targets: + (Path(tmp) / rel).parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(ROOT / "upstream" / rel, Path(tmp) / rel) + result = subprocess.run( + ["patch", "-p1", "--batch", "--forward", "--dry-run", "-F", "0", "-d", tmp, "-i", str(patch)], + capture_output=True, text=True, timeout=30, env=env, + ) + output = result.stdout + result.stderr + self.assertEqual(result.returncode, 0, output) + self.assertNotRegex(output, r"(?i)\boffset\b|\bfuzz\b") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..34b01da --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,170 @@ +import re +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "tools" / "pstack_opencodex.py" + +# Skill folders cursor/plugins pstack added between 12d587d and 88d4751 (0.15.29). +ADDED_IN_0_15_29 = ( + "benchmark-checklist", + "correct", + "create-formal-verification-skill", + "in-your-own-words", + "ketchup", + "maintain-formal-verification-skill", + "poteto-help", + "principle-explain-the-number", + "sharpen", +) +NEGATION = re.compile(r"(?i)\b(?:never|not|no|don't|do not|without)\b") +# A "-fast" counts as forbidden only when a negation precedes it in the same clause. +NEGATED_FAST = re.compile(r"(?i)\b(?:never|not|don't|do not|without)\b[^,;.]*-fast") + + +def section(text, heading): + """The body of a '## heading' section, up to the next '## ' heading.""" + match = re.search(rf"^## {re.escape(heading)}\n(.*?)(?=^## |\Z)", text, re.M | re.S) + if match is None: + raise AssertionError(f"HARNESS.md has no '## {heading}' section") + return match.group(1) + + +def bullets(text): + """Top-level '- ' bullets of a section, each joined onto one line.""" + return [" ".join(item.split()) for item in re.split(r"(?m)^- ", text)[1:]] + + +def sentences(text): + flat = " ".join(text.split()) + return [part for part in re.split(r"(?<=[.!?])\s+(?=[A-Z`$\[(\"'])", flat) if part] + + +class BuiltHarness(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.TemporaryDirectory(prefix="pstack-harness-test-") + cls.dist = Path(cls.tmp.name).resolve() / "dist" + result = subprocess.run([sys.executable, str(TOOL), "build", "--out", str(cls.dist)], capture_output=True, text=True, timeout=120) + if result.returncode != 0: + cls.tmp.cleanup() + raise AssertionError(f"build failed:\n{result.stdout}{result.stderr}") + cls.harness = (cls.dist / "HARNESS.md").read_text() + cls.readme = (ROOT / "README.md").read_text() + + @classmethod + def tearDownClass(cls): + cls.tmp.cleanup() + + def bullet(self, heading, predicate, what): + found = [item for item in bullets(section(self.harness, heading)) if predicate(item)] + self.assertEqual(len(found), 1, f"expected one '{heading}' bullet that {what}, found {found}") + return found[0] + + def test_each_skill_added_in_0_15_29_has_a_skill_note(self): + notes = section(self.harness, "Skill notes") + for name in ADDED_IN_0_15_29: + with self.subTest(skill=name): + self.assertIn(f"`{name}`", notes) + + def test_every_quoted_subagent_type_in_built_skills_is_mapped(self): + quoted = set() + for path in (self.dist / "skills").rglob("*.md"): + quoted.update(re.findall(r'subagent_type: "([^"]+)"', path.read_text())) + self.assertIn("comment-sicko", quoted, "expected upstream's folder-named agent in no-comments") + for value in sorted(quoted): + with self.subTest(subagent_type=value): + self.assertIn(f'subagent_type: "{value}"', self.harness) + + def test_every_dist_path_named_by_the_harness_exists(self): + self.assertNotIn("{{", self.harness) + paths = re.findall(re.escape(str(self.dist)) + r"/[^\s`\"'()<>]*", self.harness) + self.assertTrue(paths) + for raw in sorted(set(paths)): + path = raw.rstrip(".,:;") + if path.endswith("/...") or "<" in path: + continue + with self.subTest(path=path): + self.assertTrue(Path(path).exists(), path) + + def test_subagent_effort_choice_adjusts_only_reasoning_effort(self): + # pstack #613: "Pick each subagent's reasoning level and fast or standard speed for the job". + rule = self.bullet("Subagents", lambda item: re.search(r"(?i)\bspeed\b", item), "maps the per-spawn choice") + self.assertIn("poteto-mode", rule) + parts = sentences(rule) + self.assertTrue( + any(re.search(r"(?i)\bonly\b.*`reasoning_effort`|`reasoning_effort`.*\bonly\b", s) for s in parts), + "the rule must say reasoning_effort is the only setting it adjusts", + ) + self.assertRegex(rule, r"(?i)\bclamp") + self.assertIn("Models", rule, "clamping follows the Models section") + fallback = [s for s in parts if re.search(r"(?i)\breject", s)] + self.assertTrue(fallback, "the rule must say what to do when the spawn rejects the adjusted effort") + self.assertTrue(any(re.search(r"(?i)\brole(?:'s)? value", s) for s in fallback), fallback) + + def test_subagent_speed_choice_is_dropped_and_fast_is_never_added(self): + rule = self.bullet("Subagents", lambda item: re.search(r"(?i)\bspeed\b", item), "maps the per-spawn choice") + self.assertTrue( + any(re.search(r"(?i)\b(?:ignore|omit|drop|skip)\b.*\bspeed\b|\bno\b.*\bspeed\b", s) for s in sentences(rule)), + "the rule must drop the speed half of the choice", + ) + self.assertIn("-fast", rule, "the rule names the suffix it forbids") + for sentence in sentences(self.harness): + if "-fast" in sentence: + with self.subTest(sentence=sentence): + self.assertEqual(len(re.findall(r"-fast", sentence)), len(NEGATED_FAST.findall(sentence))) + + def test_generated_project_skills_go_to_the_codex_skill_folder(self): + rule = self.bullet("Skill notes", lambda item: ".agents/skills/" in item, "names the generated skill folder") + for name in ("create-formal-verification-skill", "maintain-formal-verification-skill"): + with self.subTest(skill=name): + self.assertIn(f"`{name}`", rule) + self.assertRegex(rule, r"\.agents/skills/[^/\s`]+/SKILL\.md") + self.assertRegex(rule, r"(?i)`name`.*\bfolder") + self.assertIn("`description`", rule) + self.assertRegex(rule, r"(?i)\bby path\b|\bnew session\b") + self.assertNotIn(".cursor/", rule) + + def test_ketchup_counts_from_the_last_message_the_user_typed(self): + rule = self.bullet("Skill notes", lambda item: item.startswith("`ketchup`"), "covers ketchup") + parts = sentences(rule) + self.assertTrue(any(re.search(r"(?i)\blast\b.*\btyped\b|\btyped\b.*\blast\b", s) for s in parts), rule) + others = [s for s in parts if re.search(r"wait_agent|(?i:subagent)", s)] + self.assertTrue(others, "the rule must say which messages are not the user's") + for sentence in others: + with self.subTest(sentence=sentence): + self.assertRegex(sentence, NEGATION) + self.assertRegex(rule, r"(?i)notification") + self.assertRegex(rule, r"(?i)\babsolute\b.*\bpath\b") + self.assertIn("mermaid", rule) + + def test_cursor_only_names_from_the_new_skills_are_mapped(self): + visualize = self.bullet("Tools pstack does not ship", lambda item: item.startswith("`/visualize`"), "maps /visualize") + self.assertIn("mermaid", visualize) + adapter = self.bullet("Where pstack lives", lambda item: "claude-code.md" in item, "covers the Claude Code adapter") + self.assertTrue( + any("Codex" in s and NEGATION.search(s) for s in sentences(adapter)), + "the adapter bullet must say the adapter does not apply to Codex", + ) + plugin = self.bullet("Where pstack lives", lambda item: ".cursor-plugin/plugin.json" in item, "covers the plugin-path search") + self.assertIn(str(self.dist), plugin) + correct = self.bullet("Skill notes", lambda item: item.startswith("`correct`"), "covers correct") + self.assertIn("`AGENTS.md`", correct) + + def test_graphite_is_no_longer_required(self): + for label, text in (("HARNESS.md", self.harness), ("README.md", self.readme)): + for phrase in ("gt auth", "gt init", "authToken", "graphite/user_config"): + with self.subTest(file=label, phrase=phrase): + self.assertNotIn(phrase, text) + self.assertNotIn("Graphite (`gt`) for Orchestrate", self.readme) + + def test_readme_keeps_the_lock_file_name(self): + self.assertIn("`UPSTREAM.lock`", self.readme) + self.assertNotIn("`UPSTREAM`", self.readme) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..4796807 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,140 @@ +import json +import os +import re +import subprocess +import sys +import tempfile +import unittest +from collections import Counter +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TOOL = ROOT / "tools" / "pstack_opencodex.py" +sys.path.insert(0, str(ROOT / "tools")) +from pstack_opencodex import parse_roles, upstream_roles # noqa: E402 + +OPUS = "claude-opus-5-5-xhigh" +GROK = "grok-4.7-xhigh-fast" + +# The role table in upstream setup-pstack step 5 at cursor/plugins 88d4751 (pstack 0.15.29), +# written out by hand so the tests do not take their expectations from the parser under test. +# Update it when a sync changes upstream's defaults; that change should be reviewed. +DEFAULTS_AT_0_15_29 = { + "feature, refactoring": [GROK], + "bug-fix": [GROK], + "perf-issue": [GROK], + "hillclimb": [GROK], + "judgment and prose": [OPUS], + "hardest tasks": [OPUS], + "how explorer": [GROK], + "how explainer": [OPUS], + "why investigators": [GROK], + "why synthesizer": [OPUS], + "reflect tooling": [GROK], + "reflect judgment, divergent, synthesizer": [OPUS], + "arena runners": [OPUS, GROK], + "arena cross-judge pool": [OPUS, GROK], + "swarm workers": [GROK], + "architect runners": [OPUS, GROK], + "interrogate reviewers": [OPUS, GROK], +} +PANELS = {"arena runners", "arena cross-judge pool", "architect runners", "interrogate reviewers"} + +# What each default must resolve to against the synthetic catalog below: the provider-prefixed +# slug and xhigh, with Grok's speed suffix dropped rather than carried into the model or effort. +RESOLVED = { + OPUS: ("anthropic/claude-opus-5-5", "xhigh"), + GROK: ("devin/grok-4.7", "xhigh"), +} + +# One check-models line for a resolved value, e.g. +# "OK arena runners: grok-4.7-xhigh-fast -> model=devin/grok-4.7 reasoning_effort=xhigh". +# Anything after the effort (a clamp note, "[not an upstream role line]") makes the line not match. +OK_LINE = re.compile(r"OK +(?P