From 94d3ad81ce672241a1d851973441c62a033849c5 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Tue, 25 Aug 2026 23:08:05 -0400 Subject: [PATCH 1/4] feat(pstack): inline the verified multi-PR checklist Replace the plan.md pointer with the adapted 0.14.3 checklist and a Node checker. One CONTRACT and tagged PR_BLOCKS own the required headings, boxes, and phrases. The copied fence names the configured swarm workers role. Selected provider, model, and effort stay in receipts. The extracted skeleton passes node check-plan.mjs. --- .../poteto-mode/playbooks/multi-phase-plan.md | 156 ++++++- .../skills/poteto-mode/references/plan.md | 105 ----- .../skills/poteto-mode/scripts/check-plan.mjs | 432 ++++++++++++++++++ 3 files changed, 587 insertions(+), 106 deletions(-) delete mode 100644 plugins/pstack/skills/poteto-mode/references/plan.md create mode 100755 plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs diff --git a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md index 956eee2..de969f0 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md @@ -1,3 +1,157 @@ ### Multi-phase or multi-PR plan -Follow [../references/plan.md](../references/plan.md). +**You own the plan, not the code. The plan is a checklist an owner runs box by box and the operator audits from the evidence.** For work that spans phases or stacked PRs. The plan is the deliverable. Do not implement. + +1. When the change is one or two files with an obvious approach, skip the plan. Say so and stop. +2. Settle open questions by prototype before you write. For a question about layout, timing, behavior, or whether an API works, run `playbooks/prototype.md`. Keep the branch, the SHA, and the screenshots for Appendix A. Ask the operator only about a product or preference call that no run can settle. Give options (the **never-block-on-the-human** principle skill). +3. Explore in subagents. Resolve the configured role through [`../references/provider-dispatch.md`](../references/provider-dispatch.md). Use `poteto-agent` only for an unqualified `inherit-parent` / `auto` native helper. Never use Claude Code's built-in `Plan` agent. It ignores this skill (the **guard-the-context-window** principle skill). Each explorer returns file pointers, conventions, test commands, and entry points. No inlined dumps. Children do not detect the parent harness or choose a route. Preserve the selected effort. A dropout stays a dropout. Do not add an implicit timeout. +4. Copy the skeleton below into the plan file and fill every placeholder. Unless the operator names a path, write the file under the working repository's `docs/`. Keep every heading and every sub-block in the order shown. One section per PR. One PR is one change with its own evidence (the **sequence-verifiable-units** principle skill). Name the execution playbook in **How to read this**. Pick between `playbooks/autopilot-full.md` and `playbooks/autopilot-stack.md` per the rule at the end of `playbooks/autopilot-stack.md`. A standing program takes `playbooks/orchestrate.md`. +5. Write under `/technical-writing` in full, then `/unslop`. The body is one Diátaxis mode, how-to. Appendices hold explanation and reference. Two rules apply verbatim. "i dont want any abstract metaphors" and "write like hemingway". Each heading states the task or the finding. No long dashes. No mid-sentence colons. +6. Run `node skills/poteto-mode/scripts/check-plan.mjs ` under the installed plugin and fix every line it prints (the **encode-lessons-in-structure** principle skill). It enforces the skeleton's shape, the verification rule in every verification block, and the punctuation rules. The playbook file is not checker input. Copy or extract the fenced skeleton first. +7. Hand back. Post the plan path and the script's output, then stop. Execution starts on the operator's explicit go, under the execution playbook the plan names. + +**Verification.** Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked (the **prove-it-works** principle skill). That sentence is the verification rule. Every verification block opens with it. The live block is mandatory. Ten lanes on the configured `swarm workers` role at the PR head drive the real surface through the driver skill, per the **swarm** skill. The role resolves once through [`../references/provider-dispatch.md`](../references/provider-dispatch.md) at execution time, and each lane's receipt records the selected provider, model, and effort. Each lane is one box with a concrete scenario, the screenshot it saves, and its pass predicate. The perf block names the metric, the probe, the trunk baseline measured first, and the rule with the number that fails. A PR that changes an interaction is review-gated. The operator reviews it in chat with screenshots and a video before merge. A PR that changes no interaction writes `**Review gate.** None. is not review-gated.` and no boxes under it. + +**Driver skill.** Pick it by surface. Browser, Electron, and web UIs use Claude Code's **verify** skill. CLIs and TUIs use Claude Code's **run** skill. Native mobile uses whatever simulator-driving skill the repo has. On Codex, substitute per [`../references/codex-tools.md`](../references/codex-tools.md). A PR that touches two surfaces gets lanes on both. A surface with no driver skill is a risk in Appendix C, and its live block still names how each lane drives it. + +On Claude Code, arm the 30-minute audit tick as a real `/loop` in dynamic mode. On Codex, arm the cadence per [`../references/codex-tools.md`](../references/codex-tools.md). Never leave the cadence to memory. Keep skill-relative links in this playbook body. Do not copy them into the plan file. + +````markdown +# plan + + + +## How to read this + +One box is one unit of work. Every box names the evidence that checks it. A nested box is a sub-step of the box above it. Check a box only when its evidence exists, a file, a log line, a screenshot, a test run, or a SHA. The body is a how-to. The appendices explain and record. + +The program runs `skills/poteto-mode/playbooks/.md` under the installed plugin. + +Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. + +## Program checklist + +### Arm the program + +- [ ] State the protocol and this plan to the operator, then stop. Start execution only on her explicit go. +- [ ] On her go, write the program objective into the standing orders and your todolist with this exact text. "" +- [ ] Read these from the installed plugin at program start. Re-read them at every tick. + - [ ] `skills/poteto-mode/playbooks/.md` + - [ ] `skills/swarm/SKILL.md` + - [ ] `` + - [ ] `skills/poteto-mode/playbooks/opening-a-pr.md` + - [ ] `skills//SKILL.md` +- [ ] Arm the 30-minute audit tick as a real cadence. Never leave the cadence to memory. +- [ ] Use this tick prompt, verbatim. "Re-read the execution playbook from the installed plugin and the standing orders. Audit the operation against both and fix drift in this tick. Probe every active lane and judge progress by side effects only. Stand down a lane only on affirmative failure evidence, and dispatch its replacement in the same tick. Then send the operator a status message, whether or not anything changed, with the queue table of PR, owner, state, and head SHA, the verdicts since the last tick, what merged, open operator gates, and blockers." +- [ ] On the operator's hold or stand-down, send every owner a zero-writes order at once. + +### Spawn owners + +- [ ] Spawn one owner per PR with the full lifecycle the execution playbook names. +- [ ] Follow this dependency graph. Start dependent work only after its parent merges, or base it on the parent branch when the execution playbook stacks. + - [ ] and are independent and first. Both branch from `main`. + - [ ] after . +- [ ] Hold the file boundaries. touches only ``. +- [ ] Hold the review gate. change an interaction. They wait for the operator's review in chat with screenshots and a video before merge. + +### PR mechanics, for every PR + +- [ ] Open the PR ready, never draft, with `gh pr create` and `draft: false`, or with Graphite `gt` for a stack. +- [ ] Run the repo's lint and typecheck once before the PR-facing push. Push with hooks on. +- [ ] Run `/deslop` before each commit and `/no-comments` before review. +- [ ] Triage every Bugbot and security-reviewer comment per `skills/poteto-mode/references/bugbot-triage.md` under the installed plugin. +- [ ] Rebase onto current trunk before babysit and again before the merge-ready report. + +### Verdict and merge, for every PR + +- [ ] At the merge-ready head SHA, run the swarm per `skills/swarm/SKILL.md`. One gates lane. The ten live lanes from the PR's **Verify, live** block. The perf lane from its **Verify, perf** block. One audit lane that reads the diff and the receipts and distrusts the PR body. +- [ ] Clean only when every lane is `PASS`. Findings go back to the owner. A new head gets a fresh swarm and a fresh verdict. +- [ ] + +### Boot recipe, for every live lane + +Each live lane is one `swarm workers` lane at the PR head, resolved through provider dispatch, in its own worktree or output directory, with its own receipt. Drive the surface only through the driver skill this plan names. + +- [ ] `git fetch origin && git checkout ` in the lane's worktree. +- [ ] +- [ ] +- [ ] Save every screenshot to `/tmp/swarm-/worker-/.png` and return the paths with the receipt path. + +## () + +**Depends on.** + +**Files.** + +- [ ] Edit ``. +- [ ] Create ``. +- [ ] Delete ``. + +**Build.** + +- [ ] + +**You see.** + +- [ ] + +**Verify, unit.** Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. + +- [ ] Run ``. + +**Verify, live.** Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. Ten lanes on the configured `swarm workers` role at the PR head, per the boot recipe. + +- [ ] Lane 1. Save `.png`. Pass when . +- [ ] Lane 2. Save `.png`. Pass when . +- [ ] Lane 3. Save `.png`. Pass when . +- [ ] Lane 4. Save `.png`. Pass when . +- [ ] Lane 5. Save `.png`. Pass when . +- [ ] Lane 6. Save `.png`. Pass when . +- [ ] Lane 7. Save `.png`. Pass when . +- [ ] Lane 8. Save `.png`. Pass when . +- [ ] Lane 9. Save `.png`. Pass when . +- [ ] Lane 10. Save `.png`. Pass when . + +**Verify, perf.** Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked. + +- [ ] Metric. +- [ ] Probe. +- [ ] Baseline. Record the trunk first. +- [ ] Rule. + +**Review gate.** The operator reviews before merge. + +- [ ] Copy lane screenshots into `/-review-.png`. +- [ ] Record a 30 to 60 second video of the change on a live lane. Save it as `/-review.mp4`. +- [ ] Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator's click. + +**Merge.** + +- [ ] Root's clean verdict at the exact head SHA. +- [ ] Bugbot triage done. +- [ ] Rebased onto current trunk after the verdict, patch-id unchanged. +- [ ] + +## Close the program + +- [ ] Every box above is checked with its evidence. +- [ ] Reply to the operator with the report the execution playbook names. + +## Appendix A. Prototype evidence + + + +## Appendix B. Alternatives rejected + + + +## Appendix C. Risks + + + +## Appendix D. Links and reading list + + +```` + +**Reply:** the plan path, the PR ids with their dependencies and the review-gated set, what the prototypes proved and what stays unproven, and the check script's output. diff --git a/plugins/pstack/skills/poteto-mode/references/plan.md b/plugins/pstack/skills/poteto-mode/references/plan.md deleted file mode 100644 index a2d2c5b..0000000 --- a/plugins/pstack/skills/poteto-mode/references/plan.md +++ /dev/null @@ -1,105 +0,0 @@ -# Plan - -Produce a phased implementation plan grounded in the **Principles** section of the `poteto-mode` skill. The plan is the deliverable. Do not implement. - -Open a todolist with one item per step below. - -## 0. Triage - -Skip the plan when the change is one or two files with an obvious approach. Say so and stop. - -Plan when the change spans three or more files, introduces architecture, has competing approaches or unclear scope, or the user asked for one. - -## 1. Re-read principles - -Read the **Principles** section of the `poteto-mode` skill end to end, and the leaf `principle-*` skills it indexes. The principles govern every plan decision; cross-link them. - -## 2. Scope and constraints - -State your read of scope and constraints in one paragraph. Use `AskUserQuestion` only for genuinely ambiguous intent (the **never-block-on-the-human** principle skill); give concrete options with each open question. - -Resolve what is in scope vs explicitly out, technical or platform constraints, patterns to preserve, and the definition of done. - -## 3. Explore in subagents - -Delegate codebase exploration (the **guard-the-context-window** principle skill). - -- Resolve the configured role through `references/provider-dispatch.md`. Use `poteto-agent` only for an unqualified `inherit-parent` / `auto` native helper. Never use Claude Code's built-in `Plan` agent; it ignores this skill. -- Pass the complete exploration task and paths directly to the native subagent or external launcher. The default explanation/judgment descriptor is `claude:claude-fable-5@max`; multi-model panels run the configured four-provider quad from the panel skills. - -Each explorer returns file pointers, conventions, dependencies, test infrastructure, and entry points. No inlined dumps. - -## 4. Write the plan - -The user specifies where the plan lives. - -Single file `NN-slug.md` for small plans. For three or more phases, a directory with `overview.md` plus phase files: - -``` -NN-slug/ -├── overview.md -├── phase-1-scaffold.md -├── phase-2-...md -└── testing.md -``` - -### Phase sizing - -- One function or type plus tests, or one bug fix. Not "one file". -- Two to three files touched, max. -- Prefer eight to ten small phases over three to four large ones to preserve option value (the **foundational-thinking** principle skill). -- Split if a phase has more than five test cases or three functions. - -### Overview file - -- **Context.** Problem and why now. -- **Scope.** Included; explicitly excluded. -- **Constraints.** Technical, platform, dependency, pattern. -- **Alternatives.** Two or three approaches sketched, choice and rationale (the **exhaust-the-design-space** principle skill). Skip when constraints dictate one. -- **Applicable skills.** Domain skills the implementer should invoke, by name. -- **Phases.** Ordered standard-markdown links to phase files. -- **Verification.** Project-level commands. -- **Implementation guidance.** Per section 6. - -### Phase files - -- Back-link to overview. -- **Goal.** What the phase accomplishes. -- **Changes.** Files affected and the change at a high level. What and why, not how. No code snippets. -- **Data structures.** Name the key types or schemas. One-line sketch only (the **foundational-thinking** principle skill). -- **Verification.** Per section 6. - -Order phases so infrastructure and shared types land first (the **foundational-thinking** principle skill). Each phase should be independently shippable. - -For changes touching existing code, apply the **redesign-from-first-principles** principle skill: if we'd built this with the new requirement on day one, what would it look like? Redesign holistically; deliver incrementally. - -If a phase creates or edits a skill, the phase instructs the implementer to use the **plugin-dev:skill-development** skill (Claude Code's skill for authoring SKILL.md files). - -## 5. Verification per phase - -Each phase needs both: - -**Static.** Type check, lint, project tests pass. - -**Runtime.** Exercise the feature on the matching surface via the relevant control skill: - -- Browser / Electron / Web UIs: Claude Code's **verify** skill (VS Code extension launches/inspects browser/Electron UIs). -- CLIs and TUIs: Claude Code's **run** skill (launches/drives CLIs/TUIs). -- Native mobile: whatever simulator-driving skill your team has. -- No control skill for the touched surface: flag it in the plan. - -For bug fixes, the loop is reproduce on the surface, fix, verify on the same surface. Unit tests show a branch behaves a certain way; they do not prove the bug is gone (the **prove-it-works** principle skill). - -## 6. Implementation guidance - -In the overview, name which poteto-mode non-negotiables the implementer must apply, by name: - -- the **how** skill over each unfamiliar subsystem before changing it. -- the **interrogate** skill for adversarial review on contested designs before shipping. -- `/deslop` over each diff before commit. the **unslop** skill over any prose surface. -- the **show-me-your-work** skill to keep a decision trail when the plan is large enough to need an auditable record. -- the **babysit** skill after opening the PR. - -## 7. Hand back - -Summarize phases, scope boundaries, applicable skills, and verification. Stop. The user decides when implementation starts. diff --git a/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs b/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs new file mode 100755 index 0000000..19ed077 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs @@ -0,0 +1,432 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const RULE = + "Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked."; +const LANE_SENTENCE = + "Ten lanes on the configured `swarm workers` role at the PR head"; +const BOX = /^\s*- \[[ x]\] (.*)$/; + +/** @typedef {"none" | "any" | "lanes" | "ordered-leads" | "gate"} BoxShape */ +/** @typedef {{ name: string, shape: BoxShape, opensWithRule?: boolean, leads?: readonly string[], words?: readonly string[] }} PrBlock */ +/** @typedef {{ n: number, text: string, code: boolean }} Line */ +/** @typedef {{ title: string, n: number, body: Line[] }} Section */ +/** @typedef {{ name: string, n: number, rest: string, lines: Line[] }} Heading */ +/** @typedef {{ problems: string[], report: string[], prCount: number, ok: boolean }} CheckResult */ + +const PR_BLOCKS = Object.freeze([ + Object.freeze({ name: "Depends on.", shape: "none" }), + Object.freeze({ name: "Files.", shape: "any" }), + Object.freeze({ name: "Build.", shape: "any" }), + Object.freeze({ name: "You see.", shape: "any" }), + Object.freeze({ name: "Verify, unit.", shape: "any", opensWithRule: true }), + Object.freeze({ name: "Verify, live.", shape: "lanes", opensWithRule: true }), + Object.freeze({ + name: "Verify, perf.", + shape: "ordered-leads", + opensWithRule: true, + leads: Object.freeze(["Metric.", "Probe.", "Baseline.", "Rule."]), + }), + Object.freeze({ + name: "Review gate.", + shape: "gate", + words: Object.freeze(["screenshot", "video", "operator"]), + }), + Object.freeze({ name: "Merge.", shape: "any" }), +]); + +export const CONTRACT = Object.freeze({ + rule: RULE, + laneSentence: LANE_SENTENCE, + laneCount: 10, + prBlocks: PR_BLOCKS, + programSections: Object.freeze([ + "Arm the program", + "Spawn owners", + "PR mechanics", + "Verdict and merge", + "Boot recipe", + ]), + programMarkers: Object.freeze([ + "standing orders", + "the installed plugin", + /30[- ]minute/, + "status message", + ]), + howToReadMarkers: Object.freeze([ + "One box is one unit of work", + "names the evidence", + "Check a box only when its evidence exists", + "playbooks/", + RULE, + ]), + punctuation: Object.freeze([ + Object.freeze({ message: "long dash", pattern: /[\u2013\u2014]/ }), + Object.freeze({ + message: "curly quote", + pattern: /[\u2018\u2019\u201c\u201d]/, + }), + Object.freeze({ message: "mid-sentence colon", pattern: /: \S/ }), + ]), +}); + +const SUB_BLOCKS = CONTRACT.prBlocks.map((block) => block.name); + +function toLines(raw) { + const split = raw.split(/\r?\n/); + let start = 0; + if (split[0] === "---") { + const close = split.indexOf("---", 1); + start = close === -1 ? 0 : close + 1; + } + const lines = []; + let fence = false; + for (let i = start; i < split.length; i++) { + const text = split[i]; + const n = i + 1; + if (/^```/.test(text)) fence = !fence; + lines.push({ n, text, code: fence }); + } + return lines; +} + +function checkPunctuation(lines, fail) { + for (const line of lines) { + if (line.code) continue; + const prose = line.text + .replace(/`[^`]*`/g, "`") + .replace(/!\[[^\]]*\]\([^)]*\)/g, "") + .replace(/\]\([^)]*\)/g, "]"); + for (const rule of CONTRACT.punctuation) { + if (rule.pattern.test(prose)) fail(line.n, rule.message); + } + } +} + +function toSections(lines) { + const sections = []; + for (const line of lines) { + const title = + !line.code && line.text.startsWith("## ") + ? line.text.slice(3).trim() + : null; + if (title !== null) sections.push({ title, n: line.n, body: [] }); + else if (sections.length) sections.at(-1).body.push(line); + } + return sections; +} + +function findSection(sections, title) { + return sections.find((section) => section.title === title); +} + +function bodyText(section) { + return section.body.map((line) => line.text).join("\n"); +} + +function boxes(lines) { + return lines + .filter((line) => !line.code && BOX.test(line.text)) + .map((line) => ({ n: line.n, text: line.text.match(BOX)[1] })); +} + +function checkPreamble(lines, sections, fail) { + const h1 = lines.findIndex((line) => !line.code && line.text.startsWith("# ")); + if (h1 === -1) fail(1, "no H1 title"); + const howToRead = findSection(sections, "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((line) => line.n < howToRead.n && line.text.trim() !== ""); + if (intro.length >= 10) { + fail( + lines[h1].n, + `intro is ${intro.length} lines, under ten required`, + ); + } + const text = bodyText(howToRead); + for (const marker of CONTRACT.howToReadMarkers) { + if (!text.includes(marker)) { + fail(howToRead.n, `How to read this lacks "${marker}"`); + } + } + } +} + +function checkProgram(section, fail) { + const h3s = section.body + .filter((line) => !line.code && line.text.startsWith("### ")) + .map((line) => line.text.slice(4).trim()); + let cursor = 0; + for (const name of CONTRACT.programSections) { + const at = h3s.findIndex((title, i) => i >= cursor && title.startsWith(name)); + if (at === -1) fail(section.n, `Program checklist lacks "### ${name}" in order`); + else cursor = at + 1; + } + const text = bodyText(section); + for (const marker of CONTRACT.programMarkers) { + const ok = + marker instanceof RegExp ? marker.test(text) : text.includes(marker); + if (!ok) fail(section.n, `Program checklist lacks "${marker}"`); + } +} + +function headingsOf(section) { + const heads = []; + for (const line of section.body) { + if (line.code) continue; + const match = line.text.match(/^\*\*([^*]+)\*\*(.*)$/); + if (match && SUB_BLOCKS.includes(match[1])) { + heads.push({ + name: match[1], + n: line.n, + rest: match[2].trim(), + lines: [], + }); + } else if (heads.length) { + heads.at(-1).lines.push(line); + } + } + return heads; +} + +function checkLanes(prTitle, live, fail) { + if (!live.rest.includes(CONTRACT.laneSentence)) { + fail( + live.n, + `${prTitle}: Verify, live lacks "${CONTRACT.laneSentence}"`, + ); + } + const laneBoxes = boxes(live.lines); + const expected = Array.from( + { length: CONTRACT.laneCount }, + (_, i) => i + 1, + ).join(","); + const numbers = []; + for (const lane of laneBoxes) { + const match = lane.text.match(/^Lane (\d+)\. /); + if (!match) fail(lane.n, `${prTitle}: live box is not a lane`); + else { + numbers.push(Number(match[1])); + if (!/Save `[^`]+`/.test(lane.text)) { + fail(lane.n, `${prTitle}: lane ${match[1]} names no screenshot`); + } else if (!lane.text.includes("Pass when")) { + fail(lane.n, `${prTitle}: lane ${match[1]} has no pass predicate`); + } + } + } + if (numbers.join(",") !== expected) { + fail( + live.n, + `${prTitle}: lanes are [${numbers.join(",")}], expected 1 to ${CONTRACT.laneCount}`, + ); + } +} + +function checkOrderedLeads(prTitle, heading, spec, fail) { + const items = boxes(heading.lines).map((box) => box.text.split(" ")[0]); + const expected = spec.leads.join("|"); + if (items.join("|") !== expected) { + fail( + heading.n, + `${prTitle}: perf boxes are [${items.join(", ")}], expected [${spec.leads.join(", ")}]`, + ); + } +} + +function checkGate(prTitle, gate, spec, fail) { + const gateBoxes = boxes(gate.lines); + if (gate.rest.startsWith("None.")) { + if (gateBoxes.length) { + fail(gate.n, `${prTitle}: Review gate says None but has boxes`); + } + return; + } + const text = gate.lines.map((line) => line.text).join("\n"); + if (gateBoxes.length === 0) { + fail(gate.n, `${prTitle}: Review gate has no box`); + } + for (const word of spec.words) { + if (!text.includes(word)) { + fail(gate.n, `${prTitle}: Review gate lacks "${word}"`); + } + } +} + +function checkBlock(prTitle, heading, spec, fail) { + if (spec.opensWithRule && !heading.rest.startsWith(CONTRACT.rule)) { + fail(heading.n, `${prTitle}: ${spec.name} does not open with the rule`); + } + switch (spec.shape) { + case "none": + if (heading.rest === "") { + fail(heading.n, `${prTitle}: Depends on names nothing`); + } + break; + case "any": + if (boxes(heading.lines).length === 0) { + fail(heading.n, `${prTitle}: ${spec.name} has no box`); + } + break; + case "lanes": + checkLanes(prTitle, heading, fail); + break; + case "ordered-leads": + checkOrderedLeads(prTitle, heading, spec, fail); + break; + case "gate": + checkGate(prTitle, heading, spec, fail); + break; + } +} + +function reportLine(section, heads) { + const counts = Object.fromEntries( + heads.map((head) => [head.name, boxes(head.lines).length]), + ); + const total = boxes(section.body).length; + const cells = SUB_BLOCKS.filter((name) => name !== "Depends on.").map( + (name) => + `${name.replace(/[ ,.]+/g, "-").replace(/-$/, "").toLowerCase()}=${counts[name] ?? 0}`, + ); + return `${section.title} boxes=${total} ${cells.join(" ")}`; +} + +function checkPrSection(section, fail) { + const heads = headingsOf(section); + const names = heads.map((head) => head.name); + if (names.join("|") !== SUB_BLOCKS.join("|")) { + fail( + section.n, + `${section.title}: sub-blocks are [${names.join(", ")}], expected [${SUB_BLOCKS.join(", ")}]`, + ); + } + const byName = Object.fromEntries(heads.map((head) => [head.name, head])); + for (const spec of CONTRACT.prBlocks) { + const heading = byName[spec.name]; + if (heading) checkBlock(section.title, heading, spec, fail); + } + return reportLine(section, heads); +} + +function checkTail(sections, close, fail) { + const closeIndex = sections.indexOf(close); + if (closeIndex === -1) return; + const tail = sections.slice(closeIndex + 1); + for (const section of tail) { + if (!section.title.startsWith("Appendix")) { + fail( + section.n, + `"## ${section.title}" after Close the program is not an appendix`, + ); + } + } + if (!tail.some((section) => section.title.includes("Prototype evidence"))) { + fail(close.n, 'no "## Appendix ... Prototype evidence" section'); + } +} + +/** + * @param {string} raw + * @param {string} [file] + * @returns {CheckResult} + */ +export function checkPlan(raw, file = "plan.md") { + const problems = []; + const fail = (line, message) => problems.push(`${file}:${line}: ${message}`); + const lines = toLines(raw); + checkPunctuation(lines, fail); + const sections = toSections(lines); + checkPreamble(lines, sections, fail); + + const program = findSection(sections, "Program checklist"); + if (!program) fail(1, 'no "## Program checklist" section'); + else checkProgram(program, fail); + + const close = findSection(sections, "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 section of prSections) report.push(checkPrSection(section, fail)); + if (close) checkTail(sections, close, fail); + + return { + problems, + report, + prCount: prSections.length, + ok: problems.length === 0, + }; +} + +/** + * @param {string} playbookRaw + * @returns {string} + */ +export function extractSkeleton(playbookRaw) { + const lines = playbookRaw.split(/\r?\n/); + const starts = []; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("````markdown")) starts.push(i); + } + if (starts.length !== 1) { + throw new Error( + `expected exactly one fenced skeleton, found ${starts.length}`, + ); + } + const start = starts[0]; + let end = -1; + for (let i = start + 1; i < lines.length; i++) { + if (/^````\s*$/.test(lines[i])) { + end = i; + break; + } + } + if (end === -1) throw new Error("unclosed skeleton fence"); + return `${lines.slice(start + 1, end).join("\n")}\n`; +} + +function isCliEntry() { + const entry = process.argv[1]; + if (!entry) return false; + try { + return fileURLToPath(import.meta.url) === resolve(entry); + } catch { + return false; + } +} + +function main() { + const file = process.argv[2]; + if (!file) { + console.error("Usage: node check-plan.mjs "); + process.exit(2); + } + let raw; + try { + raw = fs.readFileSync(file, "utf8"); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(2); + } + const result = checkPlan(raw, file); + for (const line of result.report) console.log(line); + console.log(`${result.prCount} PR sections, ${result.problems.length} problems`); + for (const problem of result.problems) console.error(problem); + process.exit(result.problems.length ? 1 : 0); +} + +if (isCliEntry()) main(); From 95e6f52e57dd7ec9107477189b020cbd22838404 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Tue, 25 Aug 2026 23:08:05 -0400 Subject: [PATCH 2/4] test(pstack): cover check-plan against the extracted skeleton Derive fixtures from the playbook fence. Prove every contract phrase is load-bearing, cover each block shape, and spawn the Node CLI for exit 0, 1, and 2. bun test check-plan: 30 pass. --- .../poteto-mode/scripts/check-plan.test.ts | 380 ++++++++++++++++++ .../skills/poteto-mode/scripts/package.json | 2 +- 2 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts diff --git a/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts b/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts new file mode 100644 index 0000000..d3c7069 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts @@ -0,0 +1,380 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { CONTRACT, checkPlan, extractSkeleton } from "./check-plan.mjs"; + +const SCRIPT = join(import.meta.dir, "check-plan.mjs"); +const PLAYBOOK = join(import.meta.dir, "../playbooks/multi-phase-plan.md"); +const directories: string[] = []; + +const FORBIDDEN_FENCE = [ + "/goal", + "/loop", + "control-ui", + "control-cli", + "git show origin/main:", + "grok-4.6-fast-xhigh", + "~/.claude", + "../references/", +]; + +type CheckPlanResult = { + readonly problems: string[]; + readonly report: string[]; + readonly prCount: number; + readonly ok: boolean; +}; + +function replaceOnce(source: string, target: string, replacement: string): string { + const first = source.indexOf(target); + if (first === -1) { + throw new Error(`missing mutation target: ${JSON.stringify(target)}`); + } + const second = source.indexOf(target, first + target.length); + if (second !== -1) { + throw new Error(`duplicated mutation target: ${JSON.stringify(target)}`); + } + return ( + source.slice(0, first) + replacement + source.slice(first + target.length) + ); +} + +function removePhrase(source: string, phrase: string): string { + if (!source.includes(phrase)) { + throw new Error(`missing contract phrase: ${JSON.stringify(phrase)}`); + } + return source.split(phrase).join(""); +} + +function contractPhrases(): string[] { + const phrases: string[] = [ + CONTRACT.rule, + CONTRACT.laneSentence, + ...CONTRACT.programSections, + ...CONTRACT.howToReadMarkers.filter((marker): marker is string => typeof marker === "string"), + ...CONTRACT.programMarkers.filter((marker): marker is string => typeof marker === "string"), + ...CONTRACT.prBlocks.map((block) => block.name), + ]; + for (const block of CONTRACT.prBlocks) { + if (block.leads) phrases.push(...block.leads); + if (block.words) phrases.push(...block.words); + if (block.shape === "lanes") { + for (let i = 1; i <= CONTRACT.laneCount; i++) { + phrases.push(`Lane ${i}.`); + } + } + } + return [...new Set(phrases)]; +} + +function problemsOf(source: string, file = "plan.md"): string[] { + return (checkPlan(source, file) as CheckPlanResult).problems; +} + +function runNode(args: readonly string[]): { + readonly status: number; + readonly stdout: string; + readonly stderr: string; +} { + const result = Bun.spawnSync(["node", SCRIPT, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + return { + status: result.exitCode ?? 1, + stdout: result.stdout.toString(), + stderr: result.stderr.toString(), + }; +} + +async function writePlan(source: string): Promise { + const directory = await mkdtemp(join(tmpdir(), "check-plan-")); + directories.push(directory); + const file = join(directory, "plan.md"); + await writeFile(file, source); + return file; +} + +afterEach(async () => { + await Promise.all( + directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +const playbook = await readFile(PLAYBOOK, "utf8"); +const skeleton = extractSkeleton(playbook); + +describe("check-plan", () => { + it("accepts the extracted playbook skeleton", () => { + const result = checkPlan(skeleton, "skeleton.md") as CheckPlanResult; + expect(result.problems).toEqual([]); + expect(result.ok).toBe(true); + expect(result.prCount).toBe(1); + expect(result.report[0]).toContain("verify-live=10"); + expect(result.report[0]).toContain("verify-perf=4"); + }); + + it("rejects the playbook file as checker input", () => { + const result = checkPlan(playbook, "multi-phase-plan.md") as CheckPlanResult; + expect(result.ok).toBe(false); + expect(result.problems.some((problem) => problem.includes("no H1 title"))).toBe(true); + }); + + it("keeps the copied fence free of harness-private strings", () => { + for (const item of FORBIDDEN_FENCE) { + expect(skeleton.includes(item), item).toBe(false); + } + expect(/30[- ]minute/.test(skeleton)).toBe(true); + expect(skeleton).toContain(CONTRACT.laneSentence); + }); + + it("makes every contract phrase load-bearing", () => { + for (const phrase of contractPhrases()) { + expect(skeleton.includes(phrase), phrase).toBe(true); + const mutated = removePhrase(skeleton, phrase); + expect(problemsOf(mutated).length).toBeGreaterThan(0); + } + }); + + it("accepts an ungated review block", () => { + const ungated = replaceOnce( + skeleton, + `**Review gate.** The operator reviews before merge. + +- [ ] Copy lane screenshots into \`/-review-.png\`. +- [ ] Record a 30 to 60 second video of the change on a live lane. Save it as \`/-review.mp4\`. +- [ ] Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator's click.`, + "**Review gate.** None. PR1 is not review-gated.", + ); + const result = checkPlan(ungated, "ungated.md") as CheckPlanResult; + expect(result.problems).toEqual([]); + }); + + it.each([ + [ + "a missing Program checklist", + replaceOnce(skeleton, "## Program checklist", "## Program list"), + "no \"## Program checklist\" section", + ], + [ + "reordered program and PR headings", + replaceOnce( + replaceOnce( + replaceOnce(skeleton, "## Program checklist", "## TMP heading"), + "## ()", + "## Program checklist", + ), + "## TMP heading", + "## ()", + ), + "no PR sections between Program checklist and Close the program", + ], + [ + "reordered PR blocks", + replaceOnce( + replaceOnce( + replaceOnce(skeleton, "**Build.**", "**TMP.**"), + "**You see.**", + "**Build.**", + ), + "**TMP.**", + "**You see.**", + ), + "sub-blocks are", + ], + [ + "empty Depends on rest", + replaceOnce(skeleton, "**Depends on.** ", "**Depends on.**"), + "Depends on names nothing", + ], + [ + "Files with no box", + replaceOnce( + skeleton, + `**Files.** + +- [ ] Edit \`\`. +- [ ] Create \`\`. +- [ ] Delete \`\`.`, + "**Files.**", + ), + "Files. has no box", + ], + [ + "a dropped live lane", + replaceOnce( + skeleton, + "- [ ] Lane 10. Save `.png`. Pass when .\n", + "", + ), + "expected 1 to 10", + ], + [ + "reordered live lanes", + replaceOnce( + replaceOnce( + replaceOnce(skeleton, "Lane 9.", "Lane TMP."), + "Lane 10.", + "Lane 9.", + ), + "Lane TMP.", + "Lane 10.", + ), + "lanes are [1,2,3,4,5,6,7,8,10,9]", + ], + [ + "a lane with no screenshot", + replaceOnce( + skeleton, + "Lane 4. Save `.png`. Pass when .", + "Lane 4. Pass when .", + ), + "lane 4 names no screenshot", + ], + [ + "a lane with no pass predicate", + replaceOnce( + skeleton, + "Lane 7. Save `.png`. Pass when .", + "Lane 7. Save `.png`.", + ), + "lane 7 has no pass predicate", + ], + [ + "a live box that is not a lane", + replaceOnce( + skeleton, + "Lane 2. Save `.png`. Pass when .", + "Extra. Save `.png`. Pass when .", + ), + "live box is not a lane", + ], + [ + "a hard-coded Cursor Grok string", + replaceOnce( + skeleton, + CONTRACT.laneSentence, + "Ten lanes on `grok-4.6-fast-xhigh` at the PR head", + ), + `Verify, live lacks "${CONTRACT.laneSentence}"`, + ], + [ + "incomplete perf evidence", + replaceOnce(skeleton, "- [ ] Rule. \n", ""), + "perf boxes are", + ], + [ + "swapped perf leads", + replaceOnce( + replaceOnce( + replaceOnce(skeleton, "Baseline.", "TMP."), + "Probe.", + "Baseline.", + ), + "TMP.", + "Probe.", + ), + "expected [Metric., Probe., Baseline., Rule.]", + ], + [ + "a None review gate that keeps boxes", + replaceOnce( + skeleton, + "**Review gate.** The operator reviews before merge.", + "**Review gate.** None. PR1 is not review-gated.", + ), + "Review gate says None but has boxes", + ], + [ + "a gated review missing video", + replaceOnce( + skeleton, + `**Review gate.** The operator reviews before merge. + +- [ ] Copy lane screenshots into \`/-review-.png\`. +- [ ] Record a 30 to 60 second video of the change on a live lane. Save it as \`/-review.mp4\`. +- [ ] Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator's click.`, + `**Review gate.** The operator reviews before merge. + +- [ ] Copy lane screenshots into \`/-review-.png\`. +- [ ] Record a 30 to 60 second clip of the change on a live lane. Save it as \`/-review.mp4\`. +- [ ] Post the screenshots in chat. Stop at merge-ready. Wait for the operator's click.`, + ), + 'Review gate lacks "video"', + ], + [ + "a non-appendix tail heading", + replaceOnce( + skeleton, + "## Appendix A. Prototype evidence", + "## Extra notes\n\n## Appendix A. Prototype evidence", + ), + 'after Close the program is not an appendix', + ], + [ + "a missing prototype appendix", + replaceOnce( + skeleton, + "## Appendix A. Prototype evidence", + "## Appendix A. Other evidence", + ), + 'no "## Appendix ... Prototype evidence" section', + ], + [ + "a long dash", + replaceOnce(skeleton, "One box is one unit of work", "One box is one unit of work\u2014"), + "long dash", + ], + [ + "a curly quote", + replaceOnce(skeleton, "One box is one unit of work", "One box is one unit of work\u2019"), + "curly quote", + ], + [ + "a mid-sentence colon", + replaceOnce(skeleton, "The body is a how-to.", "The body is a how-to: now."), + "mid-sentence colon", + ], + [ + "a verify block without the rule", + replaceOnce( + skeleton, + "**Verify, unit.** Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked.", + "**Verify, unit.** Skip the rule.", + ), + "Verify, unit. does not open with the rule", + ], + ])("rejects %s", (_name, mutated, expected) => { + expect(problemsOf(mutated as string).join("\n")).toContain(expected as string); + }); + + it("CLI exits 0 on the extracted skeleton", async () => { + const file = await writePlan(skeleton); + const result = runNode([file]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("1 PR sections, 0 problems"); + expect(result.stderr).toBe(""); + }); + + it("CLI exits 1 and prints the problem", async () => { + const file = await writePlan(replaceOnce(skeleton, "- [ ] Baseline.", "- [ ] Base.")); + const result = runNode([file]); + expect(result.status).toBe(1); + expect(result.stderr).toContain("perf boxes are"); + expect(result.stdout).toContain("1 PR sections,"); + }); + + it("CLI exits 2 when the plan path is missing", () => { + const result = runNode([]); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Usage: node check-plan.mjs "); + }); + + it("CLI exits 2 when the plan file cannot be read", () => { + const result = runNode([join(tmpdir(), "check-plan-missing.md")]); + expect(result.status).toBe(2); + expect(result.stderr.length).toBeGreaterThan(0); + expect(result.stderr).not.toContain("Usage: node check-plan.mjs "); + }); +}); diff --git a/plugins/pstack/skills/poteto-mode/scripts/package.json b/plugins/pstack/skills/poteto-mode/scripts/package.json index 22a39a9..8995fd7 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/package.json +++ b/plugins/pstack/skills/poteto-mode/scripts/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "test": "\"$npm_execpath\" test --parallel bootstrap orch watch-pr runner", + "test": "\"$npm_execpath\" test --parallel bootstrap orch watch-pr runner check-plan", "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict && tsc --project runner/tsconfig.json --noEmit --strict" }, "dependencies": { From cf6d97b3c958df2f47e519c1205a36cb116ef3e1 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Tue, 25 Aug 2026 23:08:05 -0400 Subject: [PATCH 3/4] docs(pstack): record Cursor pstack 0.14.3 at bdf7aa3 Move the UPSTREAM.md sync point, the README and reference version sentences, and the NOTICE.md pstack provenance row. Leave Open Pstack at 1.1.0. --- NOTICE.md | 2 +- README.md | 2 +- UPSTREAM.md | 8 ++++---- docs/reference.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/NOTICE.md b/NOTICE.md index ff3f4f7..c61e5ee 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -16,7 +16,7 @@ This plugin is a port of upstream MIT-licensed work. All upstream copyright noti | `plugins/pstack/hooks/run-hook.cmd` (near-verbatim) | [anthropics/claude-plugins-official → superpowers @ 6.1.0](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/superpowers) (originally obra/superpowers) | (c) 2025 Jesse Vincent | MIT | [LICENSE-superpowers](LICENSE-superpowers) | | `plugins/pstack/skills/what-did-i-get-done/` | [cursor/plugins/cursor-team-kit/skills/what-did-i-get-done @ e46364b](https://github.com/cursor/plugins/tree/e46364b8be46000b7df0f260550cd712afbb8d36/cursor-team-kit/skills/what-did-i-get-done) | (c) 2026 Cursor | MIT | [LICENSE-cursor-team-kit](LICENSE-cursor-team-kit) | | `plugins/pstack/skills/teach/`, `plugins/pstack/skills/principle-model-the-domain/`, `plugins/pstack/skills/create-verification-skill/`, `plugins/pstack/skills/maintain-verification-skill/` (v0.11.3 additions) | [cursor/plugins/pstack @ 3fe2823](https://github.com/cursor/plugins/tree/3fe2823ce17c1656c222d4b7c59d3f82fbf20143/pstack) | (c) 2026 Lauren Tan | MIT | [LICENSE](LICENSE) | -| `plugins/pstack/skills/{swarm,no-comments,technical-writing,bro}/`, `plugins/pstack/agents/comment-sicko.md`, `plugins/pstack/skills/poteto-mode/playbooks/{babysit,shipping,orchestrate,autopilot-full,autopilot-stack,worktree-cleanup}.md`, `plugins/pstack/skills/poteto-mode/references/bugbot-triage.md`, `plugins/pstack/skills/poteto-mode/scripts/`, `plugins/pstack/skills/architect/references/design-red-flags.md`, `plugins/pstack/skills/create-verification-skill/references/feature-map-example/` (v0.14.2 additions) | [cursor/plugins/pstack @ 4612556](https://github.com/cursor/plugins/tree/4612556/pstack) | (c) 2026 Lauren Tan | MIT | [LICENSE](LICENSE) | +| `plugins/pstack/skills/{swarm,no-comments,technical-writing,bro}/`, `plugins/pstack/agents/comment-sicko.md`, `plugins/pstack/skills/poteto-mode/playbooks/{babysit,shipping,orchestrate,autopilot-full,autopilot-stack,worktree-cleanup,multi-phase-plan}.md`, `plugins/pstack/skills/poteto-mode/references/bugbot-triage.md`, `plugins/pstack/skills/poteto-mode/scripts/`, `plugins/pstack/skills/architect/references/design-red-flags.md`, `plugins/pstack/skills/create-verification-skill/references/feature-map-example/` (v0.14.2 additions, v0.14.3 checklist) | [cursor/plugins/pstack @ bdf7aa3](https://github.com/cursor/plugins/tree/bdf7aa355337897f167153e05069aca505dae17c/pstack) | (c) 2026 Lauren Tan | MIT | [LICENSE](LICENSE) | ## What changed in the port diff --git a/README.md b/README.md index 477a3af..2644fdd 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ This repository also keeps: ## Staying close to Lauren's pstack -Open Pstack 1.1.0 tracks pstack 0.14.2 at Cursor commit [`46125561306434d8a1d7745d540d8932ab0cd2a2`](https://github.com/cursor/plugins/commit/46125561306434d8a1d7745d540d8932ab0cd2a2). +Open Pstack 1.1.0 tracks pstack 0.14.3 at Cursor commit [`bdf7aa355337897f167153e05069aca505dae17c`](https://github.com/cursor/plugins/commit/bdf7aa355337897f167153e05069aca505dae17c). The two projects have separate version numbers. The pstack version identifies Lauren's upstream content. The Open Pstack version identifies the Claude Code and Codex package built from it. diff --git a/UPSTREAM.md b/UPSTREAM.md index 0154249..826450a 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -8,8 +8,8 @@ open-pstack tracks [Cursor's pstack](https://github.com/cursor/plugins/tree/main | --- | --- | | Repository | `https://github.com/cursor/plugins.git` | | Path | `pstack/` | -| Commit | `46125561306434d8a1d7745d540d8932ab0cd2a2` | -| Upstream version | `0.14.2` | +| Commit | `bdf7aa355337897f167153e05069aca505dae17c` | +| Upstream version | `0.14.3` | | open-pstack version | `1.1.0` | The same Cursor commit remains the sync point for open-pstack 1.1.0. `README-UPSTREAM.md` preserves its pstack README verbatim. `CHANGES.md` and `NOTICE.md` describe the adaptations and provenance. @@ -26,8 +26,8 @@ Fetch and inspect only commits that touched pstack after the recorded sync point ```shell git fetch cursor main -git log --oneline 46125561306434d8a1d7745d540d8932ab0cd2a2..cursor/main -- pstack -git diff --stat 46125561306434d8a1d7745d540d8932ab0cd2a2..cursor/main -- pstack +git log --oneline bdf7aa355337897f167153e05069aca505dae17c..cursor/main -- pstack +git diff --stat bdf7aa355337897f167153e05069aca505dae17c..cursor/main -- pstack ``` No output means the tracked pstack tree has not changed. This comparison does not need a polling service or generated mirror branch. diff --git a/docs/reference.md b/docs/reference.md index e22e103..d47bee7 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -2,7 +2,7 @@ This page contains the full skill, dependency, runtime, and porting reference. For the plain-English introduction and quick start, see the [main README](../README.md). -[Poteto](https://x.com/poteto)'s [pstack](https://github.com/cursor/plugins/tree/main/pstack), adapted to run in Claude Code and Codex without Cursor. One shared skill tree serves both harnesses; Grok remains available as a model-provider lane. Version 1.1.0 is synced to Cursor pstack v0.14.2 at `46125561306434d8a1d7745d540d8932ab0cd2a2`. See [UPSTREAM.md](../UPSTREAM.md) for the exact sync contract. +[Poteto](https://x.com/poteto)'s [pstack](https://github.com/cursor/plugins/tree/main/pstack), adapted to run in Claude Code and Codex without Cursor. One shared skill tree serves both harnesses; Grok remains available as a model-provider lane. Version 1.1.0 is synced to Cursor pstack v0.14.3 at `bdf7aa355337897f167153e05069aca505dae17c`. See [UPSTREAM.md](../UPSTREAM.md) for the exact sync contract. Original by Lauren Tan. This distribution builds on Michael Denyer's [pstack-claude](https://github.com/michael-denyer/pstack-claude) port and retains its history and MIT attribution. It imports seven MIT-licensed skills from [cursor-team-kit](https://github.com/cursor/plugins/tree/main/cursor-team-kit): `deslop`, `thermo-nuclear-code-quality-review`, `make-pr-easy-to-review`, `fix-ci`, `fix-merge-conflicts`, `get-pr-comments`, `what-did-i-get-done`. From ebfb7c636016075d91d7953ebc84ddc7bae75e64 Mon Sep 17 00:00:00 2001 From: Eric Litman Date: Tue, 25 Aug 2026 23:58:59 -0400 Subject: [PATCH 4/4] fix(pstack): harden verified plan contract --- NOTICE.md | 3 +- UPSTREAM.md | 2 +- docs/reference.md | 3 +- .../poteto-mode/playbooks/multi-phase-plan.md | 12 +- .../skills/poteto-mode/scripts/check-plan.mjs | 569 ++++++++++++++---- .../poteto-mode/scripts/check-plan.test.ts | 284 ++++++++- .../scripts/check-plan.tsconfig.json | 16 + .../skills/poteto-mode/scripts/package.json | 2 +- 8 files changed, 735 insertions(+), 156 deletions(-) create mode 100644 plugins/pstack/skills/poteto-mode/scripts/check-plan.tsconfig.json diff --git a/NOTICE.md b/NOTICE.md index c61e5ee..0c4500a 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -29,7 +29,7 @@ Summary of structural changes: - The native `plugins/pstack/skills/` tree is the only user-facing workflow surface. Claude Code and Codex invoke those skills directly. - Seven skills imported from `cursor-team-kit`: `deslop`, `thermo-nuclear-code-quality-review`, `make-pr-easy-to-review`, `fix-ci`, `fix-merge-conflicts`, `get-pr-comments`, `what-did-i-get-done`. All copied verbatim — no rewiring needed. - `plugins/pstack/skills/babysit/` is independently authored as the Claude Code analog of Cursor's `/babysit` built-in. It has no upstream pstack equivalent; its workflow is informed by Cursor's public `/babysit` behavior. No code or prose was copied from any source. -- `plugins/pstack/skills/poteto-mode/scripts/` is vendored from upstream (`watch-pr`, `orch`, `bootstrap.ts`, `worktree-audit.sh`, `package.json`, `bun.lock`) with five edits and one port-authored test: `worktree-audit.sh` reads `~/.claude/projects/` instead of Cursor's transcript directory and warns when `jq` or `rg` is missing (their absence silently blanks the columns the prune decision reads), the private workspace package is named `@open-pstack/poteto-mode-tools`, `bootstrap.ts` rejects Node before it reads Bun-only APIs, and `package.json` includes `bootstrap.test.ts` in `bun run test`. The `bootstrap.test.ts` file is authored for this port. Everything else is upstream's code under the same MIT license. +- `plugins/pstack/skills/poteto-mode/scripts/` is vendored from upstream (`watch-pr`, `orch`, `bootstrap.ts`, `worktree-audit.sh`, `package.json`, `bun.lock`) with these port edits: `worktree-audit.sh` reads `~/.claude/projects/` instead of Cursor's transcript directory and warns when `jq` or `rg` is missing (their absence silently blanks the columns the prune decision reads), the private workspace package is named `@open-pstack/poteto-mode-tools`, `bootstrap.ts` rejects Node before it reads Bun-only APIs, and `package.json` includes the port-authored tests in `bun run test`. `check-plan.mjs` is the Cursor 0.14.3 checker adapted for the shared Claude Code and Codex skeleton. `bootstrap.test.ts` and `check-plan.test.ts` are authored for this port. - `plugins/pstack/agents/comment-sicko.md` is upstream's `Comment Sicko` agent, renamed to `comment-sicko` so the name works as a Claude Code `subagent_type`. The body is verbatim. - Claude-native Fable and Opus lanes are port-authored agent definitions. They pin model plus requested effort for every selectable Claude-native pair in the provider-dispatch model matrix. - A Codex build shares the same `skills/` tree. It adds `plugins/pstack/.codex-plugin/plugin.json`, a root `.agents/plugins/marketplace.json`, and `plugins/pstack/skills/poteto-mode/references/codex-tools.md` (the Claude-to-Codex tool, model, and built-in map), plus a one-line Platform note in the skills that name a Claude primitive. The skill content itself is unchanged. See [CHANGES.md](CHANGES.md#codex-port). @@ -46,6 +46,7 @@ Files authored for this port (not derived from upstream): - `.agents/plugins/marketplace.json` (repo root) - `plugins/pstack/skills/poteto-mode/references/codex-tools.md` - `plugins/pstack/skills/poteto-mode/scripts/bootstrap.test.ts` +- `plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts` - `plugins/pstack/skills/babysit/SKILL.md` (independently authored; workflow informed by Cursor's public `/babysit` behavior) - `plugins/pstack/agents/pstack-fable-*.md` and `plugins/pstack/agents/pstack-opus-*.md` (Claude-native frontier lanes at each selectable effort) - `plugins/pstack/hooks/hooks.json`, `plugins/pstack/hooks/session-start`, and `plugins/pstack/hooks/session-start-context.md` (the auto-fire hook and its mandate) diff --git a/UPSTREAM.md b/UPSTREAM.md index 826450a..f6b6fd0 100644 --- a/UPSTREAM.md +++ b/UPSTREAM.md @@ -12,7 +12,7 @@ open-pstack tracks [Cursor's pstack](https://github.com/cursor/plugins/tree/main | Upstream version | `0.14.3` | | open-pstack version | `1.1.0` | -The same Cursor commit remains the sync point for open-pstack 1.1.0. `README-UPSTREAM.md` preserves its pstack README verbatim. `CHANGES.md` and `NOTICE.md` describe the adaptations and provenance. +The table above is the current Cursor sync point. Open Pstack 1.1.0 shipped against an earlier commit. The next release consolidates this 0.14.3 sync. `README-UPSTREAM.md` preserves its pstack README verbatim. `CHANGES.md` and `NOTICE.md` describe the adaptations and provenance. ## Check for changes diff --git a/docs/reference.md b/docs/reference.md index d47bee7..ffef6db 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -61,7 +61,7 @@ The marketplace install is the normal user path. Direct links are only for testi │ ├── .codex-plugin/plugin.json # Codex manifest (skills: ./skills/) │ ├── skills/ # 52 skills shared by Claude Code and Codex │ │ ├── poteto-mode/references/{codex-tools,provider-dispatch}.md # tool + provider routing -│ │ └── poteto-mode/scripts/ # bun/bash tooling: watch-pr, orch, runner, worktree-audit.sh +│ │ └── poteto-mode/scripts/ # bun/bash/node tooling: watch-pr, orch, runner, check-plan.mjs, worktree-audit.sh │ ├── hooks/ # SessionStart auto-fire: injects the poteto-mode mandate (Claude Code only) │ └── agents/ # Claude subagents, including native Fable and Opus lanes at each selectable effort ├── tests/skill-collision-repro.sh # native-skill package invariants and Claude invocation checks @@ -108,6 +108,7 @@ Not declared as deps, but referenced in skill bodies: - **`run`, `verify`, `loop`** — Claude Code CLI built-ins (ship with the binary, always available). - **`gh` CLI** — system-level requirement of the `babysit` skill and the Babysit / Shipping playbooks. Install via [`brew install gh`](https://cli.github.com) and authenticate with `gh auth login`. - **`bun`** — runs the vendored `skills/poteto-mode/scripts/` tooling (`watch-pr`, `orch`, `runner`). Install via [`brew install oven-sh/bun/bun`](https://bun.sh). `bootstrap.ts` installs dependencies for `watch-pr` and `orch`; the runner uses only Bun and Node built-ins, so it launches directly without an install/re-exec layer. +- **`node`** — runs `skills/poteto-mode/scripts/check-plan.mjs`. The checker uses only Node built-ins and does not need Bun. - **Claude Code, Codex, and Grok Build CLIs** — the external runner uses the assigned subscribed CLI directly. Install and authenticate only the providers present in your model sheet. Same-provider work stays native; the runner refuses it. - **`gt` (Graphite CLI)** — only for the stack playbooks (Shipping, Orchestrate, the autopilots). Everything else works without it. - **`jq` and `rg` (ripgrep)** — only for `scripts/worktree-audit.sh` (the Worktree cleanup playbook). Without them the audit still runs but blanks its PR and LAST_CHAT columns, so it warns on stderr rather than returning a table that looks complete. diff --git a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md index de969f0..854142d 100644 --- a/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md +++ b/plugins/pstack/skills/poteto-mode/playbooks/multi-phase-plan.md @@ -4,10 +4,10 @@ 1. When the change is one or two files with an obvious approach, skip the plan. Say so and stop. 2. Settle open questions by prototype before you write. For a question about layout, timing, behavior, or whether an API works, run `playbooks/prototype.md`. Keep the branch, the SHA, and the screenshots for Appendix A. Ask the operator only about a product or preference call that no run can settle. Give options (the **never-block-on-the-human** principle skill). -3. Explore in subagents. Resolve the configured role through [`../references/provider-dispatch.md`](../references/provider-dispatch.md). Use `poteto-agent` only for an unqualified `inherit-parent` / `auto` native helper. Never use Claude Code's built-in `Plan` agent. It ignores this skill (the **guard-the-context-window** principle skill). Each explorer returns file pointers, conventions, test commands, and entry points. No inlined dumps. Children do not detect the parent harness or choose a route. Preserve the selected effort. A dropout stays a dropout. Do not add an implicit timeout. +3. Explore in subagents. Resolve the configured `judgment and prose` role through [`../references/provider-dispatch.md`](../references/provider-dispatch.md). Use `poteto-agent` only for an unqualified `inherit-parent` / `auto` native helper. Never use Claude Code's built-in `Plan` agent. It ignores this skill (the **guard-the-context-window** principle skill). Each explorer returns file pointers, conventions, test commands, and entry points. No inlined dumps. Children do not detect the parent harness or choose a route. Preserve the selected effort. A dropout stays a dropout. Do not add a fallback or an implicit timeout. 4. Copy the skeleton below into the plan file and fill every placeholder. Unless the operator names a path, write the file under the working repository's `docs/`. Keep every heading and every sub-block in the order shown. One section per PR. One PR is one change with its own evidence (the **sequence-verifiable-units** principle skill). Name the execution playbook in **How to read this**. Pick between `playbooks/autopilot-full.md` and `playbooks/autopilot-stack.md` per the rule at the end of `playbooks/autopilot-stack.md`. A standing program takes `playbooks/orchestrate.md`. 5. Write under `/technical-writing` in full, then `/unslop`. The body is one Diátaxis mode, how-to. Appendices hold explanation and reference. Two rules apply verbatim. "i dont want any abstract metaphors" and "write like hemingway". Each heading states the task or the finding. No long dashes. No mid-sentence colons. -6. Run `node skills/poteto-mode/scripts/check-plan.mjs ` under the installed plugin and fix every line it prints (the **encode-lessons-in-structure** principle skill). It enforces the skeleton's shape, the verification rule in every verification block, and the punctuation rules. The playbook file is not checker input. Copy or extract the fenced skeleton first. +6. Run `node skills/poteto-mode/scripts/check-plan.mjs ` under the installed plugin and fix every line it prints (the **encode-lessons-in-structure** principle skill). It enforces the skeleton's shape, the verification rule in every verification block, and the punctuation rules. The playbook file is not checker input. Check the plan file created in step 4. 7. Hand back. Post the plan path and the script's output, then stop. Execution starts on the operator's explicit go, under the execution playbook the plan names. **Verification.** Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked (the **prove-it-works** principle skill). That sentence is the verification rule. Every verification block opens with it. The live block is mandatory. Ten lanes on the configured `swarm workers` role at the PR head drive the real surface through the driver skill, per the **swarm** skill. The role resolves once through [`../references/provider-dispatch.md`](../references/provider-dispatch.md) at execution time, and each lane's receipt records the selected provider, model, and effort. Each lane is one box with a concrete scenario, the screenshot it saves, and its pass predicate. The perf block names the metric, the probe, the trunk baseline measured first, and the rule with the number that fails. A PR that changes an interaction is review-gated. The operator reviews it in chat with screenshots and a video before merge. A PR that changes no interaction writes `**Review gate.** None. is not review-gated.` and no boxes under it. @@ -33,8 +33,8 @@ Tests alone are not sufficient verification. A PR is verified only when its unit ### Arm the program -- [ ] State the protocol and this plan to the operator, then stop. Start execution only on her explicit go. -- [ ] On her go, write the program objective into the standing orders and your todolist with this exact text. "" +- [ ] State the protocol and this plan to the operator, then stop. Start execution only on the operator's explicit go. +- [ ] On the operator's go, write the program objective into the standing orders and your todolist with this exact text. "" - [ ] Read these from the installed plugin at program start. Re-read them at every tick. - [ ] `skills/poteto-mode/playbooks/.md` - [ ] `skills/swarm/SKILL.md` @@ -75,7 +75,7 @@ Each live lane is one `swarm workers` lane at the PR head, resolved through prov - [ ] `git fetch origin && git checkout ` in the lane's worktree. - [ ] - [ ] -- [ ] Save every screenshot to `/tmp/swarm-/worker-/.png` and return the paths with the receipt path. +- [ ] Save every screenshot to `/swarm-/worker-/.png` and return the paths with the receipt path. ## () @@ -117,7 +117,7 @@ Each live lane is one `swarm workers` lane at the PR head, resolved through prov - [ ] Metric. - [ ] Probe. - [ ] Baseline. Record the trunk first. -- [ ] Rule. +- [ ] Rule. **Review gate.** The operator reviews before merge. diff --git a/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs b/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs index 19ed077..7b53f34 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs +++ b/plugins/pstack/skills/poteto-mode/scripts/check-plan.mjs @@ -8,52 +8,133 @@ const RULE = "Tests alone are not sufficient verification. A PR is verified only when its unit, live, and perf boxes are all checked."; const LANE_SENTENCE = "Ten lanes on the configured `swarm workers` role at the PR head"; -const BOX = /^\s*- \[[ x]\] (.*)$/; +const BOX = /^\s*- \[[ xX]\] (.*)$/; +const TOP_BOX = /^- \[[ xX]\] (.*)$/; -/** @typedef {"none" | "any" | "lanes" | "ordered-leads" | "gate"} BoxShape */ -/** @typedef {{ name: string, shape: BoxShape, opensWithRule?: boolean, leads?: readonly string[], words?: readonly string[] }} PrBlock */ -/** @typedef {{ n: number, text: string, code: boolean }} Line */ -/** @typedef {{ title: string, n: number, body: Line[] }} Section */ -/** @typedef {{ name: string, n: number, rest: string, lines: Line[] }} Heading */ -/** @typedef {{ problems: string[], report: string[], prCount: number, ok: boolean }} CheckResult */ +/** + * @typedef {"none" | "any" | "lanes" | "ordered-leads" | "gate"} BoxShape + */ + +/** + * @typedef {object} PerfLead + * @property {string} lead + * @property {"nonempty" | "trunk-first" | "numeric"} payload + */ + +/** + * @typedef {object} PrBlock + * @property {string} name + * @property {BoxShape} shape + * @property {boolean} [opensWithRule] + * @property {string} [save] + * @property {string} [passWhen] + * @property {readonly PerfLead[]} [leads] + * @property {RegExp} [nonePattern] + * @property {string} [gatedRest] + * @property {readonly string[]} [gatedStarts] + */ + +/** + * @typedef {object} PunctuationRule + * @property {string} message + * @property {RegExp} pattern + */ + +/** + * @typedef {object} PlanContract + * @property {string} rule + * @property {string} laneSentence + * @property {number} laneCount + * @property {number} introMaxNonBlank + * @property {string} howToRead + * @property {string} program + * @property {string} close + * @property {RegExp} prTitle + * @property {readonly string[]} appendices + * @property {readonly string[]} screenshotExt + * @property {readonly PrBlock[]} prBlocks + * @property {readonly string[]} programSections + * @property {readonly string[]} programMarkers + * @property {readonly string[]} howToReadMarkers + * @property {readonly PunctuationRule[]} punctuation + */ +/** + * @typedef {object} CheckResult + * @property {string[]} problems + * @property {string[]} report + * @property {number} prCount + * @property {boolean} ok + */ + +/** @type {readonly PrBlock[]} */ const PR_BLOCKS = Object.freeze([ - Object.freeze({ name: "Depends on.", shape: "none" }), - Object.freeze({ name: "Files.", shape: "any" }), - Object.freeze({ name: "Build.", shape: "any" }), - Object.freeze({ name: "You see.", shape: "any" }), - Object.freeze({ name: "Verify, unit.", shape: "any", opensWithRule: true }), - Object.freeze({ name: "Verify, live.", shape: "lanes", opensWithRule: true }), - Object.freeze({ + { name: "Depends on.", shape: "none" }, + { name: "Files.", shape: "any" }, + { name: "Build.", shape: "any" }, + { name: "You see.", shape: "any" }, + { name: "Verify, unit.", shape: "any", opensWithRule: true }, + { + name: "Verify, live.", + shape: "lanes", + opensWithRule: true, + save: "Save `", + passWhen: "Pass when", + }, + { name: "Verify, perf.", shape: "ordered-leads", opensWithRule: true, - leads: Object.freeze(["Metric.", "Probe.", "Baseline.", "Rule."]), - }), - Object.freeze({ + leads: [ + { lead: "Metric.", payload: "nonempty" }, + { lead: "Probe.", payload: "nonempty" }, + { lead: "Baseline.", payload: "trunk-first" }, + { lead: "Rule.", payload: "numeric" }, + ], + }, + { name: "Review gate.", shape: "gate", - words: Object.freeze(["screenshot", "video", "operator"]), - }), - Object.freeze({ name: "Merge.", shape: "any" }), + nonePattern: /^None\. \S+ is not review-gated\.$/, + gatedRest: "The operator reviews before merge.", + gatedStarts: [ + "Copy lane", + "Record a 30 to 60 second video", + "Post the screenshots and the video in chat", + ], + }, + { name: "Merge.", shape: "any" }, ]); +/** @type {PlanContract} */ export const CONTRACT = Object.freeze({ rule: RULE, laneSentence: LANE_SENTENCE, laneCount: 10, + introMaxNonBlank: 9, + howToRead: "How to read this", + program: "Program checklist", + close: "Close the program", + prTitle: /^.+ \([^)]+\)$/, + appendices: Object.freeze([ + "Appendix A. Prototype evidence", + "Appendix B. Alternatives rejected", + "Appendix C. Risks", + "Appendix D. Links and reading list", + ]), + screenshotExt: Object.freeze([".png", ".jpg", ".jpeg", ".webp"]), prBlocks: PR_BLOCKS, programSections: Object.freeze([ "Arm the program", "Spawn owners", - "PR mechanics", - "Verdict and merge", - "Boot recipe", + "PR mechanics, for every PR", + "Verdict and merge, for every PR", + "Boot recipe, for every live lane", ]), programMarkers: Object.freeze([ "standing orders", "the installed plugin", - /30[- ]minute/, + "30-minute", "status message", ]), howToReadMarkers: Object.freeze([ @@ -64,17 +145,21 @@ export const CONTRACT = Object.freeze({ RULE, ]), punctuation: Object.freeze([ - Object.freeze({ message: "long dash", pattern: /[\u2013\u2014]/ }), - Object.freeze({ + { message: "long dash", pattern: /[\u2013\u2014]/ }, + { message: "curly quote", pattern: /[\u2018\u2019\u201c\u201d]/, - }), - Object.freeze({ message: "mid-sentence colon", pattern: /: \S/ }), + }, + { message: "mid-sentence colon", pattern: /: \S/ }, ]), }); const SUB_BLOCKS = CONTRACT.prBlocks.map((block) => block.name); +/** + * @param {string} raw + * @returns {{ n: number, text: string, code: boolean }[]} + */ function toLines(raw) { const split = raw.split(/\r?\n/); let start = 0; @@ -85,7 +170,7 @@ function toLines(raw) { const lines = []; let fence = false; for (let i = start; i < split.length; i++) { - const text = split[i]; + const text = split[i] ?? ""; const n = i + 1; if (/^```/.test(text)) fence = !fence; lines.push({ n, text, code: fence }); @@ -93,6 +178,10 @@ function toLines(raw) { return lines; } +/** + * @param {{ n: number, text: string, code: boolean }[]} lines + * @param {(line: number, message: string) => void} fail + */ function checkPunctuation(lines, fail) { for (const line of lines) { if (line.code) continue; @@ -106,7 +195,12 @@ function checkPunctuation(lines, fail) { } } +/** + * @param {{ n: number, text: string, code: boolean }[]} lines + * @returns {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} + */ function toSections(lines) { + /** @type {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} */ const sections = []; for (const line of lines) { const title = @@ -114,109 +208,255 @@ function toSections(lines) { ? line.text.slice(3).trim() : null; if (title !== null) sections.push({ title, n: line.n, body: [] }); - else if (sections.length) sections.at(-1).body.push(line); + else if (sections.length > 0) { + const current = sections[sections.length - 1]; + if (current) current.body.push(line); + } } return sections; } +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} sections + * @param {string} title + */ function findSection(sections, title) { return sections.find((section) => section.title === title); } +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }} section + */ function bodyText(section) { return section.body.map((line) => line.text).join("\n"); } -function boxes(lines) { - return lines - .filter((line) => !line.code && BOX.test(line.text)) - .map((line) => ({ n: line.n, text: line.text.match(BOX)[1] })); +/** + * @param {{ n: number, text: string, code: boolean }[]} lines + * @param {RegExp} [pattern] + * @returns {{ n: number, text: string }[]} + */ +function boxes(lines, pattern = BOX) { + /** @type {{ n: number, text: string }[]} */ + const found = []; + for (const line of lines) { + if (line.code) continue; + const match = line.text.match(pattern); + if (match && match[1] !== undefined) { + found.push({ n: line.n, text: match[1] }); + } + } + return found; } -function checkPreamble(lines, sections, fail) { - const h1 = lines.findIndex((line) => !line.code && line.text.startsWith("# ")); - if (h1 === -1) fail(1, "no H1 title"); - const howToRead = findSection(sections, "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((line) => line.n < howToRead.n && line.text.trim() !== ""); - if (intro.length >= 10) { - fail( - lines[h1].n, - `intro is ${intro.length} lines, under ten required`, +/** + * @param {{ n: number, text: string, code: boolean }[]} lines + */ +function topBoxes(lines) { + return boxes(lines, TOP_BOX); +} + +/** + * @param {string} rest + * @param {PerfLead["payload"]} kind + */ +function hasPayload(rest, kind) { + switch (kind) { + case "nonempty": + return /[A-Za-z0-9<]/.test(rest); + case "trunk-first": + return ( + /[A-Za-z0-9<]/.test(rest) && + /\btrunk\b/i.test(rest) && + /\bfirst\b/i.test(rest) ); + case "numeric": + return /\d/.test(rest); + default: + return true; + } +} + +/** + * @param {string} text + * @param {string} save + */ +function namedScreenshot(text, save) { + const start = text.indexOf(save); + if (start === -1) return false; + const from = start + save.length; + const end = text.indexOf("`", from); + if (end === -1) return false; + const inner = text.slice(from, end).toLowerCase(); + return CONTRACT.screenshotExt.some((ext) => inner.endsWith(ext)); +} + +/** + * @param {string} text + * @param {string} passWhen + */ +function namedPassPredicate(text, passWhen) { + const at = text.indexOf(passWhen); + if (at === -1) return false; + return /[A-Za-z0-9<]/.test(text.slice(at + passWhen.length)); +} + +/** + * @param {{ n: number, text: string, code: boolean }[]} lines + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} sections + * @param {(line: number, message: string) => void} fail + */ +function checkPreamble(lines, sections, fail) { + const h1s = lines.filter( + (line) => !line.code && line.text.startsWith("# "), + ); + const h1 = h1s[0]; + if (h1s.length === 0) { + fail(1, "no H1 title"); + } else if (h1s.length !== 1) { + fail(1, `found ${h1s.length} H1 titles, exactly one required`); + } + const howToRead = findSection(sections, CONTRACT.howToRead); + if (!howToRead) fail(1, `no "## ${CONTRACT.howToRead}" section`); + if (h1 && howToRead) { + const intro = lines.filter( + (line) => + line.n > h1.n && line.n < howToRead.n && line.text.trim() !== "", + ); + if (intro.length > CONTRACT.introMaxNonBlank) { + fail(h1.n, `intro is ${intro.length} lines, under ten required`); } const text = bodyText(howToRead); for (const marker of CONTRACT.howToReadMarkers) { if (!text.includes(marker)) { - fail(howToRead.n, `How to read this lacks "${marker}"`); + fail(howToRead.n, `${CONTRACT.howToRead} lacks "${marker}"`); } } } } +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} sections + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] } | undefined} program + * @param {(line: number, message: string) => void} fail + */ +function checkLowerHeadings(sections, program, fail) { + for (const section of sections) { + for (const line of section.body) { + if (line.code || !/^#{3,6} /.test(line.text)) continue; + if (section === program && line.text.startsWith("### ")) continue; + fail(line.n, `"${line.text.trim()}" is an unexpected heading`); + } + } +} + +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} sections + * @param {(line: number, message: string) => void} fail + */ +function checkUnexpectedHeadings(sections, fail) { + const how = findSection(sections, CONTRACT.howToRead); + const program = findSection(sections, CONTRACT.program); + if (how) { + for (const section of sections) { + if (section.n < how.n) { + fail(section.n, `"## ${section.title}" is an unexpected heading`); + } + } + } + if (how && program) { + const howIndex = sections.indexOf(how); + const programIndex = sections.indexOf(program); + if (howIndex !== -1 && programIndex !== -1) { + for (const section of sections.slice(howIndex + 1, programIndex)) { + fail(section.n, `"## ${section.title}" is an unexpected heading`); + } + } + } +} + +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }} section + * @param {(line: number, message: string) => void} fail + */ function checkProgram(section, fail) { const h3s = section.body .filter((line) => !line.code && line.text.startsWith("### ")) .map((line) => line.text.slice(4).trim()); - let cursor = 0; - for (const name of CONTRACT.programSections) { - const at = h3s.findIndex((title, i) => i >= cursor && title.startsWith(name)); - if (at === -1) fail(section.n, `Program checklist lacks "### ${name}" in order`); - else cursor = at + 1; + if (h3s.join("|") !== CONTRACT.programSections.join("|")) { + fail( + section.n, + `Program checklist H3s are [${h3s.join(", ")}], expected [${CONTRACT.programSections.join(", ")}]`, + ); } const text = bodyText(section); for (const marker of CONTRACT.programMarkers) { - const ok = - marker instanceof RegExp ? marker.test(text) : text.includes(marker); - if (!ok) fail(section.n, `Program checklist lacks "${marker}"`); + if (!text.includes(marker)) { + fail(section.n, `Program checklist lacks "${marker}"`); + } } } +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }} section + */ function headingsOf(section) { + /** @type {{ name: string, n: number, rest: string, lines: { n: number, text: string, code: boolean }[] }[]} */ const heads = []; for (const line of section.body) { if (line.code) continue; const match = line.text.match(/^\*\*([^*]+)\*\*(.*)$/); - if (match && SUB_BLOCKS.includes(match[1])) { + if (match && match[1] !== undefined) { heads.push({ name: match[1], n: line.n, - rest: match[2].trim(), + rest: (match[2] ?? "").trim(), lines: [], }); - } else if (heads.length) { - heads.at(-1).lines.push(line); + } else if (heads.length > 0) { + const current = heads[heads.length - 1]; + if (current) current.lines.push(line); } } return heads; } -function checkLanes(prTitle, live, fail) { +/** + * @param {string} prTitle + * @param {{ n: number, rest: string, lines: { n: number, text: string, code: boolean }[] }} live + * @param {PrBlock} spec + * @param {(line: number, message: string) => void} fail + */ +function checkLanes(prTitle, live, spec, fail) { if (!live.rest.includes(CONTRACT.laneSentence)) { fail( live.n, `${prTitle}: Verify, live lacks "${CONTRACT.laneSentence}"`, ); } - const laneBoxes = boxes(live.lines); + const save = spec.save; + const passWhen = spec.passWhen; + if (save === undefined || passWhen === undefined) return; + const laneBoxes = topBoxes(live.lines); const expected = Array.from( { length: CONTRACT.laneCount }, (_, i) => i + 1, ).join(","); + /** @type {number[]} */ const numbers = []; for (const lane of laneBoxes) { const match = lane.text.match(/^Lane (\d+)\. /); - if (!match) fail(lane.n, `${prTitle}: live box is not a lane`); - else { - numbers.push(Number(match[1])); - if (!/Save `[^`]+`/.test(lane.text)) { - fail(lane.n, `${prTitle}: lane ${match[1]} names no screenshot`); - } else if (!lane.text.includes("Pass when")) { - fail(lane.n, `${prTitle}: lane ${match[1]} has no pass predicate`); - } + if (!match || match[1] === undefined) { + fail(lane.n, `${prTitle}: live box is not a lane`); + continue; + } + const laneNo = match[1]; + numbers.push(Number(laneNo)); + if (!namedScreenshot(lane.text, save)) { + fail(lane.n, `${prTitle}: lane ${laneNo} names no screenshot`); + } + if (!namedPassPredicate(lane.text, passWhen)) { + fail(lane.n, `${prTitle}: lane ${laneNo} has no pass predicate`); } } if (numbers.join(",") !== expected) { @@ -227,36 +467,82 @@ function checkLanes(prTitle, live, fail) { } } +/** + * @param {string} prTitle + * @param {{ n: number, lines: { n: number, text: string, code: boolean }[] }} heading + * @param {PrBlock} spec + * @param {(line: number, message: string) => void} fail + */ function checkOrderedLeads(prTitle, heading, spec, fail) { - const items = boxes(heading.lines).map((box) => box.text.split(" ")[0]); - const expected = spec.leads.join("|"); - if (items.join("|") !== expected) { + const leads = spec.leads; + if (!leads) return; + const items = topBoxes(heading.lines); + const expected = leads.map((item) => item.lead).join("|"); + const observed = items.map((box) => box.text.split(" ")[0]).join("|"); + if (observed !== expected) { fail( heading.n, - `${prTitle}: perf boxes are [${items.join(", ")}], expected [${spec.leads.join(", ")}]`, + `${prTitle}: perf boxes are [${items.map((box) => box.text.split(" ")[0]).join(", ")}], expected [${leads.map((item) => item.lead).join(", ")}]`, ); } + for (const box of items) { + const specLead = leads.find((item) => box.text.startsWith(item.lead)); + if (!specLead) continue; + const rest = box.text.slice(specLead.lead.length).trim(); + if (hasPayload(rest, specLead.payload)) continue; + const why = + specLead.payload === "numeric" + ? "names no numeric failure threshold" + : specLead.payload === "trunk-first" + ? "names no trunk-first baseline" + : "has no payload"; + fail(box.n, `${prTitle}: ${specLead.lead} ${why}`); + } } +/** + * @param {string} prTitle + * @param {{ n: number, rest: string, lines: { n: number, text: string, code: boolean }[] }} gate + * @param {PrBlock} spec + * @param {(line: number, message: string) => void} fail + */ function checkGate(prTitle, gate, spec, fail) { - const gateBoxes = boxes(gate.lines); - if (gate.rest.startsWith("None.")) { - if (gateBoxes.length) { + const nonePattern = spec.nonePattern; + const gatedRest = spec.gatedRest; + const gatedStarts = spec.gatedStarts; + if (!nonePattern || gatedRest === undefined || !gatedStarts) return; + const all = boxes(gate.lines); + if (nonePattern.test(gate.rest)) { + if (all.length > 0) { fail(gate.n, `${prTitle}: Review gate says None but has boxes`); } return; } - const text = gate.lines.map((line) => line.text).join("\n"); - if (gateBoxes.length === 0) { - fail(gate.n, `${prTitle}: Review gate has no box`); - } - for (const word of spec.words) { - if (!text.includes(word)) { - fail(gate.n, `${prTitle}: Review gate lacks "${word}"`); - } + const texts = topBoxes(gate.lines).map((box) => box.text); + const restOk = gate.rest === gatedRest; + const boxesOk = + texts.length === gatedStarts.length && + gatedStarts.every((start, i) => (texts[i] ?? "").startsWith(start)); + if (restOk && boxesOk) return; + if (restOk) { + fail( + gate.n, + `${prTitle}: Review gate boxes are [${texts.map((text) => text.split(" ")[0]).join(", ")}], expected [${gatedStarts.join(", ")}]`, + ); + return; } + fail( + gate.n, + `${prTitle}: Review gate is not the gated evidence flow or "None. is not review-gated."`, + ); } +/** + * @param {string} prTitle + * @param {{ name: string, n: number, rest: string, lines: { n: number, text: string, code: boolean }[] }} heading + * @param {PrBlock} spec + * @param {(line: number, message: string) => void} fail + */ function checkBlock(prTitle, heading, spec, fail) { if (spec.opensWithRule && !heading.rest.startsWith(CONTRACT.rule)) { fail(heading.n, `${prTitle}: ${spec.name} does not open with the rule`); @@ -266,6 +552,9 @@ function checkBlock(prTitle, heading, spec, fail) { if (heading.rest === "") { fail(heading.n, `${prTitle}: Depends on names nothing`); } + if (boxes(heading.lines).length > 0) { + fail(heading.n, `${prTitle}: Depends on. has a box`); + } break; case "any": if (boxes(heading.lines).length === 0) { @@ -273,7 +562,7 @@ function checkBlock(prTitle, heading, spec, fail) { } break; case "lanes": - checkLanes(prTitle, heading, fail); + checkLanes(prTitle, heading, spec, fail); break; case "ordered-leads": checkOrderedLeads(prTitle, heading, spec, fail); @@ -284,6 +573,10 @@ function checkBlock(prTitle, heading, spec, fail) { } } +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }} section + * @param {{ name: string, lines: { n: number, text: string, code: boolean }[] }[]} heads + */ function reportLine(section, heads) { const counts = Object.fromEntries( heads.map((head) => [head.name, boxes(head.lines).length]), @@ -296,6 +589,10 @@ function reportLine(section, heads) { return `${section.title} boxes=${total} ${cells.join(" ")}`; } +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }} section + * @param {(line: number, message: string) => void} fail + */ function checkPrSection(section, fail) { const heads = headingsOf(section); const names = heads.map((head) => head.name); @@ -313,21 +610,57 @@ function checkPrSection(section, fail) { return reportLine(section, heads); } -function checkTail(sections, close, fail) { +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} sections + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] } | undefined} program + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] } | undefined} close + * @param {(line: number, message: string) => void} fail + */ +function collectPrSections(sections, program, close, fail) { + if (!program || !close) return []; + const programIndex = sections.indexOf(program); const closeIndex = sections.indexOf(close); - if (closeIndex === -1) return; - const tail = sections.slice(closeIndex + 1); - for (const section of tail) { - if (!section.title.startsWith("Appendix")) { - fail( - section.n, - `"## ${section.title}" after Close the program is not an appendix`, - ); + if (programIndex === -1 || closeIndex === -1) return []; + if (closeIndex <= programIndex) { + fail(1, "no PR sections between Program checklist and Close the program"); + return []; + } + const between = sections.slice(programIndex + 1, closeIndex); + if (between.length === 0) { + fail(1, "no PR sections between Program checklist and Close the program"); + return []; + } + /** @type {typeof between} */ + const prs = []; + for (const section of between) { + if (!CONTRACT.prTitle.test(section.title)) { + fail(section.n, `"## ${section.title}" is not a PR title`); + continue; } + prs.push(section); } - if (!tail.some((section) => section.title.includes("Prototype evidence"))) { - fail(close.n, 'no "## Appendix ... Prototype evidence" section'); + if (prs.length === 0) { + fail(1, "no PR sections between Program checklist and Close the program"); } + return prs; +} + +/** + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] }[]} sections + * @param {{ title: string, n: number, body: { n: number, text: string, code: boolean }[] } | undefined} close + * @param {(line: number, message: string) => void} fail + */ +function checkAppendices(sections, close, fail) { + if (!close) return; + const closeIndex = sections.indexOf(close); + if (closeIndex === -1) return; + const tail = sections.slice(closeIndex + 1); + const titles = tail.map((section) => section.title); + if (titles.join("|") === CONTRACT.appendices.join("|")) return; + fail( + close.n, + `appendices are [${titles.join(", ")}], expected [${CONTRACT.appendices.join(", ")}]`, + ); } /** @@ -336,33 +669,28 @@ function checkTail(sections, close, fail) { * @returns {CheckResult} */ export function checkPlan(raw, file = "plan.md") { + /** @type {string[]} */ const problems = []; + /** @param {number} line @param {string} message */ const fail = (line, message) => problems.push(`${file}:${line}: ${message}`); const lines = toLines(raw); checkPunctuation(lines, fail); const sections = toSections(lines); checkPreamble(lines, sections, fail); + checkUnexpectedHeadings(sections, fail); - const program = findSection(sections, "Program checklist"); - if (!program) fail(1, 'no "## Program checklist" section'); + const program = findSection(sections, CONTRACT.program); + if (!program) fail(1, `no "## ${CONTRACT.program}" section`); else checkProgram(program, fail); + checkLowerHeadings(sections, program, fail); - const close = findSection(sections, "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 close = findSection(sections, CONTRACT.close); + if (!close) fail(1, `no "## ${CONTRACT.close}" section`); + const prSections = collectPrSections(sections, program, close, fail); const report = []; for (const section of prSections) report.push(checkPrSection(section, fail)); - if (close) checkTail(sections, close, fail); + checkAppendices(sections, close, fail); return { problems, @@ -378,19 +706,20 @@ export function checkPlan(raw, file = "plan.md") { */ export function extractSkeleton(playbookRaw) { const lines = playbookRaw.split(/\r?\n/); + /** @type {number[]} */ const starts = []; for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith("````markdown")) starts.push(i); + if ((lines[i] ?? "").startsWith("````markdown")) starts.push(i); } if (starts.length !== 1) { throw new Error( `expected exactly one fenced skeleton, found ${starts.length}`, ); } - const start = starts[0]; + const start = starts[0] ?? -1; let end = -1; for (let i = start + 1; i < lines.length; i++) { - if (/^````\s*$/.test(lines[i])) { + if (/^````\s*$/.test(lines[i] ?? "")) { end = i; break; } @@ -402,14 +731,14 @@ export function extractSkeleton(playbookRaw) { function isCliEntry() { const entry = process.argv[1]; if (!entry) return false; - try { - return fileURLToPath(import.meta.url) === resolve(entry); - } catch { - return false; - } + return fileURLToPath(import.meta.url) === fs.realpathSync(resolve(entry)); } function main() { + if (process.argv.length !== 3) { + console.error("Usage: node check-plan.mjs "); + process.exit(2); + } const file = process.argv[2]; if (!file) { console.error("Usage: node check-plan.mjs "); diff --git a/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts b/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts index d3c7069..9dc4c26 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/check-plan.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { CONTRACT, checkPlan, extractSkeleton } from "./check-plan.mjs"; @@ -17,14 +17,13 @@ const FORBIDDEN_FENCE = [ "grok-4.6-fast-xhigh", "~/.claude", "../references/", + "/tmp", + "her explicit go", + "On her go", ]; -type CheckPlanResult = { - readonly problems: string[]; - readonly report: string[]; - readonly prCount: number; - readonly ok: boolean; -}; +const INTRO = + ""; function replaceOnce(source: string, target: string, replacement: string): string { const first = source.indexOf(target); @@ -47,18 +46,32 @@ function removePhrase(source: string, phrase: string): string { return source.split(phrase).join(""); } +function introLines(count: number): string { + return Array.from( + { length: count }, + (_, index) => `Intro line ${index + 1} states the change.`, + ).join("\n"); +} + function contractPhrases(): string[] { const phrases: string[] = [ CONTRACT.rule, CONTRACT.laneSentence, + CONTRACT.howToRead, + CONTRACT.program, + CONTRACT.close, + ...CONTRACT.appendices, ...CONTRACT.programSections, - ...CONTRACT.howToReadMarkers.filter((marker): marker is string => typeof marker === "string"), - ...CONTRACT.programMarkers.filter((marker): marker is string => typeof marker === "string"), + ...CONTRACT.howToReadMarkers, + ...CONTRACT.programMarkers, ...CONTRACT.prBlocks.map((block) => block.name), ]; for (const block of CONTRACT.prBlocks) { - if (block.leads) phrases.push(...block.leads); - if (block.words) phrases.push(...block.words); + if (block.leads) phrases.push(...block.leads.map((item) => item.lead)); + if (block.save) phrases.push(block.save); + if (block.passWhen) phrases.push(block.passWhen); + if (block.gatedRest) phrases.push(block.gatedRest); + if (block.gatedStarts) phrases.push(...block.gatedStarts); if (block.shape === "lanes") { for (let i = 1; i <= CONTRACT.laneCount; i++) { phrases.push(`Lane ${i}.`); @@ -69,15 +82,15 @@ function contractPhrases(): string[] { } function problemsOf(source: string, file = "plan.md"): string[] { - return (checkPlan(source, file) as CheckPlanResult).problems; + return checkPlan(source, file).problems; } -function runNode(args: readonly string[]): { +function runNode(args: readonly string[], script = SCRIPT): { readonly status: number; readonly stdout: string; readonly stderr: string; } { - const result = Bun.spawnSync(["node", SCRIPT, ...args], { + const result = Bun.spawnSync(["node", script, ...args], { stdout: "pipe", stderr: "pipe", }); @@ -107,7 +120,7 @@ const skeleton = extractSkeleton(playbook); describe("check-plan", () => { it("accepts the extracted playbook skeleton", () => { - const result = checkPlan(skeleton, "skeleton.md") as CheckPlanResult; + const result = checkPlan(skeleton, "skeleton.md"); expect(result.problems).toEqual([]); expect(result.ok).toBe(true); expect(result.prCount).toBe(1); @@ -116,7 +129,7 @@ describe("check-plan", () => { }); it("rejects the playbook file as checker input", () => { - const result = checkPlan(playbook, "multi-phase-plan.md") as CheckPlanResult; + const result = checkPlan(playbook, "multi-phase-plan.md"); expect(result.ok).toBe(false); expect(result.problems.some((problem) => problem.includes("no H1 title"))).toBe(true); }); @@ -125,7 +138,7 @@ describe("check-plan", () => { for (const item of FORBIDDEN_FENCE) { expect(skeleton.includes(item), item).toBe(false); } - expect(/30[- ]minute/.test(skeleton)).toBe(true); + expect(skeleton).toContain("30-minute"); expect(skeleton).toContain(CONTRACT.laneSentence); }); @@ -133,7 +146,7 @@ describe("check-plan", () => { for (const phrase of contractPhrases()) { expect(skeleton.includes(phrase), phrase).toBe(true); const mutated = removePhrase(skeleton, phrase); - expect(problemsOf(mutated).length).toBeGreaterThan(0); + expect(problemsOf(mutated).length, phrase).toBeGreaterThan(0); } }); @@ -147,16 +160,65 @@ describe("check-plan", () => { - [ ] Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator's click.`, "**Review gate.** None. PR1 is not review-gated.", ); - const result = checkPlan(ungated, "ungated.md") as CheckPlanResult; + const result = checkPlan(ungated, "ungated.md"); expect(result.problems).toEqual([]); }); - it.each([ + it("accepts a nine-line intro", () => { + const mutated = replaceOnce(skeleton, INTRO, introLines(CONTRACT.introMaxNonBlank)); + expect(problemsOf(mutated)).toEqual([]); + }); + + it("accepts nested sub-boxes under live and perf", () => { + const withLaneNest = replaceOnce( + skeleton, + "- [ ] Lane 3. Save `.png`. Pass when .\n", + "- [ ] Lane 3. Save `.png`. Pass when .\n - [ ] Reset cookies first.\n", + ); + const mutated = replaceOnce( + withLaneNest, + "- [ ] Probe. \n", + "- [ ] Probe. \n - [ ] Warm the cache first.\n", + ); + expect(problemsOf(mutated)).toEqual([]); + }); + + it("accepts capital-X checked boxes", () => { + const mutated = replaceOnce( + skeleton, + "- [ ] Lane 1. Save `.png`. Pass when .", + "- [X] Lane 1. Save `.png`. Pass when .", + ); + expect(problemsOf(mutated)).toEqual([]); + }); + + it("reports a missing screenshot and an empty pass predicate independently", () => { + const mutated = replaceOnce( + skeleton, + "Lane 5. Save `.png`. Pass when .", + "Lane 5. ", + ); + const problems = problemsOf(mutated).join("\n"); + expect(problems).toContain("lane 5 names no screenshot"); + expect(problems).toContain("lane 5 has no pass predicate"); + }); + + const rejections: ReadonlyArray = [ [ "a missing Program checklist", replaceOnce(skeleton, "## Program checklist", "## Program list"), "no \"## Program checklist\" section", ], + [ + "a missing How to read this heading", + replaceOnce(skeleton, "## How to read this", "## How to parse this"), + "no \"## How to read this\" section", + ], + [ + "a missing Close the program heading", + replaceOnce(skeleton, "## Close the program", "## Shut the program"), + "no \"## Close the program\" section", + ], [ "reordered program and PR headings", replaceOnce( @@ -188,6 +250,15 @@ describe("check-plan", () => { replaceOnce(skeleton, "**Depends on.** ", "**Depends on.**"), "Depends on names nothing", ], + [ + "Depends on with a box", + replaceOnce( + skeleton, + "**Depends on.** \n", + "**Depends on.** \n\n- [ ] Merge PR0 first.\n", + ), + "Depends on. has a box", + ], [ "Files with no box", replaceOnce( @@ -232,6 +303,15 @@ describe("check-plan", () => { ), "lane 4 names no screenshot", ], + [ + "a fake screenshot path", + replaceOnce( + skeleton, + "Lane 4. Save `.png`. Pass when .", + "Lane 4. Save `notes.txt`. Pass when .", + ), + "lane 4 names no screenshot", + ], [ "a lane with no pass predicate", replaceOnce( @@ -241,6 +321,15 @@ describe("check-plan", () => { ), "lane 7 has no pass predicate", ], + [ + "an empty pass predicate", + replaceOnce( + skeleton, + "Lane 7. Save `.png`. Pass when .", + "Lane 7. Save `.png`. Pass when.", + ), + "lane 7 has no pass predicate", + ], [ "a live box that is not a lane", replaceOnce( @@ -261,7 +350,7 @@ describe("check-plan", () => { ], [ "incomplete perf evidence", - replaceOnce(skeleton, "- [ ] Rule. \n", ""), + replaceOnce(skeleton, "- [ ] Rule. \n", ""), "perf boxes are", ], [ @@ -277,6 +366,38 @@ describe("check-plan", () => { ), "expected [Metric., Probe., Baseline., Rule.]", ], + [ + "an empty Metric payload", + replaceOnce(skeleton, "- [ ] Metric. ", "- [ ] Metric."), + "Metric. has no payload", + ], + [ + "an empty Probe payload", + replaceOnce( + skeleton, + "- [ ] Probe. ", + "- [ ] Probe.", + ), + "Probe. has no payload", + ], + [ + "a Baseline without trunk-first wording", + replaceOnce( + skeleton, + "- [ ] Baseline. Record the trunk first.", + "- [ ] Baseline. Record the .", + ), + "Baseline. names no trunk-first baseline", + ], + [ + "a Rule without a numeric threshold", + replaceOnce( + skeleton, + "- [ ] Rule. ", + "- [ ] Rule. Head against trunk.", + ), + "Rule. names no numeric failure threshold", + ], [ "a None review gate that keeps boxes", replaceOnce( @@ -286,6 +407,32 @@ describe("check-plan", () => { ), "Review gate says None but has boxes", ], + [ + "a malformed None review gate", + replaceOnce( + skeleton, + `**Review gate.** The operator reviews before merge. + +- [ ] Copy lane screenshots into \`/-review-.png\`. +- [ ] Record a 30 to 60 second video of the change on a live lane. Save it as \`/-review.mp4\`. +- [ ] Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator's click.`, + "**Review gate.** None.", + ), + "Review gate is not the gated evidence flow or \"None. is not review-gated.\"", + ], + [ + "a keyword-soup review gate", + replaceOnce( + skeleton, + `**Review gate.** The operator reviews before merge. + +- [ ] Copy lane screenshots into \`/-review-.png\`. +- [ ] Record a 30 to 60 second video of the change on a live lane. Save it as \`/-review.mp4\`. +- [ ] Post the screenshots and the video in chat. Stop at merge-ready. Wait for the operator's click.`, + "**Review gate.** screenshot video operator", + ), + "Review gate is not the gated evidence flow or \"None. is not review-gated.\"", + ], [ "a gated review missing video", replaceOnce( @@ -301,7 +448,7 @@ describe("check-plan", () => { - [ ] Record a 30 to 60 second clip of the change on a live lane. Save it as \`/-review.mp4\`. - [ ] Post the screenshots in chat. Stop at merge-ready. Wait for the operator's click.`, ), - 'Review gate lacks "video"', + "expected [Copy lane, Record a 30 to 60 second video, Post the screenshots and the video in chat]", ], [ "a non-appendix tail heading", @@ -310,7 +457,20 @@ describe("check-plan", () => { "## Appendix A. Prototype evidence", "## Extra notes\n\n## Appendix A. Prototype evidence", ), - 'after Close the program is not an appendix', + "appendices are [Extra notes, Appendix A. Prototype evidence, Appendix B. Alternatives rejected, Appendix C. Risks, Appendix D. Links and reading list], expected [Appendix A. Prototype evidence, Appendix B. Alternatives rejected, Appendix C. Risks, Appendix D. Links and reading list]", + ], + [ + "reordered appendices", + replaceOnce( + replaceOnce( + replaceOnce(skeleton, "## Appendix A. Prototype evidence", "## Appendix TMP"), + "## Appendix B. Alternatives rejected", + "## Appendix A. Prototype evidence", + ), + "## Appendix TMP", + "## Appendix B. Alternatives rejected", + ), + "expected [Appendix A. Prototype evidence, Appendix B. Alternatives rejected, Appendix C. Risks, Appendix D. Links and reading list]", ], [ "a missing prototype appendix", @@ -319,7 +479,57 @@ describe("check-plan", () => { "## Appendix A. Prototype evidence", "## Appendix A. Other evidence", ), - 'no "## Appendix ... Prototype evidence" section', + "expected [Appendix A. Prototype evidence, Appendix B. Alternatives rejected, Appendix C. Risks, Appendix D. Links and reading list]", + ], + [ + "an unknown heading before Close the program", + replaceOnce( + skeleton, + "## Close the program", + "## Extra notes\n\n## Close the program", + ), + "\"## Extra notes\" is not a PR title", + ], + [ + "an unexpected H3 inside a PR", + replaceOnce( + skeleton, + "**Depends on.** ", + "### Hidden work\n\n**Depends on.** ", + ), + '"### Hidden work" is an unexpected heading', + ], + [ + "a second H1", + replaceOnce( + skeleton, + "## How to read this", + "# Extra program\n\n## How to read this", + ), + "found 2 H1 titles, exactly one required", + ], + [ + "a malformed PR heading", + replaceOnce( + skeleton, + "## ()", + "## Task without an id", + ), + "\"## Task without an id\" is not a PR title", + ], + [ + "an extra program H3", + replaceOnce( + skeleton, + "### Spawn owners", + "### Extra owners\n\n### Spawn owners", + ), + "Program checklist H3s are", + ], + [ + "a ten-line intro", + replaceOnce(skeleton, INTRO, introLines(CONTRACT.introMaxNonBlank + 1)), + "intro is 10 lines, under ten required", ], [ "a long dash", @@ -345,8 +555,10 @@ describe("check-plan", () => { ), "Verify, unit. does not open with the rule", ], - ])("rejects %s", (_name, mutated, expected) => { - expect(problemsOf(mutated as string).join("\n")).toContain(expected as string); + ]; + + it.each(rejections)("rejects %s", (_name, mutated, expected) => { + expect(problemsOf(mutated).join("\n")).toContain(expected); }); it("CLI exits 0 on the extracted skeleton", async () => { @@ -371,10 +583,30 @@ describe("check-plan", () => { expect(result.stderr).toContain("Usage: node check-plan.mjs "); }); + it("CLI exits 2 when extra arguments are present", async () => { + const file = await writePlan(skeleton); + const result = runNode([file, "extra"]); + expect(result.status).toBe(2); + expect(result.stderr).toContain("Usage: node check-plan.mjs "); + expect(result.stdout).toBe(""); + }); + it("CLI exits 2 when the plan file cannot be read", () => { const result = runNode([join(tmpdir(), "check-plan-missing.md")]); expect(result.status).toBe(2); expect(result.stderr.length).toBeGreaterThan(0); expect(result.stderr).not.toContain("Usage: node check-plan.mjs "); }); + + it("CLI runs through a symlinked install path", async () => { + const file = await writePlan(skeleton); + const directory = await mkdtemp(join(tmpdir(), "check-plan-link-")); + directories.push(directory); + const link = join(directory, "check-plan.mjs"); + await symlink(SCRIPT, link); + const result = runNode([file], link); + expect(result.status).toBe(0); + expect(result.stdout).toContain("1 PR sections, 0 problems"); + expect(result.stderr).toBe(""); + }); }); diff --git a/plugins/pstack/skills/poteto-mode/scripts/check-plan.tsconfig.json b/plugins/pstack/skills/poteto-mode/scripts/check-plan.tsconfig.json new file mode 100644 index 0000000..00559cc --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/check-plan.tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "allowImportingTsExtensions": true, + "module": "esnext", + "moduleResolution": "bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "esnext", + "types": ["bun-types"], + "maxNodeModuleJsDepth": 0 + }, + "include": ["check-plan.mjs", "check-plan.test.ts"] +} diff --git a/plugins/pstack/skills/poteto-mode/scripts/package.json b/plugins/pstack/skills/poteto-mode/scripts/package.json index 8995fd7..069d0bc 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/package.json +++ b/plugins/pstack/skills/poteto-mode/scripts/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "test": "\"$npm_execpath\" test --parallel bootstrap orch watch-pr runner check-plan", - "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict && tsc --project runner/tsconfig.json --noEmit --strict" + "typecheck": "tsc --project watch-pr/tsconfig.json --noEmit --strict && tsc --project runner/tsconfig.json --noEmit --strict && tsc --project check-plan.tsconfig.json --noEmit --strict" }, "dependencies": { "commander": "14.0.0"