From 5956c3475a5f5d3384e846fd87b02100386d96b2 Mon Sep 17 00:00:00 2001 From: Alphalab Admin Date: Sat, 12 Sep 2026 02:57:41 +0000 Subject: [PATCH] fix(skills): keep Pi design workflows single-model --- README.md | 13 +- bin/conform-skills.py | 23 +- install.sh | 8 +- overlay/AGENTS.md | 4 +- overlay/APPEND_SYSTEM.md | 2 +- overlay/skills/architect/SKILL.md | 49 +++++ overlay/skills/poteto-mode/patch.json | 30 +++ .../SKILL.md | 15 ++ prompts/poteto.md | 2 +- scripts/check-overlay.sh | 4 +- scripts/fixtures/poteto-mode/SKILL.md | 25 +++ scripts/jig_tests/test_integration.py | 4 + scripts/test_skill_overlays.py | 203 ++++++++++++++++++ 13 files changed, 368 insertions(+), 14 deletions(-) create mode 100644 overlay/skills/architect/SKILL.md create mode 100644 overlay/skills/poteto-mode/patch.json create mode 100644 overlay/skills/principle-exhaust-the-design-space/SKILL.md create mode 100644 scripts/fixtures/poteto-mode/SKILL.md create mode 100644 scripts/test_skill_overlays.py diff --git a/README.md b/README.md index 30c0717..24e2446 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The prompt and `-y` update only when a bootstrap invocation selects the default The installer does not update the nested pstack clone or installed package versions. Use `update-pstack` for that independent update. After a source update, the installer installs each newly required package that is absent. -A second run with the same inputs leaves all owned file bytes unchanged. It removes stale files only from the installed Jig and pstack updater resource directories. It never writes `auth.json`, `models-store.json`, `private/`, or `sessions/`. +A second run with the same inputs leaves all owned file bytes unchanged. It removes stale files from installed skills and the installed Jig and pstack updater resource directories. It never writes `auth.json`, `models-store.json`, `private/`, or `sessions/`. To use existing source trees, run: @@ -84,6 +84,16 @@ The command fast-forwards only the independent pstack Git checkout. It then reru `/skill:update-pstack` runs the same procedure without the `/update-pstack` prompt alias. +## Pi-specific design skills + +Every install applies `overlay/skills` while it conforms pstack into `$HOME/.pi/agent/skills-pstack`. `update-pstack` reapplies the same overlays through `install.sh`. The source pstack tree stays unchanged. + +Architect uses one model to sketch, an optional parent pick, then implementation. Exhaust the Design Space permits sequential or parent-inline sketches without a candidate quota. Poteto-mode no longer routes design or parallel work to arena or swarm. Same-model parallel work remains available for disjoint workstreams. The Pi adapter's model policy takes precedence over model defaults in other imported skills and playbooks. + +A replacement `SKILL.md` selects the overlay directory as the skill source. Conformance removes old supporting-file symlinks instead of retaining upstream runner prompts. A `patch.json` contains exact-match `old` and `new` text blocks and preserves the skill's other files. If an upstream edit removes or duplicates a patch target, installation fails with `overlay drift` rather than silently keeping the old routing. Review and update the patch before retrying. + +The sticky prompt, `/poteto`, and poteto-agent read the installed poteto-mode copy. Existing sessions need to reload their instructions to use the new routes. + ## Required packages Fresh installs get these packages. Refresh removes retired npm package registrations and uninstalls their copies from Pi's managed npm directory. It preserves unrelated packages and backs up settings before removing registrations. `PI_STACK_SKIP_PACKAGES=1` skips physical package operations until the next normal install. @@ -160,6 +170,7 @@ bash -n bin/jig.sh install.sh scripts/check-jig.sh python3 -m unittest discover -s scripts/jig_tests -p 'test_*.py' bash scripts/check-overlay.sh bash scripts/check-conform-skills.sh +python3 scripts/test_skill_overlays.py bash scripts/check-update-pstack.sh bash scripts/check-subagents.sh bash scripts/check-jig.sh diff --git a/bin/conform-skills.py b/bin/conform-skills.py index ec72302..7a7fec7 100755 --- a/bin/conform-skills.py +++ b/bin/conform-skills.py @@ -12,6 +12,7 @@ from __future__ import annotations import argparse +import json import os import re import sys @@ -159,18 +160,29 @@ def link_or_replace(src: Path, dest: Path) -> None: dest.symlink_to(target) -def conform_one(src: Path, out_root: Path, verbose: bool) -> Path: +def conform_one(src: Path, out_root: Path, verbose: bool, overlays: Path | None = None) -> Path: src = src.resolve() + original = src + overlay = overlays / src.name if overlays else None + if overlay and (overlay / "SKILL.md").is_file(): + src = overlay.resolve() skill_md = src / "SKILL.md" if not skill_md.is_file(): raise SystemExit(f"no SKILL.md in {src}") dest_dir = (out_root / src.name).resolve() - if dest_dir == src: - raise SystemExit(f"refusing in-place rewrite of {src}. Set --out to a different directory") - dest_dir.mkdir(parents=True, exist_ok=True) + if dest_dir in (src, original): + raise SystemExit(f"refusing in-place rewrite of {dest_dir}. Set --out to a different directory") text = skill_md.read_text(encoding="utf-8") + if overlay and (overlay / "patch.json").is_file(): + for patch in json.loads((overlay / "patch.json").read_text(encoding="utf-8")): + if not patch["old"] or text.count(patch["old"]) != 1: + raise SystemExit(f"overlay drift in {skill_md}: expected one match for {patch['old']!r}") + text = text.replace(patch["old"], patch["new"], 1) new_text, name, changed = rewrite_skill_text(text, src.name) + dest_dir.mkdir(parents=True, exist_ok=True) dest_md = dest_dir / "SKILL.md" + if dest_md.is_symlink(): + dest_md.unlink() if not dest_md.exists() or dest_md.read_text(encoding="utf-8") != new_text: dest_md.write_text(new_text, encoding="utf-8") if verbose and changed: @@ -195,6 +207,7 @@ def main(argv: list[str] | None = None) -> int: ) parser.add_argument("--out", required=True, type=Path, help="directory that will hold one child per skill") parser.add_argument("--tree", type=Path, help="walk this directory for SKILL.md (Pi discovery rules)") + parser.add_argument("--overlays", type=Path, help="skill directories with replacement SKILL.md or exact-match patch.json") parser.add_argument("skills", nargs="*", type=Path, help="skill directories that contain SKILL.md") parser.add_argument("-v", "--verbose", action="store_true") args = parser.parse_args(argv) @@ -212,7 +225,7 @@ def main(argv: list[str] | None = None) -> int: if src in seen: continue seen.add(src) - conform_one(src, out, args.verbose) + conform_one(src, out, args.verbose, args.overlays) return 0 diff --git a/install.sh b/install.sh index 01d8933..e500552 100755 --- a/install.sh +++ b/install.sh @@ -6,6 +6,7 @@ pstack_skill_names=( how why architect + principle-exhaust-the-design-space interrogate tdd unslop @@ -27,6 +28,7 @@ Disables builtin agents, the subagent intercom bridge, and intercom notification Existing children keep their prompts until respawn. Dated backups go to $HOME/.pi/agent/backups/subagents/. Rewrites Cursor skill names into $HOME/.pi/agent/skills-pstack. Does not edit pstack. +Applies pi-stack's single-model skill overlays on every install and refresh. Copies the Jig launcher, controller, skill, and references into $HOME/.pi/agent/jig/. Copies the pstack updater command and controller into $HOME/.pi/agent/update-pstack/. Merges defaultTools, skills, and packages into settings.json without changing project trust. @@ -303,14 +305,14 @@ PY install_md() { local src="$1" dest="$2" - PSTACK="$pstack" python3 - "$src" "$dest" <<'PY' + PSTACK="$pstack" SKILLS_PSTACK="$agent/skills-pstack" python3 - "$src" "$dest" <<'PY' import os import sys from pathlib import Path src = Path(sys.argv[1]) dest = Path(sys.argv[2]) -text = src.read_text().replace("__PSTACK__", os.environ["PSTACK"]) +text = src.read_text().replace("__PSTACK__", os.environ["PSTACK"]).replace("__SKILLS_PSTACK__", os.environ["SKILLS_PSTACK"]) if dest.exists() and dest.read_text() == text: sys.exit(0) dest.parent.mkdir(parents=True, exist_ok=True) @@ -363,7 +365,7 @@ for name in cross-repo update-pstack; do done conform_src+=("$installed_jig/skills/jig") if [[ ${#conform_src[@]} -gt 0 ]]; then - python3 "$here/bin/conform-skills.py" --out "$conform_out" "${conform_src[@]}" + python3 "$here/bin/conform-skills.py" --out "$conform_out" --overlays "$overlay/skills" "${conform_src[@]}" fi export PI_AGENT_DIR="$agent" diff --git a/overlay/AGENTS.md b/overlay/AGENTS.md index c090209..518feec 100644 --- a/overlay/AGENTS.md +++ b/overlay/AGENTS.md @@ -12,7 +12,9 @@ The child reads poteto-mode in full and decides reversible details without super Leave `async` on. That is the default. `async:false` only when this turn cannot continue without the child. Do not sleep-poll. Use blocking `subagent_wait` only when this turn must consume the result. -Do not pin child models to `cursor/*` unless that provider is authenticated. A missing pattern warns and the child waits on a model that never comes. Use `inherit` or a listed `provider/id`. Call `{ action: "models" }` before an explicit model. +Use the parent's model for every child. Do not fan out across model types or select models by role. This policy overrides model defaults in imported skills and playbooks. Same-model parallel work is allowed for disjoint workstreams with separate ownership. Use parent-inline or sequential sketches for design alternatives, then let the parent pick if needed. No runner competition or judge is required. + +Read architect, poteto-mode, and principle-exhaust-the-design-space from `__SKILLS_PSTACK__`, not raw pstack. Installation and refresh reapply these Pi-specific overlays. Other imported skills remain upstream copies subject to this adapter's model policy. `TodoWrite` is `TODO.md` in the working tree. Do not register a todo tool. diff --git a/overlay/APPEND_SYSTEM.md b/overlay/APPEND_SYSTEM.md index 4e2018c..2240652 100644 --- a/overlay/APPEND_SYSTEM.md +++ b/overlay/APPEND_SYSTEM.md @@ -1,6 +1,6 @@ # Sticky process -Non-trivial work. Read `__PSTACK__/skills/poteto-mode/SKILL.md` in full, including the Principles index, before you act. Trivial one-liners skip that read. +Non-trivial work. Read `__SKILLS_PSTACK__/poteto-mode/SKILL.md` in full, including the Principles index, before you act. Trivial one-liners skip that read. After pstack's built-in Principles, read every trusted project `.cursor/skills/principle-*/SKILL.md` in full when present. Also read `~/.pi/agent/AGENTS.md`. Read `TODO.md` and `PLAN.md` in the working tree when they exist. diff --git a/overlay/skills/architect/SKILL.md b/overlay/skills/architect/SKILL.md new file mode 100644 index 0000000..2fb6afa --- /dev/null +++ b/overlay/skills/architect/SKILL.md @@ -0,0 +1,49 @@ +--- +name: architect +description: Sketch types, signatures, and module boundaries with one model before implementation. Use for /architect, design requests, or changes that need a new code shape. +disable-model-invocation: true +--- + +# Architect + +Use one model to sketch, then implement. Work parent-inline by default. Do not launch competing runners or delegate the choice to a judge. + +Track these steps in `TODO.md`: + +1. Ground the problem. +2. Sketch the design. +3. Let the parent pick if needed. +4. Implement and verify. +5. Revisit the sketch if evidence contradicts it. + +## Ground the problem + +Trace the affected callers, data, ownership, and constraints in the existing code. Identify the behavior that must stay unchanged. Skip this step only for greenfield work with no surrounding system. + +## Sketch the design + +Write the caller's usage first. Derive the types, signatures, module boundaries, and ownership from that usage. Use pseudocode or `not implemented` bodies where logic would obscure the shape. + +Start with one sketch. If a concrete uncertainty remains, explore another sketch sequentially or parent-inline with the same model. There is no candidate quota or required second design. + +Check the sketch for information leakage, shallow wrappers, order-dependent APIs, and unnecessary shared state. Prefer the smallest interface that hides the required complexity. + +## Let the parent pick if needed + +Proceed with the sketch when it satisfies the constraints. If alternatives remain, the parent compares their evidence and picks one. Do not add a judge or cross-judging phase. + +Pause for human sign-off only when explicitly requested or when authorization or a genuine product decision is missing. A requested checkpoint shows the sketch before implementation. + +## Implement and verify + +Fill in the chosen sketch. Run checks against the requested behavior. Report deviations and the evidence that required them. + +Same-model parallel work is allowed for disjoint implementation workstreams. Give each writer separate ownership. Follow the Pi adapter's model policy. + +## Revisit the sketch + +If repeated workarounds contradict the ownership or types, trace the new evidence and replace the wrong sketch. Return to a single-model sketch, not a competition. + +## Output + +For a small change, keep the usage, types, and signatures in one sketch. For a larger change, include a module map. Record the constraints, chosen design, unresolved questions, and verification plan. Explain rejected alternatives only when you actually explored them. diff --git a/overlay/skills/poteto-mode/patch.json b/overlay/skills/poteto-mode/patch.json new file mode 100644 index 0000000..cf9c7e6 --- /dev/null +++ b/overlay/skills/poteto-mode/patch.json @@ -0,0 +1,30 @@ +[ + { + "old": "- Code crossing a function boundary → the **architect** skill, parallel design exploration before implementing.", + "new": "- Code crossing a function boundary → the installed **architect** skill. Sketch with one model, let the parent pick if needed, then implement." + }, + { + "old": "- Parallel fan-out → the **swarm** skill for coverage matrices, races, gauntlets, and exploration partitions. Use **arena** for design or code bakeoffs with base selection and grafting.", + "new": "- Parallel work is optional. Use same-model children only for disjoint workstreams with separate ownership. Do not fan out across model types." + }, + { + "old": "- Contested design → the **interrogate** skill (multi-model adversarial) before shipping.", + "new": "- Contested design → examine the disputed constraint and evidence parent-inline with the same model. The parent decides whether another sequential sketch is useful." + }, + { + "old": "- **Exhaust the Design Space** (**principle-exhaust-the-design-space**). A novel interaction or architectural decision with no precedent. Build 2-3 competing prototypes and compare before committing.", + "new": "- **Exhaust the Design Space** (**principle-exhaust-the-design-space**). A novel interaction or architectural decision with unresolved tradeoffs. Read the installed leaf skill. Sequential or parent-inline sketches with one model are sufficient. No candidate quota or cross-model fanout." + }, + { + "old": "**Use `subagent_type: \"poteto-agent\"` for any subagent you spawn inside a playbook step** (code-writing delegates, ad-hoc helpers). `/poteto-mode` and `poteto-agent` route through the same wrapper. Routed workflow skills (`how`, `why`, `interrogate`, `reflect`, `swarm`) set their own `subagent_type` for diverse-model review. Respect what the skill prescribes, don't override to `poteto-agent`.", + "new": "Use the Pi `subagent` tool with `agent: \"poteto-agent\"` only when delegation earns its cost. Work parent-inline otherwise. The Pi adapter's single-model policy overrides runner and role defaults in every routed skill and playbook. Do not use a routed skill to introduce cross-model fanout." + }, + { + "old": "**Defaults for every `Task` call.** `run_in_background: true`, agent mode (readonly strips MCP), file pointers not inlined context, explicit model per role (configurable via `/setup-pstack`. Defaults `grok-4.6-fast-xhigh` for code, `claude-fable-5-1-thinking-max` for prose and judgment). Code delegates tier by difficulty. The hardest changes (cross-cutting design, gnarly concurrency, subtle algorithms) go to your strongest judgment model (`claude-fable-5-1-thinking-max`), whether the task needs judgment on vague intent or is a precisely specified sequence of steps to execute to the letter. Trivial mechanical edits go to your fast code model. Per-role lines in the `/setup-pstack` rule override these defaults and the model choices in the routed skills (`how`, `why`, `arena`, `swarm`, `architect`, `interrogate`, `reflect`). A role with no line keeps its default, and a role line of `inherit-parent` or `auto` runs that role on the parent chat model (omit Task `model`).", + "new": "Keep delegation asynchronous and pass file pointers instead of bulk context. Use the parent's model for every child. Do not select models by role, difficulty, or a model-diversity requirement. Same-model parallel children may handle disjoint workstreams. Use one workflow with `await runs.all` for that work, not for competing design candidates." + }, + { + "old": "A second opinion is the same prompt against a different model. Agreement is high-signal.", + "new": "A second opinion is optional and uses the same model. The parent owns the final choice." + } +] diff --git a/overlay/skills/principle-exhaust-the-design-space/SKILL.md b/overlay/skills/principle-exhaust-the-design-space/SKILL.md new file mode 100644 index 0000000..132b444 --- /dev/null +++ b/overlay/skills/principle-exhaust-the-design-space/SKILL.md @@ -0,0 +1,15 @@ +--- +name: principle-exhaust-the-design-space +description: Explore concrete alternatives when a novel interaction or architectural choice has unresolved tradeoffs. Sequential or parent-inline sketches are sufficient. +disable-model-invocation: true +--- + +# Exhaust the Design Space + +Start with the simplest sketch that meets the constraints. Explore another concrete alternative only when it can resolve a named uncertainty. Compare the evidence before implementing. + +Sequential or parent-inline sketches with one model are sufficient. There is no prototype quota, mandatory second design, or cross-model fanout. The parent can pick among alternatives without a judge. + +Use this principle for novel interactions or architectural choices with unresolved tradeoffs. Skip extra sketches when an established pattern or the constraints already determine the shape. + +Same-model parallel work is allowed for disjoint workstreams. Parallelism is not a requirement for design exploration. diff --git a/prompts/poteto.md b/prompts/poteto.md index f208759..e1b38ad 100644 --- a/prompts/poteto.md +++ b/prompts/poteto.md @@ -1,4 +1,4 @@ --- description: Load poteto-mode process for this turn --- -Read `__PSTACK__/skills/poteto-mode/SKILL.md` in full, including the Principles index. Then do the user request in that style. Trivial one-liners skip the full read. Also read `~/.pi/agent/AGENTS.md`. +Read `__SKILLS_PSTACK__/poteto-mode/SKILL.md` in full, including the Principles index. Then do the user request in that style. Trivial one-liners skip the full read. Also read `~/.pi/agent/AGENTS.md`. diff --git a/scripts/check-overlay.sh b/scripts/check-overlay.sh index 6505035..bcb1722 100755 --- a/scripts/check-overlay.sh +++ b/scripts/check-overlay.sh @@ -108,7 +108,7 @@ description: stub for $name install test # stub EOF done < <(bash "$root/install.sh" --print-pstack-skills) -sed -i 's/^name: poteto-mode$/name: Poteto Mode/' "$tmp/pstack/skills/poteto-mode/SKILL.md" +cp "$root/scripts/fixtures/poteto-mode/SKILL.md" "$tmp/pstack/skills/poteto-mode/SKILL.md" mkdir -p "$tmp/pstack/skills/poteto-mode/playbooks" printf 'playbook\n' >"$tmp/pstack/skills/poteto-mode/playbooks/investigation.md" stub="$tmp/pstack" @@ -371,7 +371,7 @@ description: stub for $name clone test # stub EOF done < <(bash "$root/install.sh" --print-pstack-skills) -sed -i 's/^name: poteto-mode$/name: Poteto Mode/' "$fake/pstack/skills/poteto-mode/SKILL.md" +cp "$root/scripts/fixtures/poteto-mode/SKILL.md" "$fake/pstack/skills/poteto-mode/SKILL.md" git init -q "$fake" git -C "$fake" add pstack git -C "$fake" -c user.email=t@t -c user.name=t commit -qm stub diff --git a/scripts/fixtures/poteto-mode/SKILL.md b/scripts/fixtures/poteto-mode/SKILL.md new file mode 100644 index 0000000..490e608 --- /dev/null +++ b/scripts/fixtures/poteto-mode/SKILL.md @@ -0,0 +1,25 @@ +--- +name: Poteto Mode +description: Upstream routing excerpt for overlay regression tests. +disable-model-invocation: true +--- + +# Poteto mode + +Keep this unrelated instruction unchanged. +See [investigation](playbooks/investigation.md). + +## Routing excerpt + +- Code crossing a function boundary → the **architect** skill, parallel design exploration before implementing. +- Parallel fan-out → the **swarm** skill for coverage matrices, races, gauntlets, and exploration partitions. Use **arena** for design or code bakeoffs with base selection and grafting. +- Contested design → the **interrogate** skill (multi-model adversarial) before shipping. +- **Exhaust the Design Space** (**principle-exhaust-the-design-space**). A novel interaction or architectural decision with no precedent. Build 2-3 competing prototypes and compare before committing. + +## Subagents + +**Use `subagent_type: "poteto-agent"` for any subagent you spawn inside a playbook step** (code-writing delegates, ad-hoc helpers). `/poteto-mode` and `poteto-agent` route through the same wrapper. Routed workflow skills (`how`, `why`, `interrogate`, `reflect`, `swarm`) set their own `subagent_type` for diverse-model review. Respect what the skill prescribes, don't override to `poteto-agent`. + +**Defaults for every `Task` call.** `run_in_background: true`, agent mode (readonly strips MCP), file pointers not inlined context, explicit model per role (configurable via `/setup-pstack`. Defaults `grok-4.6-fast-xhigh` for code, `claude-fable-5-1-thinking-max` for prose and judgment). Code delegates tier by difficulty. The hardest changes (cross-cutting design, gnarly concurrency, subtle algorithms) go to your strongest judgment model (`claude-fable-5-1-thinking-max`), whether the task needs judgment on vague intent or is a precisely specified sequence of steps to execute to the letter. Trivial mechanical edits go to your fast code model. Per-role lines in the `/setup-pstack` rule override these defaults and the model choices in the routed skills (`how`, `why`, `arena`, `swarm`, `architect`, `interrogate`, `reflect`). A role with no line keeps its default, and a role line of `inherit-parent` or `auto` runs that role on the parent chat model (omit Task `model`). + +You own every subagent's work. Review the diff and write your own summary, don't pass through what it said. Interrupt-chained resumes silently drop directives, so fire a fresh subagent with consolidated scope rather than trusting a "done" summary. A second opinion is the same prompt against a different model. Agreement is high-signal. diff --git a/scripts/jig_tests/test_integration.py b/scripts/jig_tests/test_integration.py index de709f2..378a9a4 100644 --- a/scripts/jig_tests/test_integration.py +++ b/scripts/jig_tests/test_integration.py @@ -31,6 +31,10 @@ def setUp(self): encoding="utf-8", ) + (self.pstack / "skills/poteto-mode/SKILL.md").write_bytes( + (ROOT / "scripts/fixtures/poteto-mode/SKILL.md").read_bytes() + ) + def tearDown(self): self.temporary.cleanup() diff --git a/scripts/test_skill_overlays.py b/scripts/test_skill_overlays.py new file mode 100644 index 0000000..ec071d2 --- /dev/null +++ b/scripts/test_skill_overlays.py @@ -0,0 +1,203 @@ +#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +FIXTURE = ROOT / "scripts/fixtures/poteto-mode/SKILL.md" +NAMES = ("poteto-mode", "architect", "principle-exhaust-the-design-space") + + +def run(*args, cwd=ROOT, env=None, check=True): + return subprocess.run( + [str(arg) for arg in args], cwd=cwd, env=env, + check=check, text=True, capture_output=True, + ) + + +def snapshot(root): + return {str(path.relative_to(root)): path.read_bytes() + for path in root.rglob("*") if path.is_file()} + + +class SkillOverlays(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.base = Path(self.temp.name) + self.pstack = self.base / "pstack" + names = run("bash", ROOT / "install.sh", "--print-pstack-skills").stdout.splitlines() + for name in names: + skill = self.pstack / "skills" / name + skill.mkdir(parents=True) + (skill / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: Upstream fixture.\n---\n\n# Upstream fixture\n" + ) + shutil.copyfile(FIXTURE, self.pstack / "skills/poteto-mode/SKILL.md") + for name, folder, filename in ( + ("poteto-mode", "playbooks", "investigation.md"), + ("architect", "references", "runner-prompt.md"), + ): + refs = self.pstack / "skills" / name / folder + refs.mkdir() + (refs / filename).write_text("Upstream supporting file.\n") + self.out = self.base / "out" + + def conform(self, *extra, overlays=True, out=None, check=True): + args = ["python3", ROOT / "bin/conform-skills.py", "--out", out or self.out] + if overlays: + args.extend(["--overlays", ROOT / "overlay/skills"]) + args.extend(self.pstack / "skills" / name for name in NAMES) + return run(*args, *extra, check=check) + + def assert_policy(self, out): + poteto = (out / "poteto-mode/SKILL.md").read_text() + self.assertIn("Keep this unrelated instruction unchanged.", poteto) + self.assertIn("Use same-model children only for disjoint workstreams", poteto) + self.assertIn("Do not fan out across model types.", poteto) + self.assertIn("let the parent pick if needed, then implement.", poteto) + self.assertIn("Sequential or parent-inline sketches with one model are sufficient.", poteto) + self.assertIn("Use the parent's model for every child.", poteto) + for forbidden in ("**arena**", "**swarm**", "multi-model", "diverse-model", + "2-3 competing", "different model", "grok-", "claude-", "gpt-"): + self.assertNotIn(forbidden, poteto) + architect = (out / "architect/SKILL.md").read_text() + self.assertIn("Start with one sketch.", architect) + self.assertIn("the parent compares their evidence and picks one", architect) + self.assertIn("Same-model parallel work is allowed for disjoint implementation workstreams.", architect) + for forbidden in ("arena", "runner-prompt", "rationale-template", "Design it twice", "interrogate"): + self.assertNotIn(forbidden, architect) + self.assertFalse((out / "architect/references").exists()) + principle = (out / "principle-exhaust-the-design-space/SKILL.md").read_text() + self.assertIn("Sequential or parent-inline sketches with one model are sufficient.", principle) + self.assertNotIn("2-3", principle) + self.assertIn("Same-model parallel work is allowed for disjoint workstreams.", principle) + for name in NAMES: + path = out / name / "SKILL.md" + text = path.read_text() + self.assertRegex(text, rf"(?m)^name: {name}$") + self.assertRegex(text, r"(?m)^description: .+$") + for link in re.findall(r"\]\(([^)]+)\)", text): + self.assertTrue((path.parent / link).exists(), link) + self.assertTrue((out / "poteto-mode/playbooks").is_symlink()) + self.assertEqual((out / "poteto-mode/playbooks/investigation.md").read_text(), "Upstream supporting file.\n") + + def test_replacement_patch_migration_and_idempotence(self): + original = snapshot(self.pstack) + self.conform(overlays=False) + self.assertTrue((self.out / "architect/references").is_symlink()) + self.conform() + self.assert_policy(self.out) + installed = snapshot(self.out) + self.conform() + self.assertEqual(snapshot(self.out), installed) + self.assertEqual(snapshot(self.pstack), original) + + def test_missing_or_duplicate_patch_anchor_leaves_installed_skill_unchanged(self): + self.conform() + installed = snapshot(self.out) + path = self.pstack / "skills/poteto-mode/SKILL.md" + original = path.read_text() + anchor = "A second opinion is the same prompt against a different model. Agreement is high-signal." + for changed in (original.replace(anchor, "Upstream changed this rule."), original + anchor): + with self.subTest(changed=changed[-100:]): + path.write_text(changed) + result = self.conform(check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("overlay drift", result.stderr) + self.assertEqual(snapshot(self.out), installed) + self.assertEqual(path.read_text(), changed) + + def test_source_symlink_is_not_written_through(self): + original = snapshot(self.pstack) + dest = self.out / "poteto-mode" + dest.mkdir(parents=True) + (dest / "SKILL.md").symlink_to(self.pstack / "skills/poteto-mode/SKILL.md") + self.conform() + self.assertFalse((dest / "SKILL.md").is_symlink()) + self.assertEqual(snapshot(self.pstack), original) + self.assert_policy(self.out) + + def test_replacement_refuses_in_place_output(self): + original = snapshot(self.pstack) + for output in (self.pstack / "skills", ROOT / "overlay/skills"): + with self.subTest(output=output): + result = run("python3", ROOT / "bin/conform-skills.py", "--out", output, + "--overlays", ROOT / "overlay/skills", self.pstack / "skills/architect", + check=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("in-place", result.stderr) + self.assertEqual(snapshot(self.pstack), original) + + def test_real_install_and_update_reapply_overlays(self): + stack = self.base / "pi-stack" + stack.mkdir() + for name in ("bin", "overlay", "prompts", "skills"): + shutil.copytree(ROOT / name, stack / name, ignore=shutil.ignore_patterns("__pycache__")) + shutil.copyfile(ROOT / "install.sh", stack / "install.sh") + (stack / ".gitignore").write_text("__pycache__/\n") + run("git", "init", "-q", "-b", "main", stack) + run("git", "add", ".", cwd=stack) + run("git", "-c", "user.name=test", "-c", "user.email=test@example.com", + "commit", "-qm", "fixture", cwd=stack) + upstream = self.base / "upstream" + upstream.mkdir() + shutil.copytree(self.pstack, upstream / "pstack") + manifest = upstream / "pstack/.cursor-plugin/plugin.json" + manifest.parent.mkdir() + manifest.write_text('{"name":"pstack","version":"0.1.0"}\n') + run("git", "init", "-q", "-b", "main", upstream) + run("git", "add", ".", cwd=upstream) + run("git", "-c", "user.name=test", "-c", "user.email=test@example.com", + "commit", "-qm", "fixture", cwd=upstream) + plugins = self.base / "plugins" + run("git", "clone", "-q", upstream, plugins) + home = self.base / "home" + env = {**os.environ, "HOME": str(home), "PI_STACK": str(stack), + "PSTACK": str(plugins / "pstack"), "PI_STACK_SKIP_PACKAGES": "1"} + original = snapshot(plugins / "pstack") + run("bash", stack / "install.sh", env=env) + agent = home / ".pi/agent" + installed = agent / "skills-pstack" + self.assert_policy(installed) + self.assertEqual(snapshot(plugins / "pstack"), original) + settings = json.loads((agent / "settings.json").read_text()) + for name in NAMES: + self.assertIn(str(installed / name), settings["skills"]) + for entry in ("APPEND_SYSTEM.md", "prompts/poteto.md", "agents/poteto-agent.md"): + text = (agent / entry).read_text() + self.assertIn(str(installed / "poteto-mode/SKILL.md"), text) + self.assertNotIn(str(plugins / "pstack"), text) + self.assertNotIn("__SKILLS_PSTACK__", text) + self.assertIn("Do not fan out across model types", (agent / "AGENTS.md").read_text()) + expected = snapshot(installed) + self.out = installed + self.conform(overlays=False) + self.assertTrue((installed / "architect/references").is_symlink()) + manifest.write_text('{"name":"pstack","version":"0.2.0"}\n') + run("git", "add", ".", cwd=upstream) + run("git", "-c", "user.name=test", "-c", "user.email=test@example.com", + "commit", "-qm", "refresh", cwd=upstream) + updater = home / ".local/bin/update-pstack" + plan = json.loads(run(updater, "status", env=env).stdout) + args = ("apply", "--expected-pi-stack", plan["piStack"]["revision"], + "--expected-current", plan["pstack"]["currentRevision"], + "--expected-upstream", plan["pstack"]["upstreamRevision"]) + run(updater, *args, env=env) + self.assert_policy(installed) + self.assertEqual(snapshot(installed), expected) + self.assertEqual(snapshot(plugins / "pstack"), snapshot(upstream / "pstack")) + owned = snapshot(agent) + run(updater, *args, env=env) + self.assertEqual(snapshot(agent), owned) + self.assertEqual(run("git", "status", "--porcelain", cwd=plugins).stdout, "") + self.assertEqual(run("git", "status", "--porcelain", cwd=stack).stdout, "") + + +if __name__ == "__main__": + unittest.main()