PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
audit_agent_setup.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2"""Audit and synchronize the repository-portable agent instruction setup."""
3
4from __future__ import annotations
5
6import argparse
7import shutil
8import subprocess
9import sys
10from pathlib import Path
11
12
13REPO_ROOT = Path(__file__).resolve().parents[2]
14CANONICAL_ROOT = REPO_ROOT / ".agents" / "skills"
15CLAUDE_ROOT = REPO_ROOT / ".claude" / "skills"
16EXPECTED_SKILLS = {
17 "picurv-capability-change",
18 "picurv-solver-debugging",
19 "picurv-spatial-kernel-change",
20}
21LOCAL_SETTINGS = ".claude/settings.local.json"
22LOCAL_SETTINGS_PATTERN = "/.claude/settings.local.json"
23
24
25def frontmatter(path: Path) -> dict[str, str]:
26 """!
27 @brief Parse the simple YAML front matter used by a skill file.
28 @param[in] path Skill file to inspect.
29 @return Parsed scalar front-matter fields.
30 """
31
32 lines = path.read_text(encoding="utf-8").splitlines()
33 if not lines or lines[0] != "---":
34 return {}
35 fields: dict[str, str] = {}
36 for line in lines[1:]:
37 if line == "---":
38 return fields
39 if ":" in line:
40 key, value = line.split(":", 1)
41 fields[key.strip()] = value.strip()
42 return {}
43
44
45def skill_entries(root: Path) -> dict[str, Path]:
46 """!
47 @brief Return skill directories directly below one discovery root.
48 @param[in] root Skill discovery directory.
49 @return Mapping from directory name to path, including symlinked directories.
50 """
51
52 if not root.is_dir():
53 return {}
54 return {
55 child.name: child
56 for child in root.iterdir()
57 if child.is_dir() or child.is_symlink()
58 }
59
60
61def entry_names(root: Path) -> set[str]:
62 """!
63 @brief Return every direct child name under a skill discovery root.
64 @param[in] root Skill discovery directory.
65 @return Child-name set, including unexpected files and links.
66 """
67
68 return {child.name for child in root.iterdir()} if root.is_dir() else set()
69
70
71def remove_entry(path: Path) -> None:
72 """!
73 @brief Remove one skill-tree entry without following a directory symlink.
74 @param[in] path Exact materialized or symlinked skill path.
75 @return None.
76 """
77
78 if path.is_symlink() or path.is_file():
79 path.unlink()
80 elif path.is_dir():
81 shutil.rmtree(path)
82
83
84def synchronize() -> None:
85 """!
86 @brief Materialize byte-identical Claude skill copies from the canonical tree.
87 @return None.
88 @throws RuntimeError when the canonical skill set is incomplete or unexpected.
89 """
90
91 canonical = skill_entries(CANONICAL_ROOT)
92 canonical_names = entry_names(CANONICAL_ROOT)
93 if canonical_names != EXPECTED_SKILLS:
94 missing = sorted(EXPECTED_SKILLS - canonical_names)
95 extra = sorted(canonical_names - EXPECTED_SKILLS)
96 raise RuntimeError(f"canonical skill set differs: missing={missing}, extra={extra}")
97
98 CLAUDE_ROOT.mkdir(parents=True, exist_ok=True)
99 for path in list(CLAUDE_ROOT.iterdir()):
100 remove_entry(path)
101 for name in sorted(EXPECTED_SKILLS):
102 shutil.copytree(canonical[name], CLAUDE_ROOT / name, symlinks=False)
103
104
105def git(*args: str) -> subprocess.CompletedProcess[str]:
106 """!
107 @brief Run Git with machine-global excludes disabled.
108 @param[in] args Arguments following `git`.
109 @return Completed process with captured text output.
110 """
111
112 return subprocess.run(
113 ["git", "-c", "core.excludesFile=/dev/null", "-C", str(REPO_ROOT), *args],
114 capture_output=True,
115 text=True,
116 check=False,
117 )
118
119
120def audit() -> list[str]:
121 """!
122 @brief Validate shared instructions, skill discovery copies, and local-settings hygiene.
123 @return Human-readable violations; an empty list means the audit passed.
124 """
125
126 errors: list[str] = []
127 agents = REPO_ROOT / "AGENTS.md"
128 claude = REPO_ROOT / "CLAUDE.md"
129
130 if not agents.is_file() or agents.is_symlink():
131 errors.append("AGENTS.md must be a tracked regular file, not a symlink")
132 if not claude.is_file() or claude.is_symlink():
133 errors.append("CLAUDE.md must be a regular file, not a symlink")
134 elif "@AGENTS.md" not in {line.strip() for line in claude.read_text(encoding="utf-8").splitlines()}:
135 errors.append("CLAUDE.md must import the canonical @AGENTS.md instructions")
136
137 canonical = skill_entries(CANONICAL_ROOT)
138 copies = skill_entries(CLAUDE_ROOT)
139 canonical_names = entry_names(CANONICAL_ROOT)
140 copy_names = entry_names(CLAUDE_ROOT)
141 if canonical_names != EXPECTED_SKILLS:
142 errors.append(
143 "canonical skill set mismatch: "
144 f"missing={sorted(EXPECTED_SKILLS - canonical_names)}, "
145 f"extra={sorted(canonical_names - EXPECTED_SKILLS)}"
146 )
147 if copy_names != EXPECTED_SKILLS:
148 errors.append(
149 "Claude skill set mismatch: "
150 f"missing={sorted(EXPECTED_SKILLS - copy_names)}, "
151 f"extra={sorted(copy_names - EXPECTED_SKILLS)}; run make sync-agent-skills"
152 )
153
154 for name in sorted(EXPECTED_SKILLS & set(canonical)):
155 source_dir = canonical[name]
156 source = source_dir / "SKILL.md"
157 if source_dir.is_symlink() or not source.is_file() or source.is_symlink():
158 errors.append(f"canonical skill {name} must contain a regular SKILL.md")
159 continue
160 metadata = frontmatter(source)
161 if metadata.get("name") != name:
162 errors.append(f"{source.relative_to(REPO_ROOT)} front-matter name must be {name!r}")
163 if not metadata.get("description"):
164 errors.append(f"{source.relative_to(REPO_ROOT)} requires a non-empty description")
165
166 target_dir = copies.get(name)
167 target = target_dir / "SKILL.md" if target_dir else None
168 if target_dir is None:
169 continue
170 if target_dir.is_symlink() or target is None or not target.is_file() or target.is_symlink():
171 errors.append(f"Claude skill {name} must be a materialized regular-file copy")
172 continue
173 source_files = sorted(
174 path.relative_to(source_dir) for path in source_dir.rglob("*") if path.is_file()
175 )
176 target_files = sorted(
177 path.relative_to(target_dir) for path in target_dir.rglob("*") if path.is_file()
178 )
179 if source_files != target_files:
180 errors.append(f"Claude skill {name} file set differs; run make sync-agent-skills")
181 continue
182 for relative in source_files:
183 if (source_dir / relative).read_bytes() != (target_dir / relative).read_bytes():
184 errors.append(
185 f"Claude skill {name}/{relative} differs byte-for-byte; "
186 "run make sync-agent-skills"
187 )
188
189 tracked = git("ls-files", "--error-unmatch", "--", LOCAL_SETTINGS)
190 if tracked.returncode == 0:
191 errors.append(f"{LOCAL_SETTINGS} is machine-local and must not be tracked")
192 ignored = git("check-ignore", "-v", "--", LOCAL_SETTINGS)
193 if ignored.returncode != 0:
194 errors.append(f"{LOCAL_SETTINGS} must be ignored by the repository .gitignore")
195 else:
196 fields = ignored.stdout.rstrip().split("\t", 1)[0].rsplit(":", 2)
197 source = fields[0] if len(fields) == 3 else ""
198 pattern = fields[2] if len(fields) == 3 else ""
199 if Path(source).name != ".gitignore" or pattern != LOCAL_SETTINGS_PATTERN:
200 errors.append(
201 f"{LOCAL_SETTINGS} must use the exact repository ignore pattern "
202 f"{LOCAL_SETTINGS_PATTERN!r}, not a user-global or incidental rule"
203 )
204
205 return errors
206
207
208def parse_args(argv: list[str]) -> argparse.Namespace:
209 """!
210 @brief Parse audit command-line arguments.
211 @param[in] argv Arguments excluding the executable name.
212 @return Parsed command-line namespace.
213 """
214
215 parser = argparse.ArgumentParser(
216 description="Audit or synchronize PICurv's portable shared agent setup."
217 )
218 parser.add_argument(
219 "--sync",
220 action="store_true",
221 help="replace Claude skill entries with materialized copies of canonical .agents skills",
222 )
223 return parser.parse_args(argv)
224
225
226def main(argv: list[str] | None = None) -> int:
227 """!
228 @brief Synchronize when requested, then fail closed on portability drift.
229 @param[in] argv Optional arguments excluding the executable name.
230 @return Zero on success and one on any setup violation.
231 """
232
233 args = parse_args(sys.argv[1:] if argv is None else argv)
234 try:
235 if args.sync:
237 except (OSError, RuntimeError) as error:
238 print(f"Agent setup synchronization failed: {error}", file=sys.stderr)
239 return 1
240
241 errors = audit()
242 if errors:
243 for error in errors:
244 print(f"ERROR: {error}", file=sys.stderr)
245 return 1
246 action = "synchronized and audited" if args.sync else "audited"
247 print(f"Agent setup {action}: shared instructions and {len(EXPECTED_SKILLS)} skills are portable.")
248 return 0
249
250
251if __name__ == "__main__":
252 raise SystemExit(main())
int main(list[str]|None argv=None)
Synchronize when requested, then fail closed on portability drift.
dict[str, str] frontmatter(Path path)
Parse the simple YAML front matter used by a skill file.
subprocess.CompletedProcess[str] git(*str args)
Run Git with machine-global excludes disabled.
argparse.Namespace parse_args(list[str] argv)
Parse audit command-line arguments.
dict[str, Path] skill_entries(Path root)
Return skill directories directly below one discovery root.
set[str] entry_names(Path root)
Return every direct child name under a skill discovery root.
list[str] audit()
Validate shared instructions, skill discovery copies, and local-settings hygiene.
None synchronize()
Materialize byte-identical Claude skill copies from the canonical tree.
None remove_entry(Path path)
Remove one skill-tree entry without following a directory symlink.
Head of a generic C-style linked list.
Definition variables.h:475