Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,24 @@ jobs:
run: |
git diff --check
git diff --exit-code -- upstream/
verify-macos:
# Case-insensitive filesystem: catches tracked paths that differ only by case.
runs-on: macos-15
steps:
- uses: actions/checkout@v7.0.1
- name: Check the checkout is complete
run: test -z "$(git status --porcelain)"
- uses: actions/setup-python@v7.0.0
with:
python-version: "3.13"
- name: Test runtime diagnostics
run: python3 -m unittest discover -s tests -v
- name: Build skills and verify license notices
run: |
python3 tools/pstack_opencodex.py build
cmp LICENSE dist/LICENSE
cmp upstream/LICENSE dist/LICENSE.pstack
- name: Check whitespace and upstream preservation
run: |
git diff --check
git diff --exit-code -- upstream/
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Issues and pull requests are welcome. Include the command or workflow you used,

## Make a change

- Keep `upstream/` identical to the source pinned in `UPSTREAM`. Use the sync command to update it.
- Keep `upstream/` identical to the source pinned in `UPSTREAM.lock`. Use the sync command to update it.
- Put harness instructions and patches in `overlay/`, and Python tooling in `tools/`.
- Keep `dist/` out of Git. It is generated and contains local absolute paths.
- Use English for project documentation and Conventional Commits for commit messages.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ The harness maps Cursor cloud-agent steps to local workers. Available tools and

| Path | Purpose |
| --- | --- |
| `UPSTREAM` | Source repository, subtree, and pinned commit. |
| `UPSTREAM.lock` | Source repository, subtree, and pinned commit. |
| `upstream/` | Unmodified copy of `cursor/plugins/pstack`. |
| `overlay/` | Harness instructions, patches, exclusions, and model example. |
| `tools/` | Build, install, synchronization, and diagnostic tools. |
Expand Down
File renamed without changes.
44 changes: 44 additions & 0 deletions tests/test_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import shutil
import subprocess
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def case_collisions(paths):
"""Return groups of distinct tracked spellings that only differ by case.

Every directory prefix counts as a path too, so a file named UPSTREAM and a
directory named upstream/ collide on case-insensitive filesystems (default
macOS and Windows), where one of them cannot be checked out.
"""
spellings = {}
for path in paths:
parts = path.split("/")
for depth in range(1, len(parts) + 1):
name = "/".join(parts[:depth])
spellings.setdefault(name.casefold(), set()).add(name)
return sorted(sorted(group) for group in spellings.values() if len(group) > 1)


class CaseCollisions(unittest.TestCase):
def test_helper_flags_a_file_that_collides_with_a_directory(self):
self.assertEqual(case_collisions(["UPSTREAM", "upstream/LICENSE"]), [["UPSTREAM", "upstream"]])

def test_helper_allows_shared_parents(self):
self.assertEqual(case_collisions(["upstream/a", "upstream/b", "tools/x.py"]), [])

def test_tracked_paths_check_out_on_case_insensitive_filesystems(self):
if shutil.which("git") is None:
self.skipTest("git is not available")
probe = subprocess.run(["git", "-C", str(ROOT), "rev-parse", "--is-inside-work-tree"], capture_output=True, text=True)
if probe.returncode != 0:
self.skipTest("not a git work tree")
listed = subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"], capture_output=True, check=True)
paths = [p for p in listed.stdout.decode("utf-8").split("\0") if p]
self.assertEqual(case_collisions(paths), [])


if __name__ == "__main__":
unittest.main()
63 changes: 62 additions & 1 deletion tests/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ class RuntimeCLI(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="pstack-runtime-test-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
# Resolve symlinked temp roots (macOS /var -> /private/var) so path comparisons match the CLI.
self.root = Path(self.temporary.name).resolve()
self.catalog = self.root / "selected.json"
self.roles = self.root / "models.md"
self.codex = self.root / "config.toml"
Expand Down Expand Up @@ -197,5 +198,65 @@ def test_relative_configured_catalog_is_rejected_without_guessing(self):
self.assertIn("catalog path must be absolute", result.stdout)


class CheckModelsCLI(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory(prefix="pstack-models-test-")
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name).resolve()
self.catalog = self.root / "catalog.json"
self.roles = self.root / "models.md"
self.roles.write_text("how explainer: anthropic/claude-opus-5-5-xhigh\n")

def run_models(self):
return subprocess.run([sys.executable, str(ROOT / "tools/pstack_opencodex.py"), "check-models", "--file", str(self.roles), "--catalog", str(self.catalog)], text=True, capture_output=True, timeout=10)

def test_unreadable_catalogs_fail_with_a_message_instead_of_a_traceback(self):
bad_effort = models()
bad_effort["gpt-6.1-sol"]["supported_reasoning_levels"] = [{}]
cases = {
"missing file": None,
"malformed JSON": "not-json",
"non-object document": "[]",
"non-array models": '{"models": {}}',
"non-object entry": '{"models": [null]}',
"missing slug": '{"models": [{}]}',
"non-string slug": '{"models": [{"slug": 7}]}',
"malformed effort": json.dumps({"models": list(bad_effort.values())}),
}
for name, contents in cases.items():
with self.subTest(name):
if contents is None:
self.catalog.unlink(missing_ok=True)
else:
self.catalog.write_text(contents)
before = self.catalog.read_bytes() if self.catalog.exists() else None
result = self.run_models()
self.assertEqual(result.returncode, 1, result.stderr)
self.assertTrue(result.stderr.startswith("cannot read model catalog " + str(self.catalog)), result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertNotIn("checking names", result.stdout)
after = self.catalog.read_bytes() if self.catalog.exists() else None
self.assertEqual(before, after)

def test_every_duplicate_slug_entry_is_still_checked(self):
disabled_first = [{"slug": "demo", "multi_agent_version": "disabled"}, {"slug": "demo", "multi_agent_version": "v1"}]
self.roles.write_text("how explainer: demo\n")
self.catalog.write_text(json.dumps({"models": disabled_first}))
result = self.run_models()
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertIn("disabled for collaboration", result.stdout)
bad_first = [{"slug": "demo", "supported_reasoning_levels": [{}]}, {"slug": "demo", "supported_reasoning_levels": [{"effort": "high"}]}]
self.catalog.write_text(json.dumps({"models": bad_first}))
result = self.run_models()
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertTrue(result.stderr.startswith("cannot read model catalog"), result.stderr)

def test_a_valid_catalog_still_resolves_and_clamps(self):
self.catalog.write_text(json.dumps({"models": list(models().values())}))
result = self.run_models()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("anthropic/claude-opus-5-5", result.stdout)
self.assertIn("clamped from xhigh", result.stdout)

if __name__ == "__main__":
unittest.main()
37 changes: 30 additions & 7 deletions tools/pstack_opencodex.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Keep pstack identical to Cursor's upstream and build a Codex-ready copy.

upstream/ verbatim copy of cursor/plugins/pstack at the ref in UPSTREAM
upstream/ verbatim copy of cursor/plugins/pstack at the ref in UPSTREAM.lock
overlay/ the only files this repository owns (harness map, patches, examples)
dist/ generated by "build"; skills are installed as symlinks into dist/skills
"""
Expand All @@ -23,7 +23,7 @@
UPSTREAM_DIR = REPO / "upstream"
OVERLAY_DIR = REPO / "overlay"
DIST_DIR = REPO / "dist"
LOCK_FILE = REPO / "UPSTREAM"
LOCK_FILE = REPO / "UPSTREAM.lock"
CODEX_HOME = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
DEFAULT_MODELS_FILE = CODEX_HOME / "pstack-models.md"
DEFAULT_CATALOG = CODEX_HOME / "models_cache.json"
Expand Down Expand Up @@ -148,20 +148,39 @@ def cmd_build(args: argparse.Namespace) -> None:
print(f"built {len(skills)} skills into {dist} ({explicit} explicit-only)")


def load_catalog(path: Path) -> dict[str, list[str]]:
def catalog_models(path: Path) -> list[dict]:
"""Read and shape-check every model entry, keeping duplicates in file order."""
data = json.loads(path.read_text())
if not isinstance(data, dict):
raise ValueError("expected an object")
models = data.get("models", [])
if not isinstance(models, list):
raise ValueError("expected a models array")
for model in models:
if not isinstance(model, dict) or not isinstance(model.get("slug"), str):
raise ValueError("expected model objects with string slugs")
return models


def catalog_efforts(models: list[dict]) -> dict[str, list[str]]:
"""Map each catalog slug to the reasoning efforts pstack understands."""
catalog = {}
for model in data.get("models", []):
for model in models:
slug = model["slug"]
raw_levels = model.get("supported_reasoning_levels") or []
if not isinstance(raw_levels, list):
raise ValueError("supported_reasoning_levels must be an array")
levels = [lvl.get("effort") if isinstance(lvl, dict) else lvl for lvl in raw_levels]
if any(not isinstance(lvl, str) for lvl in levels):
raise ValueError("reasoning levels must be strings or objects with string efforts")
catalog[model["slug"]] = [lvl for lvl in levels if lvl in EFFORTS]
catalog[slug] = [lvl for lvl in levels if lvl in EFFORTS]
return catalog


def load_catalog(path: Path) -> dict[str, list[str]]:
return catalog_efforts(catalog_models(path))


def split_effort(value: str) -> tuple[str, str | None]:
tokens = value.split("-")
if tokens[-1] == "fast":
Expand Down Expand Up @@ -222,7 +241,12 @@ def upstream_roles() -> dict[str, list[str]]:


def cmd_check_models(args: argparse.Namespace) -> None:
catalog = load_catalog(Path(args.catalog))
catalog_path = Path(args.catalog)
try:
records = catalog_models(catalog_path)
catalog = catalog_efforts(records)
except (OSError, ValueError) as error:
raise SystemExit(f"cannot read model catalog {catalog_path}: {error}")
defaults = upstream_roles()
if args.defaults:
roles, source = defaults, "upstream defaults"
Expand All @@ -233,7 +257,6 @@ def cmd_check_models(args: argparse.Namespace) -> None:
roles, source = parse_roles(path.read_text()), str(path)
print(f"checking names and reasoning efforts in {source} against {args.catalog}")
print("This does not verify collaboration transport or live provider availability; run check-runtime before delegation.")
records = json.loads(Path(args.catalog).read_text()).get("models", [])
disabled = {model["slug"] for model in records if model.get("multi_agent_version") == "disabled"}
failures = 0
for label, values in roles.items():
Expand Down