Skip to content
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Grok Build port of [pstack](https://github.com/cursor/plugins/tree/4ec7ffa173eaa70d3d9467a627baf0954645a353/pstack) (`0.15.30`) by [Lauren Tan (poteto)](https://x.com/poteto), bundling `deslop`, `control-cli`, and `control-ui` from [cursor-team-kit](https://github.com/cursor/plugins/tree/4ec7ffa173eaa70d3d9467a627baf0954645a353/cursor-team-kit) (`1.2.0`) by Eric Zakariasson.
Grok Build port of [pstack](https://github.com/cursor/plugins/tree/e73df471cd4bd918367e99f3dd9c51bd53ddf5f0/pstack) (`0.15.32`) by [Lauren Tan (poteto)](https://x.com/poteto), bundling `deslop`, `control-cli`, and `control-ui` from [cursor-team-kit](https://github.com/cursor/plugins/tree/e73df471cd4bd918367e99f3dd9c51bd53ddf5f0/cursor-team-kit) (`1.2.0`) by Eric Zakariasson.

# pstack

Expand Down Expand Up @@ -129,6 +129,7 @@ to keep [`/poteto-mode`](./skills/poteto-mode/SKILL.md) on across turns, pick it
| [`/why`](./skills/why/SKILL.md) | you want to know why something was built this way. discovers available MCPs at run time and queries each evidence category in parallel (source control, issue tracker, long-form docs, real-time chat, infra observability, error tracking, analytics warehouse). |
| [`/recall`](./skills/recall/SKILL.md) | you're starting or resuming work and want your recent context on a topic rebuilt from your own chat history and the shared record, handed back as a tight current-state brief. |
| [`/ketchup`](./skills/ketchup/SKILL.md) | you want what changed in this chat since your last message, in plain words and small visuals, with every item waiting on you listed and decidable from the reply alone, even when that makes the reply long. |
| [`/ketchup-video`](./skills/ketchup-video/SKILL.md) | you want the `/ketchup` catch-up as a short silent video of slides, with the calls waiting on you as text under it. |
| [`/blast-radius`](./skills/blast-radius/SKILL.md) | you have a small-looking change and want to know what else it could break, with the one fact it's safe because of proven by running code, not asserted. |
| [`/architect`](./skills/architect/SKILL.md) | you're about to write code that crosses a function boundary and want the caller's usage, types, and module shape settled first. |
| [`/arena`](./skills/arena/SKILL.md) | you want N parallel attempts at the same thing, then to grab the best parts of each. |
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/10-recipes-and-pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ That's the whole prompt. [`/bro`](../../skills/bro/SKILL.md) restates the last m

[`/ketchup`](../../skills/ketchup/SKILL.md) tells you in plain words what changed since your last message, then lists each call waiting on you with its pick, evidence, and risk. It changes nothing while it reports.

To watch the catch-up instead, run [`/ketchup-video`](../../skills/ketchup-video/SKILL.md). It shows what happened in a short silent video of slides, and lists the calls waiting on you as text under it.

## The pitfalls

- **Enumerating skills in the prompt.** "use /how then /architect then /arena" reorders steps the playbook already sequences. State the goal and constraints. Name a skill only to override a default.
Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "pstack",
"version": "0.15.30",
"version": "0.15.32",
"description": "if you want to go fast, go deep first. pstack helps you write less, but higher quality code. rigorous agent workflows you can parallelize with confidence.",
"author": {
"name": "Lauren Tan"
Expand Down
2 changes: 2 additions & 0 deletions skills/create-formal-verification-skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Pick the tool that answers the question at the lowest cost: a model checker, a p

Write a project-local skill with frontmatter that names the area and says when to use it. Without frontmatter, the skill never registers. From the skill alone, the next agent must be able to tell whether a change touches the verified code, run the check, tell a real bug from a modeling error, and update the verification when a change is intended. Put the coverage record and the sync commit in the skill, and tell the next agent to move the sync commit forward only after it confirms that the verification describes the code at the new commit. Use this repo's real commands, paths, and nouns, and leave no placeholders.

If the check is a TLA+ model, tell the next agent to read every result with the pstack [`/statechart`](../statechart/SKILL.md) skill. It shows each invariant violation the checker finds as its shortest path in plain words, and which of those violations a change fixes. When the check passes, `/statechart` draws a chart of how the system moves between states. Refer to `/statechart` by name. Its commands, options, and file paths change between releases and machines, so copy none of them into the generated skill or its scripts.

## Prove it before you hand off

Follow the generated skill once, as the next agent would, on a scratch change that breaks the property in the real code. It must catch the break. Then drop the scratch change and confirm that the check is back where it started: passing, or failing only on the findings you will report. A verify skill that was never run is a draft. If the skill misses the break or a step does not run, fix the skill or the verification, not the scratch change.
Expand Down
23 changes: 23 additions & 0 deletions skills/ketchup-video/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
name: ketchup-video
description: "Catch the user up like /ketchup, with a short silent video of slides for what happened and the waiting calls as text under it. Use for /ketchup-video."
disable-model-invocation: true
---

# Ketchup video

Follow [`/ketchup`](../ketchup/SKILL.md), but show what happened in a short silent video instead of its summary and visuals. Its window starts at the last message the user wrote before `/ketchup-video`, not before any earlier `/ketchup`. Writing and rendering the video, outside the repo, is the one exception to its read-only rule. The reply is ketchup's opening sentence, the embedded video, one line naming any work left that you can do yourself, and the action items. When the video shows a call's options, call each option by the name the video gives it. If the render fails, say so in one line and give only the calls.

## Slides

Use the screenshots, recordings, diffs, and data the work produced, not the chat's prose about them. Before you draw, write the one sentence the user should leave with and one line per slide. Drop each slide that does not change what the user knows. Use at most 10 slides, and fewer is better. Neither note goes in the reply.

Each slide makes one point. Build the slides on one picture and change it from slide to slide, rather than cut to an unrelated one. A color or shape means the same thing on every slide. Show the work in pictures, such as how a system works, a PR stack with each PR's state, a before and after of a fix, or options side by side. Point at what changed with arrows, highlights, and motion. A slide is never a bullet list, a status list, a table, or boxes with nothing connecting them.

Keep text to the minimum. Put each label on the thing it names, in plain words a person would say, not tags like "Tests: pass". Never caption what a picture shows, and never give a fact twice, across slides or in the text under them.

Show what came of each thing the user asked for, and whether it is done, still running, failed, or not checked. Never show something done that still waits on the user. Show any bug found and fixed on the way. If a visible change has no screenshot, say so on its slide and name what changed on screen. Draw only from facts you have. Never invent a number, and never draw a screen you did not capture.

## Render

Render one silent landscape video, such as an mp4, with motion between slides. Every frame is the slide itself, edge to edge. Make the frames from the slides, never by recording a screen. Hold each slide long enough to read its words, at least one second for each three words it adds. Words inside a screenshot do not count. Before you embed the video, look at one frame from each slide in the rendered file, and count the words it adds against its hold in that file. Fix any slide you cannot read in that time, render again, and check again.
1 change: 1 addition & 0 deletions skills/poteto-help/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ The default answer is `/poteto-mode`, which runs most of the others when its ste
| Understand a change or subsystem, explained plainly | [`/teach`](../teach/SKILL.md) |
| Catch up on their own recent work on a topic | [`/recall`](../recall/SKILL.md) |
| Catch up on what changed in this chat since their last message, in plain words and small visuals, with every item waiting on them listed and decidable from the reply alone | [`/ketchup`](../ketchup/SKILL.md) |
| Catch up like `/ketchup`, in a short silent video of slides, with the waiting calls as text under it | [`/ketchup-video`](../ketchup-video/SKILL.md) |
| Know what a small diff could break outside itself | [`/blast-radius`](../blast-radius/SKILL.md) |
| Settle types and module shape before code that crosses a function boundary | [`/architect`](../architect/SKILL.md) |
| Get several attempts at one brief, merged into the best one | [`/arena`](../arena/SKILL.md) |
Expand Down
2 changes: 1 addition & 1 deletion skills/poteto-mode/scripts/check-plan.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ for (let i = start; i < raw.length; i++) {
lines.push({ n, text, code });
if (code) continue;
const prose = text
.replace(/`[^`]*`/g, "`")
.replace(/(?<!`)(`+)(?!`)(.*?)(?<!`)\1(?!`)/g, "`")
.replace(/!\[[^\]]*\]\([^)]*\)/g, "")
.replace(/\]\([^)]*\)/g, "]");
if (/[\u2013\u2014]/.test(prose)) fail(n, "long dash");
Expand Down
14 changes: 14 additions & 0 deletions skills/poteto-mode/scripts/check-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,17 @@ test("rejects backtick info strings containing a backtick", () => {
const result = runPlan("```markdown `invalid`\n## Example only\n```\n", 1);
expect(result.stderr).toContain('"## Example only" after Close the program is not an appendix');
});

test("ignores prose punctuation inside matching multi-backtick code spans", () => {
for (const count of [2, 3, 4]) {
const delimiter = "`".repeat(count);
runPlan(`Use ${delimiter}echo \`x\`: “quoted” — example${delimiter}.\n`, 0);
}
});

test("checks prose following a multi-backtick span and unmatched delimiters", () => {
const outside = runPlan("Use ``echo `x`: y``. Bad: prose.\n", 1);
expect(outside.stderr).toContain("mid-sentence colon");
const unmatched = runPlan("Use ``unclosed: bad` here.\n", 1);
expect(unmatched.stderr).toContain("mid-sentence colon");
});
34 changes: 34 additions & 0 deletions skills/poteto-mode/scripts/orch/orch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,40 @@ describe("Store", () => {
});
});

it(`resolves a replacement ${forge} PR alongside closed branch history`, async () => {
for (const historicalState of ["closed", "merged"] as const) {
const { directory, store } = await initializedStore();
const stack = await makeGitStack(directory);
await withFakeForge({
directory, forge,
currentRows: forgeRows(forge, [
{ number: 9, state: historicalState, head: "stack/open", base: "main" },
{ number: 11, state: "open", head: "stack/open", base: "stack/closed" },
]),
operation: async () => {
const frontier = await store.frontier.set({ repo: stack.repo, forge });
expect(frontier.prs.map((row) => row.pr)).toEqual([10, 13, 11, 14]);
expect(frontier.lowestUnmerged).toBe(11);
},
});
}
});

it(`still rejects multiple active ${forge} PRs on one branch`, async () => {
const { directory, store } = await initializedStore();
const stack = await makeGitStack(directory);
await withFakeForge({
directory, forge,
currentRows: forgeRows(forge, [
{ number: 11, state: "open", head: "stack/open", base: "stack/closed" },
{ number: 12, state: "open", head: "stack/open", base: "main" },
]),
operation: async () => {
await expect(store.frontier.set({ repo: stack.repo, forge })).rejects.toThrow("multiple pull requests");
},
});
});

it(`rejects unparseable ${forge} output loudly`, async () => {
const { directory, store } = await initializedStore();
const stack = await makeGitStack(directory);
Expand Down
10 changes: 7 additions & 3 deletions skills/poteto-mode/scripts/orch/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1113,15 +1113,19 @@ function branchPullRequest({
repo: string;
}): ForgePullRequest {
const rows = listPullRequests({ filter: "--head", branch, forge, repo });
const row = rows[0];
// A branch may have closed or merged historical PRs after stack surgery.
// Prefer its sole active PR, but retain historical anchors when none is open.
const active = rows.filter((row) => row.state === "OPEN");
const candidates = active.length > 0 ? active : rows;
const row = candidates[0];
if (row === undefined) {
throw new UserError(
`branch ${branch} has no pull request; open one with ${forge} pr create, or resolve the frontier from a branch whose chain is fully submitted`
);
}
if (rows.length > 1) {
if (candidates.length > 1) {
throw new UserError(
`branch ${branch} has multiple pull requests (${rows.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first`
`branch ${branch} has multiple pull requests (${candidates.map((item) => `#${item.pr}`).join(", ")}); close the stale ones first`
);
}
return row;
Expand Down
62 changes: 62 additions & 0 deletions skills/poteto-mode/scripts/watch-pr/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { describe, expect, it } from "bun:test";
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
ChecksUnavailable,
WatcherQueryError,
Expand Down Expand Up @@ -418,19 +422,77 @@ describe("context and stack discovery", () => {
{
number: parsePrNumber(41),
headRefName: "base-feature",
isCrossRepository: false,
baseRefName: "main",
},
{
number: context.number,
headRefName: "feature",
isCrossRepository: false,
baseRefName: "base-feature",
},
{
number: parsePrNumber(43),
headRefName: "upstack",
isCrossRepository: false,
baseRefName: "feature",
},
]);
expect(ordered.map((item) => Number(item.number))).toEqual([41, 42, 43]);
});
it("does not use a fork's same-named head as a stack parent", () => {
const ordered = orderStack(context, [
{ number: parsePrNumber(41), headRefName: "base-feature", baseRefName: "main", isCrossRepository: true },
{ number: context.number, headRefName: "feature", baseRefName: "base-feature", isCrossRepository: false },
]);
expect(ordered.map((item) => Number(item.number))).toEqual([42]);
});

it("does not attach upstream branch children to a fork head", () => {
const ordered = orderStack(context, [
{ number: context.number, headRefName: "feature", baseRefName: "main", isCrossRepository: true },
{ number: parsePrNumber(43), headRefName: "upstack", baseRefName: "feature", isCrossRepository: false },
]);
expect(ordered.map((item) => Number(item.number))).toEqual([42]);
});

it("terminates when an unrelated fork opens main against main", () => {
// Bound the subprocess so this regression reports a failure instead of hanging the suite.
const program = `import { orderStack } from ${JSON.stringify(import.meta.dir + "/github.ts")};
console.log(JSON.stringify(orderStack(${JSON.stringify(context)}, [
{ number: 41, headRefName: "main", baseRefName: "main", isCrossRepository: true },
{ number: 42, headRefName: "feature", baseRefName: "main", isCrossRepository: false }
])));`;
const child = spawnSync(process.execPath, ["-e", program], { encoding: "utf8", timeout: 2000 });
expect(child.error).toBeUndefined();
expect(child.status).toBe(0);
expect(JSON.parse(child.stdout).map((item: { number: number }) => item.number)).toEqual([42]);
});

it("requests and parses fork identity from gh before stack discovery", () => {
const directory = mkdtempSync(join(tmpdir(), "watch-pr-fork-"));
try {
writeFileSync(join(directory, "gh"), `#!/bin/sh
case "$*" in
*number,headRefName,baseRefName,isCrossRepository*) ;;
*) exit 2 ;;
esac
printf '%s\\n' '[{"number":42,"headRefName":"feature","baseRefName":"main","isCrossRepository":true}]'
`, { mode: 0o755 });
const program = `import { GhGitHubReader } from ${JSON.stringify(import.meta.dir + "/github.ts")};
console.log(JSON.stringify(await new GhGitHubReader().openPullRequests({ owner: "owner", repo: "repo" })));`;
const child = spawnSync(process.execPath, ["-e", program], {
encoding: "utf8", timeout: 2000,
env: { ...process.env, PATH: directory + ":" + process.env.PATH },
});
expect(child.error).toBeUndefined();
expect(child.status).toBe(0);
expect(JSON.parse(child.stdout)).toEqual([
{ number: 42, headRefName: "feature", baseRefName: "main", isCrossRepository: true },
]);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});

});
12 changes: 10 additions & 2 deletions skills/poteto-mode/scripts/watch-pr/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,11 +500,14 @@ export class GhGitHubReader implements T.GitHubReader {
"--limit",
"300",
"--json",
"number,headRefName,baseRefName",
"number,headRefName,baseRefName,isCrossRepository",
]);
return list(value, "open PRs").map((item, index) => {
const object = record(item, `open PRs[${index}]`);
if (typeof object.isCrossRepository !== "boolean")
missing(`open PRs[${index}].isCrossRepository`, object.isCrossRepository);
return {
isCrossRepository: object.isCrossRepository,
number: parsePrNumber(object.number, `open PRs[${index}].number`),
headRefName: string(
object.headRefName,
Expand Down Expand Up @@ -671,7 +674,11 @@ export function orderStack(
open: readonly T.OpenPullRequest[]
): T.NonEmpty<T.PrContext> {
const byNumber = new Map(open.map((pr) => [pr.number, pr]));
const byHead = new Map(open.map((pr) => [pr.headRefName, pr]));
// A base ref belongs to this repository. A fork head with the same name
// cannot be its parent, even when that name is the default branch.
const byHead = new Map(
open.filter((pr) => !pr.isCrossRepository).map((pr) => [pr.headRefName, pr])
);
const children = new Map<string, T.OpenPullRequest[]>();
for (const pr of open)
children.set(pr.baseRefName, [...(children.get(pr.baseRefName) ?? []), pr]);
Expand All @@ -693,6 +700,7 @@ export function orderStack(
]);
const up: T.OpenPullRequest[] = [];
const visit = (parent: T.OpenPullRequest): void => {
if (parent.isCrossRepository) return;
for (const child of children.get(parent.headRefName) ?? []) {
if (seen.has(child.number)) continue;
seen.add(child.number);
Expand Down
Loading
Loading