@delorenj/pjangler 1.4.6 → 1.5.2

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 (83) hide show
  1. package/README.md +41 -596
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +9385 -18379
  10. package/dist/mcp-server.js +9432 -17852
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -584
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. 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()