@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.2.21",
3
+ "version": "1.2.25",
4
4
  "description": "Project subsystem bootstrapper CLI",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,14 +20,18 @@
20
20
  },
21
21
  "scripts": {
22
22
  "build": "esbuild src/index.ts src/mcp-server.ts --bundle --packages=external --platform=node --format=esm --outdir=dist",
23
+ "check:audit:prod": "npm audit --omit=dev",
24
+ "check:lock": "node scripts/check-package-lock-parity.mjs",
25
+ "check:submodules": "node scripts/check-submodule-contract.mjs",
26
+ "check:tracked-secrets": "node scripts/check-tracked-secrets.mjs",
23
27
  "start": "node dist/index.js",
24
28
  "mcp": "node dist/mcp-server.js",
25
29
  "typecheck": "tsc --noEmit",
26
- "test": "node tests/parity-migrate-regressions.mjs && node tests/mcp-catalog-regressions.mjs && node tests/mcp-server-regressions.mjs && node tests/project-registry-regressions.mjs && node tests/pg-registry-regressions.mjs",
30
+ "test": "npm run check:lock && npm run check:submodules && npm run check:tracked-secrets && node tests/release-regressions.mjs && node tests/submodule-contract-regressions.mjs && node tests/secret-publication-gate-regressions.mjs && node tests/bmad-version-surface-regressions.mjs && node tests/bmad-transaction-regressions.mjs && node tests/parity-migrate-regressions.mjs && node tests/pjan-43-regressions.mjs && node tests/skillex-init-regressions.mjs && node tests/fleet-shared-bloodbank-regressions.mjs && node tests/mcp-catalog-regressions.mjs && node tests/mcp-server-regressions.mjs && node tests/project-registry-regressions.mjs && node tests/pg-registry-regressions.mjs",
27
31
  "migrate:up": "node-pg-migrate --migrations-dir migrations up",
28
32
  "migrate:down": "node-pg-migrate --migrations-dir migrations down",
29
33
  "migrate:create": "node-pg-migrate --migrations-dir migrations create",
30
- "prepublishOnly": "npm run build"
34
+ "prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets"
31
35
  },
32
36
  "keywords": [
33
37
  "cli",
@@ -52,5 +56,12 @@
52
56
  "esbuild": "^0.25.0",
53
57
  "node-pg-migrate": "^8.0.0",
54
58
  "typescript": "^5"
59
+ },
60
+ "repository": {
61
+ "type": "git",
62
+ "url": "https://github.com/delorenj/pjangler.git"
63
+ },
64
+ "publishConfig": {
65
+ "registry": "https://npm.pkg.github.com"
55
66
  }
56
67
  }
@@ -119,5 +119,8 @@ _tasks:
119
119
  - "printf '\\n# Secrets + per-dev agent-hook/skill overrides (see .agents/local.example.json)\\n.env\\n.env.*\\n!.env.op\\n.agents/local.json\\n.kimi-code/\\n.lastagent\\n**/.claude/settings.local.json\\n' >> .gitignore"
120
120
  - "ln -sf AGENTS.md CLAUDE.md"
121
121
  - "ln -sf AGENTS.md GEMINI.md"
122
+ # Materialize the pinned Skillex BMAD manifest + symlink topology without
123
+ # copying pack content or disturbing project-specific, non-BMAD skills.
124
+ - "python3 .mise/scripts/provision-bmad-skills.py"
122
125
  # Stamp the repo's absolute path into .project.json (the SOT) post-render.
123
126
  - "python3 -c \"import json,os,pathlib; p=pathlib.Path('.project.json'); d=json.loads(p.read_text()); d['repo_path']=os.getcwd(); p.write_text(json.dumps(d,indent=2)+chr(10))\""
@@ -0,0 +1,480 @@
1
+ #!/usr/bin/env python3
2
+ """Provision project BMAD skills from the pinned Skillex pack."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import hashlib
8
+ import os
9
+ import shutil
10
+ import stat
11
+ import tempfile
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import Callable
15
+ from urllib.parse import unquote, urlparse
16
+
17
+
18
+ BMAD_PACK_VERSION = "6.10.1-next.31"
19
+ BMAD_PACK_CHECKSUMS_SHA256 = "a8bc005612ac60e3ec775fff5a11eafe38be6acdae96efa3d770b48322cb3224"
20
+ BMAD_PACK_SKILL_COUNT = 76
21
+ BMAD_PACK_PAYLOAD_FILES = 1072
22
+ SKILLS_SCHEMA = "https://raw.githubusercontent.com/skillex/schemas/main/skills.schema.json"
23
+ SKILLS_REGISTRY = "https://github.com/delorenj/skillex.git"
24
+
25
+
26
+ def pack_root() -> Path:
27
+ override = os.environ.get("PJ_BMAD_PACK_ROOT", "").strip()
28
+ return (
29
+ Path(override).expanduser()
30
+ if override
31
+ else Path.home() / "code" / "skillex" / "packs" / "bmad" / BMAD_PACK_VERSION
32
+ ).absolute()
33
+
34
+
35
+ def read_regular_file(path: Path) -> bytes:
36
+ descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
37
+ try:
38
+ if not stat.S_ISREG(os.fstat(descriptor).st_mode):
39
+ raise ValueError(f"BMAD pack entry is not a regular file: {path}")
40
+ with os.fdopen(os.dup(descriptor), "rb") as handle:
41
+ return handle.read()
42
+ finally:
43
+ os.close(descriptor)
44
+
45
+
46
+ def safe_checksum_path(value: str) -> Path:
47
+ path = Path(value)
48
+ if not value or "\\" in value or path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
49
+ raise ValueError(f"Unsafe BMAD checksum path: {value!r}")
50
+ return path
51
+
52
+
53
+ def walk_regular_tree(root: Path) -> tuple[dict[str, bytes], set[str]]:
54
+ files: dict[str, bytes] = {}
55
+ directories: set[str] = set()
56
+
57
+ def visit(directory: Path) -> None:
58
+ for entry in sorted(os.scandir(directory), key=lambda item: item.name):
59
+ path = Path(entry.path)
60
+ mode = entry.stat(follow_symlinks=False).st_mode
61
+ if stat.S_ISLNK(mode):
62
+ raise ValueError(f"BMAD pack may not contain symlinks: {path}")
63
+ if stat.S_ISDIR(mode):
64
+ directories.add(path.relative_to(root).as_posix())
65
+ visit(path)
66
+ elif stat.S_ISREG(mode):
67
+ files[path.relative_to(root).as_posix()] = read_regular_file(path)
68
+ else:
69
+ raise ValueError(f"BMAD pack may contain only regular files/directories: {path}")
70
+
71
+ visit(root)
72
+ return files, directories
73
+
74
+
75
+ def validate_trusted_pack(root: Path) -> list[Path]:
76
+ mode = root.lstat().st_mode
77
+ if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
78
+ raise ValueError(f"BMAD pack root must be a real directory: {root}")
79
+
80
+ checksum_bytes = read_regular_file(root / "SHA256SUMS")
81
+ if hashlib.sha256(checksum_bytes).hexdigest() != BMAD_PACK_CHECKSUMS_SHA256:
82
+ raise ValueError(
83
+ f"BMAD pack checksum manifest is not the trusted {BMAD_PACK_VERSION} manifest"
84
+ )
85
+ expected: dict[str, str] = {}
86
+ for line in checksum_bytes.decode("utf-8").splitlines():
87
+ digest, separator, value = line.partition(" ")
88
+ if separator != " " or len(digest) != 64 or any(char not in "0123456789abcdef" for char in digest):
89
+ raise ValueError(f"Invalid BMAD SHA256SUMS entry: {line}")
90
+ relative = safe_checksum_path(value).as_posix()
91
+ if relative in expected:
92
+ raise ValueError(f"Duplicate BMAD SHA256SUMS entry: {relative}")
93
+ expected[relative] = digest
94
+
95
+ actual, directories = walk_regular_tree(root)
96
+ actual.pop("SHA256SUMS", None)
97
+ missing = sorted(set(expected) - set(actual))
98
+ extra = sorted(set(actual) - set(expected))
99
+ if missing or extra:
100
+ raise ValueError(f"BMAD checksum coverage mismatch; missing={missing} extra={extra}")
101
+ for relative, digest in expected.items():
102
+ if hashlib.sha256(actual[relative]).hexdigest() != digest:
103
+ raise ValueError(f"BMAD pack digest mismatch: {relative}")
104
+ for directory in directories:
105
+ if not any(relative.startswith(f"{directory}/") for relative in actual):
106
+ raise ValueError(
107
+ f"BMAD pack contains an unauthenticated empty directory: {directory}"
108
+ )
109
+
110
+ metadata = tomllib.loads(actual["pack.toml"].decode("utf-8"))
111
+ pack = metadata.get("pack", {})
112
+ source = metadata.get("source", {})
113
+ freeform = metadata.get("freeform", {})
114
+ policy = metadata.get("policy", {})
115
+ if (
116
+ pack.get("name") != "bmad"
117
+ or pack.get("version") != BMAD_PACK_VERSION
118
+ or source.get("upstream") != "bmad-method"
119
+ or source.get("upstream_version") != BMAD_PACK_VERSION
120
+ or source.get("rendered_from") != ".agent/skills"
121
+ or policy.get("immutable") is not True
122
+ or policy.get("project_projection") != "symlink"
123
+ ):
124
+ raise ValueError(f"BMAD pack.toml does not declare the trusted {BMAD_PACK_VERSION} contract")
125
+ skill_names = freeform.get("skills")
126
+ payload_files = source.get("payload_files")
127
+ if (
128
+ not isinstance(skill_names, list)
129
+ or not all(isinstance(name, str) for name in skill_names)
130
+ or len(skill_names) != BMAD_PACK_SKILL_COUNT
131
+ or len(set(skill_names)) != len(skill_names)
132
+ ):
133
+ raise ValueError(f"BMAD pack.toml must declare exactly {BMAD_PACK_SKILL_COUNT} unique skills")
134
+ if payload_files != BMAD_PACK_PAYLOAD_FILES:
135
+ raise ValueError(f"BMAD pack.toml must declare exactly {BMAD_PACK_PAYLOAD_FILES} payload files")
136
+ for name in skill_names:
137
+ if not name.startswith("bmad-"):
138
+ raise ValueError(f"Unsafe BMAD skill identity: {name!r}")
139
+ validate_skill_name(name)
140
+
141
+ top_level_directories = sorted(
142
+ entry.name for entry in os.scandir(root) if stat.S_ISDIR(entry.stat(follow_symlinks=False).st_mode)
143
+ )
144
+ if top_level_directories != sorted(skill_names):
145
+ raise ValueError("BMAD pack directory inventory differs from authenticated pack.toml skills")
146
+ for name in skill_names:
147
+ skill_md = root / name / "SKILL.md"
148
+ if not stat.S_ISREG(skill_md.lstat().st_mode):
149
+ raise ValueError(f"BMAD skill is missing a regular SKILL.md: {name}")
150
+ skill_set = set(skill_names)
151
+ payload_count = sum(1 for relative in actual if relative.split("/", 1)[0] in skill_set)
152
+ if payload_count != payload_files:
153
+ raise ValueError(f"BMAD payload inventory mismatch: {payload_count} != {payload_files}")
154
+ return [root / name for name in skill_names]
155
+
156
+
157
+ def load_manifest(path: Path) -> dict:
158
+ if not path.exists():
159
+ return {}
160
+ value = json.loads(path.read_text())
161
+ if not isinstance(value, dict):
162
+ raise ValueError(f"{path} must contain a JSON object")
163
+ return value
164
+
165
+
166
+ def manifest_entry_name(value: object) -> str | None:
167
+ if isinstance(value, str):
168
+ return value
169
+ if isinstance(value, dict):
170
+ name = value.get("name")
171
+ return name if isinstance(name, str) else None
172
+ return None
173
+
174
+
175
+ def is_pack_managed_manifest_entry(
176
+ value: object, expected_names: set[str], root: Path
177
+ ) -> bool:
178
+ name = manifest_entry_name(value)
179
+ if name is None:
180
+ return False
181
+ if name in expected_names:
182
+ return True
183
+ if not isinstance(value, dict):
184
+ return False
185
+ source = value.get("source")
186
+ if not isinstance(source, str):
187
+ return False
188
+ try:
189
+ parsed = urlparse(source)
190
+ if parsed.scheme != "file" or parsed.netloc not in {"", "localhost"}:
191
+ return False
192
+ source_path = Path(os.path.abspath(unquote(parsed.path)))
193
+ source_path.relative_to(root)
194
+ except (OSError, ValueError):
195
+ return False
196
+ return source_path.name == name
197
+
198
+
199
+ def validate_skill_name(name: str) -> str:
200
+ if not name or name in {".", ".."} or Path(name).is_absolute():
201
+ raise ValueError(f"Unsafe BMAD skill name: {name!r}")
202
+ if "/" in name or "\\" in name or Path(name).name != name:
203
+ raise ValueError(f"BMAD skill name must be one path component: {name!r}")
204
+ return name
205
+
206
+
207
+ def preflight_project_directory(project_root: Path, target: Path) -> None:
208
+ try:
209
+ relative = target.absolute().relative_to(project_root)
210
+ except ValueError as error:
211
+ raise ValueError(f"Project destination escapes {project_root}: {target}") from error
212
+ if len(relative.parts) > 2:
213
+ raise ValueError(f"Unexpected project skill destination depth: {target}")
214
+ current = project_root
215
+ for part in relative.parts:
216
+ current = current / part
217
+ if not current.exists() and not current.is_symlink():
218
+ break
219
+ if current.is_symlink():
220
+ raise ValueError(f"Refusing symlinked project skill directory: {current}")
221
+ if not current.is_dir():
222
+ raise ValueError(f"Project skill parent is not a directory: {current}")
223
+
224
+
225
+ def prepare_project_skill_dirs(project_root: Path) -> tuple[Path, Path]:
226
+ agents_dir = project_root / ".agents"
227
+ skills_dir = agents_dir / "skills"
228
+ # Validate the complete existing chain before creating or mutating anything.
229
+ preflight_project_directory(project_root, agents_dir)
230
+ preflight_project_directory(project_root, skills_dir)
231
+ agents_dir.mkdir(exist_ok=True)
232
+ skills_dir.mkdir(exist_ok=True)
233
+ for path in (agents_dir, skills_dir):
234
+ if path.is_symlink() or not path.is_dir():
235
+ raise ValueError(f"Unsafe project skill directory: {path}")
236
+ resolved = path.resolve(strict=True)
237
+ resolved.relative_to(project_root)
238
+ return agents_dir, skills_dir
239
+
240
+
241
+ def lexical_link_target(link: Path) -> Path | None:
242
+ if not link.is_symlink():
243
+ return None
244
+ target = Path(os.readlink(link))
245
+ return (target if target.is_absolute() else link.parent / target).absolute()
246
+
247
+
248
+ def remove_entry(path: Path) -> None:
249
+ try:
250
+ mode = path.lstat().st_mode
251
+ except FileNotFoundError:
252
+ return
253
+ if stat.S_ISDIR(mode) and not stat.S_ISLNK(mode):
254
+ shutil.rmtree(path)
255
+ else:
256
+ path.unlink()
257
+
258
+
259
+ def atomic_write(path: Path, content: bytes, mode: int) -> None:
260
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
261
+ temporary = Path(temporary_name)
262
+ try:
263
+ with os.fdopen(descriptor, "wb") as handle:
264
+ handle.write(content)
265
+ handle.flush()
266
+ os.fsync(handle.fileno())
267
+ temporary.chmod(mode)
268
+ os.replace(temporary, path)
269
+ finally:
270
+ if temporary.exists():
271
+ temporary.unlink()
272
+
273
+
274
+ def provision(
275
+ *,
276
+ after_preflight: Callable[[], None] | None = None,
277
+ create_link: Callable[[Path, Path, int], None] | None = None,
278
+ after_apply: Callable[[Path, Path], None] | None = None,
279
+ ) -> int:
280
+ root = pack_root()
281
+ pack_skills = validate_trusted_pack(root)
282
+ if after_preflight is not None:
283
+ after_preflight()
284
+
285
+ project_root = Path.cwd().resolve(strict=True)
286
+ agents_path = project_root / ".agents"
287
+ skills_path = agents_path / "skills"
288
+ agents_existed = agents_path.exists() or agents_path.is_symlink()
289
+ skills_existed = skills_path.exists() or skills_path.is_symlink()
290
+ preflight_project_directory(project_root, agents_path)
291
+ preflight_project_directory(project_root, skills_path)
292
+ manifest_path = agents_path / "skills.json"
293
+ if manifest_path.is_symlink():
294
+ raise ValueError(f"Refusing symlinked skills manifest: {manifest_path}")
295
+ if manifest_path.exists() and not manifest_path.is_file():
296
+ raise ValueError(f"Skills manifest is not a regular file: {manifest_path}")
297
+ manifest_existed = manifest_path.exists()
298
+ manifest_bytes = read_regular_file(manifest_path) if manifest_existed else None
299
+ manifest_mode = stat.S_IMODE(manifest_path.lstat().st_mode) if manifest_existed else 0o644
300
+ manifest = json.loads(manifest_bytes) if manifest_bytes is not None else {}
301
+ if not isinstance(manifest, dict):
302
+ raise ValueError(f"{manifest_path} must contain a JSON object")
303
+ existing = manifest.get("skills", [])
304
+ if not isinstance(existing, list):
305
+ raise ValueError(f"{manifest_path} skills must be an array")
306
+ agents_dir, skills_dir = prepare_project_skill_dirs(project_root)
307
+
308
+ manifest["$schema"] = SKILLS_SCHEMA
309
+ manifest["inherit_global"] = True
310
+ manifest["registry"] = SKILLS_REGISTRY
311
+ expected = {path.name: path for path in pack_skills}
312
+ expected_names = set(expected)
313
+ managed_manifest_names = {
314
+ name
315
+ for entry in existing
316
+ if is_pack_managed_manifest_entry(entry, expected_names, root)
317
+ if (name := manifest_entry_name(entry)) is not None
318
+ }
319
+ manifest["skills"] = [
320
+ *[
321
+ entry
322
+ for entry in existing
323
+ if not is_pack_managed_manifest_entry(entry, expected_names, root)
324
+ ],
325
+ *[{"name": path.name, "source": path.as_uri()} for path in pack_skills],
326
+ ]
327
+ next_manifest = (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
328
+ affected: list[str] = []
329
+ stale_managed_names: set[str] = set()
330
+ original_correct_links: dict[str, str] = {}
331
+ for entry in skills_dir.iterdir():
332
+ if entry.parent.resolve(strict=True) != skills_dir.resolve(strict=True):
333
+ raise ValueError(f"BMAD skill entry escapes skills directory: {entry}")
334
+ validate_skill_name(entry.name)
335
+ link_target = lexical_link_target(entry)
336
+ try:
337
+ link_targets_pack = link_target is not None and link_target.is_relative_to(root)
338
+ except OSError:
339
+ link_targets_pack = False
340
+ if (
341
+ entry.name not in expected
342
+ and entry.name not in managed_manifest_names
343
+ and not link_targets_pack
344
+ ):
345
+ continue
346
+ target = expected.get(entry.name)
347
+ if target is not None and link_target == target:
348
+ original_correct_links[entry.name] = os.readlink(entry)
349
+ else:
350
+ affected.append(entry.name)
351
+ if target is None:
352
+ stale_managed_names.add(entry.name)
353
+ for name, target in expected.items():
354
+ link = skills_dir / validate_skill_name(name)
355
+ if link.parent.resolve(strict=True) != skills_dir.resolve(strict=True):
356
+ raise ValueError(f"BMAD skill destination escapes skills directory: {link}")
357
+ if lexical_link_target(link) != target and name not in affected:
358
+ affected.append(name)
359
+
360
+ manifest_changed = manifest_bytes != next_manifest
361
+ if not affected and not manifest_changed:
362
+ validate_trusted_pack(root)
363
+ return 0
364
+
365
+ transaction = Path(tempfile.mkdtemp(prefix=".bmad-transaction-", dir=agents_dir))
366
+ backup = transaction / "entries"
367
+ backup.mkdir()
368
+ moved: list[str] = []
369
+ created: list[str] = []
370
+
371
+ def rollback() -> None:
372
+ errors: list[str] = []
373
+ try:
374
+ for name in affected:
375
+ remove_entry(skills_dir / validate_skill_name(name))
376
+ for name in original_correct_links:
377
+ remove_entry(skills_dir / validate_skill_name(name))
378
+ except OSError as error:
379
+ errors.append(f"remove applied projection: {error}")
380
+ for name in reversed(moved):
381
+ try:
382
+ os.replace(backup / name, skills_dir / name)
383
+ except OSError as error:
384
+ errors.append(f"restore {name}: {error}")
385
+ for name, raw_target in original_correct_links.items():
386
+ try:
387
+ (skills_dir / name).symlink_to(raw_target, target_is_directory=True)
388
+ except OSError as error:
389
+ errors.append(f"restore {name}: {error}")
390
+ try:
391
+ if manifest_bytes is None:
392
+ remove_entry(manifest_path)
393
+ else:
394
+ atomic_write(manifest_path, manifest_bytes, manifest_mode)
395
+ except OSError as error:
396
+ errors.append(f"restore manifest: {error}")
397
+ shutil.rmtree(transaction, ignore_errors=True)
398
+ try:
399
+ if not skills_existed and skills_dir.exists() and not any(skills_dir.iterdir()):
400
+ skills_dir.rmdir()
401
+ if not agents_existed and agents_dir.exists() and not any(agents_dir.iterdir()):
402
+ agents_dir.rmdir()
403
+ except OSError as error:
404
+ errors.append(f"remove created directories: {error}")
405
+ if errors:
406
+ raise RuntimeError("BMAD rollback was incomplete: " + "; ".join(errors))
407
+
408
+ link_creator = create_link or (
409
+ lambda target, link, _index: link.symlink_to(target, target_is_directory=True)
410
+ )
411
+ try:
412
+ for name in affected:
413
+ entry = skills_dir / name
414
+ if entry.exists() or entry.is_symlink():
415
+ os.replace(entry, backup / name)
416
+ moved.append(name)
417
+ for index, (name, target) in enumerate(expected.items(), start=1):
418
+ link = skills_dir / name
419
+ if lexical_link_target(link) == target:
420
+ continue
421
+ link_creator(target, link, index)
422
+ created.append(name)
423
+ if manifest_changed:
424
+ atomic_write(manifest_path, next_manifest, manifest_mode)
425
+ postflight = validate_trusted_pack(root)
426
+ if [path.name for path in postflight] != list(expected):
427
+ raise ValueError("BMAD pack inventory changed after preflight")
428
+ if after_apply is not None:
429
+ after_apply(manifest_path, skills_dir)
430
+ for name in stale_managed_names:
431
+ entry = skills_dir / name
432
+ if entry.exists() or entry.is_symlink():
433
+ raise ValueError(f"Applied BMAD projection retained stale managed entry: {name}")
434
+ for name, target in expected.items():
435
+ if lexical_link_target(skills_dir / name) != target:
436
+ raise ValueError(f"Applied BMAD projection link differs from plan: {name}")
437
+ final_mode = manifest_path.lstat().st_mode
438
+ if (
439
+ not stat.S_ISREG(final_mode)
440
+ or read_regular_file(manifest_path) != next_manifest
441
+ or stat.S_IMODE(final_mode) != manifest_mode
442
+ ):
443
+ raise ValueError("Applied BMAD skills manifest differs from planned bytes or mode")
444
+ final_manifest = json.loads(read_regular_file(manifest_path))
445
+ if (
446
+ not isinstance(final_manifest, dict)
447
+ or final_manifest.get("$schema") != SKILLS_SCHEMA
448
+ or final_manifest.get("inherit_global") is not True
449
+ or final_manifest.get("registry") != SKILLS_REGISTRY
450
+ or not isinstance(final_manifest.get("skills"), list)
451
+ ):
452
+ raise ValueError("Applied BMAD skills manifest schema differs from plan")
453
+ except Exception as error:
454
+ try:
455
+ rollback()
456
+ except Exception as rollback_error:
457
+ raise RuntimeError(f"BMAD provisioning failed ({error}); {rollback_error}") from error
458
+ raise
459
+
460
+ shutil.rmtree(transaction)
461
+ return len(affected)
462
+
463
+
464
+ def main() -> None:
465
+ try:
466
+ changed = provision()
467
+ except (FileNotFoundError, OSError, ValueError, RuntimeError) as error:
468
+ raise SystemExit(
469
+ f"BMAD Skillex pack {BMAD_PACK_VERSION} provisioning failed: {error}; "
470
+ "set PJ_BMAD_PACK_ROOT to the installed canonical pack"
471
+ ) from error
472
+
473
+ print(
474
+ f"bmad-skills: {BMAD_PACK_SKILL_COUNT} skills from pack "
475
+ f"{BMAD_PACK_VERSION}; {changed} symlink(s) updated"
476
+ )
477
+
478
+
479
+ if __name__ == "__main__":
480
+ main()