feat: add agents-sync hook and AGENTS.md
Keep AGENTS.md as the single source of agent docs: agents-sync runs before every Bash command and makes each directory canonical — AGENTS.md real file, CLAUDE.md a symlink to it. Renames a lone CLAUDE.md, creates or re-points symlinks, replaces identical duplicates; differing files are only reported, never merged. Fails open so it can never block a command. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project goals
|
||||||
|
|
||||||
|
1. **Unify and systematize issue workflow** for the development team with minimal context usage. Issue operations (create, fetch, format) are wrapped in scripts so agents spend tokens on the task, not on re-deriving commands and formats.
|
||||||
|
2. **Route all Gitea interaction through the `tea` CLI via scripts** instead of direct ad-hoc calls wherever possible. Scripts give deterministic, reviewable behavior; the `tea-guard` hook enforces that every `tea` invocation runs under the operator-pinned login.
|
||||||
|
|
||||||
|
## Repo layout
|
||||||
|
|
||||||
|
- `skills/auth` — pin the Gitea login used by `tea` (`/tea:auth`)
|
||||||
|
- `skills/use` — `tea` CLI reference, loaded on demand (`/tea:use`); `scripts/` holds helper scripts (e.g. `fetch_issue.py`), `references/` holds command docs and the canonical issue format
|
||||||
|
- `skills/issue` — create issues in the canonical format (`/tea:issue`)
|
||||||
|
- `hooks/` — PreToolUse hooks: `tea-guard` blocks or rewrites `tea` invocations that don't use the pinned login; `agents-sync` keeps every directory canonical (`AGENTS.md` real file, `CLAUDE.md` symlink to it)
|
||||||
Executable
+141
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
agents-sync — PreToolUse(Bash) hook.
|
||||||
|
|
||||||
|
Before any Bash command runs, walks the project tree and enforces one
|
||||||
|
filesystem invariant in every directory:
|
||||||
|
|
||||||
|
AGENTS.md is the real file; CLAUDE.md is a symlink pointing at it.
|
||||||
|
|
||||||
|
Per directory:
|
||||||
|
- AGENTS.md real, no CLAUDE.md ........ create symlink CLAUDE.md -> AGENTS.md
|
||||||
|
- CLAUDE.md real, no AGENTS.md ........ rename to AGENTS.md, symlink back
|
||||||
|
- CLAUDE.md symlink -> AGENTS.md ...... already canonical, nothing to do
|
||||||
|
- CLAUDE.md symlink elsewhere ......... re-point at AGENTS.md
|
||||||
|
- AGENTS.md symlink -> real CLAUDE.md . reversed layout: swap to canonical
|
||||||
|
- both real, identical content ........ replace CLAUDE.md with the symlink
|
||||||
|
- both real, different content ........ DON'T touch; report the conflict
|
||||||
|
|
||||||
|
The hook never blocks the tool call and never deletes content: every branch
|
||||||
|
either performs a lossless fix or reports. Fixes/conflicts are surfaced via
|
||||||
|
hookSpecificOutput.additionalContext; silence means the tree was already
|
||||||
|
canonical. Any unexpected error fails open (exit 0).
|
||||||
|
"""
|
||||||
|
import sys, os, json, filecmp
|
||||||
|
|
||||||
|
SKIP_DIRS = {"node_modules", "__pycache__", "venv", "vendor"}
|
||||||
|
|
||||||
|
|
||||||
|
def same_file(a, b):
|
||||||
|
try:
|
||||||
|
return os.path.realpath(a) == os.path.realpath(b)
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def fix_dir(d, root, fixes, conflicts):
|
||||||
|
agents = os.path.join(d, "AGENTS.md")
|
||||||
|
claude = os.path.join(d, "CLAUDE.md")
|
||||||
|
a = os.path.lexists(agents)
|
||||||
|
c = os.path.lexists(claude)
|
||||||
|
if not a and not c:
|
||||||
|
return
|
||||||
|
|
||||||
|
rel = lambda p: os.path.relpath(p, root)
|
||||||
|
a_link = a and os.path.islink(agents)
|
||||||
|
c_link = c and os.path.islink(claude)
|
||||||
|
|
||||||
|
if a and not c:
|
||||||
|
if a_link and not os.path.exists(agents):
|
||||||
|
conflicts.append("%s: broken symlink and no CLAUDE.md" % rel(agents))
|
||||||
|
return
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: created symlink -> AGENTS.md" % rel(claude))
|
||||||
|
return
|
||||||
|
|
||||||
|
if c and not a:
|
||||||
|
if c_link:
|
||||||
|
conflicts.append("%s: symlink to missing target (%s)"
|
||||||
|
% (rel(claude), os.readlink(claude)))
|
||||||
|
return
|
||||||
|
os.rename(claude, agents)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: renamed to AGENTS.md, symlink left in place" % rel(claude))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Both exist.
|
||||||
|
if c_link:
|
||||||
|
if same_file(claude, agents):
|
||||||
|
return # canonical
|
||||||
|
old = os.readlink(claude)
|
||||||
|
os.remove(claude)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: re-pointed symlink (%s -> AGENTS.md)" % (rel(claude), old))
|
||||||
|
return
|
||||||
|
|
||||||
|
if a_link:
|
||||||
|
# Reversed layout: AGENTS.md is the symlink, CLAUDE.md the real file.
|
||||||
|
if same_file(agents, claude):
|
||||||
|
os.remove(agents)
|
||||||
|
os.rename(claude, agents)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: swapped — AGENTS.md is now the real file" % rel(agents))
|
||||||
|
else:
|
||||||
|
conflicts.append("%s: symlink elsewhere while CLAUDE.md is a real file"
|
||||||
|
% rel(agents))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Both are real files.
|
||||||
|
try:
|
||||||
|
identical = filecmp.cmp(agents, claude, shallow=False)
|
||||||
|
except OSError:
|
||||||
|
identical = False
|
||||||
|
if identical:
|
||||||
|
os.remove(claude)
|
||||||
|
os.symlink("AGENTS.md", claude)
|
||||||
|
fixes.append("%s: identical to AGENTS.md, replaced with symlink" % rel(claude))
|
||||||
|
else:
|
||||||
|
conflicts.append("%s: AGENTS.md and CLAUDE.md are different real files — "
|
||||||
|
"merge manually" % (rel(d) if rel(d) != "." else "<root>"))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
try:
|
||||||
|
payload = json.load(sys.stdin)
|
||||||
|
except Exception:
|
||||||
|
payload = {}
|
||||||
|
|
||||||
|
root = os.environ.get("CLAUDE_PROJECT_DIR") or payload.get("cwd") or os.getcwd()
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
return
|
||||||
|
|
||||||
|
fixes, conflicts = [], []
|
||||||
|
for dirpath, dirnames, _ in os.walk(root):
|
||||||
|
dirnames[:] = [n for n in dirnames
|
||||||
|
if n not in SKIP_DIRS and not n.startswith(".")]
|
||||||
|
try:
|
||||||
|
fix_dir(dirpath, root, fixes, conflicts)
|
||||||
|
except OSError:
|
||||||
|
pass # unwritable dir etc. — skip, never block the command
|
||||||
|
|
||||||
|
if fixes or conflicts:
|
||||||
|
parts = []
|
||||||
|
if fixes:
|
||||||
|
parts.append("agents-sync fixed:\n " + "\n ".join(fixes))
|
||||||
|
if conflicts:
|
||||||
|
parts.append("agents-sync needs manual resolution:\n "
|
||||||
|
+ "\n ".join(conflicts))
|
||||||
|
print(json.dumps({
|
||||||
|
"hookSpecificOutput": {
|
||||||
|
"hookEventName": "PreToolUse",
|
||||||
|
"additionalContext": "\n".join(parts),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception:
|
||||||
|
pass # fail open — this hook must never break Bash
|
||||||
|
sys.exit(0)
|
||||||
@@ -4,6 +4,10 @@
|
|||||||
{
|
{
|
||||||
"matcher": "Bash",
|
"matcher": "Bash",
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/agents-sync.sh"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"
|
||||||
|
|||||||
Reference in New Issue
Block a user