From 1ee7f612a11e1623e1f7f9c3f019d5948fa26bce Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 9 Oct 2026 15:12:00 +0900 Subject: [PATCH 1/2] fix: rename the upstream lock so it does not collide with upstream/ UPSTREAM and the upstream/ directory differ only by case, so a fresh clone on a case-insensitive filesystem (default macOS and Windows) cannot hold both: git reports UPSTREAM as deleted and build fails with IsADirectoryError. Rename the lock to UPSTREAM.lock (content unchanged) and update its readers. Add a test that fails when two tracked paths, including directory prefixes, differ only by case, and a macOS CI job that checks the checkout is complete. The macOS job also surfaced a temp-path comparison in test_runtime.py that failed on macOS before this change (/var is a symlink to /private/var); the test now resolves its temp root. --- .github/workflows/ci.yml | 21 +++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 2 +- UPSTREAM => UPSTREAM.lock | 0 tests/test_repository.py | 44 +++++++++++++++++++++++++++++++++++++++ tests/test_runtime.py | 3 ++- tools/pstack_opencodex.py | 4 ++-- 7 files changed, 71 insertions(+), 5 deletions(-) rename UPSTREAM => UPSTREAM.lock (100%) create mode 100644 tests/test_repository.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 77eb885..c6f485e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bdb23a9..4a26e1f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index d5fef57..79400ed 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/UPSTREAM b/UPSTREAM.lock similarity index 100% rename from UPSTREAM rename to UPSTREAM.lock diff --git a/tests/test_repository.py b/tests/test_repository.py new file mode 100644 index 0000000..404c0c2 --- /dev/null +++ b/tests/test_repository.py @@ -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() diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 7901f99..484a1b4 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -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" diff --git a/tools/pstack_opencodex.py b/tools/pstack_opencodex.py index 0bd9a34..d34dfef 100644 --- a/tools/pstack_opencodex.py +++ b/tools/pstack_opencodex.py @@ -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 """ @@ -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" From 1f6db410bea0027df08a71a71dd8c43530434ec2 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Fri, 9 Oct 2026 15:12:55 +0900 Subject: [PATCH 2/2] fix: report unreadable model catalogs without a traceback check-models read the catalog with a bare json.loads and indexed model["slug"], so a missing file, malformed JSON or an unexpected shape ended in a Python traceback. check-runtime already reports the same inputs as a BLOCKED diagnostic. catalog_models() now shape-checks every entry in file order (duplicates are kept, so a disabled or malformed earlier entry is still seen), catalog_efforts() projects reasoning efforts, and load_catalog() keeps its return shape. check-models reads the catalog once inside one error boundary and exits 1 with "cannot read model catalog : ". --- tests/test_runtime.py | 60 +++++++++++++++++++++++++++++++++++++++ tools/pstack_opencodex.py | 33 +++++++++++++++++---- 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 484a1b4..ef6d822 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -198,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() diff --git a/tools/pstack_opencodex.py b/tools/pstack_opencodex.py index d34dfef..b18a49c 100644 --- a/tools/pstack_opencodex.py +++ b/tools/pstack_opencodex.py @@ -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": @@ -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" @@ -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():