diff --git a/.gitignore b/.gitignore index e10c5db..3ddcd02 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ node_modules/ __pycache__/ *.pyc e2e/.run/ -.tmp-*/ +.tmp*/ +_fm*-proof* diff --git a/docs/AGENTS.md b/docs/AGENTS.md index abf6b78..2153910 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -13,6 +13,7 @@ Human docs for this plugin. First-run is the README (`omp plugin link ./` and Cl - README owns first-run for both hosts: `omp plugin link ./` and Claude plugin install (`marketplace add` + `pstack@pstack`, or `claude --plugin-dir ./`). Do not add `docs/getting-started.md` or teach `omp -e` as an install path. - README may link the numbered guide. - Guide on-disk skill links use `skills/do-*` so they resolve in this tree. Slash names in guide prose may still say `/how`; the live omp surface is `/skill:do-how`, Claude is `/pstack:do-how`. `/poteto-mode` stays unprefixed on omp. +- Setup guide (`01-setup.md`) matches `do-setup-pstack`: `/model` Roles and `/agents` only. Do not teach `pstack-models.mdc`. - Operator install matches the root rail Native omp and Native Claude Code contracts. ## Work Guidance diff --git a/docs/guide/01-setup.md b/docs/guide/01-setup.md index ad0a167..7f4094f 100644 --- a/docs/guide/01-setup.md +++ b/docs/guide/01-setup.md @@ -41,11 +41,11 @@ Run: On Claude Code the same skill is [`/pstack:do-setup-pstack`](../../skills/do-setup-pstack/SKILL.md). -[`/skill:do-setup-pstack`](../../skills/do-setup-pstack/SKILL.md) detects the models you have access to, shows you each role (code delegates, judgment, the review panels), and asks what you want. Answer the questions. It writes `~/.cursor/rules/pstack-models.mdc`, a small rule every pstack skill reads. +[`/skill:do-setup-pstack`](../../skills/do-setup-pstack/SKILL.md) lists each plugin agent and the `@role` it uses. Routing lives in `/model` → Roles and each agent's `model: "@role"` line, or an override in `/agents`. The skill writes nothing. Re-run it to re-list. -You only override what you care about. A role with no line in the rule keeps the skill's default. To restore a default later, delete that role's line, or just run `/skill:do-setup-pstack` again. +Want a different concrete model for a role? Open `/model` → Roles and change that `@role`. Want one agent on a different role or a concrete selector? Open `/agents` and override that agent. New `task` calls pick it up. -You might be wondering what happens if you use Auto. Set a role to `inherit-parent` or `auto` and pstack omits the subagent `model` field, so the subagent inherits your parent chat model. Both values mean the same thing, and neither is a model slug. For a panel role the value is a list, and one subagent runs per entry, so the list length sets the panel size. Setup also configures `swarm workers`, the default model for every `/skill:do-swarm` worker unless a race names a model for each arm. +N parallel models is not available from one agent definition. Diversity is prompt, path, or label unless you change that agent's `model` in `/agents`. ## Accept the verification offer, or don't @@ -53,8 +53,6 @@ At the end of setup, `/skill:do-setup-pstack` looks for a way to prove app behav Say yes and it writes `.cursor/skills/verify-/`, a project-local skill that teaches agents to drive your app the way a user does. It proves the skill works once before handing it over. Say no and setup moves on. You can run `/skill:do-create-verification-skill` yourself any time. [Verify and ship](./06-verify-and-ship.md#create-a-project-verification-skill) covers when it earns its place. -After setup, start a new chat. The model rule applies to new sessions. - ## Run your first task Pick something real but small, and describe it the way you'd describe it to a colleague: diff --git a/docs/guide/10-recipes-and-pitfalls.md b/docs/guide/10-recipes-and-pitfalls.md index 6c24a9b..32121c7 100644 --- a/docs/guide/10-recipes-and-pitfalls.md +++ b/docs/guide/10-recipes-and-pitfalls.md @@ -85,7 +85,7 @@ That's the whole prompt. [`/bro`](../../skills/do-bro/SKILL.md) restates the las - **Parallel agents in one worktree.** They overwrite each other and the diff becomes archaeology. Say "own worktree per attempt" and the isolation is free. - **Using `/arena` for coverage.** `/arena` repeats one design or code brief, then picks a base and grafts the best parts. `/swarm` partitions slices or declared race arms and aggregates one report. - **Accepting every review comment.** Bots and humans both file real catches and noise in one list. `/interrogate` sorts findings into act-on and dismissed buckets with reasons, and you can override either way. -- **Treating `auto` as a model slug.** `auto` and `inherit-parent` mean "omit the model field so the subagent inherits the parent chat model." [Setup](./01-setup.md) covers the roles. +- **Editing routing files by hand.** Change a role in `/model` → Roles, or override one agent in `/agents`. Do not pass `model` on `task`. [Setup](./01-setup.md) covers it. - **Reporting success off a green build.** A build proves it compiles. Ask for the real command, flow, stored value, or profile, and expect the evidence in the reply. - **Writing a `SKILL.md` freehand.** Route it through the [Authoring or modifying a skill playbook](../../skills/do-poteto-mode/playbooks/authoring-a-skill.md) so validation and review happen. diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index ef4c2d4..1745b31 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -7,7 +7,7 @@ Prove the plugin without chat-only guesses. Static doctor/lint + Vitest on every ## Ownership - `run.py` — live RPC evals; `--skip-rpc` is static-only -- `ci_static.py` — doctor, frontmatter, size, product pack, Claude plugin layout +- `ci_static.py` — doctor, frontmatter, size, product pack, Claude plugin layout, packaging scratch - `unit/` — Vitest helpers and tests - `ci-shims/` — `node:fs` stub for `tsc` without `@types/node` @@ -21,6 +21,7 @@ Prove the plugin without chat-only guesses. Static doctor/lint + Vitest on every - Do not keep a repo `.omp/skills` symlink. `static_install_docs` and `unit/product-capabilities.test.ts` assert filesystem absence, not only README text. - Claude components stay at plugin root. `.claude-plugin/` may hold `plugin.json` and `marketplace.json` only — not `skills/`. - Official skill vendor-sync tests (`unit/sync-skills.test.ts`) stay offline (`--from` fixture). Do not clone `cursor/plugins` on CI. +- Do not keep `.tmp-*` dirs or `e2e/_fm*-proof*` in the tree. Gitignore those names. `ci_static.py --quality` packaging asserts absence. ## Work Guidance diff --git a/e2e/_fm230-proof.mjs b/e2e/_fm230-proof.mjs deleted file mode 100644 index 012eb82..0000000 --- a/e2e/_fm230-proof.mjs +++ /dev/null @@ -1,382 +0,0 @@ -import { spawn } from "node:child_process"; -import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs"; -import { createInterface } from "node:readline"; -import { homedir } from "node:os"; - -const ROOT = "/Users/ed/Developer/pstack"; -const NEEDLES = [ - "# Create a verification skill", - "Every serious project needs a scripted way to drive the real app", -]; -const TARGET = "skill:ps-create-verification-skill"; -const OLD = "skill:create-verification-skill"; -const SLASH = "/skill:ps-create-verification-skill"; -const SKILL_SUFFIX = "pstack/skills/ps-create-verification-skill/SKILL.md"; -const AGENTS = homedir() + "/.agents"; -const OUT = "/Users/ed/Desktop/fm230-proof-out.json"; -const work = "/Users/ed/Desktop/fm230-proof-work"; -const sessionDir = work + "/sessions"; - -rmSync(work, { recursive: true, force: true }); -mkdirSync(sessionDir, { recursive: true }); - -function dumpBlob(obj) { - try { - return JSON.stringify(obj); - } catch { - return String(obj); - } -} - -function needlesIn(text) { - const hits = NEEDLES.filter((n) => text.includes(n)); - const leftover = text.includes(SLASH); - return { hits, leftover }; -} - -function collectPaths(node, acc) { - if (!node || typeof node !== "object") return acc; - if (Array.isArray(node)) { - for (const x of node) collectPaths(x, acc); - return acc; - } - if (typeof node.path === "string") acc.push({ path: node.path, keys: Object.keys(node) }); - if (node.details && typeof node.details === "object" && typeof node.details.path === "string") { - acc.push({ path: node.details.path, via: "details.path", keys: Object.keys(node) }); - } - for (const v of Object.values(node)) { - if (v && typeof v === "object") collectPaths(v, acc); - } - return acc; -} - -function scanJsonl(path) { - if (!path || !existsSync(path)) { - return { exists: false, path: String(path), lines: 0, hits: [], user_msgs: [], skill_prompts: [], paths: [] }; - } - const lines = readFileSync(path, "utf8").split(/\n/); - const hits = []; - const user_msgs = []; - const skill_prompts = []; - const paths = []; - lines.forEach((line, i) => { - if (!line) return; - const { hits: nh, leftover } = needlesIn(line); - if (nh.length || leftover || line.includes("create-verification-skill") || line.includes("skill-prompt") || line.includes("skill_prompt")) { - hits.push({ i, needles: nh, leftover_slash: leftover, snip: line.slice(0, 800) }); - } - let row; - try { - row = JSON.parse(line); - } catch { - return; - } - const t = row.type || row.role || row.customType; - if (String(t).toLowerCase().includes("skill") || dumpBlob(row).includes("skill-prompt")) { - skill_prompts.push({ i, type: t, snip: dumpBlob(row).slice(0, 1200) }); - } - collectPaths(row, paths); - let text = ""; - if (typeof row.message === "string") text = row.message; - else if (typeof row.text === "string") text = row.text; - else if (typeof row.content === "string") text = row.content; - if (t === "user" || t === "message" || t === "prompt" || row.role === "user") { - user_msgs.push({ i, type: t, snip: (text || line).slice(0, 800) }); - } - }); - return { exists: true, path, lines: lines.length, hits, user_msgs: user_msgs.slice(0, 20), skill_prompts: skill_prompts.slice(0, 20), paths: paths.slice(0, 40) }; -} - -function send(proc, obj) { - proc.stdin.write(JSON.stringify(obj) + "\n"); -} - -function readUntil(proc, rl, pred, timeoutMs) { - return new Promise((resolve, reject) => { - const frames = []; - const timer = setTimeout(() => { - cleanup(); - reject(new Error("timeout; last frames: " + dumpBlob(frames.slice(-8)).slice(0, 2000))); - }, timeoutMs); - function onLine(line) { - if (!line.trim()) return; - let frame; - try { - frame = JSON.parse(line); - } catch { - frame = { _unparsed: line.slice(0, 500) }; - frames.push(frame); - return; - } - frames.push(frame); - if (frame.type === "extension_ui_request") { - const rid = frame.id; - const method = frame.method; - if (method === "confirm") send(proc, { type: "extension_ui_response", id: rid, confirmed: true }); - else if (["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"].includes(method)) { - send(proc, { type: "extension_ui_response", id: rid, value: "ok" }); - } else { - send(proc, { type: "extension_ui_response", id: rid, cancelled: true }); - } - } - if (pred(frame, frames)) { - cleanup(); - resolve(frames); - } - } - function onExit(code) { - cleanup(); - reject(new Error("omp exited " + code + " after " + frames.length + " frames")); - } - function cleanup() { - clearTimeout(timer); - rl.off("line", onLine); - proc.off("exit", onExit); - } - rl.on("line", onLine); - proc.on("exit", onExit); - }); -} - -function waitResponse(proc, rl, reqId, timeoutMs) { - return readUntil(proc, rl, (f) => f.type === "response" && f.id === reqId, timeoutMs).then((frames) => ({ - resp: frames[frames.length - 1], - frames, - })); -} - -function pluginDirInArgv(argv) { - const i = argv.indexOf("--plugin-dir"); - if (i < 0) return null; - return argv[i + 1]; -} - -function argvHasPersonalPluginDir(argv) { - return argv.some((a) => a === "/Users/ed/Developer/pstack") && argv.includes("--plugin-dir"); -} - -const omp = "/opt/homebrew/bin/omp"; -const pluginDirCandidates = [null]; - -async function runOnce(_pluginDir) { - const args = [ - "-e", - "./extensions/pstack.ts", - "--mode", - "rpc", - "--session-dir", - sessionDir, - "--thinking", - "off", - "--no-title", - "--auto-approve", - "--no-tools", - "--cwd", - ".", - "--max-time", - "45", - ]; - const cmd = [omp, ...args]; - const report = { - cmd, - argv: cmd, - plugin_dir: null, - cwd: ROOT, - session_dir: sessionDir, - ok: false, - }; - console.log("CMD", cmd.join(" ")); - console.log("CWD", ROOT); - console.log("no plugin-dir; -e file only"); - - const proc = spawn(omp, args, { cwd: ROOT, stdio: ["pipe", "pipe", "pipe"] }); - const rl = createInterface({ input: proc.stdout }); - let stderr = ""; - proc.stderr.on("data", (d) => { - stderr += d.toString(); - }); - - try { - await readUntil(proc, rl, (f) => f.type === "ready", 30000); - send(proc, { id: "proto", type: "negotiate_protocol", protocolVersion: 2 }); - try { - await waitResponse(proc, rl, "proto", 10000); - } catch (exc) { - report.proto_err = String(exc); - } - - send(proc, { id: "cmds", type: "get_available_commands" }); - const { resp } = await waitResponse(proc, rl, "cmds", 20000); - const data = resp.data || {}; - const commands = data.commands || []; - const names = commands.map((c) => (c && typeof c === "object" ? c.name : c)); - const skill_ps = names.filter((n) => typeof n === "string" && n.startsWith("skill:ps-")).sort(); - const skill_unprefixed = names - .filter((n) => typeof n === "string" && n.startsWith("skill:") && !n.startsWith("skill:ps-")) - .sort(); - const all_skill = names.filter((n) => typeof n === "string" && n.startsWith("skill:")).sort(); - const poteto = names.filter((n) => typeof n === "string" && n.toLowerCase().includes("poteto")); - report.get_available_commands = { - success: resp.success, - n_commands: names.length, - n_skill_ps: skill_ps.length, - n_skill_unprefixed: skill_unprefixed.length, - n_skill_all: all_skill.length, - has_skill_ps_create_verification_skill: names.includes(TARGET), - has_old_skill_create_verification_skill: names.includes(OLD), - skill_ps, - skill_unprefixed_sample: skill_unprefixed.slice(0, 30), - poteto_related: poteto, - has_poteto_mode_unprefixed: names.includes("poteto-mode"), - has_skill_ps_poteto_mode: names.includes("skill:ps-poteto-mode"), - sample_names: names.slice(0, 40), - }; - console.log(JSON.stringify(report.get_available_commands, null, 2)); - - send(proc, { id: "s0", type: "get_state" }); - const st0 = await waitResponse(proc, rl, "s0", 15000); - report.state0 = st0.resp.data || {}; - - send(proc, { id: "inj", type: "prompt", message: SLASH }); - let inj_frames = []; - try { - inj_frames = await readUntil( - proc, - rl, - (f) => - (f.type === "response" && f.id === "inj") || - (f.type === "prompt_result" && f.id === "inj") || - (f.type === "agent_end" && f.isTerminal !== false) || - NEEDLES.some((n) => dumpBlob(f).includes(n)) || - dumpBlob(f).includes("skill-prompt") || - dumpBlob(f).includes("skill_prompt"), - 60000 - ); - } catch (exc) { - report.inject_wait_err = String(exc); - inj_frames = []; - } - - const blob = dumpBlob(inj_frames); - const frameNeedles = needlesIn(blob); - const framePaths = []; - for (const f of inj_frames) collectPaths(f, framePaths); - const skillPromptFrames = inj_frames.filter((f) => { - const t = String(f.type || f.customType || ""); - const b = dumpBlob(f); - return t.toLowerCase().includes("skill") || b.includes("skill-prompt") || b.includes("skill_prompt") || b.includes("SKILL.md"); - }); - - report.inject_frames = { - n: inj_frames.length, - types: inj_frames.slice(-30).map((f) => f.type), - needle_hits: frameNeedles.hits, - leftover_slash_in_frames: frameNeedles.leftover, - paths: framePaths, - skill_prompt_snips: skillPromptFrames.slice(0, 10).map((f) => ({ type: f.type, id: f.id, snip: dumpBlob(f).slice(0, 1200) })), - }; - - send(proc, { id: "s1", type: "get_state" }); - try { - const st1 = await waitResponse(proc, rl, "s1", 15000); - report.state1 = st1.resp.data || {}; - } catch (exc) { - report.state1_err = String(exc); - report.state1 = {}; - } - - const sessionFile = (report.state1 && report.state1.sessionFile) || (report.state0 && report.state0.sessionFile); - const jsonl = scanJsonl(sessionFile); - report.jsonl = jsonl; - - const allPaths = [...framePaths, ...(jsonl.paths || [])]; - const detailsPaths = allPaths.map((p) => (typeof p === "string" ? p : p.path)).filter(Boolean); - const repoSkillPaths = detailsPaths.filter((p) => p.endsWith(SKILL_SUFFIX) && !p.includes("/.agents/")); - const agentsPaths = detailsPaths.filter((p) => p.includes("/.agents/") || p.startsWith(AGENTS)); - const details_path = repoSkillPaths[0] || agentsPaths[0] || detailsPaths.find((p) => p.includes("SKILL.md")) || detailsPaths[0] || null; - - const jsonlHits = []; - for (const h of jsonl.hits || []) jsonlHits.push(...(h.needles || [])); - const needle_yes = frameNeedles.hits.length > 0 || jsonlHits.length > 0; - - let user_has_literal_slash = false; - let user_has_needle = false; - for (const um of jsonl.user_msgs || []) { - const snip = um.snip || ""; - if (snip.includes(SLASH)) user_has_literal_slash = true; - if (NEEDLES.some((n) => snip.includes(n))) user_has_needle = true; - } - // leftover slash: user text still has the slash command and no skill body - const leftover_in_user = user_has_literal_slash && !user_has_needle; - const leftover_slash = leftover_in_user || (frameNeedles.leftover && !needle_yes); - - const path_ok = Boolean(details_path && details_path.includes("/Users/ed/Developer/pstack/skills/") && details_path.endsWith(SKILL_SUFFIX) && !details_path.includes("/.agents/") && !details_path.includes("/.omp/")); - const path_agents = Boolean(details_path && details_path.includes("/.agents/")); - - const inject_yes = Boolean(needle_yes && path_ok && !leftover_in_user && !path_agents); - - report.inject = { - yes: inject_yes, - needles_in_frames: frameNeedles.hits, - needles_in_jsonl: [...new Set(jsonlHits)].sort(), - needle_yes, - details_path, - details_paths: detailsPaths.slice(0, 20), - path_ok, - path_agents, - leftover_slash, - leftover_slash_in_user: leftover_in_user, - user_has_literal_slash, - user_has_needle, - }; - report.ok = Boolean( - report.get_available_commands.has_skill_ps_create_verification_skill && - report.get_available_commands.has_poteto_mode_unprefixed && - inject_yes - ); - console.log(JSON.stringify(report.inject, null, 2)); - return report; - } finally { - try { - proc.stdin.end(); - } catch {} - await new Promise((resolve) => { - const t = setTimeout(() => { - proc.kill(); - resolve(); - }, 5000); - proc.on("exit", () => { - clearTimeout(t); - resolve(); - }); - }); - report.stderr_tail = stderr.slice(-3000); - } -} - -const attempts = []; -let final = null; -try { - final = await runOnce(null); - attempts.push({ plugin_dir: null, ok: final.ok, ready: true, inject: final.inject, n_skill_ps: final.get_available_commands && final.get_available_commands.n_skill_ps }); -} catch (exc) { - attempts.push({ plugin_dir: null, ok: false, error: String(exc) }); - console.log("FAIL", String(exc)); -} - -const summary = { - attempts, - argv: final && final.argv, - cmd_joined: final && final.argv && final.argv.join(" "), - plugin_dir: final && final.plugin_dir, - cwd: ROOT, - inject: final && final.inject, - get_available_commands: final && final.get_available_commands, - ok: final && final.ok, - stderr_tail: final && final.stderr_tail, - jsonl: final && final.jsonl && { exists: final.jsonl.exists, path: final.jsonl.path, lines: final.jsonl.lines, n_hits: (final.jsonl.hits || []).length, n_skill_prompts: (final.jsonl.skill_prompts || []).length, paths: final.jsonl.paths }, - inject_frames: final && final.inject_frames, -}; -writeFileSync(OUT, JSON.stringify({ summary, final }, null, 2)); -console.log("WROTE", OUT); -console.log(JSON.stringify(summary, null, 2)); diff --git a/e2e/ci-shims/node-fs.d.ts b/e2e/ci-shims/node-fs.d.ts index 36a5cfa..e01dfac 100644 --- a/e2e/ci-shims/node-fs.d.ts +++ b/e2e/ci-shims/node-fs.d.ts @@ -1,4 +1,5 @@ declare module "node:fs" { export function existsSync(path: string): boolean; export function readFileSync(path: string, encoding?: string): string; + export function statSync(path: string): { mtimeMs: number; size: number }; } diff --git a/e2e/ci_static.py b/e2e/ci_static.py index 71c6c94..3b6ffb3 100755 --- a/e2e/ci_static.py +++ b/e2e/ci_static.py @@ -12,6 +12,7 @@ PSTACK_MAX_BYTES = 32 * 1024 KEBAB = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") CLAUDE_FORBIDDEN = ("skills", "agents", "commands", "hooks") +WALK_SKIP = {".git", "node_modules"} def pstack_ts(root: Path) -> Path: @@ -118,6 +119,7 @@ def quality(root: Path | None = None) -> list[dict]: "no @zenspc/pi-pstack import", ) ) + results.extend(packaging(root)) pi = pkg.get("pi") if isinstance(pi, dict) and isinstance(pi.get("extensions"), list): results.append( @@ -131,6 +133,36 @@ def quality(root: Path | None = None) -> list[dict]: return results +def scratch_paths(root: Path) -> list[str]: + """`.tmp-*` anywhere, and `e2e/_fm*-proof*` files. Report the top matching path only.""" + hits: set[str] = set() + for path in root.rglob("*"): + rel_parts = path.relative_to(root).parts + if any(part in WALK_SKIP for part in rel_parts): + continue + for i, part in enumerate(rel_parts): + if part.startswith(".tmp-"): + hits.add("/".join(rel_parts[: i + 1])) + break + else: + name = rel_parts[-1] + if ( + len(rel_parts) >= 2 + and rel_parts[0] == "e2e" + and name.startswith("_fm") + and "-proof" in name + ): + hits.add("/".join(rel_parts)) + return sorted(hits) + + +def packaging(root: Path) -> list[dict]: + hits = scratch_paths(root) + ok = not hits + detail = "no .tmp-* or e2e/_fm*-proof*" if ok else "scratch " + ", ".join(hits[:12]) + return [check("packaging", ok, detail)] + + def claude_plugin(root: Path) -> list[dict]: """Official Claude Code plugin layout: manifest in .claude-plugin/, skills at plugin root.""" results = [] diff --git a/e2e/run.py b/e2e/run.py index 643aecd..8680f06 100755 --- a/e2e/run.py +++ b/e2e/run.py @@ -217,6 +217,12 @@ def static_worktree(): def static_setup_docs(): skill = (ROOT / "skills/do-setup-pstack/SKILL.md").read_text() + setup = (ROOT / "docs/guide/01-setup.md").read_text() + leftover = [ + path.relative_to(ROOT).as_posix() + for path in (ROOT / "docs/guide").rglob("*.md") + if "pstack-models.mdc" in path.read_text() + ] ok = ( "Do not write" in skill and "models.json" in skill @@ -225,8 +231,16 @@ def static_setup_docs(): and "/model" in skill and "/agents" in skill and not (ROOT / "commands/setup-pstack.md").exists() + and "pstack-models.mdc" not in setup + and "/agents" in setup + and "Roles" in setup + and not leftover + ) + return check( + "setup_pstack_docs", + ok, + "setup lists Roles /agents; guide has no pstack-models.mdc" if ok else f"leftover={leftover}", ) - return check("setup_pstack_docs", ok, "setup-pstack skill lists roles; no commands/ wrapper") def static_install_docs(): diff --git a/e2e/unit/ci-static-cli.test.ts b/e2e/unit/ci-static-cli.test.ts index 00a0f93..8da87c3 100644 --- a/e2e/unit/ci-static-cli.test.ts +++ b/e2e/unit/ci-static-cli.test.ts @@ -44,6 +44,20 @@ describe("ci_static.py CLI fixtures", () => { expect(summary.failed).toContain("pstack_size"); }); + it("fails quality when scratch .tmp-* or e2e/_fm*-proof* files are in the tree", () => { + const root = fixture({ + "package.json": JSON.stringify({ omp: { extensions: ["./extensions/pstack.ts"] } }), + "extensions/pstack.ts": "export default function pstack() {}\n", + "skills/.keep": "", + "agents/.keep": "", + ".tmp-fm-x/keep": "", + "e2e/_fm1-proof.mjs": "export {}\n", + }); + const { status, summary } = runCiStatic(["--quality"], root); + expect(status).toBe(1); + expect(summary.failed).toContain("packaging"); + }); + it("fails quality without Claude plugin.json", () => { const root = fixture({ "package.json": JSON.stringify({ omp: { extensions: ["./extensions/pstack.ts"] } }), diff --git a/e2e/unit/product-capabilities.test.ts b/e2e/unit/product-capabilities.test.ts index e7be572..4b70b5b 100644 --- a/e2e/unit/product-capabilities.test.ts +++ b/e2e/unit/product-capabilities.test.ts @@ -123,6 +123,31 @@ describe("original product pack", () => { expect(existsSync(join(root, "skills/do-poteto-mode/playbooks/shipping.md"))).toBe(false); }); + it("scratch .tmp-* and e2e/_fm*-proof* are gone and gitignored", () => { + const root = repoDir(); + const gitignore = readFileSync(join(root, ".gitignore"), "utf8"); + expect(gitignore).toMatch(/^\.tmp\*\//m); + expect(gitignore).toMatch(/_fm\*-proof\*/); + expect(existsSync(join(root, "e2e/_fm230-proof.mjs"))).toBe(false); + expect(existsSync(join(root, ".tmp-fm-ponytail"))).toBe(false); + expect(existsSync(join(root, ".tmp-fm320-ponytail"))).toBe(false); + const { status, summary } = runCiStatic(["--quality"], root); + expect(status).toBe(0); + expect(summary.failed).not.toContain("packaging"); + }); + + it("guide setup matches do-setup-pstack Roles /agents; no pstack-models.mdc leftover", () => { + const root = repoDir(); + const leftover = walkMarkdown(root, "docs/guide").filter((rel) => + readFileSync(join(root, rel), "utf8").includes("pstack-models.mdc"), + ); + expect(leftover, JSON.stringify(leftover)).toEqual([]); + const setup = readFileSync(join(root, "docs/guide/01-setup.md"), "utf8"); + expect(setup).toContain("/agents"); + expect(setup).toContain("Roles"); + expect(setup).not.toContain("inherit-parent"); + }); + it("README and docs relative links resolve (no 404 on original guide paths)", () => { const files = ["README.md", ...walkMarkdown(root, "docs"), ...walkMarkdown(root, "automations")]; const exists = (rel: string) => existsSync(join(root, rel)); diff --git a/extensions/pstack.ts b/extensions/pstack.ts index 67227a6..f5049a4 100644 --- a/extensions/pstack.ts +++ b/extensions/pstack.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, statSync } from "node:fs"; const POTETO_SKILL = "/skill:do-poteto-mode"; const POTETO_PROMPT = @@ -10,6 +10,9 @@ type ModeEntry = { data?: { enabled?: unknown }; }; +type PotetoScan = { found: boolean; enabled: boolean }; +const jsonlCache = new Map(); + function sessionIdFromFile(path: unknown): string | undefined { if (typeof path !== "string" || !path) return undefined; const base = path.split("/").pop() ?? path; @@ -29,16 +32,28 @@ function sessionFileFromCtx(ctx: any): string | undefined { return typeof file === "string" && file.length > 0 ? file : undefined; } -function lastPotetoInJsonl(path: string): { found: boolean; enabled: boolean } | undefined { - if (!existsSync(path)) return undefined; - let found = false; - let enabled = false; +function lastPotetoInJsonl(path: string): PotetoScan | undefined { + if (!existsSync(path)) { + jsonlCache.delete(path); + return undefined; + } + let st; + try { + st = statSync(path); + } catch { + jsonlCache.delete(path); + return undefined; + } + const hit = jsonlCache.get(path); + if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size) return hit.value; let text = ""; try { text = readFileSync(path, "utf8"); } catch { return undefined; } + let found = false; + let enabled = false; for (const line of text.split("\n")) { if (!line.includes("pstack-mode")) continue; try { @@ -53,7 +68,9 @@ function lastPotetoInJsonl(path: string): { found: boolean; enabled: boolean } | // ignore bad lines } } - return { found, enabled }; + const value = { found, enabled }; + jsonlCache.set(path, { mtimeMs: st.mtimeMs, size: st.size, value }); + return value; } /** This conversation only. Missing pstack-mode entry means off. Never a process-wide boolean. */