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 .claude/skills/sync-upstream/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ This repo is two branches. `upstream` is a verbatim mirror of `cursor/plugins/ps
2. **Mirror.** Run `scripts/sync-upstream.sh`. It fast-forwards `upstream` from `origin`, commits the new snapshot, and pushes it, or reports that nothing changed. If nothing changed, stop and say so.
3. **Read why.** `gh api --paginate 'repos/cursor/plugins/commits?path=pstack&per_page=100'` lists upstream's commits. Read down to the previously synced SHA, which the script printed. Read the messages of the new ones so the merge preserves poteto's intent, not just the text.
4. **Merge.** `git merge upstream`. Resolve each conflict per `references/translation.md`: take upstream's new substance and keep the port's translation of it. Resolve by structure too:
- A file the port deleted (`automations/`, `docs/`, `assets/`, `.cursor-plugin/`, `skills/make-bot-ui/`, `skills/setup-pstack/`, `skills/poteto-help/`) stays deleted. Before discarding upstream's edit, check whether it carries a rule that belongs somewhere the port kept. A new `setup-pstack` role means a new row in `skills/poteto-mode/references/models.md`.
- A file the port deleted (`automations/`, `docs/`, `assets/`, `.cursor-plugin/`, `skills/make-bot-ui/`, `skills/setup-pstack/`) stays deleted. Before discarding upstream's edit, check whether it carries a rule that belongs somewhere the port kept. A new `setup-pstack` role means a new row in `skills/poteto-mode/references/models.md`.
- `skills/poteto-help/` is rewritten for Claude Code, not mirrored. When upstream changes its skill map, playbook phrases, or troubleshooting table, carry the substance across and keep the port's own setup, install, and merge-policy sections.
- `README.md` is the port's own. Keep ours, and add any new skill to its table.
- A new `skills/principle-*/SKILL.md` moves to `skills/poteto-mode/principles/principle-*.md`, rewriting its relative links: `../principle-x/SKILL.md` becomes `./principle-x.md`, and `../<skill>/` becomes `../../<skill>/`.
- A new skill or playbook gets ported, unless it only makes sense in Cursor. Then delete it and add it to the list above.
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/sync-upstream/references/translation.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,4 @@ A sync must keep these even when upstream's line differs.
- `skills/poteto-mode/scripts/merge-gate/` is port-only. It reuses watch-pr's readiness verdict without editing watch-pr, and adds the policy: allowed repos, human-only paths needing a human approval on the head, and head pinning with `--match-head-commit`. Every playbook merges only through it, and a refusal is final: human-only changes are the user's to merge. The policy lives outside git, and editing it is on poteto-mode's always-pause list.
- `principle-never-block-on-the-human` narrows its force-push example to "force-push to a shared branch", matching poteto-mode's Autonomy. Autopilot owners force-push their own branches with a lease on every rebase.
- models.md uses only `opus` and `sonnet`. `fable` is dropped for cost: every role that upstream maps to it, and every panel slot, goes to `opus` or `sonnet`. interrogate runs two reviewers (`opus`, `sonnet`), accepting that both share one family and give less independent review than a cross-family pair.
- `skills/poteto-help/` is deleted. It answers questions about Cursor setup (`/setup-pstack`, Custom Modes, the deleted `docs/guide/`), so the port's README does that job.
- `skills/poteto-help/` is rewritten, not ported line for line. Setup, Custom Modes, and the guide pages are replaced by `claude plugin` install and update, `models.md`, and the README's merge-policy section. Its skill map uses relative links, and the skill tells the model to give users the public GitHub URL.
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ You can also call the other skills directly:
| `bro` | you want the last message restated in plain language |
| `correct` | agents keep repeating the same mistake in a repo and each one should become impossible |
| `benchmark-checklist` | you ran a benchmark and want the measurement vetted before you report it |
| `poteto-help` | you're new to pstack or unsure which skill fits. Type `/pstack:poteto-help` with a question |
| `blast-radius` | a small-looking change might break something else |
| `architect` | code crosses a function boundary and the caller's shape should be settled first |
| `arena` | you want several parallel attempts at the same thing, then the best parts of each |
Expand Down Expand Up @@ -72,7 +73,7 @@ Edit it for your repos. Both `defaults.humanOnly` and `repos` are required, and
- **Claude models only.** Roles map to `sonnet` and `opus` in [`skills/poteto-mode/references/models.md`](skills/poteto-mode/references/models.md). Edit that file to change a role, and fork the repo to keep the edit, because `claude plugin update` overwrites the installed copy. It replaces `/setup-pstack`.
- **Harness.** `Task` becomes the Agent tool, `readonly` becomes the `pstack:read-only` agent, `/deslop` becomes `/simplify`, `control-ui` and `control-cli` become `claude-in-chrome` and the `run` skill, and the Origin forge is gone in favor of `gh` and `gh stack`.
- **My house rules.** A few defaults follow my own workflow instead of poteto's: `interrogate` gates every PR, agents ready their own PRs, and they merge only through `merge-gate` under a policy file you keep outside git (`~/.claude/pstack/merge-policy.json`). Comment cleanup never touches pre-existing comments.
- **Not shipped.** benny, make-bot-ui, setup-pstack, poteto-help, and the guide. They depend on Cursor.
- **Not shipped.** benny, make-bot-ui, setup-pstack, and the guide. They depend on Cursor.

The full mapping is in [`.claude/skills/sync-upstream/references/translation.md`](.claude/skills/sync-upstream/references/translation.md).

Expand Down
15 changes: 15 additions & 0 deletions scripts/check-port.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ import { describe, expect, test } from "bun:test";
import { findingsFor } from "./check-port";

describe("findingsFor", () => {
test("ignores a link inside a fenced block", () => {
const text = "```md\n[x](../nope/SKILL.md)\n```\n[y](../nope/SKILL.md)";
expect(findingsFor("skills/how/SKILL.md", text).map((f) => f.line)).toEqual([4]);
});

test.each([
["skills/x/SKILL.md", "Write `~/.cursor/rules/pstack-models.mdc`.", "cursor-path"],
["skills/x/SKILL.md", "Cursor's built-in plan mode.", "cursor-brand"],
Expand All @@ -27,6 +32,10 @@ describe("findingsFor", () => {
["skills/how/SKILL.md", "Read `${CLAUDE_SKILL_DIR}/references/missing.md`.", "unresolved-skill-path"],
["skills/how/SKILL.md", "Read `${CLAUDE_SKILL_DIR}/../nope/SKILL.md`.", "unresolved-skill-path"],
["skills/x/SKILL.md", 'Spawn `subagent_type: "pstack:no-such-agent"`.', "unresolved-pstack-name"],
["skills/how/SKILL.md", "See [the missing one](../nope/SKILL.md).", "unresolved-link"],
["skills/poteto-mode/principles/principle-laziness-protocol.md", "Distinct from [x](../principle-build-the-lever/SKILL.md).", "unresolved-link"],
["skills/how/SKILL.md", 'See [x](../nope/SKILL.md "title").', "unresolved-link"],
["README.md", "See [x](skills/nope/SKILL.md).", "unresolved-link"],
["skills/poteto-mode/references/models.md", "| interrogate reviewers | `opus`, `fable` |", "fable-model"],
["skills/x/SKILL.md", 'Spawn with `model: "fable"`.', "fable-model"],
[".claude-plugin/plugin.json", ' "version": "0.15.4",', "plugin-version"],
Expand All @@ -50,6 +59,12 @@ describe("findingsFor", () => {
["a pstack agent", "skills/x/SKILL.md", 'Use `subagent_type: "pstack:read-only"`.'],
["a pstack skill", "README.md", "Run /pstack:poteto-mode."],
["a pstack skill in prose", "skills/x/SKILL.md", "Route through `pstack:poteto-mode`."],
["a link to a sibling skill", "skills/how/SKILL.md", "See [teach](../teach/SKILL.md)."],
["a link in inline code", "skills/how/SKILL.md", "Write `[x](../nope/SKILL.md)` like so."],
["a link with an anchor", "skills/how/SKILL.md", "See [teach](../teach/SKILL.md#usage)."],
["a web link", "skills/how/SKILL.md", "See [docs](https://example.com/a/b.md)."],
["an in-page anchor", "skills/how/SKILL.md", "See [below](#step-1)."],
["a principle link", "skills/poteto-mode/principles/principle-laziness-protocol.md", "See [x](./principle-build-the-lever.md)."],
["a version field elsewhere", "skills/poteto-mode/scripts/package.json", ' "version": "1.0.0",'],
])("allows %s", (_, path, line) => {
expect(findingsFor(path, line)).toEqual([]);
Expand Down
43 changes: 32 additions & 11 deletions scripts/check-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Flags Cursor-isms the Claude Code port must translate, plus the port's own
// invariants (see translation.md). Run before every commit and after every upstream merge.
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { dirname, join } from "node:path";

const repoRoot = join(import.meta.dir, "..");
const principlesDirectory = join(repoRoot, "skills/poteto-mode/principles");
Expand Down Expand Up @@ -57,6 +57,23 @@ function unresolvedSkillPaths(path: string, text: string): string[] {
.filter((relative) => !existsSync(join(repoRoot, skillDirectory, relative)));
}

function unresolvedLinks(path: string, lineText: string): string[] {
if (!path.endsWith(".md")) return [];
return [...lineText.replace(/`[^`]*`/g, "").matchAll(/\]\(\s*<?([^)\s>]+)>?(?:\s+"[^"]*")?\s*\)/g)]
.map(([, target]) => target.replace(/#.*/, ""))
.filter((target) => target !== "" && !/^[a-z][a-z0-9+.-]*:/i.test(target) && !/[<*]/.test(target) && /\/|\.md$/.test(target))
.filter((target) => !existsSync(join(repoRoot, dirname(path), target)));
}

function insideFence(text: string): boolean[] {
let open = false;
return text.split("\n").map((lineText) => {
const fence = /^\s*```/.test(lineText);
if (fence) open = !open;
return open || fence;
});
}

function unresolvedPstackNames(text: string): string[] {
return [...text.matchAll(/\bpstack:([a-z][a-z-]*[a-z])/g)]
.map(([, name]) => name)
Expand All @@ -70,17 +87,21 @@ function unresolvedPrinciples(text: string): string[] {
}

export function findingsFor(path: string, text: string): Finding[] {
if (exemptFiles.test(path)) return [];
const exempt = exemptFiles.test(path);
const fenced = insideFence(text);
return text.split("\n").flatMap((lineText, index) => {
const ruleHits = rules
.filter((rule) => (rule.paths?.test(path) ?? true) && !rule.exemptPaths?.test(path) && rule.pattern.test(lineText))
.map((rule) => rule.name);
const referenceHits = [
...unresolvedPrinciples(lineText).map(() => "unresolved-principle"),
...unresolvedSkillPaths(path, lineText).map(() => "unresolved-skill-path"),
...unresolvedPstackNames(lineText).map(() => "unresolved-pstack-name"),
];
return [...ruleHits, ...referenceHits].map((rule) => ({
const linkHits = fenced[index] ? [] : unresolvedLinks(path, lineText).map(() => "unresolved-link");
const portHits = exempt
? []
: [
...rules
.filter((rule) => (rule.paths?.test(path) ?? true) && !rule.exemptPaths?.test(path) && rule.pattern.test(lineText))
.map((rule) => rule.name),
...unresolvedPrinciples(lineText).map(() => "unresolved-principle"),
...unresolvedSkillPaths(path, lineText).map(() => "unresolved-skill-path"),
...unresolvedPstackNames(lineText).map(() => "unresolved-pstack-name"),
];
return [...portHits, ...linkHits].map((rule) => ({
path,
line: index + 1,
rule,
Expand Down
Loading
Loading