From 5969b0a91f6b3c55b5d45c2e394ce6760bcd2857 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 13:33:15 +0000 Subject: [PATCH 1/4] add linting rules --- Taskfile.yml | 8 +++- tools/validate_cursor_rules.py | 72 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 tools/validate_cursor_rules.py diff --git a/Taskfile.yml b/Taskfile.yml index 4d905d92ea..e54442bf4c 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -313,8 +313,13 @@ tasks: cmds: - "! git grep -lF databricks.com -- '*uv.lock' '*.py.lock'" + check-cursor-rules: + desc: Fail if a .agents/rules/*.md lacks its .cursor/rules/*.mdc symlink (--fix adds it) + cmds: + - "./tools/validate_cursor_rules.py" + checks: - desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles) + desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles, cursor rules) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work # touching more paths should not race with the whitespace scanner. cmds: @@ -323,6 +328,7 @@ tasks: - task: deadcode - task: check-changelog - task: check-lockfiles + - task: check-cursor-rules install-pythons: desc: Install Python 3.9-3.13 via uv diff --git a/tools/validate_cursor_rules.py b/tools/validate_cursor_rules.py new file mode 100644 index 0000000000..3ef9ce5895 --- /dev/null +++ b/tools/validate_cursor_rules.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Keep .cursor/rules/*.mdc symlinks in sync with .agents/rules/*.md. + +The canonical rules live in .agents/rules/.md; Cursor reads them from +.cursor/rules/.mdc, so each rule needs a .mdc symlink pointing back at its +.md. This validates that every rule has a correct symlink and that no symlink is +left dangling. Run with --fix to create missing symlinks and drop stale ones. + +Non-symlink .mdc files (e.g. the standalone 00-agents-context.mdc bootstrap) are +not mirrors of a rule and are left untouched. +""" + +import os +import sys + +AGENTS_RULES = ".agents/rules" +CURSOR_RULES = ".cursor/rules" + + +def link_target(stem): + # The .mdc lives in .cursor/rules/, so ../../ reaches the repo root. + return f"../../{AGENTS_RULES}/{stem}.md" + + +def main(): + fix = "--fix" in sys.argv + + stems = sorted(f[:-3] for f in os.listdir(AGENTS_RULES) if f.endswith(".md")) + problems = [] + + # Every rule must have a .mdc symlink pointing at its .md. + for stem in stems: + mdc = os.path.join(CURSOR_RULES, stem + ".mdc") + want = link_target(stem) + have = os.readlink(mdc) if os.path.islink(mdc) else None + if have == want: + continue + if fix: + if os.path.lexists(mdc): + os.remove(mdc) + os.symlink(want, mdc) + print(f"Linked {mdc} -> {want}") + else: + problems.append(f"{mdc}: missing or wrong symlink, expected -> {want}") + + # No .mdc symlink may point at a rule that no longer exists. + known = {stem + ".mdc" for stem in stems} + for name in sorted(os.listdir(CURSOR_RULES)): + path = os.path.join(CURSOR_RULES, name) + if not os.path.islink(path) or name in known: + continue + if fix: + os.remove(path) + print(f"Removed stale {path}") + else: + problems.append(f"{path}: stale symlink, no matching {AGENTS_RULES}/ rule") + + if problems: + print("\n".join(problems)) + print( + f"\n{len(problems)} problem(s). Run: ./tools/validate_cursor_rules.py --fix", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 38fb872fe651a815f1ff84c462cc0c48c3b6d49c Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 13:41:32 +0000 Subject: [PATCH 2/4] make executable --- tools/validate_cursor_rules.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tools/validate_cursor_rules.py diff --git a/tools/validate_cursor_rules.py b/tools/validate_cursor_rules.py old mode 100644 new mode 100755 From 7fd627148261b4fc36cad9d285b82d310676cdf2 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 15:00:51 +0000 Subject: [PATCH 3/4] Generalize agent-setup lint to all agents and fail on drift Rename validate_cursor_rules.py to validate_agents_setup.py and extend it to sync every agent's symlinks (Claude, Cursor, GitHub Copilot) with the canonical AGENTS.md + .agents/ setup, not just Cursor's .mdc mirrors. Drop the --fix flag (fixing is now the default) and instead exit non-zero when a symlink was missing or wrong: a created symlink is untracked, so CI's `git diff --exit-code` gate alone would let the common missing-link case pass. Co-authored-by: Isaac --- Taskfile.yml | 10 ++--- tools/validate_agents_setup.py | 80 ++++++++++++++++++++++++++++++++++ tools/validate_cursor_rules.py | 72 ------------------------------ 3 files changed, 85 insertions(+), 77 deletions(-) create mode 100755 tools/validate_agents_setup.py delete mode 100755 tools/validate_cursor_rules.py diff --git a/Taskfile.yml b/Taskfile.yml index e54442bf4c..40b1a0d02e 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -313,13 +313,13 @@ tasks: cmds: - "! git grep -lF databricks.com -- '*uv.lock' '*.py.lock'" - check-cursor-rules: - desc: Fail if a .agents/rules/*.md lacks its .cursor/rules/*.mdc symlink (--fix adds it) + check-agents-setup: + desc: Sync each agent's symlinks with the canonical .agents/ setup; fails if any were out of sync cmds: - - "./tools/validate_cursor_rules.py" + - "./tools/validate_agents_setup.py" checks: - desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles, cursor rules) + desc: Run quick checks (tidy, whitespace, deadcode, changelog, lockfiles, agent setup) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work # touching more paths should not race with the whitespace scanner. cmds: @@ -328,7 +328,7 @@ tasks: - task: deadcode - task: check-changelog - task: check-lockfiles - - task: check-cursor-rules + - task: check-agents-setup install-pythons: desc: Install Python 3.9-3.13 via uv diff --git a/tools/validate_agents_setup.py b/tools/validate_agents_setup.py new file mode 100755 index 0000000000..753f63362a --- /dev/null +++ b/tools/validate_agents_setup.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Keep every agent's config in sync with the canonical .agents/ setup. + +The agent instructions live in AGENTS.md and .agents/ (rules, skills); each agent +reads them from its own path via a symlink: + + - CLAUDE.md -> AGENTS.md + - .github/custom-instructions.md -> AGENTS.md + - .claude/rules, .claude/skills -> .agents/rules, .agents/skills + - .cursor/rules/.mdc -> .agents/rules/.md + +Cursor gets a symlink per rule rather than a directory link like Claude, because +it only reads .mdc files and won't follow a link to the .md directory. + +This creates any missing symlinks and drops stale Cursor ones, then exits non-zero +if it had to change anything so CI fails on an out-of-sync tree. We can't lean on +CI's `git diff --exit-code` alone: the common case is a missing symlink, which we +create as an untracked file that a plain `git diff` doesn't report. + +The standalone .cursor/rules/00-agents-context.mdc bootstrap is not a mirror of a +rule and is left untouched. +""" + +import os +import sys + +AGENTS_RULES = ".agents/rules" +CURSOR_RULES = ".cursor/rules" + +# Symlink path -> target, relative to the symlink's own directory. +FIXED_LINKS = { + "CLAUDE.md": "AGENTS.md", + ".github/custom-instructions.md": "../AGENTS.md", + ".claude/rules": "../.agents/rules", + ".claude/skills": "../.agents/skills", +} + + +def ensure_link(path, target): + if os.path.islink(path) and os.readlink(path) == target: + return False + if os.path.lexists(path): + os.remove(path) + os.symlink(target, path) + print(f"Linked {path} -> {target}") + return True + + +def main(): + changed = False + + for path, target in FIXED_LINKS.items(): + changed |= ensure_link(path, target) + + stems = sorted(f[:-3] for f in os.listdir(AGENTS_RULES) if f.endswith(".md")) + + # The .mdc lives in .cursor/rules/, so ../../ reaches the repo root. + for stem in stems: + changed |= ensure_link(os.path.join(CURSOR_RULES, stem + ".mdc"), f"../../{AGENTS_RULES}/{stem}.md") + + # Drop .mdc symlinks whose rule no longer exists. + known = {stem + ".mdc" for stem in stems} + for name in sorted(os.listdir(CURSOR_RULES)): + path = os.path.join(CURSOR_RULES, name) + if os.path.islink(path) and name not in known: + os.remove(path) + print(f"Removed stale {path}") + changed = True + + if changed: + print("agent symlinks were out of sync; they have been fixed, commit the changes", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/validate_cursor_rules.py b/tools/validate_cursor_rules.py deleted file mode 100755 index 3ef9ce5895..0000000000 --- a/tools/validate_cursor_rules.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.12" -# /// -"""Keep .cursor/rules/*.mdc symlinks in sync with .agents/rules/*.md. - -The canonical rules live in .agents/rules/.md; Cursor reads them from -.cursor/rules/.mdc, so each rule needs a .mdc symlink pointing back at its -.md. This validates that every rule has a correct symlink and that no symlink is -left dangling. Run with --fix to create missing symlinks and drop stale ones. - -Non-symlink .mdc files (e.g. the standalone 00-agents-context.mdc bootstrap) are -not mirrors of a rule and are left untouched. -""" - -import os -import sys - -AGENTS_RULES = ".agents/rules" -CURSOR_RULES = ".cursor/rules" - - -def link_target(stem): - # The .mdc lives in .cursor/rules/, so ../../ reaches the repo root. - return f"../../{AGENTS_RULES}/{stem}.md" - - -def main(): - fix = "--fix" in sys.argv - - stems = sorted(f[:-3] for f in os.listdir(AGENTS_RULES) if f.endswith(".md")) - problems = [] - - # Every rule must have a .mdc symlink pointing at its .md. - for stem in stems: - mdc = os.path.join(CURSOR_RULES, stem + ".mdc") - want = link_target(stem) - have = os.readlink(mdc) if os.path.islink(mdc) else None - if have == want: - continue - if fix: - if os.path.lexists(mdc): - os.remove(mdc) - os.symlink(want, mdc) - print(f"Linked {mdc} -> {want}") - else: - problems.append(f"{mdc}: missing or wrong symlink, expected -> {want}") - - # No .mdc symlink may point at a rule that no longer exists. - known = {stem + ".mdc" for stem in stems} - for name in sorted(os.listdir(CURSOR_RULES)): - path = os.path.join(CURSOR_RULES, name) - if not os.path.islink(path) or name in known: - continue - if fix: - os.remove(path) - print(f"Removed stale {path}") - else: - problems.append(f"{path}: stale symlink, no matching {AGENTS_RULES}/ rule") - - if problems: - print("\n".join(problems)) - print( - f"\n{len(problems)} problem(s). Run: ./tools/validate_cursor_rules.py --fix", - file=sys.stderr, - ) - return 1 - return 0 - - -if __name__ == "__main__": - sys.exit(main()) From 5f5c654a11e9ef564aefbe5a1789097690e18631 Mon Sep 17 00:00:00 2001 From: Sankalp-Mittal Date: Fri, 4 Sep 2026 15:08:04 +0000 Subject: [PATCH 4/4] Drop redundant fixed-link syncing, keep Cursor mirrors only The Claude and GitHub Copilot symlinks are committed, static, and directory-level (new rules appear automatically), so any drift is already caught by CI's `git diff --exit-code`. Only Cursor's per-rule .mdc mirrors need active syncing, since adding a rule creates a new untracked symlink that a plain git diff misses. Co-authored-by: Isaac --- Taskfile.yml | 2 +- tools/validate_agents_setup.py | 57 +++++++++++++--------------------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index 40b1a0d02e..2fa44a017a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -314,7 +314,7 @@ tasks: - "! git grep -lF databricks.com -- '*uv.lock' '*.py.lock'" check-agents-setup: - desc: Sync each agent's symlinks with the canonical .agents/ setup; fails if any were out of sync + desc: Sync Cursor's per-rule .mdc symlinks with .agents/rules; fails if any were out of sync cmds: - "./tools/validate_agents_setup.py" diff --git a/tools/validate_agents_setup.py b/tools/validate_agents_setup.py index 753f63362a..001361322d 100755 --- a/tools/validate_agents_setup.py +++ b/tools/validate_agents_setup.py @@ -2,23 +2,20 @@ # /// script # requires-python = ">=3.12" # /// -"""Keep every agent's config in sync with the canonical .agents/ setup. +"""Keep .cursor/rules/*.mdc in sync with the canonical .agents/rules/*.md rules. -The agent instructions live in AGENTS.md and .agents/ (rules, skills); each agent -reads them from its own path via a symlink: +The agent instructions live in AGENTS.md and .agents/ (rules, skills). Most agents +reach them through a single committed symlink that needs no per-rule upkeep: - CLAUDE.md -> AGENTS.md - .github/custom-instructions.md -> AGENTS.md - - .claude/rules, .claude/skills -> .agents/rules, .agents/skills - - .cursor/rules/.mdc -> .agents/rules/.md + - .claude/rules, .claude/skills -> .agents/rules, .agents/skills (directory links) -Cursor gets a symlink per rule rather than a directory link like Claude, because -it only reads .mdc files and won't follow a link to the .md directory. - -This creates any missing symlinks and drops stale Cursor ones, then exits non-zero -if it had to change anything so CI fails on an out-of-sync tree. We can't lean on -CI's `git diff --exit-code` alone: the common case is a missing symlink, which we -create as an untracked file that a plain `git diff` doesn't report. +Cursor is the exception: it only reads .mdc files and won't follow a directory +link, so every rule needs its own .cursor/rules/.mdc pointing back at the .md. +This creates the missing ones and drops stale ones, then exits non-zero if it had +to change anything so CI fails on an out-of-sync tree: a created symlink is +untracked, so CI's `git diff --exit-code` gate alone would let a missing one pass. The standalone .cursor/rules/00-agents-context.mdc bootstrap is not a mirror of a rule and is left untouched. @@ -30,36 +27,24 @@ AGENTS_RULES = ".agents/rules" CURSOR_RULES = ".cursor/rules" -# Symlink path -> target, relative to the symlink's own directory. -FIXED_LINKS = { - "CLAUDE.md": "AGENTS.md", - ".github/custom-instructions.md": "../AGENTS.md", - ".claude/rules": "../.agents/rules", - ".claude/skills": "../.agents/skills", -} - - -def ensure_link(path, target): - if os.path.islink(path) and os.readlink(path) == target: - return False - if os.path.lexists(path): - os.remove(path) - os.symlink(target, path) - print(f"Linked {path} -> {target}") - return True - def main(): changed = False - for path, target in FIXED_LINKS.items(): - changed |= ensure_link(path, target) - stems = sorted(f[:-3] for f in os.listdir(AGENTS_RULES) if f.endswith(".md")) - # The .mdc lives in .cursor/rules/, so ../../ reaches the repo root. + # Every rule needs a .mdc symlink. The .mdc lives in .cursor/rules/, so ../../ + # reaches the repo root. for stem in stems: - changed |= ensure_link(os.path.join(CURSOR_RULES, stem + ".mdc"), f"../../{AGENTS_RULES}/{stem}.md") + mdc = os.path.join(CURSOR_RULES, stem + ".mdc") + want = f"../../{AGENTS_RULES}/{stem}.md" + if os.path.islink(mdc) and os.readlink(mdc) == want: + continue + if os.path.lexists(mdc): + os.remove(mdc) + os.symlink(want, mdc) + print(f"Linked {mdc} -> {want}") + changed = True # Drop .mdc symlinks whose rule no longer exists. known = {stem + ".mdc" for stem in stems} @@ -71,7 +56,7 @@ def main(): changed = True if changed: - print("agent symlinks were out of sync; they have been fixed, commit the changes", file=sys.stderr) + print("Cursor rule symlinks were out of sync; they have been fixed, commit the changes", file=sys.stderr) return 1 return 0