From 977a5e991f9437b0774161b365b17dc540b2656b Mon Sep 17 00:00:00 2001 From: Nyan Date: Mon, 28 Sep 2026 12:58:13 +0800 Subject: [PATCH 1/3] Add Muse harness support: skills + always-on rules - install.sh: install catstack skills into ${MUSE_SKILLS_DIR:-$HOME/workspace/skills} and merge always-on fragments into ~/AGENTS.md as managed blocks - install_codex_agents_md.py: generalized to an AGENTS-style merger with --agents-path (defaults to ~/.codex/AGENTS.md) - link_skill_three_harnesses.sh: link project skills into Muse's skill dir - check_skills_three_harnesses.py: four-harness parity (claude-only skills stay out of cursor, codex, and muse) - Docs: README, CONTRIBUTING, create-skill, always-on, cursor rule, provenance, CLAUDE.core updated for four harnesses - Tests: muse skill install, AGENTS.md managed blocks, content preservation, no hooks/commands dirs, reflect-enforcement for the muse block --- CONTRIBUTING.md | 6 +- README.md | 14 ++--- always-on/create-skill.md | 4 +- cursor/rules/create-skill-three-harnesses.mdc | 10 ++-- docs/provenance.md | 4 +- engine/CLAUDE.core.md | 2 +- engine/skills/create-skill/SKILL.md | 11 ++-- install.sh | 16 ++++- scripts/ci/check_skills_three_harnesses.py | 60 ++++++++++++------- scripts/install/install_codex_agents_md.py | 40 +++++++++---- scripts/install/link_skill_three_harnesses.sh | 14 +++-- tests/test_install.py | 46 +++++++++++++- tests/test_reflect_enforcement_install.py | 9 ++- tests/test_skills_three_harnesses.py | 10 +++- 14 files changed, 178 insertions(+), 68 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 77af5d52..998f860f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,14 +9,14 @@ catstack is a personal skill stack. Portable skills and hooks are welcome; proje 3. Optional (product only): add task-type files under `domains/` (`coding.md`, `equities.md`). Paste the domain selector from `engine/skills/create-skill/SKILL.md` into `SKILL.md`. Generic prose MUST NOT name repo CLIs; domain files only add triggers and cwd filename lookups. 4. If it came from another repo, add a sourcing note in [docs/provenance.md](docs/provenance.md). 5. Write its `tests/` dir: code skills need at least two real test functions; prose-only skills need a positive fixture and a negative fixture (e.g. `tests/fires_example.md` / `tests/stays_silent_example.md`). -6. Run `./install.sh` so the skill lands in **Claude, Cursor, and Codex**. Do not hand-link a single harness. +6. Run `./install.sh` so the skill lands in **Claude, Cursor, Codex, and Muse**. Do not hand-link a single harness. ### Invariants (assert) - Every new skill MUST ship a `tests/` dir (positive + negative — see `engine/skills/create-skill/SKILL.md`) before it's added. Checked by `scripts/ci/check_skill_test_coverage.py`; existing untested skills are grandfathered in `scripts/ci/skill_test_debt_allowlist.txt`, which is shrink-only. -- Every new skill MUST apply to Claude, Cursor, and Codex unless it is listed in `CLAUDE_ONLY_SKILLS`. +- Every new skill MUST apply to Claude, Cursor, Codex, and Muse unless it is listed in `CLAUDE_ONLY_SKILLS`. - `./install.sh` MUST remain the install path for portable skills. Manual `ln -s` into only `~/.cursor/skills` or only `~/.claude/skills` is a bug. -- Project-skill home links (outside this repo) MUST use `scripts/install/link_skill_three_harnesses.sh` (or equivalent links into all three roots). +- Project-skill home links (outside this repo) MUST use `scripts/install/link_skill_three_harnesses.sh` (or equivalent links into all four roots). - Follow `engine/skills/create-skill/SKILL.md` — not Cursor built-in create-skill text that only mentions `~/.cursor/skills/`. - Domain-aware product skills MUST include the selector phrase in `SKILL.md` and MUST keep repo CLIs out of the generic file. diff --git a/README.md b/README.md index 6ccc79fd..8bc4760d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # catstack -**Self-improving ecosystem engine** (engine + corpus + product) for Claude, Cursor, and Codex +**Self-improving ecosystem engine** (engine + corpus + product) for Claude, Cursor, Codex, and Muse [![CI](https://github.com/EdbertChan/catstack/actions/workflows/ci.yml/badge.svg)](https://github.com/EdbertChan/catstack/actions/workflows/ci.yml) [![Agents](https://img.shields.io/badge/agents-Claude%20%7C%20Cursor%20%7C%20Codex-lightgrey?style=flat-square)](#install) @@ -13,7 +13,7 @@ One clone. One `./install.sh`. Same stack on every machine. **[Install](#install)** · **[Ecosystem](docs/ecosystem.md)** · **[Skills](#skills)** · **[Hooks](#hooks)** · **[Provenance](docs/provenance.md)** -catstack — Claude, Cursor, Codex +catstack — Claude, Cursor, Codex, Muse ### Agent DORA (personal) @@ -81,7 +81,7 @@ Bucket inventory and ownership rules: [docs/ecosystem.md](docs/ecosystem.md). ### One install -`./install.sh` symlinks skills, hooks, slash commands, and always-on rules into Claude, Cursor, and Codex. Safe to rerun. Edit here, `git pull` on another machine, every symlink updates. At the end of each run it removes any link into catstack that the run did not create, so renamed or deleted hooks and skills do not linger. +`./install.sh` symlinks skills into Claude, Cursor, Codex, and Muse; hooks, slash commands, and Cursor rules into Claude, Cursor, and Codex; and merges the always-on rules as marked blocks into Codex's and Muse's `AGENTS.md`. Safe to rerun. Edit here, `git pull` on another machine, every symlink updates. At the end of each run it removes any link into catstack that the run did not create, so renamed or deleted hooks and skills do not linger. (Muse — Meta's personal agent — has no hook or slash-command pipeline, so skills plus the `~/AGENTS.md` blocks are its whole harness.) @@ -122,11 +122,11 @@ Already have local copies? `./install.sh --force` backs them up, then links. ### Engine-only mode -`./install.sh --engine-only` links only the engine: `reflect`, `automate-me`, `create-skill`, `draft-pr`, `make-pr`, `thrash-reflect-automate`, every engine hook, the always-on rules, plus the four gates the engine cites (`diu`, `visual-proof`, `split-scope`, `narrow-the-scope`). It prunes every other corpus and product symlink from the three harness skill folders and points `~/.claude/CLAUDE.md` at `engine/CLAUDE.core.md`, so the mined rules in `corpus/CLAUDE.learned.md` are not loaded. A plain `./install.sh` restores everything. +`./install.sh --engine-only` links only the engine: `reflect`, `automate-me`, `create-skill`, `draft-pr`, `make-pr`, `thrash-reflect-automate`, every engine hook, the always-on rules, plus the four gates the engine cites (`diu`, `visual-proof`, `split-scope`, `narrow-the-scope`). It prunes every other corpus and product symlink from every harness skill folder and points `~/.claude/CLAUDE.md` at `engine/CLAUDE.core.md`, so the mined rules in `corpus/CLAUDE.learned.md` are not loaded. A plain `./install.sh` restores everything. Corpus stays in git and keeps refilling as `reflect` and `automate-me` run, so a newer model can regenerate the principles from scratch while you keep working. -Claude-only skills (`automate-me`, `cat-mode`, `narrow-the-scope`) skip Cursor and Codex on purpose. +Claude-only skills (`automate-me`, `narrow-the-scope`) skip Cursor, Codex, and Muse on purpose. (`cat-mode` installs everywhere as a skill; only its auto-apply hook is Claude-only.) ## Skills @@ -136,7 +136,7 @@ Each skill is a `SKILL.md` package under `engine/skills/`, `corpus/skills/`, or | --- | --- | | `diu` | Short answers by default. Lead with the outcome. | | `draft-pr` | Draft or update a PR with a real schema, not a generic template. | -| `create-skill` | Author/install skills for Claude, Cursor, and Codex — never one harness. | +| `create-skill` | Author/install skills for Claude, Cursor, Codex, and Muse — never one harness. | | `split-scope` | Shape diffs so each PR is one reviewable unit. | | `land-stack` | Land a stacked PR by SHA, never by branch name. | | `reflect` | Mine a transcript for durable learnings. Accepted items open a catstack worktree + PR (never merge); working-style routes to `automate-me`. | @@ -187,7 +187,7 @@ Four hooks and one always-on rule push you toward `/reflect` and | `reflect-on-thrash` hook | end of session | asks for a reflect at the end of a thrashy session | | `wrong-check-reflect` hook | end of turn | queues a judge on a retraction-shaped reply | | `verdict-flip-watch` hook | after a check runs | notes a verifier that passed and then failed | -| "same complaint type twice: invoke `automate-me`" rule | `./install.sh` | installs the rule for Claude, Cursor and Codex | +| "same complaint type twice: invoke `automate-me`" rule | `./install.sh` | installs the rule for Claude, Cursor, Codex, and Muse | ```sh echo 'CATSTACK_REFLECT_ENFORCEMENT=1' >> ~/.catstack.env 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/docs/provenance.md b/docs/provenance.md index 184ccf1d..89cf9fc3 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -10,11 +10,11 @@ Skills live under `engine/skills/`, `corpus/skills/`, or `product/skills/` (see `CLAUDE.md` at the repo root is the canonical copy of `~/.claude/CLAUDE.md` (personal, cross-project Claude Code instructions — communication rules, evidence rules, session hygiene). `install.sh` symlinks it into place the same way it symlinks skills; edit here, not in the live `~/.claude/CLAUDE.md`, so every machine picks up the change on the next `git pull`. Claude-only by nature (it's Claude Code's own config file), so `install.sh` doesn't try to place it for Cursor or Codex. -Cursor PR drafting is always-on after install: `cursor/rules/draft-pr-precedence.mdc` lands in `~/.cursor/rules/`. The same `/pr-skill`, `/draft-pr`, and `/make-pr` stubs land in Claude, Cursor, and Codex command dirs. Codex also gets a marked block in `~/.codex/AGENTS.md` (other AGENTS.md content is left alone). Claude already has the same rule in this repo's `CLAUDE.md`. A repo with `skills/make-pr/SKILL.md` uses that overlay. Installing the skill into `~/./skills` alone is not enough — that path is description-only and loses to a generic `gh pr create` recipe. +Cursor PR drafting is always-on after install: `cursor/rules/draft-pr-precedence.mdc` lands in `~/.cursor/rules/`. The same `/pr-skill`, `/draft-pr`, and `/make-pr` stubs land in Claude, Cursor, and Codex command dirs. Codex also gets a marked block in `~/.codex/AGENTS.md`, and Muse gets the same marked blocks in `~/AGENTS.md` (other AGENTS.md content is left alone in both). Claude already has the same rule in this repo's `CLAUDE.md`. A repo with `skills/make-pr/SKILL.md` uses that overlay. Installing the skill into `~/./skills` alone is not enough — that path is description-only and loses to a generic `gh pr create` recipe. Learned session hygiene reaches Cursor the same way: `install.sh` generates `~/.cursor/rules/session-hygiene.mdc` (`alwaysApply: true`) from the Session hygiene section of `corpus/CLAUDE.learned.md` via `scripts/install/install_cursor_session_hygiene.py`, and removes it again under `--engine-only`. It is a generated real file, not a symlink, because Cursor needs frontmatter and the source file carries other sections; a real file that catstack did not generate is left alone and reported. -Skill creation is likewise always-on for three harnesses: `cursor/rules/create-skill-three-harnesses.mdc`, `always-on/create-skill.md` (Codex AGENTS.md block), and the `create-skill` skill. A skill MUST land in Claude, Cursor, and Codex unless listed in `CLAUDE_ONLY_SKILLS`. Mechanical catch: `scripts/ci/check_skills_three_harnesses.py` (CI) and `--home` for live personal roots; project-skill helper: `scripts/install/link_skill_three_harnesses.sh`. +Skill creation is likewise always-on for four harnesses: `cursor/rules/create-skill-three-harnesses.mdc`, `always-on/create-skill.md` (Codex and Muse AGENTS.md blocks), and the `create-skill` skill. A skill MUST land in Claude, Cursor, Codex, and Muse unless listed in `CLAUDE_ONLY_SKILLS`. Mechanical catch: `scripts/ci/check_skills_three_harnesses.py` (CI) and `--home` for live personal roots; project-skill helper: `scripts/install/link_skill_three_harnesses.sh`. ## Hooks 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/install.sh b/install.sh index dcdba86d..31ce4e52 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,11 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# Muse (Meta's personal agent) has no hook or slash-command pipeline, so it +# gets skills only, linked into the agent's workspace skills dir. Override +# per machine when that dir lives elsewhere. +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 +287,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 +482,13 @@ else fi python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" "${CODEX_AGENTS_ARGS[@]}" +# Muse reads ~/AGENTS.md as global instructions: same always-on fragments and +# same reflect-enforcement gating as Codex, merged as marked blocks. Muse has +# no hook or slash-command pipeline, so skills + this file are the whole +# harness. +echo "--- muse global AGENTS.md (\$HOME/AGENTS.md) ---" +python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" --agents-path "$HOME/AGENTS.md" "${CODEX_AGENTS_ARGS[@]}" + 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 +499,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..a93e3455 100755 --- a/scripts/ci/check_skills_three_harnesses.py +++ b/scripts/ci/check_skills_three_harnesses.py @@ -1,16 +1,16 @@ #!/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 + - CONTRIBUTING.md must state the four-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. @@ -37,7 +37,7 @@ ) 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 +46,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 +79,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,12 +105,13 @@ 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 ( @@ -162,9 +179,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 +192,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 +212,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 +230,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 +254,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 +268,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..8817d910 100755 --- a/scripts/install/link_skill_three_harnesses.sh +++ b/scripts/install/link_skill_three_harnesses.sh @@ -1,10 +1,15 @@ #!/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. +# Symlink one skill directory into Claude, Cursor, Codex, and Muse 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. +# +# Muse (Meta's agent) keeps skills in its workspace skills dir, overridable +# per machine via MUSE_SKILLS_DIR (same default as ./install.sh). set -euo pipefail +MUSE_SKILLS_DIR="${MUSE_SKILLS_DIR:-$HOME/workspace/skills}" + FORCE=0 if [ "${1:-}" = "--force" ]; then FORCE=1 @@ -55,4 +60,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..a02da7e1 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -877,6 +877,50 @@ 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): + # Muse has no hook or slash-command pipeline: skills + AGENTS.md blocks + # are the whole harness. + 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"))) + class TestRegistryHookInstall(unittest.TestCase): def test_install_writes_only_active_registry_hooks_through_runner(self): @@ -975,7 +1019,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..78d0b73c 100644 --- a/tests/test_skills_three_harnesses.py +++ b/tests/test_skills_three_harnesses.py @@ -46,7 +46,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 +56,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 +84,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 +104,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__": From d63e276b326781dbc85d1c73c50f3f196c108962 Mon Sep 17 00:00:00 2001 From: Nyan Date: Mon, 28 Sep 2026 13:30:00 +0800 Subject: [PATCH 2/3] Muse: auto-apply cat-mode via AGENTS.md directive when the flag is on Muse has no hook pipeline, so the Claude-only cat-mode-default hook can never fire there: the installed skill sat inert behind disable-model-invocation with nothing to override it. install.sh now merges an equivalent one-line cat-mode-default fragment into the muse ~/AGENTS.md block when CATSTACK_CAT_MODE_DEFAULT=on (and removes it otherwise), mirroring the reflect-enforcement --fragment/--without pattern. Codex behavior intentionally unchanged. Tests: block present when on, removed when off, always-on blocks otherwise untouched, and no such block in Codex's AGENTS.md. --- README.md | 2 +- install.sh | 21 +++++++++++++++++++-- tests/test_install.py | 39 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8bc4760d..8f1dde3b 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Already have local copies? `./install.sh --force` backs them up, then links. Corpus stays in git and keeps refilling as `reflect` and `automate-me` run, so a newer model can regenerate the principles from scratch while you keep working. -Claude-only skills (`automate-me`, `narrow-the-scope`) skip Cursor, Codex, and Muse on purpose. (`cat-mode` installs everywhere as a skill; only its auto-apply hook is Claude-only.) +Claude-only skills (`automate-me`, `narrow-the-scope`) skip Cursor, Codex, and Muse on purpose. (`cat-mode` installs everywhere as a skill; its auto-apply hook is Claude-only, and Muse gets the equivalent as a `cat-mode-default` directive merged into its `~/AGENTS.md` block.) ## Skills diff --git a/install.sh b/install.sh index 31ce4e52..72f6a7a8 100755 --- a/install.sh +++ b/install.sh @@ -485,9 +485,26 @@ python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" "${CODEX_AGENTS_A # Muse reads ~/AGENTS.md as global instructions: same always-on fragments and # same reflect-enforcement gating as Codex, merged as marked blocks. Muse has # no hook or slash-command pipeline, so skills + this file are the whole -# harness. +# harness. That also means the cat-mode-default hook (Claude-only) can't fire +# here: when CATSTACK_CAT_MODE_DEFAULT=on, merge an equivalent one-line +# directive into the muse block so cat-mode applies on every turn; otherwise +# remove it again. echo "--- muse global AGENTS.md (\$HOME/AGENTS.md) ---" -python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" --agents-path "$HOME/AGENTS.md" "${CODEX_AGENTS_ARGS[@]}" +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)" diff --git a/tests/test_install.py b/tests/test_install.py index a02da7e1..e0676099 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -921,6 +921,45 @@ 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): + # Muse has no hook pipeline, so the cat-mode-default hook can't fire: + # install.sh merges an equivalent directive into the muse AGENTS.md + # block when CATSTACK_CAT_MODE_DEFAULT=on. This is the firing chain + # for "cat-mode automatically applies when chatting with Muse". + 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) + # the always-on blocks stay; only the cat-mode directive is removed + self.assertIn("", text) + + def test_codex_agents_md_does_not_get_cat_mode_default_block(self): + # Deliberate scoping: the muse fix doesn't change Codex behavior. + 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): From 58012e0383d22d111b88c3522beca6fa8d08f4f1 Mon Sep 17 00:00:00 2001 From: CI Bot Date: Mon, 28 Sep 2026 07:03:58 +0000 Subject: [PATCH 3/3] Repair Muse harness PR validation --- CONTRIBUTING.md | 6 +++--- README.md | 14 ++++++------- docs/provenance.md | 4 ++-- drafter.config.json | 2 ++ .../create-skill/tests/fires_example.md | 4 ++-- engine/skills/make-pr/tests/test_preflight.py | 4 ++++ install.sh | 10 ---------- scripts/ci/check_skills_three_harnesses.py | 3 --- scripts/install/link_skill_three_harnesses.sh | 7 ------- tests/test_install.py | 12 ++--------- tests/test_skills_three_harnesses.py | 20 +++++++++++++++++++ 11 files changed, 42 insertions(+), 44 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 998f860f..77af5d52 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,14 +9,14 @@ catstack is a personal skill stack. Portable skills and hooks are welcome; proje 3. Optional (product only): add task-type files under `domains/` (`coding.md`, `equities.md`). Paste the domain selector from `engine/skills/create-skill/SKILL.md` into `SKILL.md`. Generic prose MUST NOT name repo CLIs; domain files only add triggers and cwd filename lookups. 4. If it came from another repo, add a sourcing note in [docs/provenance.md](docs/provenance.md). 5. Write its `tests/` dir: code skills need at least two real test functions; prose-only skills need a positive fixture and a negative fixture (e.g. `tests/fires_example.md` / `tests/stays_silent_example.md`). -6. Run `./install.sh` so the skill lands in **Claude, Cursor, Codex, and Muse**. Do not hand-link a single harness. +6. Run `./install.sh` so the skill lands in **Claude, Cursor, and Codex**. Do not hand-link a single harness. ### Invariants (assert) - Every new skill MUST ship a `tests/` dir (positive + negative — see `engine/skills/create-skill/SKILL.md`) before it's added. Checked by `scripts/ci/check_skill_test_coverage.py`; existing untested skills are grandfathered in `scripts/ci/skill_test_debt_allowlist.txt`, which is shrink-only. -- Every new skill MUST apply to Claude, Cursor, Codex, and Muse unless it is listed in `CLAUDE_ONLY_SKILLS`. +- Every new skill MUST apply to Claude, Cursor, and Codex unless it is listed in `CLAUDE_ONLY_SKILLS`. - `./install.sh` MUST remain the install path for portable skills. Manual `ln -s` into only `~/.cursor/skills` or only `~/.claude/skills` is a bug. -- Project-skill home links (outside this repo) MUST use `scripts/install/link_skill_three_harnesses.sh` (or equivalent links into all four roots). +- Project-skill home links (outside this repo) MUST use `scripts/install/link_skill_three_harnesses.sh` (or equivalent links into all three roots). - Follow `engine/skills/create-skill/SKILL.md` — not Cursor built-in create-skill text that only mentions `~/.cursor/skills/`. - Domain-aware product skills MUST include the selector phrase in `SKILL.md` and MUST keep repo CLIs out of the generic file. diff --git a/README.md b/README.md index 8f1dde3b..6ccc79fd 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # catstack -**Self-improving ecosystem engine** (engine + corpus + product) for Claude, Cursor, Codex, and Muse +**Self-improving ecosystem engine** (engine + corpus + product) for Claude, Cursor, and Codex [![CI](https://github.com/EdbertChan/catstack/actions/workflows/ci.yml/badge.svg)](https://github.com/EdbertChan/catstack/actions/workflows/ci.yml) [![Agents](https://img.shields.io/badge/agents-Claude%20%7C%20Cursor%20%7C%20Codex-lightgrey?style=flat-square)](#install) @@ -13,7 +13,7 @@ One clone. One `./install.sh`. Same stack on every machine. **[Install](#install)** · **[Ecosystem](docs/ecosystem.md)** · **[Skills](#skills)** · **[Hooks](#hooks)** · **[Provenance](docs/provenance.md)** -catstack — Claude, Cursor, Codex, Muse +catstack — Claude, Cursor, Codex ### Agent DORA (personal) @@ -81,7 +81,7 @@ Bucket inventory and ownership rules: [docs/ecosystem.md](docs/ecosystem.md). ### One install -`./install.sh` symlinks skills into Claude, Cursor, Codex, and Muse; hooks, slash commands, and Cursor rules into Claude, Cursor, and Codex; and merges the always-on rules as marked blocks into Codex's and Muse's `AGENTS.md`. Safe to rerun. Edit here, `git pull` on another machine, every symlink updates. At the end of each run it removes any link into catstack that the run did not create, so renamed or deleted hooks and skills do not linger. (Muse — Meta's personal agent — has no hook or slash-command pipeline, so skills plus the `~/AGENTS.md` blocks are its whole harness.) +`./install.sh` symlinks skills, hooks, slash commands, and always-on rules into Claude, Cursor, and Codex. Safe to rerun. Edit here, `git pull` on another machine, every symlink updates. At the end of each run it removes any link into catstack that the run did not create, so renamed or deleted hooks and skills do not linger. @@ -122,11 +122,11 @@ Already have local copies? `./install.sh --force` backs them up, then links. ### Engine-only mode -`./install.sh --engine-only` links only the engine: `reflect`, `automate-me`, `create-skill`, `draft-pr`, `make-pr`, `thrash-reflect-automate`, every engine hook, the always-on rules, plus the four gates the engine cites (`diu`, `visual-proof`, `split-scope`, `narrow-the-scope`). It prunes every other corpus and product symlink from every harness skill folder and points `~/.claude/CLAUDE.md` at `engine/CLAUDE.core.md`, so the mined rules in `corpus/CLAUDE.learned.md` are not loaded. A plain `./install.sh` restores everything. +`./install.sh --engine-only` links only the engine: `reflect`, `automate-me`, `create-skill`, `draft-pr`, `make-pr`, `thrash-reflect-automate`, every engine hook, the always-on rules, plus the four gates the engine cites (`diu`, `visual-proof`, `split-scope`, `narrow-the-scope`). It prunes every other corpus and product symlink from the three harness skill folders and points `~/.claude/CLAUDE.md` at `engine/CLAUDE.core.md`, so the mined rules in `corpus/CLAUDE.learned.md` are not loaded. A plain `./install.sh` restores everything. Corpus stays in git and keeps refilling as `reflect` and `automate-me` run, so a newer model can regenerate the principles from scratch while you keep working. -Claude-only skills (`automate-me`, `narrow-the-scope`) skip Cursor, Codex, and Muse on purpose. (`cat-mode` installs everywhere as a skill; its auto-apply hook is Claude-only, and Muse gets the equivalent as a `cat-mode-default` directive merged into its `~/AGENTS.md` block.) +Claude-only skills (`automate-me`, `cat-mode`, `narrow-the-scope`) skip Cursor and Codex on purpose. ## Skills @@ -136,7 +136,7 @@ Each skill is a `SKILL.md` package under `engine/skills/`, `corpus/skills/`, or | --- | --- | | `diu` | Short answers by default. Lead with the outcome. | | `draft-pr` | Draft or update a PR with a real schema, not a generic template. | -| `create-skill` | Author/install skills for Claude, Cursor, Codex, and Muse — never one harness. | +| `create-skill` | Author/install skills for Claude, Cursor, and Codex — never one harness. | | `split-scope` | Shape diffs so each PR is one reviewable unit. | | `land-stack` | Land a stacked PR by SHA, never by branch name. | | `reflect` | Mine a transcript for durable learnings. Accepted items open a catstack worktree + PR (never merge); working-style routes to `automate-me`. | @@ -187,7 +187,7 @@ Four hooks and one always-on rule push you toward `/reflect` and | `reflect-on-thrash` hook | end of session | asks for a reflect at the end of a thrashy session | | `wrong-check-reflect` hook | end of turn | queues a judge on a retraction-shaped reply | | `verdict-flip-watch` hook | after a check runs | notes a verifier that passed and then failed | -| "same complaint type twice: invoke `automate-me`" rule | `./install.sh` | installs the rule for Claude, Cursor, Codex, and Muse | +| "same complaint type twice: invoke `automate-me`" rule | `./install.sh` | installs the rule for Claude, Cursor and Codex | ```sh echo 'CATSTACK_REFLECT_ENFORCEMENT=1' >> ~/.catstack.env diff --git a/docs/provenance.md b/docs/provenance.md index 89cf9fc3..184ccf1d 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -10,11 +10,11 @@ Skills live under `engine/skills/`, `corpus/skills/`, or `product/skills/` (see `CLAUDE.md` at the repo root is the canonical copy of `~/.claude/CLAUDE.md` (personal, cross-project Claude Code instructions — communication rules, evidence rules, session hygiene). `install.sh` symlinks it into place the same way it symlinks skills; edit here, not in the live `~/.claude/CLAUDE.md`, so every machine picks up the change on the next `git pull`. Claude-only by nature (it's Claude Code's own config file), so `install.sh` doesn't try to place it for Cursor or Codex. -Cursor PR drafting is always-on after install: `cursor/rules/draft-pr-precedence.mdc` lands in `~/.cursor/rules/`. The same `/pr-skill`, `/draft-pr`, and `/make-pr` stubs land in Claude, Cursor, and Codex command dirs. Codex also gets a marked block in `~/.codex/AGENTS.md`, and Muse gets the same marked blocks in `~/AGENTS.md` (other AGENTS.md content is left alone in both). Claude already has the same rule in this repo's `CLAUDE.md`. A repo with `skills/make-pr/SKILL.md` uses that overlay. Installing the skill into `~/./skills` alone is not enough — that path is description-only and loses to a generic `gh pr create` recipe. +Cursor PR drafting is always-on after install: `cursor/rules/draft-pr-precedence.mdc` lands in `~/.cursor/rules/`. The same `/pr-skill`, `/draft-pr`, and `/make-pr` stubs land in Claude, Cursor, and Codex command dirs. Codex also gets a marked block in `~/.codex/AGENTS.md` (other AGENTS.md content is left alone). Claude already has the same rule in this repo's `CLAUDE.md`. A repo with `skills/make-pr/SKILL.md` uses that overlay. Installing the skill into `~/./skills` alone is not enough — that path is description-only and loses to a generic `gh pr create` recipe. Learned session hygiene reaches Cursor the same way: `install.sh` generates `~/.cursor/rules/session-hygiene.mdc` (`alwaysApply: true`) from the Session hygiene section of `corpus/CLAUDE.learned.md` via `scripts/install/install_cursor_session_hygiene.py`, and removes it again under `--engine-only`. It is a generated real file, not a symlink, because Cursor needs frontmatter and the source file carries other sections; a real file that catstack did not generate is left alone and reported. -Skill creation is likewise always-on for four harnesses: `cursor/rules/create-skill-three-harnesses.mdc`, `always-on/create-skill.md` (Codex and Muse AGENTS.md blocks), and the `create-skill` skill. A skill MUST land in Claude, Cursor, Codex, and Muse unless listed in `CLAUDE_ONLY_SKILLS`. Mechanical catch: `scripts/ci/check_skills_three_harnesses.py` (CI) and `--home` for live personal roots; project-skill helper: `scripts/install/link_skill_three_harnesses.sh`. +Skill creation is likewise always-on for three harnesses: `cursor/rules/create-skill-three-harnesses.mdc`, `always-on/create-skill.md` (Codex AGENTS.md block), and the `create-skill` skill. A skill MUST land in Claude, Cursor, and Codex unless listed in `CLAUDE_ONLY_SKILLS`. Mechanical catch: `scripts/ci/check_skills_three_harnesses.py` (CI) and `--home` for live personal roots; project-skill helper: `scripts/install/link_skill_three_harnesses.sh`. ## Hooks 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/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 72f6a7a8..f9ea03a4 100755 --- a/install.sh +++ b/install.sh @@ -10,9 +10,6 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -# Muse (Meta's personal agent) has no hook or slash-command pipeline, so it -# gets skills only, linked into the agent's workspace skills dir. Override -# per machine when that dir lives elsewhere. MUSE_SKILLS_DIR="${MUSE_SKILLS_DIR:-$HOME/workspace/skills}" resolve_main_checkout() { @@ -482,13 +479,6 @@ else fi python3 "$REPO_DIR/scripts/install/install_codex_agents_md.py" "${CODEX_AGENTS_ARGS[@]}" -# Muse reads ~/AGENTS.md as global instructions: same always-on fragments and -# same reflect-enforcement gating as Codex, merged as marked blocks. Muse has -# no hook or slash-command pipeline, so skills + this file are the whole -# harness. That also means the cat-mode-default hook (Claude-only) can't fire -# here: when CATSTACK_CAT_MODE_DEFAULT=on, merge an equivalent one-line -# directive into the muse block so cat-mode applies on every turn; otherwise -# remove it again. echo "--- muse global AGENTS.md (\$HOME/AGENTS.md) ---" MUSE_AGENTS_ARGS=("${CODEX_AGENTS_ARGS[@]}") CAT_MODE_DEFAULT_FRAGMENT="" diff --git a/scripts/ci/check_skills_three_harnesses.py b/scripts/ci/check_skills_three_harnesses.py index a93e3455..bd96cb05 100755 --- a/scripts/ci/check_skills_three_harnesses.py +++ b/scripts/ci/check_skills_three_harnesses.py @@ -4,7 +4,6 @@ Repo mode (default, CI-safe): - 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 four-harness assert Home mode (--home): - Catstack skills present in any personal root must exist in all four @@ -31,7 +30,6 @@ 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" ) @@ -116,7 +114,6 @@ def check_repo() -> list[str]: 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"), ): diff --git a/scripts/install/link_skill_three_harnesses.sh b/scripts/install/link_skill_three_harnesses.sh index 8817d910..8ab35be7 100755 --- a/scripts/install/link_skill_three_harnesses.sh +++ b/scripts/install/link_skill_three_harnesses.sh @@ -1,11 +1,4 @@ #!/bin/bash -# Symlink one skill directory into Claude, Cursor, Codex, and Muse 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. -# -# Muse (Meta's agent) keeps skills in its workspace skills dir, overridable -# per machine via MUSE_SKILLS_DIR (same default as ./install.sh). set -euo pipefail MUSE_SKILLS_DIR="${MUSE_SKILLS_DIR:-$HOME/workspace/skills}" diff --git a/tests/test_install.py b/tests/test_install.py index e0676099..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): @@ -916,16 +916,10 @@ def test_muse_agents_md_preserves_existing_content(self): self.assertIn("", text) def test_muse_gets_no_hooks_or_commands_dirs(self): - # Muse has no hook or slash-command pipeline: skills + AGENTS.md blocks - # are the whole harness. 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): - # Muse has no hook pipeline, so the cat-mode-default hook can't fire: - # install.sh merges an equivalent directive into the muse AGENTS.md - # block when CATSTACK_CAT_MODE_DEFAULT=on. This is the firing chain - # for "cat-mode automatically applies when chatting with Muse". 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) @@ -948,11 +942,9 @@ def test_muse_agents_md_drops_cat_mode_default_block_when_off(self): with open(agents_path) as handle: text = handle.read() self.assertNotIn("catstack-cat-mode-default", text) - # the always-on blocks stay; only the cat-mode directive is removed self.assertIn("", text) def test_codex_agents_md_does_not_get_cat_mode_default_block(self): - # Deliberate scoping: the muse fix doesn't change Codex behavior. 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) diff --git a/tests/test_skills_three_harnesses.py b/tests/test_skills_three_harnesses.py index 78d0b73c..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):