2"""Audit and synchronize the repository-portable agent instruction setup."""
4from __future__
import annotations
10from pathlib
import Path
13REPO_ROOT = Path(__file__).resolve().parents[2]
14CANONICAL_ROOT = REPO_ROOT /
".agents" /
"skills"
15CLAUDE_ROOT = REPO_ROOT /
".claude" /
"skills"
17 "picurv-capability-change",
18 "picurv-solver-debugging",
19 "picurv-spatial-kernel-change",
20 "picurv-technical-communication",
22LOCAL_SETTINGS =
".claude/settings.local.json"
23LOCAL_SETTINGS_PATTERN =
"/.claude/settings.local.json"
28 @brief Parse the simple YAML front matter used by a skill file.
29 @param[in] path Skill file to inspect.
30 @return Parsed scalar front-matter fields.
33 lines = path.read_text(encoding=
"utf-8").splitlines()
34 if not lines
or lines[0] !=
"---":
36 fields: dict[str, str] = {}
37 for line
in lines[1:]:
41 key, value = line.split(
":", 1)
42 fields[key.strip()] = value.strip()
48 @brief Return skill directories directly below one discovery root.
49 @param[in] root Skill discovery directory.
50 @return Mapping from directory name to path, including symlinked directories.
57 for child
in root.iterdir()
58 if child.is_dir()
or child.is_symlink()
64 @brief Return every direct child name under a skill discovery root.
65 @param[in] root Skill discovery directory.
66 @return Child-name set, including unexpected files and links.
69 return {child.name
for child
in root.iterdir()}
if root.is_dir()
else set()
74 @brief Remove one skill-tree entry without following a directory symlink.
75 @param[in] path Exact materialized or symlinked skill path.
79 if path.is_symlink()
or path.is_file():
87 @brief Materialize byte-identical Claude skill copies from the canonical tree.
89 @throws RuntimeError when the canonical skill set is incomplete or unexpected.
94 if canonical_names != EXPECTED_SKILLS:
95 missing = sorted(EXPECTED_SKILLS - canonical_names)
96 extra = sorted(canonical_names - EXPECTED_SKILLS)
97 raise RuntimeError(f
"canonical skill set differs: missing={missing}, extra={extra}")
99 CLAUDE_ROOT.mkdir(parents=
True, exist_ok=
True)
100 for path
in list(CLAUDE_ROOT.iterdir()):
102 for name
in sorted(EXPECTED_SKILLS):
103 shutil.copytree(canonical[name], CLAUDE_ROOT / name, symlinks=
False)
106def git(*args: str) -> subprocess.CompletedProcess[str]:
108 @brief Run Git with machine-global excludes disabled.
109 @param[in] args Arguments following `git`.
110 @return Completed process with captured text output.
113 return subprocess.run(
114 [
"git",
"-c",
"core.excludesFile=/dev/null",
"-C", str(REPO_ROOT), *args],
123 @brief Validate shared instructions, skill discovery copies, and local-settings hygiene.
124 @return Human-readable violations; an empty list means the audit passed.
127 errors: list[str] = []
128 agents = REPO_ROOT /
"AGENTS.md"
129 claude = REPO_ROOT /
"CLAUDE.md"
131 if not agents.is_file()
or agents.is_symlink():
132 errors.append(
"AGENTS.md must be a tracked regular file, not a symlink")
133 if not claude.is_file()
or claude.is_symlink():
134 errors.append(
"CLAUDE.md must be a regular file, not a symlink")
135 elif "@AGENTS.md" not in {line.strip()
for line
in claude.read_text(encoding=
"utf-8").splitlines()}:
136 errors.append(
"CLAUDE.md must import the canonical @AGENTS.md instructions")
142 if canonical_names != EXPECTED_SKILLS:
144 "canonical skill set mismatch: "
145 f
"missing={sorted(EXPECTED_SKILLS - canonical_names)}, "
146 f
"extra={sorted(canonical_names - EXPECTED_SKILLS)}"
148 if copy_names != EXPECTED_SKILLS:
150 "Claude skill set mismatch: "
151 f
"missing={sorted(EXPECTED_SKILLS - copy_names)}, "
152 f
"extra={sorted(copy_names - EXPECTED_SKILLS)}; run make sync-agent-skills"
155 for name
in sorted(EXPECTED_SKILLS & set(canonical)):
156 source_dir = canonical[name]
157 source = source_dir /
"SKILL.md"
158 if source_dir.is_symlink()
or not source.is_file()
or source.is_symlink():
159 errors.append(f
"canonical skill {name} must contain a regular SKILL.md")
162 if metadata.get(
"name") != name:
163 errors.append(f
"{source.relative_to(REPO_ROOT)} front-matter name must be {name!r}")
164 if not metadata.get(
"description"):
165 errors.append(f
"{source.relative_to(REPO_ROOT)} requires a non-empty description")
167 target_dir = copies.get(name)
168 target = target_dir /
"SKILL.md" if target_dir
else None
169 if target_dir
is None:
171 if target_dir.is_symlink()
or target
is None or not target.is_file()
or target.is_symlink():
172 errors.append(f
"Claude skill {name} must be a materialized regular-file copy")
174 source_files = sorted(
175 path.relative_to(source_dir)
for path
in source_dir.rglob(
"*")
if path.is_file()
177 target_files = sorted(
178 path.relative_to(target_dir)
for path
in target_dir.rglob(
"*")
if path.is_file()
180 if source_files != target_files:
181 errors.append(f
"Claude skill {name} file set differs; run make sync-agent-skills")
183 for relative
in source_files:
184 if (source_dir / relative).read_bytes() != (target_dir / relative).read_bytes():
186 f
"Claude skill {name}/{relative} differs byte-for-byte; "
187 "run make sync-agent-skills"
190 tracked =
git(
"ls-files",
"--error-unmatch",
"--", LOCAL_SETTINGS)
191 if tracked.returncode == 0:
192 errors.append(f
"{LOCAL_SETTINGS} is machine-local and must not be tracked")
193 ignored =
git(
"check-ignore",
"-v",
"--", LOCAL_SETTINGS)
194 if ignored.returncode != 0:
195 errors.append(f
"{LOCAL_SETTINGS} must be ignored by the repository .gitignore")
197 fields = ignored.stdout.rstrip().split(
"\t", 1)[0].rsplit(
":", 2)
198 source = fields[0]
if len(fields) == 3
else ""
199 pattern = fields[2]
if len(fields) == 3
else ""
200 if Path(source).name !=
".gitignore" or pattern != LOCAL_SETTINGS_PATTERN:
202 f
"{LOCAL_SETTINGS} must use the exact repository ignore pattern "
203 f
"{LOCAL_SETTINGS_PATTERN!r}, not a user-global or incidental rule"
211 @brief Parse audit command-line arguments.
212 @param[in] argv Arguments excluding the executable name.
213 @return Parsed command-line namespace.
216 parser = argparse.ArgumentParser(
217 description=
"Audit or synchronize PICurv's portable shared agent setup."
222 help=
"replace Claude skill entries with materialized copies of canonical .agents skills",
224 return parser.parse_args(argv)
227def main(argv: list[str] |
None =
None) -> int:
229 @brief Synchronize when requested, then fail closed on portability drift.
230 @param[in] argv Optional arguments excluding the executable name.
231 @return Zero on success and one on any setup violation.
234 args =
parse_args(sys.argv[1:]
if argv
is None else argv)
238 except (OSError, RuntimeError)
as error:
239 print(f
"Agent setup synchronization failed: {error}", file=sys.stderr)
245 print(f
"ERROR: {error}", file=sys.stderr)
247 action =
"synchronized and audited" if args.sync
else "audited"
248 print(f
"Agent setup {action}: shared instructions and {len(EXPECTED_SKILLS)} skills are portable.")
252if __name__ ==
"__main__":
253 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.