diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..17a01a1 --- /dev/null +++ b/AGENTS.md @@ -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) diff --git a/hooks/agents-sync.sh b/hooks/agents-sync.sh new file mode 100755 index 0000000..1bb75e3 --- /dev/null +++ b/hooks/agents-sync.sh @@ -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 "")) + + +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) diff --git a/hooks/hooks.json b/hooks/hooks.json index 7aec263..0a85d03 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -4,6 +4,10 @@ { "matcher": "Bash", "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/agents-sync.sh" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/tea-guard.sh"