@delorenj/pjangler 1.4.6 → 1.5.3
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/README.md +41 -596
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +9385 -18379
- package/dist/mcp-server.js +9432 -17852
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -584
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,587 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Provision every Skillex pack this project declares.
|
|
3
|
-
|
|
4
|
-
Generic replacement for the retired `provision-bmad-skills.py` (PACKS-CONTRACT
|
|
5
|
-
section 7). It reads `.agents/skills.json`, resolves and verifies every entry in
|
|
6
|
-
`packs[]`, and materializes each member as a symlink under `.agents/skills/`.
|
|
7
|
-
|
|
8
|
-
Pack resolution and verification are NOT reimplemented here: they are imported
|
|
9
|
-
verbatim from the sibling `sync-skills.py` engine, so a pack that provisions
|
|
10
|
-
cleanly is a pack that syncs cleanly, with byte-identical error messages.
|
|
11
|
-
|
|
12
|
-
What this script owns is the transactional projection into the project:
|
|
13
|
-
|
|
14
|
-
* every destination path is validated BEFORE anything is created or moved,
|
|
15
|
-
and re-validated at the mutation boundary
|
|
16
|
-
* `.agents` / `.agents/skills` may never be a symlink, and may never resolve
|
|
17
|
-
outside the project
|
|
18
|
-
* a failure at ANY point rolls the project back to its exact prior state, so
|
|
19
|
-
one unsafe or tampered pack produces ZERO mutation
|
|
20
|
-
* `.agents/skills.json` is rewritten atomically, preserving its mode
|
|
21
|
-
|
|
22
|
-
Only packs a project DECLARES in `packs[]` are provisioned. Nothing is pinned
|
|
23
|
-
implicitly -- in particular BMAD is not a pack: `bmad-method install` writes
|
|
24
|
-
bmad-* skills into `.agents/skills` itself, versioned per project by
|
|
25
|
-
`_bmad/_config/manifest.yaml`.
|
|
26
|
-
"""
|
|
27
|
-
|
|
28
|
-
from __future__ import annotations
|
|
29
|
-
|
|
30
|
-
import importlib.util
|
|
31
|
-
import json
|
|
32
|
-
import argparse
|
|
33
|
-
import os
|
|
34
|
-
import re
|
|
35
|
-
import shutil
|
|
36
|
-
import stat
|
|
37
|
-
import sys
|
|
38
|
-
import tempfile
|
|
39
|
-
from pathlib import Path
|
|
40
|
-
from typing import Callable
|
|
41
|
-
|
|
42
|
-
SKILLS_SCHEMA = "https://raw.githubusercontent.com/delorenj/skillex/main/skills.schema.json"
|
|
43
|
-
SKILLS_REGISTRY = "https://github.com/delorenj/skillex.git"
|
|
44
|
-
|
|
45
|
-
# BMAD is NOT a pack. `bmad-method install` writes bmad-* skills into
|
|
46
|
-
# .agents/skills itself, per project, versioned by _bmad/_config/manifest.yaml.
|
|
47
|
-
# This script used to pin a frozen `packs/bmad/<version>` in the registry and
|
|
48
|
-
# project a second copy of the same skills; when the registry dropped that pack
|
|
49
|
-
# the pin took every `pjangler project create` down with it on any machine
|
|
50
|
-
# without a warm cache. Only packs a project DECLARES are provisioned now.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def load_engine():
|
|
54
|
-
"""Import the sibling sync engine (its filename is not a valid module name)."""
|
|
55
|
-
path = Path(__file__).resolve().parent / "sync-skills.py"
|
|
56
|
-
spec = importlib.util.spec_from_file_location("skillex_sync_engine", path)
|
|
57
|
-
if spec is None or spec.loader is None:
|
|
58
|
-
raise ValueError(f"Cannot load the skills sync engine at {path}")
|
|
59
|
-
module = importlib.util.module_from_spec(spec)
|
|
60
|
-
sys.modules[spec.name] = module
|
|
61
|
-
spec.loader.exec_module(module)
|
|
62
|
-
return module
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
engine = load_engine()
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
# --------------------------------------------------------------------------- #
|
|
69
|
-
# Pack selection
|
|
70
|
-
# --------------------------------------------------------------------------- #
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def pack_root_override(name: str) -> Path | None:
|
|
74
|
-
"""Developer/test pin for one pack root, e.g. `PJ_PACK_ROOT_HERMES_BASE`."""
|
|
75
|
-
generic = os.environ.get(f"PJ_PACK_ROOT_{re.sub(r'[^A-Z0-9]', '_', name.upper())}", "").strip()
|
|
76
|
-
if generic:
|
|
77
|
-
return Path(generic).expanduser().absolute()
|
|
78
|
-
return None
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def apply_root_override(entry: dict) -> dict:
|
|
82
|
-
"""Pin a declared pack at an overridden root without changing its identity."""
|
|
83
|
-
if entry.get("source") or entry.get("registry_path"):
|
|
84
|
-
return entry
|
|
85
|
-
override = pack_root_override(entry["name"])
|
|
86
|
-
if override is None:
|
|
87
|
-
return entry
|
|
88
|
-
pinned = dict(entry)
|
|
89
|
-
pinned["source"] = override.as_uri()
|
|
90
|
-
return pinned
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def resolve_declared_packs(manifest: dict, base_dir: Path):
|
|
94
|
-
"""(declared_members, implicit_members, declared_packs, implicit_packs).
|
|
95
|
-
|
|
96
|
-
`declared_members` are projected only; `implicit_members` are ALSO recorded
|
|
97
|
-
in `skills[]` (the historical BMAD pin behaviour). Both are `name -> LEAF
|
|
98
|
-
path`: under contract 3b flatten a member lives at
|
|
99
|
-
`<root>/<container>/.../<name>`, at ANY depth, so the path may NOT be
|
|
100
|
-
reconstructed from the name anywhere downstream.
|
|
101
|
-
|
|
102
|
-
`declared_packs` / `implicit_packs` are one record per pack that resolved AND
|
|
103
|
-
verified, keeping the pack's own root, its family root and its inventory
|
|
104
|
-
SEPARATE — see `declared_pack_scope()` for why collapsing them into a flat
|
|
105
|
-
root list silently deletes user skills. The records are also the ONLY
|
|
106
|
-
reliable source of a pack's root now that `member.parent` is a container
|
|
107
|
-
under flatten rather than the pack root.
|
|
108
|
-
"""
|
|
109
|
-
entries = engine.validate_manifest_packs(manifest)
|
|
110
|
-
default_registry = manifest.get("registry", SKILLS_REGISTRY)
|
|
111
|
-
cache_dir = engine.ensure_cache_dir()
|
|
112
|
-
registry_roots: dict = {}
|
|
113
|
-
managed_roots = engine.default_managed_roots()
|
|
114
|
-
|
|
115
|
-
declared_members: dict[str, Path] = {}
|
|
116
|
-
declared_packs: list[dict] = []
|
|
117
|
-
for entry in entries:
|
|
118
|
-
members = engine.resolve_pack(
|
|
119
|
-
apply_root_override(entry),
|
|
120
|
-
cache_dir,
|
|
121
|
-
base_dir,
|
|
122
|
-
default_registry,
|
|
123
|
-
registry_roots,
|
|
124
|
-
managed_roots,
|
|
125
|
-
on_resolved=declared_packs.append,
|
|
126
|
-
)
|
|
127
|
-
for name, path in members:
|
|
128
|
-
# Later packs override earlier ones (contract section 5).
|
|
129
|
-
declared_members[name] = path
|
|
130
|
-
|
|
131
|
-
# Nothing is pinned implicitly any more. The empty pair is kept so every
|
|
132
|
-
# caller keeps one shape and the manifest writer still evicts leftovers
|
|
133
|
-
# from when something WAS pinned here.
|
|
134
|
-
implicit_members: dict[str, Path] = {}
|
|
135
|
-
implicit_packs: list[dict] = []
|
|
136
|
-
return declared_members, implicit_members, declared_packs, implicit_packs
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
def declared_ownership_roots(declared_packs) -> list[Path]:
|
|
140
|
-
"""Roots that OWN `.agents/skills` entries planted for a declared pack.
|
|
141
|
-
|
|
142
|
-
Ownership is deliberately WIDER than redundancy: a symlink into a sibling
|
|
143
|
-
version of a declared pack is still this tool's own leftover, so the family
|
|
144
|
-
root counts. Removing a manifest entry is a different, narrower question.
|
|
145
|
-
"""
|
|
146
|
-
roots: list[Path] = []
|
|
147
|
-
for pack in declared_packs:
|
|
148
|
-
for root in (pack["root"], pack["family_root"]):
|
|
149
|
-
if root is not None and root not in roots:
|
|
150
|
-
roots.append(root)
|
|
151
|
-
return roots
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
# --------------------------------------------------------------------------- #
|
|
155
|
-
# Manifest bookkeeping
|
|
156
|
-
# --------------------------------------------------------------------------- #
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def manifest_entry_name(value: object) -> str | None:
|
|
160
|
-
if isinstance(value, str):
|
|
161
|
-
return value
|
|
162
|
-
if isinstance(value, dict):
|
|
163
|
-
name = value.get("name")
|
|
164
|
-
return name if isinstance(name, str) else None
|
|
165
|
-
return None
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
# Imported, not re-implemented. A second copy of either helper is exactly how
|
|
169
|
-
# `sync-skills.py` and this script came to disagree about which pack version a
|
|
170
|
-
# name resolves to; there is now one definition and every surface shares it.
|
|
171
|
-
manifest_entry_source_path = engine.manifest_entry_source_path
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
def is_pack_managed_manifest_entry(value: object, expected_names: set[str], roots) -> bool:
|
|
175
|
-
name = manifest_entry_name(value)
|
|
176
|
-
if name is None:
|
|
177
|
-
return False
|
|
178
|
-
if name in expected_names:
|
|
179
|
-
return True
|
|
180
|
-
source_path = manifest_entry_source_path(value)
|
|
181
|
-
if source_path is None or source_path.name != name:
|
|
182
|
-
return False
|
|
183
|
-
return any(is_contained_by(root, source_path) for root in roots)
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
is_contained_by = engine.is_contained_by
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
def declared_pack_scope(declared_packs) -> "engine.PackScope":
|
|
190
|
-
"""PACKS-CONTRACT section 6 redundancy for this manifest's declared packs.
|
|
191
|
-
|
|
192
|
-
The rule itself lives in `engine.PackScope` so `sync-skills.py`, this script
|
|
193
|
-
and pjangler's `isRedundantDeclaredPackEntry` cannot drift apart. It is
|
|
194
|
-
deliberately narrow:
|
|
195
|
-
|
|
196
|
-
* an entry whose resolved source lands inside the pack's OWN root is
|
|
197
|
-
redundant — the pack projects it, so the hand-written entry is a
|
|
198
|
-
duplicate; and
|
|
199
|
-
* an entry landing in a SIBLING version of the same pack family is
|
|
200
|
-
redundant ONLY when the resolved pack actually declares that name.
|
|
201
|
-
Otherwise it is a skill this pack does not provide, and dropping it
|
|
202
|
-
would lose it — "Never remove entries that point outside the pack."
|
|
203
|
-
|
|
204
|
-
The family root therefore may NOT be flattened into the pack's own root:
|
|
205
|
-
`packs/<name>/<other-version>/<skill>` is a different pack.
|
|
206
|
-
"""
|
|
207
|
-
scope = engine.PackScope()
|
|
208
|
-
for pack in declared_packs:
|
|
209
|
-
scope.record(pack)
|
|
210
|
-
return scope
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
# --------------------------------------------------------------------------- #
|
|
214
|
-
# Project destination safety
|
|
215
|
-
# --------------------------------------------------------------------------- #
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def preflight_project_directory(project_root: Path, target: Path) -> None:
|
|
219
|
-
try:
|
|
220
|
-
relative = target.absolute().relative_to(project_root)
|
|
221
|
-
except ValueError as error:
|
|
222
|
-
raise ValueError(f"Project destination escapes {project_root}: {target}") from error
|
|
223
|
-
if len(relative.parts) > 2:
|
|
224
|
-
raise ValueError(f"Unexpected project skill destination depth: {target}")
|
|
225
|
-
current = project_root
|
|
226
|
-
for part in relative.parts:
|
|
227
|
-
current = current / part
|
|
228
|
-
if not current.exists() and not current.is_symlink():
|
|
229
|
-
break
|
|
230
|
-
if current.is_symlink():
|
|
231
|
-
raise ValueError(f"Refusing symlinked project skill directory: {current}")
|
|
232
|
-
if not current.is_dir():
|
|
233
|
-
raise ValueError(f"Project skill parent is not a directory: {current}")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def prepare_project_skill_dirs(project_root: Path) -> tuple[Path, Path]:
|
|
237
|
-
agents_dir = project_root / ".agents"
|
|
238
|
-
skills_dir = agents_dir / "skills"
|
|
239
|
-
# Validate the complete existing chain before creating or mutating anything.
|
|
240
|
-
preflight_project_directory(project_root, agents_dir)
|
|
241
|
-
preflight_project_directory(project_root, skills_dir)
|
|
242
|
-
agents_dir.mkdir(exist_ok=True)
|
|
243
|
-
skills_dir.mkdir(exist_ok=True)
|
|
244
|
-
for path in (agents_dir, skills_dir):
|
|
245
|
-
if path.is_symlink() or not path.is_dir():
|
|
246
|
-
raise ValueError(f"Unsafe project skill directory: {path}")
|
|
247
|
-
resolved = path.resolve(strict=True)
|
|
248
|
-
resolved.relative_to(project_root)
|
|
249
|
-
return agents_dir, skills_dir
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
def lexical_link_target(link: Path) -> Path | None:
|
|
253
|
-
if not link.is_symlink():
|
|
254
|
-
return None
|
|
255
|
-
target = Path(os.readlink(link))
|
|
256
|
-
return (target if target.is_absolute() else link.parent / target).absolute()
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def remove_entry(path: Path) -> None:
|
|
260
|
-
try:
|
|
261
|
-
mode = path.lstat().st_mode
|
|
262
|
-
except FileNotFoundError:
|
|
263
|
-
return
|
|
264
|
-
if stat.S_ISDIR(mode) and not stat.S_ISLNK(mode):
|
|
265
|
-
shutil.rmtree(path)
|
|
266
|
-
else:
|
|
267
|
-
path.unlink()
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def atomic_write(path: Path, content: bytes, mode: int) -> None:
|
|
271
|
-
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
272
|
-
temporary = Path(temporary_name)
|
|
273
|
-
try:
|
|
274
|
-
with os.fdopen(descriptor, "wb") as handle:
|
|
275
|
-
handle.write(content)
|
|
276
|
-
handle.flush()
|
|
277
|
-
os.fsync(handle.fileno())
|
|
278
|
-
temporary.chmod(mode)
|
|
279
|
-
os.replace(temporary, path)
|
|
280
|
-
finally:
|
|
281
|
-
if temporary.exists():
|
|
282
|
-
temporary.unlink()
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
# --------------------------------------------------------------------------- #
|
|
286
|
-
# Provisioning
|
|
287
|
-
# --------------------------------------------------------------------------- #
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def own_root() -> Path:
|
|
291
|
-
"""The repo this script file belongs to: <root>/.mise/scripts/<this>."""
|
|
292
|
-
return Path(__file__).resolve().parents[2]
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
def resolved_project_root(requested: str | None) -> Path:
|
|
296
|
-
"""Resolve the repo to provision, refusing to infer it from cwd.
|
|
297
|
-
|
|
298
|
-
A mise ENTER hook runs with cwd set to the directory the user cd'd into, not
|
|
299
|
-
config_root -- including a PARENT config's hook. That is how 33GOD's copy of
|
|
300
|
-
this script came to force-rewrite `pjangler/.agents/skills.json` and plant 75
|
|
301
|
-
dangling pack links in `pjangler/.agents/skills`. `mise run <task>` does run
|
|
302
|
-
at config_root, so the cwd assumption was only ever wrong on the hook path.
|
|
303
|
-
"""
|
|
304
|
-
mine = own_root()
|
|
305
|
-
if requested is None:
|
|
306
|
-
requested = os.environ.get("MISE_CONFIG_ROOT") or None
|
|
307
|
-
if requested is None:
|
|
308
|
-
raise SystemExit(
|
|
309
|
-
"provision-packs: --root (or $MISE_CONFIG_ROOT) is required; "
|
|
310
|
-
"refusing to infer the subject repo from cwd"
|
|
311
|
-
)
|
|
312
|
-
resolved = Path(requested).resolve(strict=True)
|
|
313
|
-
if resolved != mine:
|
|
314
|
-
raise SystemExit(
|
|
315
|
-
f"provision-packs: refusing to act on {resolved}; this script "
|
|
316
|
-
f"belongs to {mine}. A nested repo must ship its own "
|
|
317
|
-
".mise/scripts copy."
|
|
318
|
-
)
|
|
319
|
-
return resolved
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
def provision(
|
|
323
|
-
*,
|
|
324
|
-
root: str | None = None,
|
|
325
|
-
after_preflight: Callable[[], None] | None = None,
|
|
326
|
-
create_link: Callable[[Path, Path, int], None] | None = None,
|
|
327
|
-
after_apply: Callable[[Path, Path], None] | None = None,
|
|
328
|
-
) -> int:
|
|
329
|
-
project_root = resolved_project_root(root)
|
|
330
|
-
agents_path = project_root / ".agents"
|
|
331
|
-
skills_path = agents_path / "skills"
|
|
332
|
-
agents_existed = agents_path.exists() or agents_path.is_symlink()
|
|
333
|
-
skills_existed = skills_path.exists() or skills_path.is_symlink()
|
|
334
|
-
|
|
335
|
-
# `.agents` is validated before the manifest inside it is read, so the read
|
|
336
|
-
# can never traverse a symlink out of the project. `.agents/skills` is
|
|
337
|
-
# validated by prepare_project_skill_dirs() below — after the packs are
|
|
338
|
-
# resolved and verified, and still strictly before the first mutation.
|
|
339
|
-
preflight_project_directory(project_root, agents_path)
|
|
340
|
-
manifest_path = agents_path / "skills.json"
|
|
341
|
-
if manifest_path.is_symlink():
|
|
342
|
-
raise ValueError(f"Refusing symlinked skills manifest: {manifest_path}")
|
|
343
|
-
if manifest_path.exists() and not manifest_path.is_file():
|
|
344
|
-
raise ValueError(f"Skills manifest is not a regular file: {manifest_path}")
|
|
345
|
-
manifest_existed = manifest_path.exists()
|
|
346
|
-
manifest_bytes = engine.read_regular_file(manifest_path) if manifest_existed else None
|
|
347
|
-
manifest_mode = stat.S_IMODE(manifest_path.lstat().st_mode) if manifest_existed else 0o644
|
|
348
|
-
manifest = json.loads(manifest_bytes) if manifest_bytes is not None else {}
|
|
349
|
-
if not isinstance(manifest, dict):
|
|
350
|
-
raise ValueError(f"{manifest_path} must contain a JSON object")
|
|
351
|
-
existing = manifest.get("skills", [])
|
|
352
|
-
if not isinstance(existing, list):
|
|
353
|
-
raise ValueError(f"{manifest_path} skills must be an array")
|
|
354
|
-
|
|
355
|
-
declared_members, implicit_members, declared_packs, implicit_packs = resolve_declared_packs(
|
|
356
|
-
manifest, agents_path
|
|
357
|
-
)
|
|
358
|
-
if after_preflight is not None:
|
|
359
|
-
after_preflight()
|
|
360
|
-
|
|
361
|
-
implicit_names = set(implicit_members)
|
|
362
|
-
# From the resolve record, NOT `member.parent`: under contract 3b flatten a
|
|
363
|
-
# member's parent is its container — possibly several levels below the pack
|
|
364
|
-
# root — so deriving the root from a member would claim ownership of one
|
|
365
|
-
# container instead of the whole pack.
|
|
366
|
-
implicit_roots = {pack["root"] for pack in implicit_packs}
|
|
367
|
-
declared_roots = declared_ownership_roots(declared_packs)
|
|
368
|
-
declared_scope = declared_pack_scope(declared_packs)
|
|
369
|
-
|
|
370
|
-
kept = [
|
|
371
|
-
entry
|
|
372
|
-
for entry in existing
|
|
373
|
-
if not is_pack_managed_manifest_entry(entry, implicit_names, implicit_roots)
|
|
374
|
-
and not declared_scope.is_redundant(entry)
|
|
375
|
-
]
|
|
376
|
-
|
|
377
|
-
# Contract section 5: an explicit `skills[]` entry that SURVIVED the section 6
|
|
378
|
-
# pruning always overrides a declared pack member of the same name.
|
|
379
|
-
overridden = {
|
|
380
|
-
name
|
|
381
|
-
for name in (manifest_entry_name(entry) for entry in kept)
|
|
382
|
-
if name is not None and name in declared_members
|
|
383
|
-
}
|
|
384
|
-
expected: dict[str, Path] = {
|
|
385
|
-
name: path for name, path in declared_members.items() if name not in overridden
|
|
386
|
-
}
|
|
387
|
-
expected.update(implicit_members)
|
|
388
|
-
|
|
389
|
-
agents_dir, skills_dir = prepare_project_skill_dirs(project_root)
|
|
390
|
-
|
|
391
|
-
manifest["$schema"] = SKILLS_SCHEMA
|
|
392
|
-
manifest["inherit_global"] = True
|
|
393
|
-
manifest["registry"] = SKILLS_REGISTRY
|
|
394
|
-
manifest["skills"] = [
|
|
395
|
-
*kept,
|
|
396
|
-
*[
|
|
397
|
-
{"name": name, "source": implicit_members[name].as_uri()}
|
|
398
|
-
for name in implicit_members
|
|
399
|
-
],
|
|
400
|
-
]
|
|
401
|
-
next_manifest = (json.dumps(manifest, indent=2) + "\n").encode("utf-8")
|
|
402
|
-
|
|
403
|
-
ownership_roots = [*implicit_roots, *declared_roots]
|
|
404
|
-
affected: list[str] = []
|
|
405
|
-
stale_managed_names: set[str] = set()
|
|
406
|
-
original_correct_links: dict[str, str] = {}
|
|
407
|
-
managed_manifest_names = {
|
|
408
|
-
name
|
|
409
|
-
for entry in existing
|
|
410
|
-
if is_pack_managed_manifest_entry(entry, implicit_names, ownership_roots)
|
|
411
|
-
if (name := manifest_entry_name(entry)) is not None
|
|
412
|
-
}
|
|
413
|
-
for entry in skills_dir.iterdir():
|
|
414
|
-
if entry.parent.resolve(strict=True) != skills_dir.resolve(strict=True):
|
|
415
|
-
raise ValueError(f"Pack skill entry escapes skills directory: {entry}")
|
|
416
|
-
engine.validate_skill_name(entry.name)
|
|
417
|
-
link_target = lexical_link_target(entry)
|
|
418
|
-
try:
|
|
419
|
-
link_targets_pack = link_target is not None and any(
|
|
420
|
-
is_contained_by(root, link_target) for root in ownership_roots
|
|
421
|
-
)
|
|
422
|
-
except OSError:
|
|
423
|
-
link_targets_pack = False
|
|
424
|
-
# PJAN-82: a DANGLING symlink here is stale by definition.
|
|
425
|
-
#
|
|
426
|
-
# An entry stopped being recognizable as pack-managed the moment its pack
|
|
427
|
-
# declaration went away: `ownership_roots` no longer contains the pack, so
|
|
428
|
-
# `link_targets_pack` is false, and the normalized manifest no longer names
|
|
429
|
-
# it either — so the checks below skipped it and it stayed forever. That is
|
|
430
|
-
# how momo, bloodbank and candystore each ended up with 76 links into
|
|
431
|
-
# `~/.agents/.cache/registries/.../packs/bmad/6.10.1-next.31/`, a cache
|
|
432
|
-
# directory that no longer exists. A link that resolves to nothing cannot be
|
|
433
|
-
# a hand-authored skill and cannot be serving anyone, so it is reclaimable
|
|
434
|
-
# regardless of where it points. A link that still RESOLVES is left alone
|
|
435
|
-
# unless it is provably pack-managed.
|
|
436
|
-
dangling_link = link_target is not None and not entry.exists()
|
|
437
|
-
if (
|
|
438
|
-
entry.name not in expected
|
|
439
|
-
and entry.name not in managed_manifest_names
|
|
440
|
-
and not link_targets_pack
|
|
441
|
-
and not dangling_link
|
|
442
|
-
):
|
|
443
|
-
continue
|
|
444
|
-
target = expected.get(entry.name)
|
|
445
|
-
if target is not None and link_target == target:
|
|
446
|
-
original_correct_links[entry.name] = os.readlink(entry)
|
|
447
|
-
else:
|
|
448
|
-
affected.append(entry.name)
|
|
449
|
-
if target is None:
|
|
450
|
-
stale_managed_names.add(entry.name)
|
|
451
|
-
for name, target in expected.items():
|
|
452
|
-
link = skills_dir / engine.validate_skill_name(name)
|
|
453
|
-
if link.parent.resolve(strict=True) != skills_dir.resolve(strict=True):
|
|
454
|
-
raise ValueError(f"Pack skill destination escapes skills directory: {link}")
|
|
455
|
-
if lexical_link_target(link) != target and name not in affected:
|
|
456
|
-
affected.append(name)
|
|
457
|
-
|
|
458
|
-
manifest_changed = manifest_bytes != next_manifest
|
|
459
|
-
if not affected and not manifest_changed:
|
|
460
|
-
return 0
|
|
461
|
-
|
|
462
|
-
transaction = Path(tempfile.mkdtemp(prefix=".packs-transaction-", dir=agents_dir))
|
|
463
|
-
backup = transaction / "entries"
|
|
464
|
-
backup.mkdir()
|
|
465
|
-
moved: list[str] = []
|
|
466
|
-
|
|
467
|
-
def rollback() -> None:
|
|
468
|
-
errors: list[str] = []
|
|
469
|
-
try:
|
|
470
|
-
for name in affected:
|
|
471
|
-
remove_entry(skills_dir / engine.validate_skill_name(name))
|
|
472
|
-
for name in original_correct_links:
|
|
473
|
-
remove_entry(skills_dir / engine.validate_skill_name(name))
|
|
474
|
-
except OSError as error:
|
|
475
|
-
errors.append(f"remove applied projection: {error}")
|
|
476
|
-
for name in reversed(moved):
|
|
477
|
-
try:
|
|
478
|
-
os.replace(backup / name, skills_dir / name)
|
|
479
|
-
except OSError as error:
|
|
480
|
-
errors.append(f"restore {name}: {error}")
|
|
481
|
-
for name, raw_target in original_correct_links.items():
|
|
482
|
-
try:
|
|
483
|
-
(skills_dir / name).symlink_to(raw_target, target_is_directory=True)
|
|
484
|
-
except OSError as error:
|
|
485
|
-
errors.append(f"restore {name}: {error}")
|
|
486
|
-
try:
|
|
487
|
-
if manifest_bytes is None:
|
|
488
|
-
remove_entry(manifest_path)
|
|
489
|
-
else:
|
|
490
|
-
atomic_write(manifest_path, manifest_bytes, manifest_mode)
|
|
491
|
-
except OSError as error:
|
|
492
|
-
errors.append(f"restore manifest: {error}")
|
|
493
|
-
shutil.rmtree(transaction, ignore_errors=True)
|
|
494
|
-
try:
|
|
495
|
-
if not skills_existed and skills_dir.exists() and not any(skills_dir.iterdir()):
|
|
496
|
-
skills_dir.rmdir()
|
|
497
|
-
if not agents_existed and agents_dir.exists() and not any(agents_dir.iterdir()):
|
|
498
|
-
agents_dir.rmdir()
|
|
499
|
-
except OSError as error:
|
|
500
|
-
errors.append(f"remove created directories: {error}")
|
|
501
|
-
if errors:
|
|
502
|
-
raise RuntimeError("Skillex pack rollback was incomplete: " + "; ".join(errors))
|
|
503
|
-
|
|
504
|
-
link_creator = create_link or (
|
|
505
|
-
lambda target, link, _index: link.symlink_to(target, target_is_directory=True)
|
|
506
|
-
)
|
|
507
|
-
try:
|
|
508
|
-
for name in affected:
|
|
509
|
-
entry = skills_dir / name
|
|
510
|
-
if entry.exists() or entry.is_symlink():
|
|
511
|
-
os.replace(entry, backup / name)
|
|
512
|
-
moved.append(name)
|
|
513
|
-
for index, (name, target) in enumerate(expected.items(), start=1):
|
|
514
|
-
link = skills_dir / name
|
|
515
|
-
if lexical_link_target(link) == target:
|
|
516
|
-
continue
|
|
517
|
-
link_creator(target, link, index)
|
|
518
|
-
if manifest_changed:
|
|
519
|
-
atomic_write(manifest_path, next_manifest, manifest_mode)
|
|
520
|
-
# Re-validate every pack at the mutation boundary: a pack tampered with
|
|
521
|
-
# after preflight must roll the whole projection back.
|
|
522
|
-
postflight_declared, postflight_implicit, _, _ = resolve_declared_packs(
|
|
523
|
-
manifest, agents_path
|
|
524
|
-
)
|
|
525
|
-
postflight = {**postflight_declared, **postflight_implicit}
|
|
526
|
-
if {name: postflight.get(name) for name in expected} != expected:
|
|
527
|
-
raise ValueError("Skillex pack inventory changed after preflight")
|
|
528
|
-
if after_apply is not None:
|
|
529
|
-
after_apply(manifest_path, skills_dir)
|
|
530
|
-
for name in stale_managed_names:
|
|
531
|
-
entry = skills_dir / name
|
|
532
|
-
if entry.exists() or entry.is_symlink():
|
|
533
|
-
raise ValueError(f"Applied pack projection retained stale managed entry: {name}")
|
|
534
|
-
for name, target in expected.items():
|
|
535
|
-
if lexical_link_target(skills_dir / name) != target:
|
|
536
|
-
raise ValueError(f"Applied pack projection link differs from plan: {name}")
|
|
537
|
-
final_mode = manifest_path.lstat().st_mode
|
|
538
|
-
if (
|
|
539
|
-
not stat.S_ISREG(final_mode)
|
|
540
|
-
or engine.read_regular_file(manifest_path) != next_manifest
|
|
541
|
-
or stat.S_IMODE(final_mode) != manifest_mode
|
|
542
|
-
):
|
|
543
|
-
raise ValueError("Applied skills manifest differs from planned bytes or mode")
|
|
544
|
-
final_manifest = json.loads(engine.read_regular_file(manifest_path))
|
|
545
|
-
if (
|
|
546
|
-
not isinstance(final_manifest, dict)
|
|
547
|
-
or final_manifest.get("$schema") != SKILLS_SCHEMA
|
|
548
|
-
or final_manifest.get("inherit_global") is not True
|
|
549
|
-
or final_manifest.get("registry") != SKILLS_REGISTRY
|
|
550
|
-
or not isinstance(final_manifest.get("skills"), list)
|
|
551
|
-
):
|
|
552
|
-
raise ValueError("Applied skills manifest schema differs from plan")
|
|
553
|
-
except Exception as error:
|
|
554
|
-
try:
|
|
555
|
-
rollback()
|
|
556
|
-
except Exception as rollback_error:
|
|
557
|
-
raise RuntimeError(f"Skillex pack provisioning failed ({error}); {rollback_error}") from error
|
|
558
|
-
raise
|
|
559
|
-
|
|
560
|
-
shutil.rmtree(transaction)
|
|
561
|
-
return len(affected)
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
def main() -> None:
|
|
565
|
-
parser = argparse.ArgumentParser(description="Provision declared Skillex packs.")
|
|
566
|
-
parser.add_argument(
|
|
567
|
-
"--root",
|
|
568
|
-
default=None,
|
|
569
|
-
help=(
|
|
570
|
-
"Project root to provision; defaults to $MISE_CONFIG_ROOT. Never "
|
|
571
|
-
"cwd -- see resolved_project_root()."
|
|
572
|
-
),
|
|
573
|
-
)
|
|
574
|
-
args = parser.parse_args()
|
|
575
|
-
try:
|
|
576
|
-
changed = provision(root=args.root)
|
|
577
|
-
except (FileNotFoundError, OSError, ValueError, RuntimeError, engine.PackUnavailable) as error:
|
|
578
|
-
raise SystemExit(
|
|
579
|
-
f"Skillex pack provisioning failed: {error}; "
|
|
580
|
-
"declare the pack in .agents/skills.json packs[] or install it locally"
|
|
581
|
-
) from error
|
|
582
|
-
|
|
583
|
-
print(f"provision-packs: {changed} symlink(s) updated")
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
if __name__ == "__main__":
|
|
587
|
-
main()
|