From d39bc67c06d57b109fcb3898aa20cd1571aab4e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 9 Sep 2026 20:46:35 +0000 Subject: [PATCH] feat: add pstack sync from cursor/plugins into skills/do-* Official skills SoT is cursor/plugins pstack/skills, not backnotprop or skills.sh. npm run sync (node scripts/pstack.mjs sync) maps upstream skills/ onto the existing do-* tree. First apply pulled two new principles from upstream 0.15.1. Co-authored-by: Atlas --- .gitignore | 1 + .tmp-fm-ponytail/err.txt | 0 .tmp-fm-ponytail/meta.txt | 5 - .tmp-fm-ponytail/out.txt | 52 --- .tmp-fm-ponytail/prompt.txt | 15 - .tmp-fm320-ponytail/err.txt | 0 .tmp-fm320-ponytail/exit.txt | 4 - .tmp-fm320-ponytail/meta.txt | 1 - .tmp-fm320-ponytail/out.txt | 67 ---- .tmp-fm320-ponytail/pid.txt | 1 - .tmp-fm320-ponytail/prompt.txt | 24 -- .tmp-fm320-ponytail/run.sh | 17 - AGENTS.md | 2 + README.md | 14 +- e2e/AGENTS.md | 1 + e2e/README.md | 1 + e2e/unit/sync-skills.test.ts | 181 ++++++++++ package-lock.json | 3 + package.json | 6 +- scripts/AGENTS.md | 32 ++ scripts/pstack.mjs | 338 ++++++++++++++++++ skills/AGENTS.md | 2 + skills/do-poteto-mode/scripts/check-plan.mjs | 186 ++++++++++ .../do-principle-attack-the-premise/SKILL.md | 23 ++ .../SKILL.md | 25 ++ 25 files changed, 813 insertions(+), 188 deletions(-) delete mode 100644 .tmp-fm-ponytail/err.txt delete mode 100644 .tmp-fm-ponytail/meta.txt delete mode 100644 .tmp-fm-ponytail/out.txt delete mode 100644 .tmp-fm-ponytail/prompt.txt delete mode 100644 .tmp-fm320-ponytail/err.txt delete mode 100644 .tmp-fm320-ponytail/exit.txt delete mode 100644 .tmp-fm320-ponytail/meta.txt delete mode 100644 .tmp-fm320-ponytail/out.txt delete mode 100644 .tmp-fm320-ponytail/pid.txt delete mode 100644 .tmp-fm320-ponytail/prompt.txt delete mode 100755 .tmp-fm320-ponytail/run.sh create mode 100644 e2e/unit/sync-skills.test.ts create mode 100644 scripts/AGENTS.md create mode 100755 scripts/pstack.mjs create mode 100644 skills/do-poteto-mode/scripts/check-plan.mjs create mode 100644 skills/do-principle-attack-the-premise/SKILL.md create mode 100644 skills/do-principle-test-behavior-not-implementation/SKILL.md diff --git a/.gitignore b/.gitignore index e84c9ef..e10c5db 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules/ __pycache__/ *.pyc e2e/.run/ +.tmp-*/ diff --git a/.tmp-fm-ponytail/err.txt b/.tmp-fm-ponytail/err.txt deleted file mode 100644 index e69de29..0000000 diff --git a/.tmp-fm-ponytail/meta.txt b/.tmp-fm-ponytail/meta.txt deleted file mode 100644 index e607c3c..0000000 --- a/.tmp-fm-ponytail/meta.txt +++ /dev/null @@ -1,5 +0,0 @@ -PI_EXIT:0 -2026-09-05T20:01:41Z - 4304 .tmp-fm-ponytail/out.txt - 0 .tmp-fm-ponytail/err.txt - 4304 total diff --git a/.tmp-fm-ponytail/out.txt b/.tmp-fm-ponytail/out.txt deleted file mode 100644 index 98dae14..0000000 --- a/.tmp-fm-ponytail/out.txt +++ /dev/null @@ -1,52 +0,0 @@ -# VERDICT - -FAIL. FM-255 stays on the table. - -Stay independent. No `@zenspc/pi-pstack` dependency appears in the manifests or lockfiles. The knockout is simpler: **we built around loading the author's checkout instead of proving one installable omp plugin.** - -The initial import brought in 139 files and 12,426 lines. Later commits patched naming and discovery without completing the installation contract. - -# FINDINGS (severity, what, where, why fails poteto) - -1. **HIGH: Competing loading paths replaced one supported installation.** - - **Where:** `package.json:10-25`, `.omp-plugin/marketplace.json`, `.omp/config.yml`, `README.md:3-43`, `docs/getting-started.md:3-18`, `e2e/run.py:132-148`. - - The manifests describe a package. Documentation prescribes `omp -e`, rejects plugin installation, and repeatedly credits a `.omp/skills` symlink that no longer exists. The actual mount is project-local `skills.customDirectories`. - - The RPC launcher uses neither `-e` nor an explicit plugin installation. Its results therefore depend on discovery or existing machine configuration. - - **Why fails poteto:** Native `omp plugin link ./` exists, but the repository documents and tests competing arrangements instead. Directory-specific patches create maintenance work without proving installation into another project's workflow. - -2. **HIGH: The port renamed entry points but retained incompatible instructions.** - - **Where:** `agents/poteto-agent.md:4-10`, `commands/poteto-mode.md:5`, `commands/setup-pstack.md:5`, `skills/ps-poteto-mode/SKILL.md:24-36`, `skills/ps-poteto-mode/playbooks/shipping.md:7-17`. - - Agent autoload still names `poteto-mode`; command wrappers reference old skill identifiers and a deleted directory name. Five concrete relative Markdown links still target pre-prefix paths. - - Poteto Mode says Cursor-only tools are skipped, while Shipping requires Cursor cloud agents, `cursor-team-kit`, and Graphite. - - **Why fails poteto:** A discoverable `/skill:ps-…` is not an operational workflow. Keeping unsupported instructions and adding exceptions makes every invocation resolve the porting work again. - -3. **MEDIUM: Verification maintains duplicate machinery while missing the installation boundary.** - - **Where:** `e2e/unit/static-checks.ts`, `e2e/ci_static.py:125-197`, `e2e/run.py:333-340`. - - A 218-line TypeScript helper reimplements the Python checker. Both maintain homemade brace parsing although CI already runs TypeScript. - - `--skip-rpc` runs only two documentation-string checks. Neither proves skill discovery, agent availability, or installation. - - **Why fails poteto:** We maintain tests of duplicated proxies instead of testing the actual package contract. - -# PROPOSED CUTS (delete-list; do NOT apply) - -- Delete `.omp-plugin/marketplace.json` from the direct-link distribution. Keep one package manifest. -- After proving `omp plugin link ./`, delete `.omp/config.yml`'s discovery workaround and the competing installation instructions. Keep one canonical installation section in `README.md`. -- Delete duplicate command wrappers in `commands/`. Keep the factory's sticky `/poteto-mode` and `/skill:ps-setup-pstack`. -- Delete Cursor-only requirements from advertised omp workflows. Remove unsupported playbook routes rather than retaining contradictory “skip” instructions. -- Delete `e2e/unit/static-checks.ts` after redirecting useful cases to the real checker. Delete homemade brace parsing; retain `tsc`. -- Keep the independent factory, useful skills, and named agents. Skill count alone does not justify deletion. - -# TESTS / EVALS - -Read-only checks performed: - -- Static runner: **2/2 passed**. -- Quality/frontmatter checks: **58/58 passed**. -- TypeScript no-emit check: **passed**. -- Link audit: **five broken concrete relative links**. -- Counterexample: homemade parse check accepts `export default function pstack() { const = ; }`. - -Not run: installation, live RPC, or Vitest, whose fixtures write files. - -Required acceptance eval: link from `/Users/ed/Developer/pstack-for-pi` in an isolated local configuration, launch from an unrelated project, verify all 45 `/skill:ps-…` entries resolve to this clone, verify named-agent discovery, then exercise on/off/new/resume. Confirm no zenspc installation is needed. - -No cuts applied. diff --git a/.tmp-fm-ponytail/prompt.txt b/.tmp-fm-ponytail/prompt.txt deleted file mode 100644 index b3bcc83..0000000 --- a/.tmp-fm-ponytail/prompt.txt +++ /dev/null @@ -1,15 +0,0 @@ -You are ponytail (poteto adversarial). READ-ONLY. Do not edit/write/commit/land/push. - -Review how THIS omp extension was created (plugin layout, install path `omp plugin link ./`, `/skill:ps-…` slash UX, skill tree, independence lock vs @zenspc/pi-pstack). - -Prior score FM-255 stays on the table — optimize / cut dead weight in how we built the extension, not rewrite to depend on zenspc. - -Captain lock: stay independent; no depend/marry zenspc. - -Scope: this repo at /Users/ed/Developer/pstack-for-pi (GitHub origin still edheltzel/pstack; local dir renamed). Explore with read/bash only: package.json omp/pi fields, .omp-plugin/, extensions/, skills/, agents/, commands/, README, install docs, e2e. Prefer deletion. Discard nitpicks. Knockout is usually one core issue. - -Deliver exactly: -# VERDICT -# FINDINGS (severity, what, where, why fails poteto) -# PROPOSED CUTS (delete-list; do NOT apply) -# TESTS / EVALS diff --git a/.tmp-fm320-ponytail/err.txt b/.tmp-fm320-ponytail/err.txt deleted file mode 100644 index e69de29..0000000 diff --git a/.tmp-fm320-ponytail/exit.txt b/.tmp-fm320-ponytail/exit.txt deleted file mode 100644 index 95f6b13..0000000 --- a/.tmp-fm320-ponytail/exit.txt +++ /dev/null @@ -1,4 +0,0 @@ -PI_EXIT=0 - 4923 /tmp/fm320-out.txt - 0 /tmp/fm320-err.txt - 4923 total diff --git a/.tmp-fm320-ponytail/meta.txt b/.tmp-fm320-ponytail/meta.txt deleted file mode 100644 index d2c922a..0000000 --- a/.tmp-fm320-ponytail/meta.txt +++ /dev/null @@ -1 +0,0 @@ -START 2026-09-06T13:19:01Z cwd=/tmp/fm320-pi-cwd no-ext no-skills no-context verbose model=gpt-6-astra diff --git a/.tmp-fm320-ponytail/out.txt b/.tmp-fm320-ponytail/out.txt deleted file mode 100644 index a61b9c4..0000000 --- a/.tmp-fm320-ponytail/out.txt +++ /dev/null @@ -1,67 +0,0 @@ -# VERDICT - -**FAIL. Knockout: the creation path still proves the extension’s shell, not the mounted skill-and-agent workflow.** - -The loading cleanup is real; the remaining failure is not that `omp -e` is illegal or that this needs marketplace packaging. It is that imported instructions and renamed resources remain inconsistent behind passing gates. - -Reviewed HEAD `7b87853`. FM-255’s unapplied **net −1626 at `80ff27e` remains on the table**, not credited as applied. - -# FINDINGS (severity, what, where, why fails poteto) - -### HIGH — Discovery patches never closed the workflow contract - -**Creation evidence:** -- `6a11450` imported 139 files/12,426 lines, including duplicate `omp`/`pi` declarations, marketplace metadata, command wrappers, skills, and agents. -- `29fd726` prefixed skills and added `.omp/skills`; `80ff27e` documented that checkout-local mount. -- `6f281e2` removed marketplace metadata and skill manifest fields; `408e274` removed the colliding command. -- `b7bcd32`/`4d7fc6a` removed duplicate checker machinery, remaining wrappers, and the symlink. Those cuts deserve credit. `.omp/`, `.omp-plugin/`, and `commands/` are no longer tracked load paths. - -**What remains:** `e2e/run.py:136–171,257–287` links twice using the inherited operator environment, launches only from the repository, and checks command presence plus a hardcoded prompt needle/session entry. It never asserts the 45 skills’ provenance, actual expansion, or named-agent discovery. `--no-tools` precludes proving delegation. - -Concrete misses: -- `agents/poteto-agent.md:4–10` still names `poteto-mode`, absent from the current `do-*` skill catalog. -- Five concrete Markdown links inside skills still target pre-prefix directories. -- `extensions/pstack.ts:5` injects a checkout-relative skill path, while README promises operation from any cwd. - -**Why fails poteto:** changing the launcher to `plugin link` is not proof of the boundary that motivated the discovery patches. The marker can establish sticky state without establishing usable workflow resources. - -### HIGH — The subsequent product import restored incompatible operational contracts - -**Where:** `cd8d644`; `docs/guide/01-setup.md:25–39`; `skills/do-setup-pstack/SKILL.md:8,29–31`; `skills/do-poteto-mode/playbooks/authoring-a-skill.md:5`; `autopilot-full.md`; `autopilot-stack.md`. - -The setup guide promises a Cursor model-rule writer, model-list panels, and `.cursor/skills` output. The actual setup skill expressly forbids that writer and delegates routing to omp Roles/agents. - -Live playbooks still require Cursor’s `create-skill`, cloud agents, or Graphite. A general “skip Cursor-only tools” exception does not make those procedures operational. - -**Why fails poteto:** the port copied a second behavioral authority rather than subtracting unsupported behavior. Every future operator or agent must reconcile that unfinished migration. - -### MEDIUM — Tests still preserve ceremony instead of checking those contradictions - -**Where:** `e2e/run.py:232–253`; `e2e/unit/product-capabilities.test.ts:100–116,150–154`; `e2e/ci_static.py:105–148`. - -Python and Vitest duplicate the install-story/deleted-file assertions. Another test inspects launcher source text. The product gate requires copied files to exist; the link audit covers README/docs/automations, but excludes skills and agents. - -**Why fails poteto:** maintenance is spent proving prescribed wording and inventory while the actual broken references escape. - -# PROPOSED CUTS (delete-list; do NOT apply) - -- Delete the false Cursor model-writer, panel-routing, and verification-output promises from `docs/guide/01-setup.md`. -- Delete unsupported Cursor-only requirements and routes from advertised omp workflows, including the Cursor `create-skill` requirement and cloud-agent/Graphite autopilot procedures. -- Delete contradictory Cursor-path/authoring claims from `docs/guide/06-verify-and-ship.md` and `09-make-it-yours.md`. -- Delete Vitest’s duplicate install-story assertions and launcher-source-string test in `e2e/unit/product-capabilities.test.ts`. -- Delete the obsolete `commands/` validation loop in `e2e/ci_static.py` and corresponding command-authoring contracts. -- Delete the second `plugin_link()` invocation inside `start_omp()`. - -# TESTS / EVALS - -**Performed read-only:** -- Static runner: **3/3 passed**. -- Quality/product/frontmatter: **86/86 passed**. -- TypeScript no-emit: **passed**. -- Resource audit: **one unresolved agent autoload name; five broken concrete skill links**. - -**Not run:** live RPC/plugin linking or Vitest; they write configuration, sessions, or fixtures. - -**Required acceptance eval:** isolated omp configuration; link once; launch from an unrelated project; verify all 45 skills resolve uniquely to this clone, actual skill expansion, named-agent discovery/delegation, and on/off/new/resume behavior. No `@zenspc/pi-pstack` dependency or marketplace step. - -No cuts applied. diff --git a/.tmp-fm320-ponytail/pid.txt b/.tmp-fm320-ponytail/pid.txt deleted file mode 100644 index 30e93d9..0000000 --- a/.tmp-fm320-ponytail/pid.txt +++ /dev/null @@ -1 +0,0 @@ -47922 diff --git a/.tmp-fm320-ponytail/prompt.txt b/.tmp-fm320-ponytail/prompt.txt deleted file mode 100644 index aaa8250..0000000 --- a/.tmp-fm320-ponytail/prompt.txt +++ /dev/null @@ -1,24 +0,0 @@ -You are ponytail (poteto adversarial). READ-ONLY. Do not edit, write, commit, land, or push. Do not apply any cut. - -Ticket FM-320. Review HOW this omp extension was created and the structure quality of that creation path. - -Repo: /Users/ed/Developer/pstack-for-pi (GitHub origin edheltzel/pstack). - -Locks (do not fight these): -- Official omp.sh: this tree is an extension loaded with `omp -e` and/or `omp plugin link ./`. It is NOT a marketplace plugin. Do not recommend `omp plugin install`, `/add-plugin`, or becoming a marketplace listing. -- Stay independent of `@zenspc/pi-pstack`. Do not depend, marry, or rewrite onto that package. -- Prior unapplied score FM-255 at 80ff27e (net -1626) stays on the table. Optimize / cut dead weight in how we built the extension. -- FM-253/254 locked vs official omp docs: legal -e file-load extension, not authored-package best practice. - -Focus: -- How the extension was created from the initial import through the discovery/mount patches (git history, package.json omp/pi fields, extensions/pstack.ts, .omp/, .omp-plugin/, skills/, agents/, commands/, README/docs, e2e). -- Competing load paths, leftover marketplace/plugin-install ceremony, skill discovery workarounds, Cursor leftovers, duplicate tests. -- Prefer deletion. Discard nitpicks. Knockout is usually one core issue. - -Deliver exactly: -# VERDICT -# FINDINGS (severity, what, where, why fails poteto) -# PROPOSED CUTS (delete-list; do NOT apply) -# TESTS / EVALS - -Verdict is a real judgment (PASS / FAIL / CONDITIONAL) with the knockout named. Proposed cuts are a delete-list only. Do not apply them. diff --git a/.tmp-fm320-ponytail/run.sh b/.tmp-fm320-ponytail/run.sh deleted file mode 100755 index 1734a30..0000000 --- a/.tmp-fm320-ponytail/run.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/zsh -set -e -cd /Users/ed/Developer/pstack-for-pi -PROMPT=$(cat .tmp-fm320-ponytail/prompt.txt) -: > .tmp-fm320-ponytail/out.txt -: > .tmp-fm320-ponytail/err.txt -echo "START $(date -u +%Y-%m-%dT%H:%M:%SZ) prompt_len=${#PROMPT} model=openai-codex/gpt-6-astra thinking=high tools=read,bash" > .tmp-fm320-ponytail/meta.txt -/Users/ed/.bun/bin/pi -p --no-session --approve \ - --provider openai-codex --model gpt-6-astra --thinking high \ - --tools read,bash --exclude-tools write,edit \ - "$PROMPT" \ - > .tmp-fm320-ponytail/out.txt 2>> .tmp-fm320-ponytail/err.txt -EC=$? -echo "PI_EXIT=$EC" | tee .tmp-fm320-ponytail/exit.txt -echo "END $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> .tmp-fm320-ponytail/meta.txt -wc -c .tmp-fm320-ponytail/out.txt .tmp-fm320-ponytail/err.txt >> .tmp-fm320-ponytail/meta.txt -exit $EC diff --git a/AGENTS.md b/AGENTS.md index 5ef5100..d8869a8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Closeout: re-check paths, update owning docs, refresh indexes, run verification, - Static gate (CI and local): `python3 e2e/run.py --skip-rpc` and `npm test` (Vitest). - No `omp`, no `omp --mode rpc`, no TUI, no `claude` on GitHub runners. Live RPC is local only: `python3 e2e/run.py` without `--skip-rpc`. Host-only prove (`omp plugin doctor`, `claude plugin validate`) is documented in e2e/AGENTS.md. - One factory. Poteto-mode is per-session, never a process-wide boolean. +- Official skills SoT is `cursor/plugins` `pstack/skills`. Update with `npm run sync` (`node scripts/pstack.mjs sync`). Writes into existing `skills/do-*` only. Not `backnotprop/pstack`, not skills.sh, not `npx skills add`. ## Native omp @@ -91,5 +92,6 @@ npm test | [skills/AGENTS.md](skills/AGENTS.md) | Skill tree; SKILL.md frontmatter `name` + `description` | | [docs/AGENTS.md](docs/AGENTS.md) | original numbered `guide/`; first-run lives on README (omp plugin link + Claude plugin install) | | [automations/AGENTS.md](automations/AGENTS.md) | Dormant Benny pack; not slash skills | +| [scripts/AGENTS.md](scripts/AGENTS.md) | `pstack sync`: vendor official skills from cursor/plugins into `skills/do-*` | `agents/` and `commands/` have no child AGENTS.md; they follow this rail. Commands need frontmatter `description`. diff --git a/README.md b/README.md index 6157b13..8ca326f 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ cd pstack-for-omp omp plugin link ./ ``` -Later sessions (any cwd): start `omp`, type `/` (`/skill:do-…`, sticky `/poteto-mode`). Sibling `skills/` at the package root is what omp auto-discovers after link. Confirm with `omp plugin list`. Prove the link with `omp plugin doctor`. +Later sessions (any cwd): start `omp`, type `/` (`/skill:do-…`, sticky `/poteto-mode`). Sibling `skills/` at the package root is what omp auto-discovers after link. Confirm with `omp plugin list`. Prove the link with `omp plugin doctor`. Update official skills with `pstack sync` (below). Do not copy only `pstack.ts` into `~/.omp/agent/extensions/`. Do not marketplace-install this package on omp; local-link is the install path. @@ -98,6 +98,18 @@ That is the live omp extension command. It stays unprefixed. There is no worktre - Off aliases: `off`, `disable`, `stop`. - No Cursor marketplace APIs. No `models.json` writer. No `hooks/` or `src/` tree. +## Update official skills + +Official skills come from the Cursor pstack folder in [cursor/plugins](https://github.com/cursor/plugins/tree/main/pstack), not from `backnotprop/pstack` and not from skills.sh. From this package root: + +``` +npm run sync +``` + +Same command: `node scripts/pstack.mjs sync`. Dry-run: `npm run sync -- --dry-run`. + +That pulls `pstack/skills/` from `cursor/plugins` into the existing `skills/do-*` tree (YAML `name` gets the `do-` prefix). It does not create a second skill tree or install into agent skill directories. Diverged local omp forks stay unless you pass `--force`. This fork keeps `playbooks/shipping.md` deleted. + ## Skills Files under `skills/` are markdown prompts. They are not live omp functions or CLIs. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 5fb9588..ef4c2d4 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -20,6 +20,7 @@ Prove the plugin without chat-only guesses. Static doctor/lint + Vitest on every - Do not keep a TypeScript copy of `ci_static.py` (no `unit/static-checks.ts`, no homemade brace parser). - Do not keep a repo `.omp/skills` symlink. `static_install_docs` and `unit/product-capabilities.test.ts` assert filesystem absence, not only README text. - Claude components stay at plugin root. `.claude-plugin/` may hold `plugin.json` and `marketplace.json` only — not `skills/`. +- Official skill vendor-sync tests (`unit/sync-skills.test.ts`) stay offline (`--from` fixture). Do not clone `cursor/plugins` on CI. ## Work Guidance diff --git a/e2e/README.md b/e2e/README.md index 61c10a8..f4fe9ff 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -32,6 +32,7 @@ claude plugin validate . | Case | What it proves | | --- | --- | | `install_plugin_link` | README/AGENTS teach `omp plugin link ./` and Claude plugin install; competing stories gone | +| `pstack sync` | `e2e/unit/sync-skills.test.ts` — official skills SoT is `cursor/plugins`; writes into `skills/do-*` only | | `poteto_on` | `/poteto-mode` injects the needle and writes `pstack-mode` on this session | | `second_turn_sticky` | Next turn still has the needle | | `poteto_off` | `/poteto-mode off` then next turn has no needle | diff --git a/e2e/unit/sync-skills.test.ts b/e2e/unit/sync-skills.test.ts new file mode 100644 index 0000000..f01717b --- /dev/null +++ b/e2e/unit/sync-skills.test.ts @@ -0,0 +1,181 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { repoDir } from "./py-ci.ts"; +import { + LOCAL_SUBTRACTIONS, + UPSTREAM_REPO, + UPSTREAM_SKILLS, + formatReport, + parseArgs, + planSync, + rewriteLayout, + runSync, +} from "../../scripts/pstack.mjs"; + +const cli = fileURLToPath(new URL("../../scripts/pstack.mjs", import.meta.url)); + +function write(root: string, rel: string, body: string): void { + const path = join(root, rel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, body); +} + +function fixture(): { root: string; from: string } { + const root = mkdtempSync(join(tmpdir(), "pstack-sync-pkg-")); + const from = mkdtempSync(join(tmpdir(), "pstack-sync-up-")); + write( + from, + "how/SKILL.md", + "---\nname: how\ndescription: Explain code.\n---\n\nSee [Why](../why/SKILL.md).\n", + ); + write( + from, + "why/SKILL.md", + "---\nname: why\ndescription: Motivation.\n---\n\n# Why\n", + ); + write( + from, + "setup-pstack/SKILL.md", + "---\nname: setup-pstack\ndescription: Cursor setup.\n---\n\nWrite pstack-models.mdc.\n", + ); + write(from, "poteto-mode/SKILL.md", "---\nname: poteto-mode\ndescription: Router.\n---\n\n# Poteto\n"); + write(from, "poteto-mode/playbooks/shipping.md", "### Shipping\n"); + write( + from, + "principle-attack-the-premise/SKILL.md", + "---\nname: principle-attack-the-premise\ndescription: Question the premise.\n---\n\nSee [Build the Lever](../principle-build-the-lever/SKILL.md).\n", + ); + write( + from, + "principle-build-the-lever/SKILL.md", + "---\nname: principle-build-the-lever\ndescription: Build the script.\n---\n\n# Lever\n", + ); + + write( + root, + "skills/do-how/SKILL.md", + "---\nname: do-how\ndescription: Explain code (omp).\n---\n\nLocal omp fork.\n", + ); + write( + root, + "skills/do-setup-pstack/SKILL.md", + "---\nname: do-setup-pstack\ndescription: omp setup.\n---\n\nDo not write models.json.\n", + ); + write(root, "skills/do-poteto-mode/SKILL.md", "---\nname: do-poteto-mode\ndescription: Router.\n---\n\n# Poteto\n"); + write( + root, + "skills/do-principle-build-the-lever/SKILL.md", + "---\nname: do-principle-build-the-lever\ndescription: Build the script.\n---\n\n# Lever\n", + ); + return { root, from }; +} + +describe("pstack sync", () => { + it("defaults SoT to cursor/plugins, not backnotprop or skills.sh", () => { + expect(UPSTREAM_REPO).toBe("https://github.com/cursor/plugins.git"); + expect(UPSTREAM_SKILLS).toBe("pstack/skills"); + expect(UPSTREAM_REPO).not.toContain("backnotprop"); + expect(LOCAL_SUBTRACTIONS).toContain("poteto-mode/playbooks/shipping.md"); + const opts = parseArgs(["sync"]); + expect(opts.repo).toBe(UPSTREAM_REPO); + expect(opts.command).toBe("sync"); + }); + + it("rewrites official layout onto skills/do-* without a second tree", () => { + const slugs = ["how", "why", "principle-build-the-lever"]; + const text = rewriteLayout( + "---\nname: how\ndescription: x\n---\n\n[Why](../why/SKILL.md) and skills/how/SKILL.md\n", + slugs, + "how", + ); + expect(text).toContain("name: do-how"); + expect(text).toContain("../do-why/SKILL.md"); + expect(text).toContain("skills/do-how/SKILL.md"); + expect(text).not.toContain("name: how\n"); + expect(text).not.toContain("../why/"); + expect(text).not.toContain("do-do-"); + }); + + it("adds missing official skills, skips shipping.md, keeps diverged local forks", () => { + const { root, from } = fixture(); + const plan = planSync({ + upstreamSkills: from, + destSkills: join(root, "skills"), + force: false, + }); + const kinds = Object.fromEntries(plan.actions.map((a) => [a.destRel, a.kind])); + expect(kinds["skills/do-why/SKILL.md"]).toBe("add"); + expect(kinds["skills/do-principle-attack-the-premise/SKILL.md"]).toBe("add"); + expect(kinds["skills/do-poteto-mode/playbooks/shipping.md"]).toBe("skip"); + expect(kinds["skills/do-how/SKILL.md"]).toBe("diverged"); + expect(kinds["skills/do-setup-pstack/SKILL.md"]).toBe("diverged"); + expect(kinds["skills/do-poteto-mode/SKILL.md"]).toBeUndefined(); + + const report = runSync({ + command: "sync", + dryRun: false, + force: false, + from, + repo: UPSTREAM_REPO, + ref: null, + root, + help: false, + }); + const added = readFileSync(join(root, "skills/do-principle-attack-the-premise/SKILL.md"), "utf8"); + expect(added.startsWith("---\nname: do-principle-attack-the-premise\n")).toBe(true); + expect(added).toContain("../do-principle-build-the-lever/SKILL.md"); + expect(readFileSync(join(root, "skills/do-how/SKILL.md"), "utf8")).toContain("Local omp fork"); + expect(readFileSync(join(root, "skills/do-setup-pstack/SKILL.md"), "utf8")).toContain("Do not write models.json"); + expect(() => readFileSync(join(root, "skills/do-poteto-mode/playbooks/shipping.md"))).toThrow(); + expect(existsSync(join(root, "skills/why"))).toBe(false); + expect(existsSync(join(root, "skills/how"))).toBe(false); + expect(existsSync(join(root, "skills/do-why/SKILL.md"))).toBe(true); + const text = formatReport(report); + expect(text).toContain("sot: cursor/plugins pstack/skills → skills/do-*"); + expect(text).toContain("added: skills/do-why/SKILL.md"); + expect(text).toContain("skipped: skills/do-poteto-mode/playbooks/shipping.md"); + expect(text).toMatch(/summary: added=\d+ updated=0 skipped=1 diverged=\d+/); + }); + + it("dry-run writes nothing", () => { + const { root, from } = fixture(); + runSync({ + command: "sync", + dryRun: true, + force: false, + from, + repo: UPSTREAM_REPO, + ref: null, + root, + help: false, + }); + expect(() => readFileSync(join(root, "skills/do-why/SKILL.md"))).toThrow(); + }); + + it("package docs name npm run sync and cursor/plugins as SoT", () => { + const readme = readFileSync(join(repoDir(), "README.md"), "utf8"); + const agents = readFileSync(join(repoDir(), "AGENTS.md"), "utf8"); + expect(readme).toContain("npm run sync"); + expect(readme).toContain("node scripts/pstack.mjs sync"); + expect(readme).toContain("cursor/plugins"); + expect(readme).not.toMatch(/npx skills add/); + expect(agents).toContain("npm run sync"); + expect(agents).toContain("cursor/plugins"); + }); + + it("CLI sync --dry-run --from prints a plan and exits 0", () => { + const { root, from } = fixture(); + const result = spawnSync(process.execPath, [cli, "sync", "--dry-run", "--from", from, "--root", root], { + encoding: "utf8", + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("mode: dry-run"); + expect(result.stdout).toContain("added: skills/do-why/SKILL.md"); + expect(result.stdout).toContain("cursor/plugins"); + expect(() => readFileSync(join(root, "skills/do-why/SKILL.md"))).toThrow(); + }); +}); diff --git a/package-lock.json b/package-lock.json index 47e9321..fc1d922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "pstack", "version": "0.14.5", + "bin": { + "pstack": "scripts/pstack.mjs" + }, "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.11" diff --git a/package.json b/package.json index a69d77b..8e1d274 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,12 @@ "description": "pstack plugin for omp/Pi and Claude Code: sticky /poteto-mode, skills, agents.", "private": true, "type": "module", + "bin": { + "pstack": "./scripts/pstack.mjs" + }, "scripts": { - "test": "vitest run" + "test": "vitest run", + "sync": "node ./scripts/pstack.mjs sync" }, "omp": { "extensions": [ diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md new file mode 100644 index 0000000..b06bbcf --- /dev/null +++ b/scripts/AGENTS.md @@ -0,0 +1,32 @@ +# scripts + +## Purpose + +Maintainer CLI for this package. `pstack.mjs` is the `pstack sync` entry: pull official skills from `cursor/plugins` `pstack/skills` into the existing `skills/do-*` tree. + +## Ownership + +- `pstack.mjs` — `pstack sync` (and `--dry-run`). + +## Local Contracts + +- Source of truth is `https://github.com/cursor/plugins.git` folder `pstack/skills`. Not `backnotprop/pstack`. Not skills.sh. `npx skills add` is not the path (it would install a second skill tree). +- Layout map: upstream `skills//` → package `skills/do-/`. YAML `name` gets the `do-` prefix. Sibling links `..//` become `../do-/`. +- One skill tree: sibling `skills/` at the package root. Do not write `.omp/skills`, `.claude-plugin/skills`, or agent skill dirs. +- Do not restore `poteto-mode/playbooks/shipping.md` (this fork deleted it). +- Default apply adds missing official files and leaves diverged local forks (omp adaptations) in place. `--force` overwrites diverged files except local subtractions. + +## Work Guidance + +Keep this a fetch + layout rewrite. Do not fold sync into `extensions/pstack.ts`. + +## Verification + +```bash +node scripts/pstack.mjs sync --dry-run +npx vitest run e2e/unit/sync-skills.test.ts +``` + +## Child DOX Index + +None. diff --git a/scripts/pstack.mjs b/scripts/pstack.mjs new file mode 100755 index 0000000..572a258 --- /dev/null +++ b/scripts/pstack.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node +/** + * Vendor official pstack skills from cursor/plugins into this package's + * single skills/do-* tree. Not a second skill tree. Not skills.sh. + */ +import { spawnSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export const UPSTREAM_REPO = "https://github.com/cursor/plugins.git"; +export const UPSTREAM_SKILLS = "pstack/skills"; +export const UPSTREAM_PLUGIN_JSON = "pstack/.cursor-plugin/plugin.json"; +/** Paths this fork removed on purpose. Relative to upstream skills/. */ +export const LOCAL_SUBTRACTIONS = Object.freeze(["poteto-mode/playbooks/shipping.md"]); + +const TEXT = /\.(md|txt|json|yml|yaml|ts|js|mjs|cjs|sh)$/i; +const HELP = `Usage: pstack [options] + +Commands: + sync Pull official skills from cursor/plugins into skills/do-* + +pstack sync [--dry-run] [--force] [--from DIR] [--repo URL] [--ref REF] [--root DIR] + +SoT is github.com/cursor/plugins (pstack/skills), not backnotprop/pstack or skills.sh. +Writes into the existing skills/do-* tree. Does not install into agent skill dirs. +`; + +export function parseArgs(argv) { + const args = [...argv]; + const command = args.shift() ?? ""; + const opts = { + command, + dryRun: false, + force: false, + from: null, + repo: UPSTREAM_REPO, + ref: null, + root: null, + help: false, + }; + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === "-h" || a === "--help") opts.help = true; + else if (a === "--dry-run") opts.dryRun = true; + else if (a === "--force") opts.force = true; + else if (a === "--from") opts.from = args[++i]; + else if (a === "--repo") opts.repo = args[++i]; + else if (a === "--ref") opts.ref = args[++i]; + else if (a === "--root") opts.root = args[++i]; + else throw new Error(`unknown option: ${a}`); + } + return opts; +} + +export function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function rewriteLayout(text, slugs, ownSlug) { + let out = text; + if (ownSlug && !ownSlug.startsWith("do-")) { + out = out.replace(new RegExp(`^(name:\\s*)${escapeRegExp(ownSlug)}\\s*$`, "m"), `$1do-${ownSlug}`); + } + const sorted = [...slugs].filter((s) => s && !s.startsWith("do-")).sort((a, b) => b.length - a.length); + for (const slug of sorted) { + const dest = `do-${slug}`; + const reSlug = escapeRegExp(slug); + out = out.replace(new RegExp(`(? existsSync(join(skillsDir, name, "SKILL.md"))) + .sort(); +} + +function walkFiles(root) { + const out = []; + if (!existsSync(root)) return out; + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + for (const name of readdirSync(dir).sort().reverse()) { + if (name === ".git" || name === "node_modules") continue; + const full = join(dir, name); + const st = statSync(full); + if (st.isDirectory()) stack.push(full); + else out.push(relative(root, full).split("\\").join("/")); + } + } + return out.sort(); +} + +export function resolveUpstreamSkillsDir(fromDir) { + const abs = resolve(fromDir); + const nested = join(abs, UPSTREAM_SKILLS); + if (existsSync(join(nested, "how", "SKILL.md")) || existsSync(nested)) { + const slugs = listSkillSlugs(nested); + if (slugs.length) return nested; + } + const slugs = listSkillSlugs(abs); + if (slugs.length) return abs; + throw new Error(`no upstream skills at ${fromDir} (expected ${UPSTREAM_SKILLS} or a skills dir)`); +} + +function readJsonVersion(path) { + if (!existsSync(path)) return null; + try { + const json = JSON.parse(readFileSync(path, "utf8")); + return typeof json.version === "string" ? json.version : null; + } catch { + return null; + } +} + +function findPluginVersion(start) { + const abs = resolve(start); + return ( + readJsonVersion(join(abs, UPSTREAM_PLUGIN_JSON)) || + readJsonVersion(join(abs, ".cursor-plugin/plugin.json")) || + readJsonVersion(join(abs, "..", ".cursor-plugin/plugin.json")) + ); +} + +function gitRev(dir) { + const result = spawnSync("git", ["-C", dir, "rev-parse", "HEAD"], { encoding: "utf8" }); + if (result.status !== 0) return null; + return (result.stdout || "").trim() || null; +} + +export function fetchUpstream(opts) { + const dest = opts.dest; + const args = ["clone", "--depth", "1", "--filter=blob:none", "--sparse"]; + if (opts.ref) args.push("--branch", opts.ref); + args.push(opts.repo, dest); + const clone = spawnSync("git", args, { encoding: "utf8" }); + if (clone.status !== 0) { + throw new Error(`git clone failed (${clone.status}): ${(clone.stderr || clone.stdout || "").trim()}`); + } + const sparse = spawnSync( + "git", + ["-C", dest, "sparse-checkout", "set", UPSTREAM_SKILLS, "pstack/.cursor-plugin"], + { encoding: "utf8" }, + ); + if (sparse.status !== 0) { + throw new Error(`git sparse-checkout failed (${sparse.status}): ${(sparse.stderr || sparse.stdout || "").trim()}`); + } + return { + skillsDir: resolveUpstreamSkillsDir(dest), + sha: gitRev(dest), + version: findPluginVersion(dest), + repo: opts.repo, + }; +} + +function transformedBytes(relInSkill, raw, slugs, slug) { + if (!isTextPath(relInSkill)) return raw; + let text; + try { + text = raw.toString("utf8"); + } catch { + return raw; + } + return Buffer.from(rewriteLayout(text, slugs, slug), "utf8"); +} + +export function planSync(args) { + const upstreamSkills = args.upstreamSkills; + const destSkills = args.destSkills; + const force = Boolean(args.force); + const slugs = listSkillSlugs(upstreamSkills); + if (!slugs.length) throw new Error(`no SKILL.md trees under ${upstreamSkills}`); + const subtraction = new Set(LOCAL_SUBTRACTIONS); + /** @type {{kind: string, destRel: string, src: string, bytes?: Buffer}[]} */ + const actions = []; + let unchanged = 0; + let diverged = 0; + let skipped = 0; + + for (const slug of slugs) { + const srcRoot = join(upstreamSkills, slug); + const destRoot = join(destSkills, `do-${slug}`); + for (const rel of walkFiles(srcRoot)) { + const upRel = `${slug}/${rel}`; + const destRel = `skills/do-${slug}/${rel}`; + if (subtraction.has(upRel)) { + actions.push({ kind: "skip", destRel, src: upRel }); + skipped += 1; + continue; + } + const srcPath = join(srcRoot, rel); + const raw = readFileSync(srcPath); + const bytes = transformedBytes(rel, raw, slugs, slug); + const destPath = join(destRoot, rel); + if (!existsSync(destPath)) { + actions.push({ kind: "add", destRel, src: upRel, bytes }); + continue; + } + const current = readFileSync(destPath); + if (current.equals(bytes)) { + unchanged += 1; + continue; + } + if (force) { + actions.push({ kind: "update", destRel, src: upRel, bytes }); + continue; + } + actions.push({ kind: "diverged", destRel, src: upRel }); + diverged += 1; + } + } + + return { slugs, actions, unchanged, diverged, skipped }; +} + +export function applyPlan(root, plan) { + const written = []; + for (const action of plan.actions) { + if (action.kind !== "add" && action.kind !== "update") continue; + const path = join(root, action.destRel); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, action.bytes); + written.push(action.destRel); + } + return written; +} + +export function formatReport(info) { + const lines = [ + `source: ${info.repo}${info.sha ? `@${info.sha}` : ""}`, + `sot: cursor/plugins pstack/skills → skills/do-*`, + ]; + if (info.version) lines.push(`upstream-version: ${info.version}`); + if (info.dryRun) lines.push("mode: dry-run"); + const added = info.plan.actions.filter((a) => a.kind === "add"); + const updated = info.plan.actions.filter((a) => a.kind === "update"); + const skipped = info.plan.actions.filter((a) => a.kind === "skip"); + const diverged = info.plan.actions.filter((a) => a.kind === "diverged"); + for (const a of added) lines.push(`added: ${a.destRel}`); + for (const a of updated) lines.push(`updated: ${a.destRel}`); + for (const a of skipped) lines.push(`skipped: ${a.destRel} (local subtraction)`); + const divergedSkills = [...new Set(diverged.map((a) => a.destRel.split("/").slice(0, 2).join("/")))].sort(); + if (divergedSkills.length) { + lines.push(`diverged-kept-local: ${divergedSkills.join(" ")}`); + } + lines.push( + `summary: added=${added.length} updated=${updated.length} skipped=${skipped.length} diverged=${diverged.length} unchanged=${info.plan.unchanged}`, + ); + return lines.join("\n"); +} + +export function runSync(opts) { + const root = resolve(opts.root); + const destSkills = join(root, "skills"); + let tmp = null; + let fetched = { + skillsDir: "", + sha: null, + version: null, + repo: opts.from ? opts.from : opts.repo, + }; + try { + if (opts.from) { + fetched.skillsDir = resolveUpstreamSkillsDir(opts.from); + fetched.sha = gitRev(opts.from) || gitRev(fetched.skillsDir); + fetched.version = findPluginVersion(opts.from) || findPluginVersion(fetched.skillsDir); + } else { + tmp = mkdtempSync(join(tmpdir(), "pstack-sync-")); + fetched = fetchUpstream({ repo: opts.repo, ref: opts.ref, dest: tmp }); + } + const plan = planSync({ + upstreamSkills: fetched.skillsDir, + destSkills, + force: opts.force, + }); + if (!opts.dryRun) applyPlan(root, plan); + return { + repo: fetched.repo, + sha: fetched.sha, + version: fetched.version, + dryRun: Boolean(opts.dryRun), + plan, + }; + } finally { + if (tmp) rmSync(tmp, { recursive: true, force: true }); + } +} + +export function main(argv = process.argv.slice(2), io = process) { + let opts; + try { + opts = parseArgs(argv); + } catch (err) { + io.stderr.write(`${err instanceof Error ? err.message : err}\n${HELP}`); + return 2; + } + if (opts.help || !opts.command) { + io.stdout.write(HELP); + return opts.help ? 0 : 2; + } + if (opts.command !== "sync") { + io.stderr.write(`unknown command: ${opts.command}\n${HELP}`); + return 2; + } + try { + const root = opts.root ? resolve(opts.root) : resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const report = runSync({ ...opts, root }); + io.stdout.write(`${formatReport(report)}\n`); + return 0; + } catch (err) { + io.stderr.write(`${err instanceof Error ? err.message : err}\n`); + return 1; + } +} + +const entry = process.argv[1] ? resolve(process.argv[1]) : ""; +if (entry && fileURLToPath(import.meta.url) === entry) { + process.exit(main()); +} diff --git a/skills/AGENTS.md b/skills/AGENTS.md index 7ad5e83..99c47e9 100644 --- a/skills/AGENTS.md +++ b/skills/AGENTS.md @@ -18,6 +18,8 @@ pstack skill tree. Sibling `skills/` at the package root is what omp auto-discov Keep leaf skills operational. Do not add a per-skill AGENTS.md unless a skill becomes its own durable boundary. +Official skill bodies come from `cursor/plugins` `pstack/skills`. Refresh this tree with `npm run sync` (`node scripts/pstack.mjs sync`). That maps `skills/` → `skills/do-` here. Do not add a second skills tree. Do not `npx skills add`. + ## Verification ```bash diff --git a/skills/do-poteto-mode/scripts/check-plan.mjs b/skills/do-poteto-mode/scripts/check-plan.mjs new file mode 100644 index 0000000..21d350a --- /dev/null +++ b/skills/do-poteto-mode/scripts/check-plan.mjs @@ -0,0 +1,186 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import process from "node:process"; + +const RULE = + "Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked."; +const LANES = "Ten lanes on `grok-4.6-fast-xhigh` at the PR head"; +const SUB_BLOCKS = [ + "Depends on.", + "Files.", + "Build.", + "You see.", + "Verify, unit.", + "Verify, live.", + "Verify, perf.", + "Review gate.", + "Merge.", +]; +const PROGRAM_H3 = ["Arm the program", "Spawn owners", "PR mechanics", "Verdict and merge", "Boot recipe"]; +const PROGRAM_MARKERS = ["/goal", "git show origin/main:", /30[- ]minute/, "status message"]; +const HOW_TO_READ_MARKERS = [ + "One box is one unit of work", + "names the evidence", + "Check a box only when its evidence exists", + "playbooks/", + RULE, +]; +const PERF_ITEMS = ["Metric.", "Probe.", "Baseline.", "Rule."]; +const BOX = /^\s*- \[[ x]\] (.*)$/; + +const file = process.argv[2]; +if (!file) { + console.error("Usage: node check-plan.mjs "); + process.exit(2); +} + +const raw = fs.readFileSync(file, "utf8").split(/\r?\n/); +const problems = []; +const fail = (line, message) => problems.push(`${file}:${line}: ${message}`); + +let start = 0; +if (raw[0] === "---") { + start = raw.indexOf("---", 1) + 1; +} + +const lines = []; +let fence = false; +for (let i = start; i < raw.length; i++) { + const text = raw[i]; + const n = i + 1; + if (/^```/.test(text)) fence = !fence; + lines.push({ n, text, code: fence }); + if (fence) continue; + const prose = text + .replace(/`[^`]*`/g, "`") + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\]\([^)]*\)/g, "]"); + if (/[\u2013\u2014]/.test(prose)) fail(n, "long dash"); + if (/[\u2018\u2019\u201c\u201d]/.test(prose)) fail(n, "curly quote"); + if (/: \S/.test(prose)) fail(n, "mid-sentence colon"); +} + +const h2 = (l) => (!l.code && l.text.startsWith("## ") ? l.text.slice(3).trim() : null); +const sections = []; +for (const l of lines) { + const title = h2(l); + if (title !== null) sections.push({ title, n: l.n, body: [] }); + else if (sections.length) sections.at(-1).body.push(l); +} +const find = (title) => sections.find((s) => s.title === title); +const bodyText = (s) => s.body.map((l) => l.text).join("\n"); +const boxes = (ls) => ls.filter((l) => !l.code && BOX.test(l.text)).map((l) => ({ n: l.n, text: l.text.match(BOX)[1] })); + +const h1 = lines.findIndex((l) => !l.code && l.text.startsWith("# ")); +if (h1 === -1) fail(1, "no H1 title"); +const howToRead = find("How to read this"); +if (!howToRead) fail(1, 'no "## How to read this" section'); +if (h1 !== -1 && howToRead) { + const intro = lines.slice(h1 + 1).filter((l) => l.n < howToRead.n && l.text.trim() !== ""); + if (intro.length >= 10) fail(lines[h1].n, `intro is ${intro.length} lines, under ten required`); + for (const marker of HOW_TO_READ_MARKERS) { + if (!bodyText(howToRead).includes(marker)) fail(howToRead.n, `How to read this lacks "${marker}"`); + } +} + +const program = find("Program checklist"); +if (!program) fail(1, 'no "## Program checklist" section'); +else { + const h3s = program.body.filter((l) => !l.code && l.text.startsWith("### ")).map((l) => l.text.slice(4).trim()); + let cursor = 0; + for (const name of PROGRAM_H3) { + const at = h3s.findIndex((t, i) => i >= cursor && t.startsWith(name)); + if (at === -1) fail(program.n, `Program checklist lacks "### ${name}" in order`); + else cursor = at + 1; + } + for (const marker of PROGRAM_MARKERS) { + const ok = marker instanceof RegExp ? marker.test(bodyText(program)) : bodyText(program).includes(marker); + if (!ok) fail(program.n, `Program checklist lacks "${marker}"`); + } +} + +const close = find("Close the program"); +if (!close) fail(1, 'no "## Close the program" section'); +const programIndex = sections.indexOf(program); +const closeIndex = sections.indexOf(close); +const prSections = programIndex === -1 || closeIndex === -1 ? [] : sections.slice(programIndex + 1, closeIndex); +if (prSections.length === 0) fail(1, "no PR sections between Program checklist and Close the program"); + +const report = []; +for (const pr of prSections) { + const heads = []; + for (const l of pr.body) { + if (l.code) continue; + const m = l.text.match(/^\*\*([^*]+)\*\*(.*)$/); + if (m && SUB_BLOCKS.includes(m[1])) heads.push({ name: m[1], n: l.n, rest: m[2].trim(), lines: [] }); + else if (heads.length) heads.at(-1).lines.push(l); + } + const names = heads.map((h) => h.name); + if (names.join("|") !== SUB_BLOCKS.join("|")) { + fail(pr.n, `${pr.title}: sub-blocks are [${names.join(", ")}], expected [${SUB_BLOCKS.join(", ")}]`); + } + const block = (name) => heads.find((h) => h.name === name); + const counts = {}; + for (const h of heads) counts[h.name] = boxes(h.lines).length; + + const depends = block("Depends on."); + if (depends && depends.rest === "") fail(depends.n, `${pr.title}: Depends on names nothing`); + for (const name of ["Files.", "Build.", "You see.", "Verify, unit.", "Merge."]) { + const b = block(name); + if (b && boxes(b.lines).length === 0) fail(b.n, `${pr.title}: ${name} has no box`); + } + for (const name of ["Verify, unit.", "Verify, live.", "Verify, perf."]) { + const b = block(name); + if (b && !b.rest.startsWith(RULE)) fail(b.n, `${pr.title}: ${name} does not open with the rule`); + } + + const live = block("Verify, live."); + if (live) { + if (!live.rest.includes(LANES)) fail(live.n, `${pr.title}: Verify, live lacks "${LANES}"`); + const lanes = boxes(live.lines).map((b) => ({ ...b, m: b.text.match(/^Lane (\d+)\. /) })); + const numbers = lanes.filter((b) => b.m).map((b) => Number(b.m[1])).sort((a, b) => a - b); + if (numbers.join(",") !== "1,2,3,4,5,6,7,8,9,10") fail(live.n, `${pr.title}: lanes are [${numbers.join(",")}], expected 1 to 10`); + for (const lane of lanes) { + if (!lane.m) fail(lane.n, `${pr.title}: live box is not a lane`); + else if (!/Save `[^`]+`/.test(lane.text)) fail(lane.n, `${pr.title}: lane ${lane.m[1]} names no screenshot`); + else if (!lane.text.includes("Pass when")) fail(lane.n, `${pr.title}: lane ${lane.m[1]} has no pass predicate`); + } + } + + const perf = block("Verify, perf."); + if (perf) { + const items = boxes(perf.lines).map((b) => b.text.split(" ")[0]); + if (items.join("|") !== PERF_ITEMS.join("|")) fail(perf.n, `${pr.title}: perf boxes are [${items.join(", ")}], expected [${PERF_ITEMS.join(", ")}]`); + } + + const gate = block("Review gate."); + if (gate) { + const gateBoxes = boxes(gate.lines); + if (gate.rest.startsWith("None.")) { + if (gateBoxes.length) fail(gate.n, `${pr.title}: Review gate says None but has boxes`); + } else { + const text = gate.lines.map((l) => l.text).join("\n"); + if (gateBoxes.length === 0) fail(gate.n, `${pr.title}: Review gate has no box`); + for (const word of ["screenshot", "video", "operator"]) { + if (!text.includes(word)) fail(gate.n, `${pr.title}: Review gate lacks "${word}"`); + } + } + } + + const total = boxes(pr.body).length; + const cells = SUB_BLOCKS.filter((s) => s !== "Depends on.").map((s) => `${s.replace(/[ ,.]+/g, "-").replace(/-$/, "").toLowerCase()}=${counts[s] ?? 0}`); + report.push(`${pr.title} boxes=${total} ${cells.join(" ")}`); +} + +if (closeIndex !== -1) { + const tail = sections.slice(closeIndex + 1); + for (const s of tail) { + if (!s.title.startsWith("Appendix")) fail(s.n, `"## ${s.title}" after Close the program is not an appendix`); + } + if (!tail.some((s) => s.title.includes("Prototype evidence"))) fail(close.n, 'no "## Appendix ... Prototype evidence" section'); +} + +for (const line of report) console.log(line); +console.log(`${prSections.length} PR sections, ${problems.length} problems`); +for (const p of problems) console.error(p); +process.exit(problems.length ? 1 : 0); diff --git a/skills/do-principle-attack-the-premise/SKILL.md b/skills/do-principle-attack-the-premise/SKILL.md new file mode 100644 index 0000000..3ebd078 --- /dev/null +++ b/skills/do-principle-attack-the-premise/SKILL.md @@ -0,0 +1,23 @@ +--- +name: do-principle-attack-the-premise +description: "Apply when two or more fixes that share one premise have failed the same gate. Take a census of which actors hold the imbalance before the next fix, then question the premise instead of writing another fix that assumes it." +disable-model-invocation: true +--- + +# Attack the Premise + +When two or more fixes that share one premise have failed the same gate, suspect the premise, not the fixes. + +**Why:** Each failure under a shared premise is evidence about the premise. + +**Pattern:** +- **Write the premise down.** The premise is the one sentence that every failed fix assumed. +- **Take a census before the next fix.** Count the imbalance per actor. The census shows which actors hold the imbalance, not how large it is. Write the census as a rerunnable script per [Build the Lever](../do-principle-build-the-lever/SKILL.md). +- **Read the skew.** If the same few actors hold most of the imbalance on every run, something assigns them that role. Find what assigns the role. That assignment is the next "why" per [Fix Root Causes](../do-principle-fix-root-causes/SKILL.md). +- **Remove the asymmetry instead of compensating for it**, per the [Laziness Protocol](../do-principle-laziness-protocol/SKILL.md). Rotate the role between actors, randomize the assignment, or move the role, so that no actor holds it on every run. A return path, a shared pool, a batched hand-off, or a periodic rebalance leaves the assignment in place and adds work on every run. + +**Stop:** +- Do not start the next fix before the premise is written down and the census exists. +- If the census is even across actors, the premise is not the cause. Look for the cause elsewhere and keep the census as evidence. + +This principle is distinct from [Redesign from First Principles](../do-principle-redesign-from-first-principles/SKILL.md), which rebuilds a design around a new requirement. It questions a fact the current design assumes. diff --git a/skills/do-principle-test-behavior-not-implementation/SKILL.md b/skills/do-principle-test-behavior-not-implementation/SKILL.md new file mode 100644 index 0000000..1e7d00e --- /dev/null +++ b/skills/do-principle-test-behavior-not-implementation/SKILL.md @@ -0,0 +1,25 @@ +--- +name: do-principle-test-behavior-not-implementation +description: "Apply when you write, change, or keep a test. Call the code the way its users do and assert the result they observe against a literal expected value. If the test would still pass when every imported function returns undefined, rewrite the assertion or delete the test." +disable-model-invocation: true +--- + +# Test Behavior, Not Implementation + +A test calls the code the way its users do and asserts the result they observe against a literal expected value. A test that asserts which calls the code made, or restates a constant the code contains, does neither. + +The check: before you keep a test, ask whether it would still pass if every function it imports returned `undefined`. If yes, it observes no behavior and cannot fail for a defect. Rewrite the assertion or delete the test. + +**Why:** A test that cannot fail for a defect costs CI time and review attention and catches nothing. A constant pin also fails when someone edits the constant or the prompt it restates, so it prevents that edit. + +**Five shapes that still pass when every imported function returns `undefined`:** + +- **Weak or no assertion.** No `expect`, or only `toBeDefined`, `toBeTruthy`, `not.toThrow`, `toBeInstanceOf`, `toBeGreaterThan(0)`. +- **Mock or absence only.** Only `toHaveBeenCalled`, `not.toHaveBeenCalled`, `toBeUndefined`, `toEqual([])`, `toHaveLength(0)`, `not.toBe(wrongValue)`. +- **Self-referential.** The expected value comes from the code under test: `expect(f(a)).toBe(f(a))`, `expect(parsed.url).toBe(buildUrl(...))`. +- **Constant pin.** The assertion restates a hand-maintained constant, config default, table row, or prompt string: `expect(LIMITS.maxTools).toBe(8)`, `expect(PROMPT).toContain("You are")`. +- **Fixture asserts fixture.** The assertion reads data the test built or a value computed in `beforeEach`, and the subject never runs inside the body. + +**The fix:** call the subject inside the test body with one concrete input and assert the literal output or the observable effect, `expect(slugify("Hello, World!")).toBe("hello-world")`. For an absence, assert the presence on the other input in the same test. For a constant, test the mechanism that reads it with one input instead of restating the value. For a mock, assert the payload it received or the state after the call, not that it was called. When no such assertion exists, delete the test. + +**Keep** a test of a relation across a table's rows (a key present in two tables, a parent that exists), and a compile-time check in a `*.test-d.ts` file.