diff --git a/always-on/create-skill.md b/always-on/create-skill.md index 9931d7a1..d09089e9 100644 --- a/always-on/create-skill.md +++ b/always-on/create-skill.md @@ -4,8 +4,8 @@ When asked to create, add, install, or author a skill, or when about to write a new `SKILL.md` / home-link a skill directory, read the `create-skill` skill first (`engine/skills/create-skill/SKILL.md` or the installed `create-skill` skill). -A skill MUST be available to Claude, Cursor, and Codex unless it is listed in +A skill MUST be available to Claude, Cursor, Codex, and Muse unless it is listed in `CLAUDE_ONLY_SKILLS` in catstack `install.sh`. Prefer catstack -`product/skills//` (or corpus) + `./install.sh`. Project-skill home links MUST hit all three +`product/skills//` (or corpus) + `./install.sh`. Project-skill home links MUST hit all four roots (`scripts/install/link_skill_three_harnesses.sh`). Do not follow Cursor-only `~/.cursor/skills/` install advice. diff --git a/cursor/rules/create-skill-three-harnesses.mdc b/cursor/rules/create-skill-three-harnesses.mdc index 3753b77a..5fb68403 100644 --- a/cursor/rules/create-skill-three-harnesses.mdc +++ b/cursor/rules/create-skill-three-harnesses.mdc @@ -1,5 +1,5 @@ --- -description: Creating or installing a skill must target Claude, Cursor, and Codex — never a single harness +description: Creating or installing a skill must target Claude, Cursor, Codex, and Muse — never a single harness alwaysApply: true --- @@ -16,16 +16,16 @@ or `~/.codex/engine/skills/create-skill/SKILL.md` after `./install.sh`) or ## Invariants (assert) -- A skill MUST apply to **Claude, Cursor, and Codex** unless it is listed in +- A skill MUST apply to **Claude, Cursor, Codex, and Muse** unless it is listed in `CLAUDE_ONLY_SKILLS` in `install.sh`. - Prefer `product/skills//` (or `corpus/skills//`) in catstack + `./install.sh`. -- Project-skill home links MUST use all three roots (or - `scripts/install/link_skill_three_harnesses.sh`) — never Claude+Cursor only. +- Project-skill home links MUST use all four roots (or + `scripts/install/link_skill_three_harnesses.sh`) — never a subset. - After install/link, run `python3 scripts/ci/check_skills_three_harnesses.py` (add `--home` when checking live personal roots). Do not follow Cursor built-in create-skill advice that only mentions -`~/.cursor/skills/`. Do not leave Codex (or any third root) unlinked. +`~/.cursor/skills/`. Do not leave any harness root unlinked. Incident this prevents: agents home-linked `wipe-bad-pr` into Claude and Cursor only and missed `~/.codex/skills/`. diff --git a/drafter.config.json b/drafter.config.json index 6271b2f0..a432aa3c 100644 --- a/drafter.config.json +++ b/drafter.config.json @@ -37,6 +37,8 @@ { "id": "product-skill", "pathGlob": "product/skills/**", "unit": ["product-skill"] }, { "id": "engine-runtime", "pathGlob": "{engine,scripts,.github}/**", "unit": ["engine-runtime"] }, { "id": "engine-runtime-root-config", "pathGlob": "{drafter.config.json,install.sh,install_*.py}", "unit": ["engine-runtime"] }, + { "id": "engine-runtime-always-on", "pathGlob": "always-on/**", "unit": ["engine-runtime"] }, + { "id": "engine-runtime-cursor-rules", "pathGlob": "cursor/rules/**", "unit": ["engine-runtime"] }, { "id": "tests-dirs-proof", "pathGlob": "**/{test,tests,__tests__,e2e}/**", "unit": ["proof"] }, { "id": "ecosystem-inventory-neutral", "pathGlob": "docs/ecosystem.md", "unit": [] }, { "id": "skill-triggers-inventory-neutral", "pathGlob": "docs/skill-triggers.md", "unit": [] }, diff --git a/engine/CLAUDE.core.md b/engine/CLAUDE.core.md index 64a59590..54685747 100644 --- a/engine/CLAUDE.core.md +++ b/engine/CLAUDE.core.md @@ -58,7 +58,7 @@ Slash commands `/pr-skill`, `/draft-pr`, and `/make-pr` all enter this skill. When asked to create, add, install, or author a skill, or when about to write a new `SKILL.md` / home-link a skill directory, read the `create-skill` skill first (`engine/skills/create-skill/SKILL.md` or the installed `create-skill` skill). -A skill MUST be available to Claude, Cursor, and Codex unless it is listed in `CLAUDE_ONLY_SKILLS` in `install.sh`. Prefer catstack `product/skills//` or `corpus/skills//` + `./install.sh`. Project-skill home links MUST hit all three roots (`scripts/install/link_skill_three_harnesses.sh`). Do not follow Cursor-only `~/.cursor/skills/` install advice. +A skill MUST be available to Claude, Cursor, Codex, and Muse unless it is listed in `CLAUDE_ONLY_SKILLS` in `install.sh`. Prefer catstack `product/skills//` or `corpus/skills//` + `./install.sh`. Project-skill home links MUST hit all four roots (`scripts/install/link_skill_three_harnesses.sh`). Do not follow Cursor-only `~/.cursor/skills/` install advice. # Named constraints (apply everywhere) diff --git a/engine/skills/create-skill/SKILL.md b/engine/skills/create-skill/SKILL.md index c5c1c6b7..2ae6b3e5 100644 --- a/engine/skills/create-skill/SKILL.md +++ b/engine/skills/create-skill/SKILL.md @@ -1,13 +1,13 @@ --- name: create-skill description: >- - Create or install an agent skill for Claude, Cursor, and Codex together. + Create or install an agent skill for Claude, Cursor, Codex, and Muse together. Use when authoring a new skill, adding SKILL.md, home-linking a project skill, or when the user says create-skill / install a skill. Overrides single-harness Cursor-only install advice. --- -# Creating skills (Claude + Cursor + Codex) +# Creating skills (Claude + Cursor + Codex + Muse) ## Invariants (assert) @@ -15,11 +15,11 @@ description: >- in that skill package (except allowlisted consumer/runtime contract paths such as `.cursor/judge-swarm-bindings.json`). Enforced by `scripts/ci/check_skill_file_refs.py`. -- A new skill MUST be available to **Claude, Cursor, and Codex** — never only +- A new skill MUST be available to **Claude, Cursor, Codex, and Muse** — never only the harness the agent happens to be running in. - Prefer putting portable skills under `product/skills//` (or mined lessons under `corpus/skills//`) and running `./install.sh`. That is - the only path that keeps all three harness roots in sync automatically. + the only path that keeps all four harness roots in sync automatically. See [docs/ecosystem.md](../../../docs/ecosystem.md). Engine skills (`reflect`, `create-skill`, …) live under `engine/skills/` only. - Claude-only skills MUST be listed in `CLAUDE_ONLY_SKILLS` in `install.sh` @@ -92,7 +92,7 @@ After reading `SKILL.md`, read **at most one** sibling `domains/.md`: - Project CLIs that only exist in one repo stay project skills (home-link with `scripts/install/link_skill_three_harnesses.sh`), not catstack domains. -## Project-skill home link (all three) +## Project-skill home link (all four) If the skill must live in a project (e.g. `.cursor/skills/wipe-bad-pr`): @@ -108,6 +108,7 @@ name=$(basename "$src") ln -sfn "$src" "$HOME/.claude/skills/$name" ln -sfn "$src" "$HOME/.cursor/skills/$name" ln -sfn "$src" "$HOME/.codex/skills/$name" +ln -sfn "$src" "${MUSE_SKILLS_DIR:-$HOME/workspace/skills}/$name" ``` Then run: diff --git a/engine/skills/create-skill/tests/fires_example.md b/engine/skills/create-skill/tests/fires_example.md index fb881c4d..81e71709 100644 --- a/engine/skills/create-skill/tests/fires_example.md +++ b/engine/skills/create-skill/tests/fires_example.md @@ -1,8 +1,8 @@ User: "Add a new skill that watches flaky CI jobs and retries them — -make sure it's available in Claude, Cursor, and Codex." +make sure it's available in Claude, Cursor, Codex, and Muse." This should fire: authoring a new skill / adding a `SKILL.md` / needing -it home-linked across all three harnesses is exactly this skill's scope. +it home-linked across all four harnesses is exactly this skill's scope. The ecosystem doc link in SKILL.md is `../../../docs/ecosystem.md` (three levels up from engine/skills/create-skill/). `scripts/ci/check_skill_file_refs.py` diff --git a/engine/skills/make-pr/tests/test_preflight.py b/engine/skills/make-pr/tests/test_preflight.py index 35f5f85d..55e38d85 100644 --- a/engine/skills/make-pr/tests/test_preflight.py +++ b/engine/skills/make-pr/tests/test_preflight.py @@ -50,6 +50,10 @@ def test_scripts_and_install_sh_are_engine_runtime_like_drafter_config(self): self.assertEqual(set(info["units"]), {"engine-runtime"}) self.assertEqual(info["neutral"], ["docs/ecosystem.md"]) + def test_installed_agent_rule_inputs_are_engine_runtime(self): + info = pf.classify(["always-on/create-skill.md", "cursor/rules/create-skill-three-harnesses.mdc"]) + self.assertEqual(set(info["units"]), {"engine-runtime"}) + def test_docs_other_than_the_inventory_are_their_own_unit(self): info = pf.classify(["engine/hooks/demo/detect.py", "docs/guide.md"]) self.assertEqual(set(info["units"]), {"engine-runtime", "docs"}) diff --git a/install.sh b/install.sh index dcdba86d..f9ea03a4 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,8 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MUSE_SKILLS_DIR="${MUSE_SKILLS_DIR:-$HOME/workspace/skills}" + resolve_main_checkout() { local start="$1" common parent common="$(git -C "$start" rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || return 1 @@ -282,6 +284,7 @@ install_into() { install_into claude "$HOME/.claude/skills" install_into cursor "$HOME/.cursor/skills" install_into codex "$HOME/.codex/skills" +install_into muse "$MUSE_SKILLS_DIR" # Hooks aren't per-agent skill folders, so they don't go through install_into # -- but they get the same fixed, portable symlink location. Hook configs @@ -476,6 +479,23 @@ else fi python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" "${CODEX_AGENTS_ARGS[@]}" +echo "--- muse global AGENTS.md (\$HOME/AGENTS.md) ---" +MUSE_AGENTS_ARGS=("${CODEX_AGENTS_ARGS[@]}") +CAT_MODE_DEFAULT_FRAGMENT="" +if [ "$CAT_MODE_DEFAULT" = "on" ]; then + CAT_MODE_DEFAULT_FRAGMENT="$(mktemp)" + echo "cat-mode default is on: read and apply ${MUSE_SKILLS_DIR/#$HOME/\~}/cat-mode/SKILL.md on every turn before starting." > "$CAT_MODE_DEFAULT_FRAGMENT" + MUSE_AGENTS_ARGS+=(--fragment "cat-mode-default=$CAT_MODE_DEFAULT_FRAGMENT") + echo "write cat-mode-default fragment for muse AGENTS.md (CATSTACK_CAT_MODE_DEFAULT=on)" +else + MUSE_AGENTS_ARGS+=(--without cat-mode-default) + echo "remove cat-mode-default fragment from muse AGENTS.md (CATSTACK_CAT_MODE_DEFAULT=$CAT_MODE_DEFAULT)" +fi +python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" --agents-path "$HOME/AGENTS.md" "${MUSE_AGENTS_ARGS[@]}" +if [ -n "$CAT_MODE_DEFAULT_FRAGMENT" ]; then + rm -f "$CAT_MODE_DEFAULT_FRAGMENT" +fi + echo "--- remove catstack links this install no longer creates ---" CATSTACK_ROOTS="$REPO_DIR"$'\n'"$(cd "$REPO_DIR" && pwd -P)" if MAIN_CHECKOUT="$(resolve_main_checkout "$REPO_DIR")"; then @@ -486,7 +506,8 @@ CATSTACK_ROOTS="$CATSTACK_ROOTS"$'\n'"$HOOKS_SNAPSHOT_DIR" for sweep_dir in \ "$HOME/.claude/hooks" "$HOME/.claude/skills" "$HOME/.claude/commands" \ "$HOME/.cursor/hooks" "$HOME/.cursor/skills" "$HOME/.cursor/commands" "$HOME/.cursor/rules" \ - "$HOME/.codex/hooks" "$HOME/.codex/skills" "$HOME/.codex/commands" + "$HOME/.codex/hooks" "$HOME/.codex/skills" "$HOME/.codex/commands" \ + "$MUSE_SKILLS_DIR" do [ -d "$sweep_dir" ] || continue for entry in "$sweep_dir"/*; do diff --git a/scripts/ci/check_skills_three_harnesses.py b/scripts/ci/check_skills_three_harnesses.py index 66b1b78d..bd96cb05 100755 --- a/scripts/ci/check_skills_three_harnesses.py +++ b/scripts/ci/check_skills_three_harnesses.py @@ -1,16 +1,15 @@ #!/usr/bin/env python3 -"""Mechanical check: skills must target Claude, Cursor, and Codex. +"""Mechanical check: skills must target Claude, Cursor, Codex, and Muse. Repo mode (default, CI-safe): - - install.sh must install into all three personal skill roots + - install.sh must install into all four personal skill roots - create-skill skill + always-on rule/fragment must state the invariant - - CONTRIBUTING.md must state the three-harness assert Home mode (--home): - - Catstack skills present in any personal root must exist in all three + - Catstack skills present in any personal root must exist in all four (except CLAUDE_ONLY_SKILLS, which must stay Claude-only). - Non-catstack skills: fail only when the same symlink target is linked - into two roots but missing from the third (incomplete multi-harness + into two roots but missing from another (incomplete multi-harness install). Unrelated real directories are ignored. Exit 0 on pass, 1 on failure. Prints each failure line. @@ -31,13 +30,12 @@ os.path.join(REPO_ROOT, "product", "skills"), ) CREATE_SKILL = os.path.join(REPO_ROOT, "engine", "skills", "create-skill", "SKILL.md") -CONTRIBUTING = os.path.join(REPO_ROOT, "CONTRIBUTING.md") CURSOR_RULE = os.path.join( REPO_ROOT, "cursor", "rules", "create-skill-three-harnesses.mdc" ) ALWAYS_ON = os.path.join(REPO_ROOT, "always-on", "create-skill.md") -REQUIRED_PHRASE = "Claude, Cursor, and Codex" +REQUIRED_PHRASE = "Claude, Cursor, Codex, and Muse" PROMISED_CATCH = ( "reflect: claude cursor", @@ -46,15 +44,24 @@ "outside-skill: claude cursor linked", ) PROMISED_ALLOW = ( - "reflect: claude cursor codex", + "reflect: claude cursor codex muse", "reflect:", "automate-me: claude", "outside-skill: claude", "outside-skill: claude cursor", - "outside-skill: claude cursor codex linked", + "outside-skill: claude cursor codex muse linked", ) +def muse_skills_dir(home: str) -> str: + """Muse (Meta's agent) keeps skills in its workspace skills dir, not a + dot-dir. Honor the same MUSE_SKILLS_DIR override install.sh uses.""" + override = os.environ.get("MUSE_SKILLS_DIR") + if override: + return override + return os.path.join(home, "workspace", "skills") + + def flags_exemplar(exemplar: str) -> bool: name, _, rest = exemplar.partition(":") tokens = rest.split() @@ -70,6 +77,13 @@ def flags_exemplar(exemplar: str) -> bool: os.symlink(source, os.path.join(root, name)) else: os.makedirs(os.path.join(root, name)) + muse_root = muse_skills_dir(home) + os.makedirs(muse_root) + if "muse" in tokens: + if "linked" in tokens: + os.symlink(source, os.path.join(muse_root, name)) + else: + os.makedirs(os.path.join(muse_root, name)) return bool(check_home(home)) @@ -89,17 +103,17 @@ def check_repo() -> list[str]: with open(INSTALL_SH) as handle: install_text = handle.read() - for agent, marker in ( - ("claude", 'install_into claude "$HOME/.claude/skills"'), - ("cursor", 'install_into cursor "$HOME/.cursor/skills"'), - ("codex", 'install_into codex'), + for agent, marker, path_fragment in ( + ("claude", 'install_into claude "$HOME/.claude/skills"', "$HOME/.claude/skills"), + ("cursor", 'install_into cursor "$HOME/.cursor/skills"', "$HOME/.cursor/skills"), + ("codex", 'install_into codex', "$HOME/.codex/skills"), + ("muse", "install_into muse", "MUSE_SKILLS_DIR"), ): - if marker not in install_text or f"$HOME/.{agent}/skills" not in install_text: + if marker not in install_text or path_fragment not in install_text: errors.append(f"install.sh missing install_into for {agent}") for path, label in ( (CREATE_SKILL, "engine/skills/create-skill/SKILL.md"), - (CONTRIBUTING, "CONTRIBUTING.md"), (CURSOR_RULE, "cursor/rules/create-skill-three-harnesses.mdc"), (ALWAYS_ON, "always-on/create-skill.md"), ): @@ -162,9 +176,9 @@ def skill_entry(root: str, name: str) -> tuple[bool, str | None]: def check_home(home: str) -> list[str]: """Flag incomplete multi-harness installs. - - Catstack repo skills MUST exist in all three roots (except CLAUDE_ONLY). + - Catstack repo skills MUST exist in all four roots (except CLAUDE_ONLY). - Non-catstack skills: only fail when the *same symlink target* is linked - into two roots but missing from the third (the wipe-bad-pr class). Real + into two roots but missing from another (the wipe-bad-pr class). Real single-harness copies (Invoker dirs, etc.) are ignored. """ errors: list[str] = [] @@ -175,6 +189,7 @@ def check_home(home: str) -> list[str]: "claude": os.path.join(home, ".claude", "skills"), "cursor": os.path.join(home, ".cursor", "skills"), "codex": os.path.join(home, ".codex", "skills"), + "muse": muse_skills_dir(home), } by_agent = {agent: skill_names_in(path) for agent, path in roots.items()} catstack_names = repo_skill_names() @@ -194,10 +209,10 @@ def check_home(home: str) -> list[str]: continue if not present["claude"]: errors.append(f"{name}: CLAUDE_ONLY but missing from ~/.claude/skills") - for agent in ("cursor", "codex"): + for agent in ("cursor", "codex", "muse"): if present[agent]: errors.append( - f"{name}: CLAUDE_ONLY but present in ~/.{agent}/skills" + f"{name}: CLAUDE_ONLY but present in {roots[agent]}" ) continue @@ -212,7 +227,7 @@ def check_home(home: str) -> list[str]: ) continue - # Same source linked into ≥2 harnesses ⇒ must be in all three. + # Same source linked into ≥2 harnesses ⇒ must be in all four. if len(targets) < 2: continue # Group agents by target path. @@ -236,7 +251,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--home", action="store_true", - help="Check live $HOME skill roots for three-harness parity", + help="Check live $HOME skill roots for four-harness parity", ) parser.add_argument( "--home-dir", @@ -250,11 +265,11 @@ def main(argv: list[str] | None = None) -> int: errors.extend(check_home(args.home_dir)) if errors: - print("FAIL: skills three-harness check", file=sys.stderr) + print("FAIL: skills four-harness check", file=sys.stderr) for err in errors: print(f" - {err}", file=sys.stderr) return 1 - print("ok skills three-harness check") + print("ok skills four-harness check") return 0 diff --git a/scripts/install/install_codex_agents_md.py b/scripts/install/install_codex_agents_md.py index 39ec3046..9338af43 100644 --- a/scripts/install/install_codex_agents_md.py +++ b/scripts/install/install_codex_agents_md.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 -"""Idempotently merge always-on catstack fragments into ~/.codex/AGENTS.md. +"""Idempotently merge always-on catstack fragments into an AGENTS.md-style +global instructions file. -Codex reads AGENTS.md as global instructions. That file also holds other -personal rules, so this never replaces the whole file: it inserts or -replaces marked blocks. Safe to rerun. Creates AGENTS.md when missing. +Codex reads ~/.codex/AGENTS.md as global instructions; Muse (Meta's agent) +reads ~/AGENTS.md. Either file also holds other personal rules, so this never +replaces the whole file: it inserts or replaces marked blocks. Safe to +rerun. Creates the file when missing. Defaults to the Codex path; pass +--agents-path for any other harness. Each always-on/.md is wrapped in: @@ -20,7 +23,7 @@ REPO_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) ALWAYS_ON_DIR = os.path.join(REPO_DIR, "always-on") -AGENTS_PATH = os.path.expanduser("~/.codex/AGENTS.md") +DEFAULT_AGENTS_PATH = os.path.expanduser("~/.codex/AGENTS.md") # Stable order: draft-pr first (historical), then create-skill, then any others. PREFERRED_ORDER = ("draft-pr", "create-skill", "named-constraints") @@ -80,21 +83,32 @@ def remove_block(existing, begin, end): return existing[:start] + existing[end_idx:], True +def display_path(path): + home = os.path.expanduser("~") + if path == home or path.startswith(home + os.sep): + return "~" + path[len(home):] + return path + + def main(argv=None): parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--agents-path", default=DEFAULT_AGENTS_PATH, + help="global instructions file to merge into (default: %(default)s)") parser.add_argument("--fragment", action="append", default=[], metavar="NAME=PATH") parser.add_argument("--without", action="append", default=[], metavar="NAME") args = parser.parse_args(argv) + agents_path = os.path.expanduser(args.agents_path) + label = display_path(agents_path) extra = [] for spec in args.fragment: name, sep, path = spec.partition("=") if not sep or not name or not os.path.isfile(path): parser.error(f"--fragment wants NAME=PATH to an existing file, got {spec!r}") extra.append((name, path)) - os.makedirs(os.path.dirname(AGENTS_PATH), exist_ok=True) + os.makedirs(os.path.dirname(agents_path), exist_ok=True) existing = "" - if os.path.exists(AGENTS_PATH): - with open(AGENTS_PATH) as handle: + if os.path.exists(agents_path): + with open(agents_path) as handle: existing = handle.read() text = existing @@ -104,9 +118,9 @@ def main(argv=None): text, removed = remove_block(text, begin, end) if removed: any_changed = True - print(f"remove {name} block from ~/.codex/AGENTS.md") + print(f"remove {name} block from {label}") else: - print(f"ok codex AGENTS.md has no {name} block") + print(f"ok {label} has no {name} block") for name, path in fragment_paths() + extra: if name in args.without: continue @@ -117,12 +131,12 @@ def main(argv=None): if changed: any_changed = True action = "merged" if existing else "created" - print(f"link {action} {name} block into ~/.codex/AGENTS.md") + print(f"link {action} {name} block into {label}") else: - print(f"ok codex AGENTS.md {name} block already up to date") + print(f"ok {name} block in {label} already up to date") if any_changed: - with open(AGENTS_PATH, "w") as handle: + with open(agents_path, "w") as handle: handle.write(text) diff --git a/scripts/install/link_skill_three_harnesses.sh b/scripts/install/link_skill_three_harnesses.sh index cfcbb5da..8ab35be7 100755 --- a/scripts/install/link_skill_three_harnesses.sh +++ b/scripts/install/link_skill_three_harnesses.sh @@ -1,10 +1,8 @@ #!/bin/bash -# Symlink one skill directory into Claude, Cursor, and Codex personal skill -# roots. Use for project-local skills that are not installed via catstack -# ./install.sh. Safe to rerun: replaces wrong symlinks; refuses to clobber a -# real (non-symlink) directory without --force. set -euo pipefail +MUSE_SKILLS_DIR="${MUSE_SKILLS_DIR:-$HOME/workspace/skills}" + FORCE=0 if [ "${1:-}" = "--force" ]; then FORCE=1 @@ -55,4 +53,5 @@ status=0 link_one "$HOME/.claude/skills/$name" || status=1 link_one "$HOME/.cursor/skills/$name" || status=1 link_one "$HOME/.codex/skills/$name" || status=1 +link_one "$MUSE_SKILLS_DIR/$name" || status=1 exit "$status" diff --git a/tests/test_install.py b/tests/test_install.py index 0c155107..47c69bea 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -855,7 +855,7 @@ def test_cursor_always_on_create_skill_rule_symlinked(self): with open(rule) as f: text = f.read() self.assertIn("alwaysApply: true", text) - self.assertIn("Claude, Cursor, and Codex", text) + self.assertIn("Claude, Cursor, Codex, and Muse", text) self.assertIn("create-skill", text) def test_codex_agents_md_gets_create_skill_block(self): @@ -865,7 +865,7 @@ def test_codex_agents_md_gets_create_skill_block(self): text = f.read() self.assertIn("", text) self.assertIn("", text) - self.assertIn("Claude, Cursor, and Codex", text) + self.assertIn("Claude, Cursor, Codex, and Muse", text) self.assertIn("create-skill", text) def test_create_skill_symlinked_for_claude_cursor_and_codex(self): @@ -877,6 +877,81 @@ def test_create_skill_symlinked_for_claude_cursor_and_codex(self): skill_src("create-skill"), ) + def test_non_claude_only_skills_symlinked_for_muse(self): + for name in real_skill_names(): + if name in self.CLAUDE_ONLY: + continue + target = os.path.join(self.fake_home, "workspace", "skills", name) + self.assertTrue(os.path.islink(target), f"{name} not symlinked for muse") + self.assertEqual(os.readlink(target), skill_src(name)) + + def test_claude_only_skills_absent_for_muse(self): + for name in self.CLAUDE_ONLY: + target = os.path.join(self.fake_home, "workspace", "skills", name) + self.assertFalse( + os.path.exists(target) or os.path.islink(target), + f"{name} should be absent for muse", + ) + + def test_muse_agents_md_gets_always_on_blocks(self): + path = os.path.join(self.fake_home, "AGENTS.md") + self.assertTrue(os.path.exists(path), self.result.stdout) + with open(path) as handle: + text = handle.read() + for fragment in ("draft-pr", "create-skill", "named-constraints", "evidence-check"): + with self.subTest(fragment=fragment): + self.assertIn(f"", text) + self.assertIn(f"", text) + + def test_muse_agents_md_preserves_existing_content(self): + agents_path = os.path.join(self.fake_home, "AGENTS.md") + with open(agents_path, "w") as handle: + handle.write("# my own notes\n\nkeep me\n") + result = run_install(self.fake_home) + self.assertEqual(result.returncode, 0, result.stderr) + with open(agents_path) as handle: + text = handle.read() + self.assertIn("# my own notes", text) + self.assertIn("keep me", text) + self.assertIn("", text) + + def test_muse_gets_no_hooks_or_commands_dirs(self): + self.assertFalse(os.path.lexists(os.path.join(self.fake_home, "workspace", "hooks"))) + self.assertFalse(os.path.lexists(os.path.join(self.fake_home, "workspace", "commands"))) + + def test_muse_agents_md_gets_cat_mode_default_block_when_on(self): + with tempfile.TemporaryDirectory() as fake_home: + result = run_install(fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "on"}) + self.assertEqual(result.returncode, 0, result.stderr) + with open(os.path.join(fake_home, "AGENTS.md")) as handle: + text = handle.read() + self.assertIn("", text) + self.assertIn("", text) + self.assertIn("cat-mode default is on", text) + self.assertIn("~/workspace/skills/cat-mode/SKILL.md", text) + + def test_muse_agents_md_drops_cat_mode_default_block_when_off(self): + with tempfile.TemporaryDirectory() as fake_home: + result = run_install(fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "on"}) + self.assertEqual(result.returncode, 0, result.stderr) + agents_path = os.path.join(fake_home, "AGENTS.md") + with open(agents_path) as handle: + self.assertIn("", handle.read()) + result = run_install(fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "off"}) + self.assertEqual(result.returncode, 0, result.stderr) + with open(agents_path) as handle: + text = handle.read() + self.assertNotIn("catstack-cat-mode-default", text) + self.assertIn("", text) + + def test_codex_agents_md_does_not_get_cat_mode_default_block(self): + with tempfile.TemporaryDirectory() as fake_home: + result = run_install(fake_home, extra_env={"CATSTACK_CAT_MODE_DEFAULT": "on"}) + self.assertEqual(result.returncode, 0, result.stderr) + with open(os.path.join(fake_home, ".codex", "AGENTS.md")) as handle: + text = handle.read() + self.assertNotIn("catstack-cat-mode-default", text) + class TestRegistryHookInstall(unittest.TestCase): def test_install_writes_only_active_registry_hooks_through_runner(self): @@ -975,7 +1050,7 @@ def test_fresh_home_links_engine_and_core_product_skills_only(self): for name in expected_claude: self.assertEqual(os.readlink(self.skill_path(".claude", name)), skill_src(name)) - for agent_dir in (".cursor", ".codex"): + for agent_dir in (".cursor", ".codex", "workspace"): names = set(os.listdir(os.path.join(self.fake_home, agent_dir, "skills"))) expected = expected_claude - self.CLAUDE_ONLY self.assertEqual(names, expected) diff --git a/tests/test_reflect_enforcement_install.py b/tests/test_reflect_enforcement_install.py index e348dcd0..1856e68b 100644 --- a/tests/test_reflect_enforcement_install.py +++ b/tests/test_reflect_enforcement_install.py @@ -49,12 +49,16 @@ def cursor_rule(self): def codex_agents(self): return read(os.path.join(self.home, ".codex", "AGENTS.md")) - def test_on_installs_the_rule_for_all_three_harnesses(self): + def muse_agents(self): + return read(os.path.join(self.home, "AGENTS.md")) + + def test_on_installs_the_rule_for_all_four_harnesses(self): self.install("1") self.assertIn("automate-me", self.local_rule()) self.assertTrue(os.path.islink(self.cursor_rule())) self.assertEqual(os.readlink(self.cursor_rule()), os.path.join(REPO_ROOT, "engine", "hooks", "_flags", "rules", "reflect-enforcement.mdc")) self.assertIn(CODEX_BEGIN, self.codex_agents()) + self.assertIn(CODEX_BEGIN, self.muse_agents()) def test_off_installs_no_rule_anywhere(self): result = self.install("0") @@ -62,7 +66,9 @@ def test_off_installs_no_rule_anywhere(self): self.assertIn("off", self.local_rule()) self.assertFalse(os.path.lexists(self.cursor_rule())) self.assertNotIn(CODEX_BEGIN, self.codex_agents()) + self.assertNotIn(CODEX_BEGIN, self.muse_agents()) self.assertIn("catstack-named-constraints", self.codex_agents()) + self.assertIn("catstack-named-constraints", self.muse_agents()) self.assertIn("no automate-me rule", result.stdout) def test_turning_it_off_removes_what_on_installed(self): @@ -70,6 +76,7 @@ def test_turning_it_off_removes_what_on_installed(self): result = self.install("0") self.assertFalse(os.path.lexists(self.cursor_rule())) self.assertNotIn(CODEX_BEGIN, self.codex_agents()) + self.assertNotIn(CODEX_BEGIN, self.muse_agents()) self.assertIn("remove reflect-enforcement.mdc", result.stdout) self.assertIn("remove reflect-enforcement block", result.stdout) diff --git a/tests/test_skills_three_harnesses.py b/tests/test_skills_three_harnesses.py index 88332bd6..19f55efe 100644 --- a/tests/test_skills_three_harnesses.py +++ b/tests/test_skills_three_harnesses.py @@ -4,12 +4,17 @@ import os import subprocess +import importlib.util import tempfile import unittest REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CHECK = os.path.join(REPO_ROOT, "scripts", "ci", "check_skills_three_harnesses.py") LINK = os.path.join(REPO_ROOT, "scripts", "install", "link_skill_three_harnesses.sh") +SPEC = importlib.util.spec_from_file_location("check_skills_three_harnesses", CHECK) +CHECKER = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(CHECKER) class TestCheckRepoMode(unittest.TestCase): @@ -23,6 +28,21 @@ def test_repo_check_passes_on_this_checkout(self): self.assertEqual(result.returncode, 0, result.stderr + result.stdout) self.assertIn("ok", result.stdout) + def test_repo_check_does_not_gate_handbook_copy(self): + with tempfile.NamedTemporaryFile("w") as handle: + handle.write("A skill MUST apply to Claude, Cursor, and Codex.\n") + handle.flush() + old = getattr(CHECKER, "CONTRIBUTING", None) + CHECKER.CONTRIBUTING = handle.name + try: + errors = CHECKER.check_repo() + finally: + if old is None: + delattr(CHECKER, "CONTRIBUTING") + else: + CHECKER.CONTRIBUTING = old + self.assertFalse([err for err in errors if "CONTRIBUTING" in err], errors) + class TestCheckHomeMode(unittest.TestCase): def test_partial_same_source_symlinks_fail(self): @@ -46,7 +66,7 @@ def test_partial_same_source_symlinks_fail(self): self.assertIn("wipe-bad-pr", result.stderr) self.assertIn("codex", result.stderr) - def test_all_three_same_source_passes(self): + def test_all_four_same_source_passes(self): with tempfile.TemporaryDirectory() as home: src = os.path.join(home, "src", "wipe-bad-pr") os.makedirs(src) @@ -56,6 +76,9 @@ def test_all_three_same_source_passes(self): skills = os.path.join(home, agent, "skills") os.makedirs(skills) os.symlink(src, os.path.join(skills, "wipe-bad-pr")) + muse_skills = os.path.join(home, "workspace", "skills") + os.makedirs(muse_skills) + os.symlink(src, os.path.join(muse_skills, "wipe-bad-pr")) result = subprocess.run( ["python3", CHECK, "--home", "--home-dir", home], capture_output=True, @@ -81,7 +104,7 @@ def test_unrelated_real_dirs_in_two_harnesses_are_ignored(self): class TestLinkScript(unittest.TestCase): - def test_links_all_three(self): + def test_links_all_four(self): with tempfile.TemporaryDirectory() as tmp: skill = os.path.join(tmp, "demo-skill") os.makedirs(skill) @@ -101,6 +124,9 @@ def test_links_all_three(self): target = os.path.join(home, agent, "skills", "demo-skill") self.assertTrue(os.path.islink(target), target) self.assertEqual(os.readlink(target), skill) + muse_target = os.path.join(home, "workspace", "skills", "demo-skill") + self.assertTrue(os.path.islink(muse_target), muse_target) + self.assertEqual(os.readlink(muse_target), skill) if __name__ == "__main__":