From bd32c596e13a73a9b554a4619ac58f4f14c5fc22 Mon Sep 17 00:00:00 2001 From: Miyamura80 Date: Tue, 5 May 2026 17:00:24 +0100 Subject: [PATCH 1/2] Add rule syncing to dual-tool agent configuration Extends the agent configuration sync system to handle path-scoped rules. Rules are mirrored from .claude/rules/ (source of truth) to .agents/rules/ via symlinks, with the sync direction inverted from skills since Claude is the only tool that auto-discovers rules. Co-Authored-By: Claude Opus 4.6 --- .agents/rules/codex-claude-sync.md | 1 + .agents/skills/manage-agent-config/SKILL.md | 10 ++- .claude/rules/codex-claude-sync.md | 37 +++++++-- .claude/skills/new-agent-rule/SKILL.md | 47 +++++++++++ scripts/sync_agent_config.py | 86 ++++++++++++++++++--- 5 files changed, 161 insertions(+), 20 deletions(-) create mode 120000 .agents/rules/codex-claude-sync.md create mode 100644 .claude/skills/new-agent-rule/SKILL.md diff --git a/.agents/rules/codex-claude-sync.md b/.agents/rules/codex-claude-sync.md new file mode 120000 index 0000000..a2db963 --- /dev/null +++ b/.agents/rules/codex-claude-sync.md @@ -0,0 +1 @@ +../../.claude/rules/codex-claude-sync.md \ No newline at end of file diff --git a/.agents/skills/manage-agent-config/SKILL.md b/.agents/skills/manage-agent-config/SKILL.md index 28128c2..ae29cff 100644 --- a/.agents/skills/manage-agent-config/SKILL.md +++ b/.agents/skills/manage-agent-config/SKILL.md @@ -1,6 +1,6 @@ --- name: manage-agent-config -description: Use whenever creating, editing, renaming, or deleting any file under .claude/skills/, .claude/agents/, .agents/skills/, or .codex/agents/. Teaches the dual-tool Claude/Codex layout and reminds to run `make sync-agent-config`. +description: Use whenever creating, editing, renaming, or deleting any file under .claude/skills/, .claude/agents/, .claude/rules/, .agents/skills/, .agents/rules/, or .codex/agents/. Teaches the dual-tool Claude/Codex layout and reminds to run 'make sync-agent-config'. --- # Managing Claude ↔ Codex skills and subagents in this repo @@ -21,9 +21,15 @@ This repo is dual-tool. Before you create or edit anything under `.claude/`, `.a - `.codex/agents/.toml` is **generated** - never hand-edit. - Run `make sync-agent-config` - the TOML appears. +**Creating a new path-scoped rule?** + +- `.claude/rules/.md` is the **source of truth**. Use `globs:` frontmatter (not `paths:`). +- `.agents/rules/.md` is a symlink mirror, created by `make sync-agent-config`. +- Read the `new-agent-rule` skill before writing your first rule. + **Renaming or deleting?** -- Rename or delete the source file (under `.agents/skills/` or `.claude/agents/`). +- Rename or delete the source file (under `.agents/skills/`, `.claude/agents/`, or `.claude/rules/`). - Run `make sync-agent-config` - stale symlinks and orphaned TOMLs are pruned automatically. ## Frontmatter rules for shared skills diff --git a/.claude/rules/codex-claude-sync.md b/.claude/rules/codex-claude-sync.md index bd3f4b1..3a2e3c1 100644 --- a/.claude/rules/codex-claude-sync.md +++ b/.claude/rules/codex-claude-sync.md @@ -1,9 +1,9 @@ --- -paths: - - ".claude/skills/**" - - ".claude/agents/**" - - ".agents/skills/**" - - ".codex/agents/**" +description: Dual-tool (Claude Code + Codex CLI) skill, subagent, and rule sync layout +globs: + - ".claude/**" + - ".agents/**" + - ".codex/**" --- # Codex ↔ Claude skill & subagent sync @@ -18,6 +18,8 @@ This repo is dual-tool: both Claude Code and Codex CLI are expected to work. Ski .claude/agents/.md # source of truth (markdown + YAML frontmatter) .codex/agents/.toml # GENERATED from the .md; commit it scripts/sync_agent_config.py # converter (uv run) +.claude/rules/.md # source of truth (prose + globs frontmatter) +.agents/rules/.md # symlink → ../../.claude/rules/.md ``` Codex auto-scans `.agents/skills/` walking up from cwd to repo root. Claude auto-scans `.claude/skills/`. The symlink is the only reason both find the same file. @@ -55,15 +57,35 @@ Rules: - Claude-only frontmatter keys (`tools`, `model`) don't translate - document tool expectations in the prose body instead so both sides pick them up. - Inside the body, avoid literal `"""` sequences (they'd close the TOML string); the converter escapes them but it's easier to just not use them. +## Rules: symlink, don't convert + +Rules sync in the **opposite direction** from skills: +- `.claude/rules/.md` is the **source of truth**. Claude auto-discovers rules here. +- `.agents/rules/.md` is a symlink mirror, created by `make sync-agent-config`. + +The inversion is necessary because Claude is the primary consumer of rules today. `.agents/rules/` is a forward-looking mirror in case a cross-tool standard emerges. + +Rule frontmatter uses `globs:` (not `paths:`) to scope when the rule attaches. Example: + +```yaml +--- +globs: + - "src/api/**" +description: API route conventions +--- +``` + +Read the `new-agent-rule` skill before creating a rule. + ## Do not try to sync these -- `.claude/rules/*.md` vs `.codex/rules/*.rules` - different languages (prose vs permission DSL). Maintain separately. +- `.codex/rules/*.rules` - Codex permission DSL (Starlark). Separate from `.claude/rules/` prose rules; maintain independently. - `.claude/commands/*.md` - Claude-only; Codex has no slash-command runtime. - `CLAUDE.md` vs `AGENTS.md` - both auto-read by their respective tool; keep them as separate documents, though content may overlap. ## Tooling -- `make sync-agent-config` - idempotent. Creates missing `.claude/skills/` symlinks for every shared skill under `.agents/skills/`, regenerates `.codex/agents/*.toml` from `.claude/agents/*.md`, auto-prunes dangling symlinks and orphan TOMLs silently. +- `make sync-agent-config` - idempotent. Creates missing `.claude/skills/` symlinks for every shared skill under `.agents/skills/`, creates `.agents/rules/` symlinks for every rule under `.claude/rules/`, regenerates `.codex/agents/*.toml` from `.claude/agents/*.md`, auto-prunes dangling symlinks and orphan TOMLs silently. - Pre-commit: [`prek`](https://prek.j178.dev/installation/), configured in `prek.toml` at repo root. Register once per clone with `prek install`. Runs `make sync-agent-config` then fails the commit if it produced drift. - Python scripts in `scripts/` use `uv` and PEP 723 inline metadata for standalones. @@ -75,3 +97,4 @@ The `manage-agent-config` skill (at `.agents/skills/manage-agent-config/`) has t 2. Claude-only skill (uses `$ARGUMENTS`, `allowed-tools`, etc.) → `.claude/skills//SKILL.md` as a real directory. No symlink. 3. Subagent → edit `.claude/agents/.md`. Never hand-edit `.codex/agents/*.toml`. Run `make sync-agent-config`. Commit both files. 4. Delete or rename → edit/remove the source, then `make sync-agent-config` cleans up the mirror. +5. New rule → write `.claude/rules/.md` with `globs:` frontmatter. Run `make sync-agent-config`. The `.agents/rules/` symlink appears. diff --git a/.claude/skills/new-agent-rule/SKILL.md b/.claude/skills/new-agent-rule/SKILL.md new file mode 100644 index 0000000..6f54f53 --- /dev/null +++ b/.claude/skills/new-agent-rule/SKILL.md @@ -0,0 +1,47 @@ +--- +name: new-agent-rule +description: Guide for creating a new path-scoped .claude/rules/ file with proper globs frontmatter and structure. +--- + +# Creating a new path-scoped rule + +Rules keep CLAUDE.md lean by scoping guidance to specific file paths. A rule only loads when the agent touches files matching its `globs:` pattern. + +## When to create a rule + +- Guidance applies to a specific directory or file pattern, not the whole repo +- The guidance is long enough that embedding it in CLAUDE.md would bloat the file +- Multiple agents (or future tools) should pick up the same guidance automatically + +## Naming and location + +- Source of truth: `.claude/rules/.md` +- Name: `snake_case.md` describing the area (e.g., `api_routes.md`, `test_conventions.md`) +- Mirror: `.agents/rules/.md` (symlink, created by `make sync-agent-config`) + +## Frontmatter format + +Use `globs:` (NOT `paths:` -- it has known silent-failure bugs): + +```yaml +--- +description: One-line summary of what this rule covers +globs: + - "src/api/**" + - "tests/api/**" +--- +``` + +Pick the narrowest set of globs that covers the area. Overly broad globs waste context on unrelated tasks. + +## Workflow + +1. Check if CLAUDE.md already covers this guidance (if so, migrate it to a rule) +2. Write `.claude/rules/.md` with `globs:` frontmatter +3. Run `make sync-agent-config` then `make ci` + +## Anti-patterns + +- Forgetting `globs:` frontmatter (rule loads on every task, defeating the purpose) +- Restating what CLAUDE.md already says (keep rules additive, not duplicative) +- Using `paths:` instead of `globs:` (known silent-failure bugs in Claude) diff --git a/scripts/sync_agent_config.py b/scripts/sync_agent_config.py index 69375c2..e466b08 100644 --- a/scripts/sync_agent_config.py +++ b/scripts/sync_agent_config.py @@ -2,13 +2,16 @@ # requires-python = ">=3.12" # dependencies = ["pyyaml>=6.0", "tomli_w>=1.0"] # /// -"""Sync Claude ↔ Codex skills and subagents. +"""Sync Claude ↔ Codex skills, rules, and subagents. - Symlinks `.claude/skills/` → `../../.agents/skills/` for every directory under `.agents/skills/`. +- Symlinks `.agents/rules/.md` → `../../.claude/rules/.md` for every + non-symlink `.md` file under `.claude/rules/`. - Regenerates `.codex/agents/.toml` from each `.claude/agents/.md`. - Auto-prunes dangling symlinks and orphaned TOMLs silently. """ + from __future__ import annotations import argparse @@ -25,13 +28,29 @@ CLAUDE_SKILLS = REPO / ".claude" / "skills" CLAUDE_AGENTS = REPO / ".claude" / "agents" CODEX_AGENTS = REPO / ".codex" / "agents" +SHARED_RULES = REPO / ".agents" / "rules" +CLAUDE_RULES = REPO / ".claude" / "rules" FRONTMATTER_RE = re.compile(r"^---\r?\n(.*?)\r?\n---\r?\n?(.*)$", re.DOTALL) -CLAUDE_ONLY_KEYS = {"tools", "model", "color", "allowed-tools", "disable-model-invocation"} +CLAUDE_ONLY_KEYS = { + "tools", + "model", + "color", + "allowed-tools", + "disable-model-invocation", +} SHARED_SKILL_FORBIDDEN_KEYS = { - "allowed-tools", "disable-model-invocation", "user-invocable", - "context", "agent", "model", "effort", "hooks", "paths", "shell", + "allowed-tools", + "disable-model-invocation", + "user-invocable", + "context", + "agent", + "model", + "effort", + "hooks", + "paths", + "shell", "argument-hint", } SHARED_SKILL_FORBIDDEN_BODY_PATTERNS = [ @@ -77,7 +96,9 @@ def render_toml(meta: dict, body: str, source: Path | None = None) -> str: def _strip_code(text: str) -> str: - text = re.sub(r"^[ ]{0,3}(`{3,}).*?^[ ]{0,3}\1`*", "", text, flags=re.DOTALL | re.MULTILINE) + text = re.sub( + r"^[ ]{0,3}(`{3,}).*?^[ ]{0,3}\1`*", "", text, flags=re.DOTALL | re.MULTILINE + ) out: list[str] = [] i, n = 0, len(text) while i < n: @@ -87,7 +108,9 @@ def _strip_code(text: str) -> str: while i + run < n and text[i + run] == "`": run += 1 close = text.find("`" * run, i + run) - if close == -1 or any(text[i + run + k] == "\n" for k in range(close - i - run)): + if close == -1 or any( + text[i + run + k] == "\n" for k in range(close - i - run) + ): out.append(text[i : i + run]) i += run elif preceded_by_bang and run == 1: @@ -114,18 +137,26 @@ def validate_shared_skill(skill_dir: Path) -> list[str]: errs: list[str] = [] bad_keys = SHARED_SKILL_FORBIDDEN_KEYS & set(meta.keys()) if bad_keys: - errs.append(f"{skill_md.relative_to(REPO)}: Claude-only frontmatter keys in shared skill: {sorted(bad_keys)}") + errs.append( + f"{skill_md.relative_to(REPO)}: Claude-only frontmatter keys in shared skill: {sorted(bad_keys)}" + ) for pat, label in SHARED_SKILL_RAW_BODY_PATTERNS: if pat.search(body): - errs.append(f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}") + errs.append( + f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}" + ) scan_body = _strip_code(body) for pat, label in SHARED_SKILL_FORBIDDEN_BODY_PATTERNS: if pat.search(scan_body): - errs.append(f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}") + errs.append( + f"{skill_md.relative_to(REPO)}: body uses Claude-only feature: {label}" + ) if not meta.get("name"): errs.append(f"{skill_md.relative_to(REPO)}: missing `name` in frontmatter") if not meta.get("description"): - errs.append(f"{skill_md.relative_to(REPO)}: missing `description` in frontmatter") + errs.append( + f"{skill_md.relative_to(REPO)}: missing `description` in frontmatter" + ) return errs @@ -190,6 +221,39 @@ def sync_skill_symlinks() -> list[str]: return changes +def sync_rule_symlinks() -> list[str]: + """Create symlinks from .agents/rules/.md → ../../.claude/rules/.md.""" + changes: list[str] = [] + SHARED_RULES.mkdir(parents=True, exist_ok=True) + CLAUDE_RULES.mkdir(parents=True, exist_ok=True) + + wanted: set[str] = set() + for rule in CLAUDE_RULES.iterdir(): + if rule.is_symlink() or not rule.is_file() or rule.suffix != ".md": + continue + wanted.add(rule.name) + link = SHARED_RULES / rule.name + target = Path("..") / ".." / ".claude" / "rules" / rule.name + if link.is_symlink(): + if os.path.normpath(os.readlink(link)) == os.path.normpath(str(target)): + continue + link.unlink() + elif link.exists(): + raise SystemExit( + f"ERROR: name collision - .agents/rules/{rule.name} is a real file " + f"but .claude/rules/{rule.name} also exists. The .claude/rules/ version is the " + "source of truth; remove the .agents/rules/ copy." + ) + link.symlink_to(target) + changes.append(f"symlinked {link.relative_to(REPO)}") + + for link in SHARED_RULES.iterdir(): + if link.is_symlink() and link.name not in wanted: + link.unlink() + changes.append(f"pruned dangling {link.relative_to(REPO)}") + return changes + + def sync_agents() -> list[str]: changes: list[str] = [] CODEX_AGENTS.mkdir(parents=True, exist_ok=True) @@ -221,7 +285,7 @@ def main() -> int: ) args = parser.parse_args() - changes = sync_skill_symlinks() + sync_agents() + changes = sync_skill_symlinks() + sync_rule_symlinks() + sync_agents() for c in changes: print(c) if args.check and changes: From 8cfe07d277dd3e12ca25f2e61087914073331e6a Mon Sep 17 00:00:00 2001 From: Miyamura80 Date: Tue, 5 May 2026 17:16:12 +0100 Subject: [PATCH 2/2] Address review feedback: description length, prune guard, symlink error handling - Shorten manage-agent-config description to <=250 chars - Add sparse-checkout safety guard to sync_rule_symlinks() - Add OSError/NotImplementedError handling to sync_rule_symlinks() Co-Authored-By: Claude Opus 4.6 --- .agents/skills/manage-agent-config/SKILL.md | 2 +- scripts/sync_agent_config.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.agents/skills/manage-agent-config/SKILL.md b/.agents/skills/manage-agent-config/SKILL.md index ae29cff..303e74e 100644 --- a/.agents/skills/manage-agent-config/SKILL.md +++ b/.agents/skills/manage-agent-config/SKILL.md @@ -1,6 +1,6 @@ --- name: manage-agent-config -description: Use whenever creating, editing, renaming, or deleting any file under .claude/skills/, .claude/agents/, .claude/rules/, .agents/skills/, .agents/rules/, or .codex/agents/. Teaches the dual-tool Claude/Codex layout and reminds to run 'make sync-agent-config'. +description: Manage files under .claude/{skills,agents,rules}/, .agents/{skills,rules}/, or .codex/agents/. Covers dual-tool Claude/Codex layout and reminds to run make sync-agent-config. --- # Managing Claude ↔ Codex skills and subagents in this repo diff --git a/scripts/sync_agent_config.py b/scripts/sync_agent_config.py index e466b08..73b9c61 100644 --- a/scripts/sync_agent_config.py +++ b/scripts/sync_agent_config.py @@ -224,6 +224,7 @@ def sync_skill_symlinks() -> list[str]: def sync_rule_symlinks() -> list[str]: """Create symlinks from .agents/rules/.md → ../../.claude/rules/.md.""" changes: list[str] = [] + rules_existed = CLAUDE_RULES.exists() SHARED_RULES.mkdir(parents=True, exist_ok=True) CLAUDE_RULES.mkdir(parents=True, exist_ok=True) @@ -244,9 +245,19 @@ def sync_rule_symlinks() -> list[str]: f"but .claude/rules/{rule.name} also exists. The .claude/rules/ version is the " "source of truth; remove the .agents/rules/ copy." ) - link.symlink_to(target) + try: + link.symlink_to(target) + except (OSError, NotImplementedError) as e: + raise SystemExit( + f"ERROR: could not create symlink {link.relative_to(REPO)} -> {target}: {e}. " + "If you're on Windows, enable Developer Mode or run your shell as Administrator " + "so Python can create symlinks." + ) from e changes.append(f"symlinked {link.relative_to(REPO)}") + if not rules_existed and not wanted: + return changes + for link in SHARED_RULES.iterdir(): if link.is_symlink() and link.name not in wanted: link.unlink()