@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.
- package/dist/index.js +1852 -368
- package/dist/mcp-server.js +1782 -296
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +3 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +9 -2
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- 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.
|
|
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()
|