@delorenj/pjangler 1.2.21 → 1.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -0,0 +1,449 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ sync-skills.py — manifest-driven skill fanout.
4
+ Replaces the old symlink-based skillex monolithic fanout.
5
+ """
6
+
7
+ import argparse
8
+ import json
9
+ import os
10
+ import re
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from pathlib import Path
15
+ from urllib.parse import unquote, urlparse
16
+
17
+ # The list of agent CLI skill directories (relative to home or project root)
18
+ CLI_SKILL_DIRS = [
19
+ ".gemini/skills",
20
+ ".codex/skills",
21
+ ".kimi/skills",
22
+ ".augment/skills",
23
+ ".config/opencode/skills",
24
+ ".hermes/skills",
25
+ ".claude/skills",
26
+ ".openclaw/skills",
27
+ ]
28
+
29
+
30
+ def parse_args():
31
+ parser = argparse.ArgumentParser(
32
+ description="Sync skills from manifest to agent CLIs."
33
+ )
34
+ parser.add_argument(
35
+ "--scope",
36
+ choices=["global", "project"],
37
+ required=True,
38
+ help="Whether to sync global skills or project-local skills.",
39
+ )
40
+ return parser.parse_args()
41
+
42
+
43
+ def load_manifest(manifest_path):
44
+ if not manifest_path.exists():
45
+ return {"skills": []}
46
+ with open(manifest_path, "r") as handle:
47
+ return json.load(handle)
48
+
49
+
50
+ def validate_skill_name(name):
51
+ if not isinstance(name, str) or not name:
52
+ raise ValueError("Skill name must be a non-empty string")
53
+ if name in {".", ".."} or Path(name).is_absolute():
54
+ raise ValueError(f"Unsafe skill name: {name!r}")
55
+ if "/" in name or "\\" in name or Path(name).name != name:
56
+ raise ValueError(f"Skill name must be one path component: {name!r}")
57
+ return name
58
+
59
+
60
+ def manifest_skill_name(skill):
61
+ if isinstance(skill, str):
62
+ path = skill if "/" in skill else f"all-skills/{skill}"
63
+ return validate_skill_name(path.split("/")[-1])
64
+ if not isinstance(skill, dict):
65
+ raise ValueError(f"Skill entry must be a string or object: {skill!r}")
66
+ return validate_skill_name(skill.get("name"))
67
+
68
+
69
+ def validate_manifest_skill_names(manifest):
70
+ skills = manifest.get("skills", [])
71
+ if not isinstance(skills, list):
72
+ raise ValueError("Manifest skills must be an array")
73
+ for skill in skills:
74
+ manifest_skill_name(skill)
75
+
76
+
77
+ def assert_real_directory_chain(root, target):
78
+ root = root.resolve(strict=True)
79
+ target = target.absolute()
80
+ try:
81
+ relative = target.relative_to(root)
82
+ except ValueError as error:
83
+ raise ValueError(f"Destination escapes root {root}: {target}") from error
84
+ current = root
85
+ for part in relative.parts:
86
+ current = current / part
87
+ if not current.exists() and not current.is_symlink():
88
+ break
89
+ if current.is_symlink():
90
+ raise ValueError(f"Refusing symlinked destination directory: {current}")
91
+ if not current.is_dir():
92
+ raise ValueError(f"Destination parent is not a directory: {current}")
93
+
94
+
95
+ def preflight_cli_dirs(cli_dirs_base, skill_names):
96
+ base = cli_dirs_base.resolve(strict=True)
97
+ active = []
98
+ for cli_rel_path in CLI_SKILL_DIRS:
99
+ cli_dir = base / cli_rel_path
100
+ parent = cli_dir.parent
101
+ if not parent.exists() and not parent.is_symlink():
102
+ continue
103
+ assert_real_directory_chain(base, parent)
104
+ if parent.is_symlink() or not parent.is_dir():
105
+ raise ValueError(f"Unsafe CLI destination parent: {parent}")
106
+ if cli_dir.is_symlink():
107
+ # Generated projects intentionally expose the single managed skill
108
+ # projection to Claude as `.claude/skills -> ../.agents/skills`.
109
+ # Accept only that exact lexical alias. Resolving an arbitrary
110
+ # directory symlink here would let cleanup below traverse outside
111
+ # the project (or through a broken target) before failing.
112
+ raw_target = os.readlink(cli_dir)
113
+ canonical_alias = (
114
+ cli_rel_path == ".claude/skills"
115
+ and raw_target == "../.agents/skills"
116
+ )
117
+ managed_skills = base / ".agents" / "skills"
118
+ if not canonical_alias:
119
+ raise ValueError(f"Refusing symlinked CLI skills directory: {cli_dir}")
120
+ assert_real_directory_chain(base, managed_skills)
121
+ if (
122
+ not managed_skills.exists()
123
+ or managed_skills.is_symlink()
124
+ or not managed_skills.is_dir()
125
+ ):
126
+ raise ValueError(
127
+ f"Canonical Claude skills alias target is not a real directory: {managed_skills}"
128
+ )
129
+ resolved_cli = cli_dir.resolve(strict=True)
130
+ if resolved_cli != managed_skills.resolve(strict=True):
131
+ raise ValueError(
132
+ f"Canonical Claude skills alias escapes managed project skills: {cli_dir}"
133
+ )
134
+ # The alias already exposes the managed projection. Do not fan out
135
+ # into `.agents/skills` a second time: that directory is owned by
136
+ # the pinned provisioner and may also contain real custom skills.
137
+ continue
138
+ else:
139
+ if cli_dir.exists() and not cli_dir.is_dir():
140
+ raise ValueError(f"CLI skills destination is not a directory: {cli_dir}")
141
+ real_parent = parent.resolve(strict=True)
142
+ expected_cli = real_parent / cli_dir.name
143
+ if cli_dir.exists() and cli_dir.resolve(strict=True) != expected_cli:
144
+ raise ValueError(f"CLI skills directory escapes its parent: {cli_dir}")
145
+ for name in skill_names:
146
+ destination = expected_cli / name
147
+ if destination.parent != expected_cli or len(destination.relative_to(expected_cli).parts) != 1:
148
+ raise ValueError(f"Skill destination escapes CLI directory: {destination}")
149
+ active.append((cli_dir, expected_cli))
150
+ return active
151
+
152
+
153
+ def revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli):
154
+ """Revalidate the complete destination chain at the mutation boundary."""
155
+ base = cli_dirs_base.resolve(strict=True)
156
+ assert_real_directory_chain(base, cli_dir.parent)
157
+ if cli_dir.parent.is_symlink() or not cli_dir.parent.is_dir():
158
+ raise ValueError(f"Unsafe CLI destination parent after preflight: {cli_dir.parent}")
159
+ current_expected = cli_dir.parent.resolve(strict=True) / cli_dir.name
160
+ if current_expected != expected_cli:
161
+ raise ValueError(f"CLI destination parent changed after preflight: {cli_dir}")
162
+ if cli_dir.is_symlink():
163
+ raise ValueError(f"CLI skills directory changed to a symlink after preflight: {cli_dir}")
164
+ if cli_dir.exists():
165
+ if not cli_dir.is_dir() or cli_dir.resolve(strict=True) != expected_cli:
166
+ raise ValueError(f"Unsafe CLI skills directory after preflight: {cli_dir}")
167
+
168
+
169
+ def ensure_cache_dir():
170
+ cache_dir = Path(os.path.expanduser("~/.agents/.cache/skills"))
171
+ cache_dir.mkdir(parents=True, exist_ok=True)
172
+ return cache_dir
173
+
174
+
175
+ def sync_registry(registry_url):
176
+ # Sanitize registry_url to create a folder name
177
+ safe_name = re.sub(r"[^a-zA-Z0-9]", "_", registry_url)
178
+ cache_dir = (
179
+ Path(os.path.expanduser("~/.agents/.cache/registries")) / safe_name
180
+ )
181
+
182
+ if cache_dir.exists():
183
+ try:
184
+ print(f"Updating registry {registry_url}...")
185
+ subprocess.run(
186
+ ["git", "-C", str(cache_dir), "pull"],
187
+ check=True,
188
+ capture_output=True,
189
+ )
190
+ except subprocess.CalledProcessError as error:
191
+ print(
192
+ f"Warning: Failed to update registry {registry_url}: "
193
+ f"{error.stderr.decode()}",
194
+ file=sys.stderr,
195
+ )
196
+ else:
197
+ cache_dir.parent.mkdir(parents=True, exist_ok=True)
198
+ print(f"Cloning registry {registry_url}...")
199
+ subprocess.run(["git", "clone", registry_url, str(cache_dir)], check=True)
200
+
201
+ return cache_dir
202
+
203
+
204
+ def sync_git_skill(name, source, version, cache_dir):
205
+ target_dir = cache_dir / name
206
+ if target_dir.exists():
207
+ # Just pull if it exists
208
+ try:
209
+ print(f"Updating git skill {name} in cache...")
210
+ subprocess.run(
211
+ ["git", "-C", str(target_dir), "pull"],
212
+ check=True,
213
+ capture_output=True,
214
+ )
215
+ except subprocess.CalledProcessError as error:
216
+ print(
217
+ f"Warning: Failed to update {name}: {error.stderr.decode()}",
218
+ file=sys.stderr,
219
+ )
220
+ else:
221
+ print(f"Cloning git skill {name} to cache...")
222
+ subprocess.run(["git", "clone", source, str(target_dir)], check=True)
223
+
224
+ if version:
225
+ subprocess.run(
226
+ ["git", "-C", str(target_dir), "checkout", version], check=True
227
+ )
228
+
229
+ return target_dir
230
+
231
+
232
+ def resolve_skill_path(
233
+ skill, cache_dir, base_dir, default_registry, registry_cache
234
+ ):
235
+ if isinstance(skill, str):
236
+ path = skill if "/" in skill else f"all-skills/{skill}"
237
+ name = validate_skill_name(path.split("/")[-1])
238
+ skill = {"name": name, "registry_path": path}
239
+
240
+ name = validate_skill_name(skill.get("name"))
241
+
242
+ if "registry_path" in skill:
243
+ registry_url = skill.get("registry", default_registry)
244
+ if registry_url not in registry_cache:
245
+ registry_cache[registry_url] = sync_registry(registry_url)
246
+ full_path = registry_cache[registry_url] / skill["registry_path"]
247
+ if not full_path.exists():
248
+ print(
249
+ f"Warning: Registry skill {name} not found at {full_path}",
250
+ file=sys.stderr,
251
+ )
252
+ return name, None
253
+ return name, full_path
254
+
255
+ source = skill.get("source", "")
256
+ if source.startswith("git@") or source.startswith("https://"):
257
+ return name, sync_git_skill(
258
+ name, source, skill.get("version"), cache_dir
259
+ )
260
+ parsed = urlparse(source)
261
+ if parsed.scheme == "file":
262
+ if parsed.netloc not in {"", "localhost"}:
263
+ raise ValueError(f"Non-local file URI authority for skill {name}: {parsed.netloc}")
264
+ if parsed.query or parsed.fragment:
265
+ raise ValueError(f"file:// source must encode query/fragment characters: {source}")
266
+ local_path = Path(unquote(parsed.path))
267
+ full_path = (base_dir / local_path).resolve()
268
+ if not full_path.exists():
269
+ print(
270
+ f"Warning: Local skill {name} not found at {full_path}",
271
+ file=sys.stderr,
272
+ )
273
+ return name, None
274
+ return name, full_path
275
+
276
+ print(
277
+ f"Warning: Unknown source type for skill {name}: {source}",
278
+ file=sys.stderr,
279
+ )
280
+ return name, None
281
+
282
+
283
+ def fanout_to_cli(
284
+ cli_dirs_base, skills_map, active_cli_dirs=None, before_mutation=None
285
+ ):
286
+ """
287
+ Creates symlinks in each of the CLI_SKILL_DIRS relative to cli_dirs_base
288
+ pointing to the resolved paths in skills_map.
289
+ """
290
+ skill_names = [validate_skill_name(name) for name in skills_map]
291
+ if active_cli_dirs is None:
292
+ active_cli_dirs = preflight_cli_dirs(cli_dirs_base, skill_names)
293
+ if before_mutation is not None:
294
+ before_mutation()
295
+ linked_total = 0
296
+ for cli_dir, expected_cli in active_cli_dirs:
297
+ revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
298
+ if not cli_dir.is_symlink():
299
+ cli_dir.mkdir(parents=True, exist_ok=True)
300
+ revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
301
+ real_cli_dir = expected_cli.resolve(strict=True)
302
+
303
+ for name, actual_path in skills_map.items():
304
+ revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
305
+ symlink_target = real_cli_dir / name
306
+ if symlink_target.parent != real_cli_dir:
307
+ raise ValueError(f"Skill destination escapes CLI directory: {symlink_target}")
308
+
309
+ # If it's a symlink already pointing to the right place, skip
310
+ if (
311
+ symlink_target.is_symlink()
312
+ and os.readlink(symlink_target) == str(actual_path)
313
+ ):
314
+ continue
315
+
316
+ # If it exists but is wrong, remove it
317
+ if symlink_target.exists() or symlink_target.is_symlink():
318
+ revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
319
+ if symlink_target.is_dir() and not symlink_target.is_symlink():
320
+ shutil.rmtree(symlink_target)
321
+ else:
322
+ symlink_target.unlink()
323
+
324
+ revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
325
+ os.symlink(actual_path, symlink_target)
326
+ linked_total += 1
327
+ print(f"→ {symlink_target} -> {actual_path}")
328
+
329
+ print(
330
+ f"sync-skills: {linked_total} new/updated symlink(s) "
331
+ f"across CLIs in {cli_dirs_base}"
332
+ )
333
+
334
+
335
+ def main():
336
+ args = parse_args()
337
+
338
+ global_manifest_path = Path(os.path.expanduser("~/.agents/skills.json"))
339
+ project_manifest_path = Path(os.getcwd()) / ".agents" / "skills.json"
340
+
341
+ # Destination topology is a security boundary. Validate every active CLI
342
+ # directory before cloning/updating registries, creating caches, or changing
343
+ # any skill link so one unsafe/broken symlink produces zero mutation.
344
+ preflight_names = []
345
+ preflight_base = Path(os.path.expanduser("~"))
346
+ if args.scope == "global":
347
+ preflight_manifest = load_manifest(global_manifest_path)
348
+ validate_manifest_skill_names(preflight_manifest)
349
+ preflight_names.extend(
350
+ manifest_skill_name(skill)
351
+ for skill in preflight_manifest.get("skills", [])
352
+ )
353
+ else:
354
+ preflight_base = Path(os.getcwd())
355
+ preflight_manifest = load_manifest(project_manifest_path)
356
+ validate_manifest_skill_names(preflight_manifest)
357
+ preflight_names.extend(
358
+ manifest_skill_name(skill)
359
+ for skill in preflight_manifest.get("skills", [])
360
+ )
361
+ if preflight_manifest.get("inherit_global", False):
362
+ inherited = load_manifest(global_manifest_path)
363
+ validate_manifest_skill_names(inherited)
364
+ preflight_names.extend(
365
+ manifest_skill_name(skill)
366
+ for skill in inherited.get("skills", [])
367
+ )
368
+ active_cli_dirs = preflight_cli_dirs(preflight_base, preflight_names)
369
+ cache_dir = ensure_cache_dir()
370
+
371
+ skills_to_sync = {} # name -> actual_path
372
+ registry_cache = {}
373
+
374
+ if args.scope == "global":
375
+ print(f"Loading global manifest from {global_manifest_path}")
376
+ manifest = load_manifest(global_manifest_path)
377
+ validate_manifest_skill_names(manifest)
378
+ default_registry = manifest.get(
379
+ "registry", "https://github.com/delorenj/skillex.git"
380
+ )
381
+ base_dir = global_manifest_path.parent
382
+ for skill in manifest.get("skills", []):
383
+ name, path = resolve_skill_path(
384
+ skill,
385
+ cache_dir,
386
+ base_dir,
387
+ default_registry,
388
+ registry_cache,
389
+ )
390
+ if path:
391
+ skills_to_sync[name] = path
392
+
393
+ # Fanout globally (home dir)
394
+ fanout_to_cli(
395
+ Path(os.path.expanduser("~")),
396
+ skills_to_sync,
397
+ active_cli_dirs=active_cli_dirs,
398
+ )
399
+
400
+ elif args.scope == "project":
401
+ print(f"Loading project manifest from {project_manifest_path}")
402
+ manifest = load_manifest(project_manifest_path)
403
+ validate_manifest_skill_names(manifest)
404
+ default_registry = manifest.get(
405
+ "registry", "https://github.com/delorenj/skillex.git"
406
+ )
407
+
408
+ # Check if we should inherit global skills
409
+ if manifest.get("inherit_global", False):
410
+ print("Inheriting global skills...")
411
+ global_manifest = load_manifest(global_manifest_path)
412
+ validate_manifest_skill_names(global_manifest)
413
+ global_registry = global_manifest.get(
414
+ "registry", "https://github.com/delorenj/skillex.git"
415
+ )
416
+ for skill in global_manifest.get("skills", []):
417
+ name, path = resolve_skill_path(
418
+ skill,
419
+ cache_dir,
420
+ global_manifest_path.parent,
421
+ global_registry,
422
+ registry_cache,
423
+ )
424
+ if path:
425
+ skills_to_sync[name] = path
426
+
427
+ base_dir = project_manifest_path.parent
428
+ for skill in manifest.get("skills", []):
429
+ name, path = resolve_skill_path(
430
+ skill,
431
+ cache_dir,
432
+ base_dir,
433
+ default_registry,
434
+ registry_cache,
435
+ )
436
+ if path:
437
+ # Overrides global skill of the same name
438
+ skills_to_sync[name] = path
439
+
440
+ # Fanout locally (project dir)
441
+ fanout_to_cli(
442
+ Path(os.getcwd()),
443
+ skills_to_sync,
444
+ active_cli_dirs=active_cli_dirs,
445
+ )
446
+
447
+
448
+ if __name__ == "__main__":
449
+ main()
@@ -20,7 +20,9 @@ script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh
20
20
  [[hooks.enter]]
21
21
  script = "op inject -i .env.op > .env"
22
22
  [[hooks.enter]]
23
- script = "sync-skills.py --scope project"
23
+ script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-bmad-skills.py'"
24
+ [[hooks.enter]]
25
+ script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project"
24
26
  [[hooks.enter]]
25
27
  script = "[ -f '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' ] && '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' || true"
26
28
  {%- if agent_hooks_layer %}
@@ -50,7 +52,12 @@ run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh'"
50
52
 
51
53
  [tasks.skills-sync]
52
54
  description = "Sync skills from manifest to local CLI dirs"
53
- run = "sync-skills.py --scope project"
55
+ depends = ["skills-provision-bmad"]
56
+ run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project"
57
+
58
+ [tasks.skills-provision-bmad]
59
+ description = "Provision pinned BMAD skills from the Skillex pack"
60
+ run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-bmad-skills.py'"
54
61
  {% if agent_hooks_layer %}
55
62
  # --- Project-scoped agent hooks + skill fan-out (see .agents/hooks/README.md) ---
56
63
 
@@ -0,0 +1,203 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import json
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import tempfile
9
+ import unittest
10
+ from contextlib import contextmanager
11
+ from pathlib import Path
12
+
13
+
14
+ ROOT = Path(__file__).parents[1]
15
+ SCRIPT = ROOT / "template" / ".mise" / "scripts" / "provision-bmad-skills.py"
16
+ SPEC = importlib.util.spec_from_file_location("provision_bmad_skills", SCRIPT)
17
+ assert SPEC is not None and SPEC.loader is not None
18
+ PROVISIONER = importlib.util.module_from_spec(SPEC)
19
+ SPEC.loader.exec_module(PROVISIONER)
20
+
21
+
22
+ @contextmanager
23
+ def working_directory(path: Path):
24
+ previous = Path.cwd()
25
+ os.chdir(path)
26
+ try:
27
+ yield
28
+ finally:
29
+ os.chdir(previous)
30
+
31
+
32
+ def snapshot(path: Path):
33
+ try:
34
+ mode = path.lstat().st_mode
35
+ except FileNotFoundError:
36
+ return ("missing",)
37
+ permissions = stat.S_IMODE(mode)
38
+ if stat.S_ISLNK(mode):
39
+ return ("symlink", permissions, os.readlink(path))
40
+ if stat.S_ISREG(mode):
41
+ return ("file", permissions, path.read_bytes())
42
+ if stat.S_ISDIR(mode):
43
+ return (
44
+ "directory",
45
+ permissions,
46
+ tuple((child.name, snapshot(child)) for child in sorted(path.iterdir())),
47
+ )
48
+ return ("special", permissions)
49
+
50
+
51
+ class ProvisionTransactionTests(unittest.TestCase):
52
+ def setUp(self) -> None:
53
+ source = os.environ.get("PJ_BMAD_PACK_ROOT")
54
+ if not source:
55
+ self.skipTest("PJ_BMAD_PACK_ROOT must identify the canonical candidate pack")
56
+ self.temporary = tempfile.TemporaryDirectory(prefix="commonproject-bmad-transaction-")
57
+ self.root = Path(self.temporary.name)
58
+ self.pack = self.root / "pack"
59
+ shutil.copytree(source, self.pack, symlinks=True)
60
+ self.project = self.root / "project"
61
+ skills = self.project / ".agents" / "skills"
62
+ skills.mkdir(parents=True)
63
+ manifest = self.project / ".agents" / "skills.json"
64
+ private = skills / "bmad-private-custom"
65
+ private.mkdir()
66
+ (private / "SKILL.md").write_text("private custom must survive\n")
67
+ manifest.write_text(
68
+ json.dumps(
69
+ {
70
+ "skills": [
71
+ {"name": "custom", "source": "file:///custom"},
72
+ {"name": "bmad-private-custom", "source": private.as_uri()},
73
+ {"name": "bmad-stale", "source": (self.pack / "bmad-stale").as_uri()},
74
+ ]
75
+ }
76
+ )
77
+ + "\n"
78
+ )
79
+ manifest.chmod(0o600)
80
+ copied = skills / "bmad-agent-pm"
81
+ copied.mkdir()
82
+ (copied / "legacy.txt").write_text("preserve exactly\n")
83
+ (skills / "bmad-agent-analyst").symlink_to(
84
+ self.pack / "bmad-agent-analyst", target_is_directory=True
85
+ )
86
+ (skills / "bmad-stale").symlink_to(
87
+ self.pack / "bmad-stale", target_is_directory=True
88
+ )
89
+ custom = skills / "custom"
90
+ custom.mkdir()
91
+ (custom / "SKILL.md").write_text("custom\n")
92
+ self.previous_pack = os.environ.get("PJ_BMAD_PACK_ROOT")
93
+ os.environ["PJ_BMAD_PACK_ROOT"] = str(self.pack)
94
+
95
+ def tearDown(self) -> None:
96
+ if self.previous_pack is None:
97
+ os.environ.pop("PJ_BMAD_PACK_ROOT", None)
98
+ else:
99
+ os.environ["PJ_BMAD_PACK_ROOT"] = self.previous_pack
100
+ self.temporary.cleanup()
101
+
102
+ def test_nth_link_failure_restores_exact_project_state(self) -> None:
103
+ before = snapshot(self.project)
104
+
105
+ def fail_fifth(target: Path, link: Path, index: int) -> None:
106
+ if index == 5:
107
+ raise OSError("injected fifth-link failure")
108
+ link.symlink_to(target, target_is_directory=True)
109
+
110
+ with working_directory(self.project):
111
+ with self.assertRaisesRegex(OSError, "fifth-link"):
112
+ PROVISIONER.provision(create_link=fail_fifth)
113
+
114
+ self.assertEqual(snapshot(self.project), before)
115
+
116
+ def test_pack_mutation_after_preflight_rolls_back_exactly(self) -> None:
117
+ before = snapshot(self.project)
118
+
119
+ def mutate_pack() -> None:
120
+ (self.pack / "bmad-agent-pm" / "SKILL.md").write_text("mutated after preflight\n")
121
+
122
+ with working_directory(self.project):
123
+ with self.assertRaisesRegex(ValueError, "digest mismatch"):
124
+ PROVISIONER.provision(after_preflight=mutate_pack)
125
+
126
+ self.assertEqual(snapshot(self.project), before)
127
+
128
+ def test_malformed_manifest_fails_before_creating_skills_or_temp_paths(self) -> None:
129
+ project = self.root / "malformed-project"
130
+ agents = project / ".agents"
131
+ agents.mkdir(parents=True)
132
+ manifest = agents / "skills.json"
133
+ manifest.write_bytes(b"{malformed\n")
134
+ manifest.chmod(0o600)
135
+ before = snapshot(project)
136
+
137
+ with working_directory(project):
138
+ with self.assertRaises(json.JSONDecodeError):
139
+ PROVISIONER.provision()
140
+
141
+ self.assertEqual(snapshot(project), before)
142
+ self.assertFalse((agents / "skills").exists())
143
+ self.assertFalse(any(path.name.startswith(".bmad-transaction-") for path in agents.iterdir()))
144
+
145
+ def test_applied_projection_mismatch_rolls_back_exactly(self) -> None:
146
+ before = snapshot(self.project)
147
+
148
+ def corrupt_projection(_manifest: Path, skills: Path) -> None:
149
+ link = skills / "bmad-agent-analyst"
150
+ link.unlink()
151
+ link.symlink_to("/tmp/wrong-after-apply", target_is_directory=True)
152
+
153
+ with working_directory(self.project):
154
+ with self.assertRaisesRegex(ValueError, "link differs from plan"):
155
+ PROVISIONER.provision(after_apply=corrupt_projection)
156
+
157
+ self.assertEqual(snapshot(self.project), before)
158
+
159
+ def test_success_is_complete_and_idempotent(self) -> None:
160
+ with working_directory(self.project):
161
+ self.assertGreater(PROVISIONER.provision(), 0)
162
+ after_first = snapshot(self.project)
163
+ self.assertEqual(PROVISIONER.provision(), 0)
164
+
165
+ self.assertEqual(snapshot(self.project), after_first)
166
+ skills = self.project / ".agents" / "skills"
167
+ self.assertTrue((skills / "bmad-private-custom").is_dir())
168
+ self.assertEqual(
169
+ (skills / "bmad-private-custom" / "SKILL.md").read_text(),
170
+ "private custom must survive\n",
171
+ )
172
+ self.assertFalse((skills / "bmad-stale").exists())
173
+ manifest = json.loads((self.project / ".agents" / "skills.json").read_text())
174
+ self.assertIn(
175
+ {
176
+ "name": "bmad-private-custom",
177
+ "source": (skills / "bmad-private-custom").as_uri(),
178
+ },
179
+ manifest["skills"],
180
+ )
181
+ self.assertNotIn("bmad-stale", [entry.get("name") for entry in manifest["skills"]])
182
+ self.assertTrue(
183
+ all(
184
+ (skills / path.name).is_symlink()
185
+ for path in PROVISIONER.validate_trusted_pack(self.pack)
186
+ )
187
+ )
188
+
189
+ def test_unowned_bmad_prefixed_custom_skill_is_preserved(self) -> None:
190
+ custom = self.project / ".agents" / "skills" / "bmad-private-custom"
191
+ before = snapshot(custom)
192
+
193
+ with working_directory(self.project):
194
+ self.assertGreater(PROVISIONER.provision(), 0)
195
+ after_first = snapshot(self.project)
196
+ self.assertEqual(PROVISIONER.provision(), 0)
197
+
198
+ self.assertEqual(snapshot(custom), before)
199
+ self.assertEqual(snapshot(self.project), after_first)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ unittest.main()