@delorenj/pjangler 1.3.0 → 1.4.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.
- package/.mise/scripts/link-agentfiles.sh +38 -5
- package/README.md +92 -0
- package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
- package/dist/assets/project-notebook-skill/SKILL.md +64 -0
- package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
- package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
- package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
- package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
- package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
- package/dist/index.js +11307 -2809
- package/dist/mcp-server.js +9637 -2094
- package/dist/prompt.js +404 -0
- package/package.json +8 -5
- package/templates/commonproject/copier.yml +19 -5
- package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
- package/templates/commonproject/template/mise.toml.jinja +12 -6
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.gitignore.jinja +1 -0
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- package/templates/hermes-agent/template/role.yaml.jinja +19 -19
|
@@ -11,6 +11,7 @@ import argparse
|
|
|
11
11
|
import hashlib
|
|
12
12
|
import json
|
|
13
13
|
import os
|
|
14
|
+
import pathlib
|
|
14
15
|
import re
|
|
15
16
|
import shutil
|
|
16
17
|
import stat
|
|
@@ -82,6 +83,23 @@ class PackUnavailable(Exception):
|
|
|
82
83
|
"""
|
|
83
84
|
|
|
84
85
|
|
|
86
|
+
# --------------------------------------------------------------------------- #
|
|
87
|
+
# Which repo does this script own?
|
|
88
|
+
#
|
|
89
|
+
# A mise ENTER hook runs with cwd set to the directory the user cd'd into, not
|
|
90
|
+
# to config_root -- and that is true for a PARENT config's hook too, so entering
|
|
91
|
+
# 33GOD/pjangler fired 33GOD's copy of this script with cwd=pjangler. It then
|
|
92
|
+
# loaded pjangler's manifest and rewrote pjangler's .agents/ and CLI skill dirs.
|
|
93
|
+
# `mise run <task>` DOES run at config_root, which is why the cwd assumption
|
|
94
|
+
# looked correct for years: only the enter-hook path was ever wrong.
|
|
95
|
+
#
|
|
96
|
+
# So the subject is taken explicitly, defaulting to $MISE_CONFIG_ROOT (mise
|
|
97
|
+
# exports it per hook, correctly), and it must match the root this script file
|
|
98
|
+
# actually lives in. A repo-local script never acts on cwd, and never on a
|
|
99
|
+
# sibling.
|
|
100
|
+
# --------------------------------------------------------------------------- #
|
|
101
|
+
|
|
102
|
+
|
|
85
103
|
def parse_args():
|
|
86
104
|
parser = argparse.ArgumentParser(
|
|
87
105
|
description="Sync skills from manifest to agent CLIs."
|
|
@@ -100,9 +118,63 @@ def parse_args():
|
|
|
100
118
|
"directories. Without this flag they are only reported."
|
|
101
119
|
),
|
|
102
120
|
)
|
|
121
|
+
parser.add_argument(
|
|
122
|
+
"--no-reconcile",
|
|
123
|
+
action="store_true",
|
|
124
|
+
help=(
|
|
125
|
+
"Keep managed projection links this sync did not declare. By "
|
|
126
|
+
"default a projection is reconciled: a symlink into a managed "
|
|
127
|
+
"registry root that no declared skill or pack accounts for is "
|
|
128
|
+
"removed, because leaving it is what turns a projection into "
|
|
129
|
+
"sediment (81 of pjangler's 132 links were dangling)."
|
|
130
|
+
),
|
|
131
|
+
)
|
|
132
|
+
parser.add_argument(
|
|
133
|
+
"--reconcile-dry-run",
|
|
134
|
+
action="store_true",
|
|
135
|
+
help=(
|
|
136
|
+
"Print the links reconcile WOULD remove and remove nothing. Run "
|
|
137
|
+
"this first against any projection that has never been synced -- "
|
|
138
|
+
"notably --scope global, whose projection is shared by every CLI "
|
|
139
|
+
"and every project on the machine. Suppresses deletions only: "
|
|
140
|
+
"links are still created and relinked."
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
"--root",
|
|
145
|
+
default=os.environ.get("MISE_CONFIG_ROOT") or None,
|
|
146
|
+
help=(
|
|
147
|
+
"Project root this sync owns; defaults to $MISE_CONFIG_ROOT. Never "
|
|
148
|
+
"cwd: a parent mise config's enter hook runs with cwd set to the "
|
|
149
|
+
"child directory you entered, so cwd names a sibling repo."
|
|
150
|
+
),
|
|
151
|
+
)
|
|
103
152
|
return parser.parse_args()
|
|
104
153
|
|
|
105
154
|
|
|
155
|
+
def own_root():
|
|
156
|
+
"""The repo this script file belongs to: <root>/.mise/scripts/<this>."""
|
|
157
|
+
return pathlib.Path(__file__).resolve().parents[2]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolved_project_root(args):
|
|
161
|
+
mine = own_root()
|
|
162
|
+
if args.scope != "project":
|
|
163
|
+
return mine
|
|
164
|
+
if not args.root:
|
|
165
|
+
raise SystemExit(
|
|
166
|
+
"sync-skills: --root (or $MISE_CONFIG_ROOT) is required for "
|
|
167
|
+
"--scope project; refusing to infer the subject repo from cwd"
|
|
168
|
+
)
|
|
169
|
+
requested = pathlib.Path(args.root).resolve(strict=True)
|
|
170
|
+
if requested != mine:
|
|
171
|
+
raise SystemExit(
|
|
172
|
+
f"sync-skills: refusing to act on {requested}; this script belongs "
|
|
173
|
+
f"to {mine}. A nested repo must ship its own .mise/scripts copy."
|
|
174
|
+
)
|
|
175
|
+
return requested
|
|
176
|
+
|
|
177
|
+
|
|
106
178
|
def load_manifest(manifest_path):
|
|
107
179
|
if not manifest_path.exists():
|
|
108
180
|
return {"skills": []}
|
|
@@ -165,6 +237,95 @@ def assert_real_directory_chain(root, target):
|
|
|
165
237
|
raise ValueError(f"Destination parent is not a directory: {current}")
|
|
166
238
|
|
|
167
239
|
|
|
240
|
+
def alias_root_exclusions():
|
|
241
|
+
"""Managed roots that may NOT host the projection directory itself.
|
|
242
|
+
|
|
243
|
+
`~/.agents/.cache` is a managed root for reconcile purposes, but
|
|
244
|
+
`sync_registry()` CLONES and PULLS arbitrary remote git repositories into it.
|
|
245
|
+
A projection living inside one would be rewritten by whoever controls the
|
|
246
|
+
registry, so the alias may only land in a checkout the operator owns and this
|
|
247
|
+
engine never fetches into.
|
|
248
|
+
"""
|
|
249
|
+
return (Path(os.path.expanduser("~/.agents/.cache")),)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def canonical_alias_roots():
|
|
253
|
+
"""The roots the single managed-skills alias may resolve into."""
|
|
254
|
+
excluded = set()
|
|
255
|
+
for candidate in alias_root_exclusions():
|
|
256
|
+
try:
|
|
257
|
+
excluded.add(candidate.resolve(strict=True))
|
|
258
|
+
except OSError:
|
|
259
|
+
continue
|
|
260
|
+
return {root for root in default_managed_roots() if root not in excluded}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def assert_safe_resolved_chain(root, resolved):
|
|
264
|
+
"""Every component from `root` down to `resolved` is a real, safe directory.
|
|
265
|
+
|
|
266
|
+
Both arguments are already realpath-normalized, so this is a plain lstat walk:
|
|
267
|
+
no component is a symlink, each is a directory, each is owned by this user,
|
|
268
|
+
and none is world-writable.
|
|
269
|
+
|
|
270
|
+
Group-writability is deliberately ALLOWED. The canonical checkout is 0775
|
|
271
|
+
under the operator's own primary group, so a "no group-write" rule would
|
|
272
|
+
refuse the very topology this exists to admit. World-write is what actually
|
|
273
|
+
hands the path to someone else.
|
|
274
|
+
"""
|
|
275
|
+
uid = os.getuid() if hasattr(os, "getuid") else None
|
|
276
|
+
try:
|
|
277
|
+
relative = resolved.relative_to(root)
|
|
278
|
+
except ValueError as error:
|
|
279
|
+
raise ValueError(f"Resolved alias escapes its managed root {root}: {resolved}") from error
|
|
280
|
+
current = root
|
|
281
|
+
for part in relative.parts:
|
|
282
|
+
current = current / part
|
|
283
|
+
info = current.lstat()
|
|
284
|
+
if stat.S_ISLNK(info.st_mode):
|
|
285
|
+
raise ValueError(f"Resolved alias chain contains a symlink: {current}")
|
|
286
|
+
if not stat.S_ISDIR(info.st_mode):
|
|
287
|
+
raise ValueError(f"Resolved alias chain component is not a directory: {current}")
|
|
288
|
+
if uid is not None and info.st_uid != uid:
|
|
289
|
+
raise ValueError(f"Resolved alias chain component is not owned by this user: {current}")
|
|
290
|
+
if info.st_mode & stat.S_IWOTH:
|
|
291
|
+
raise ValueError(f"Resolved alias chain component is world-writable: {current}")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def resolve_managed_alias(alias, allowed_roots):
|
|
295
|
+
"""Resolve the ONE admissible symlinked projection, or refuse.
|
|
296
|
+
|
|
297
|
+
Every containment assertion in this engine is LEXICAL -- `symlink_target.parent
|
|
298
|
+
!= real_cli_dir`, `assert_destinations_contained`. A lexical guard is sound
|
|
299
|
+
only over a symlink-free path, which is why `assert_real_directory_chain`
|
|
300
|
+
refused any symlinked destination outright.
|
|
301
|
+
|
|
302
|
+
That refusal made `--scope global` unrunnable, because the canonical topology
|
|
303
|
+
IS a symlink: `~/.agents/skills -> ~/code/skillex/skill-sets/global`. The
|
|
304
|
+
relaxation admits exactly that shape and nothing else -- the alias must be a
|
|
305
|
+
symlink owned by this user that resolves, with no further symlink in the
|
|
306
|
+
chain, into a managed registry root the operator owns. Callers then address
|
|
307
|
+
the RESOLVED path for every mutation, so re-pointing the alias mid-run cannot
|
|
308
|
+
redirect a write.
|
|
309
|
+
"""
|
|
310
|
+
if not allowed_roots:
|
|
311
|
+
raise ValueError(f"No managed registry root is available to host {alias}")
|
|
312
|
+
info = alias.lstat()
|
|
313
|
+
uid = os.getuid() if hasattr(os, "getuid") else None
|
|
314
|
+
if uid is not None and info.st_uid != uid:
|
|
315
|
+
raise ValueError(f"Managed skills alias is not owned by this user: {alias}")
|
|
316
|
+
resolved = alias.resolve(strict=True)
|
|
317
|
+
for root in sorted(allowed_roots, key=lambda candidate: len(str(candidate)), reverse=True):
|
|
318
|
+
try:
|
|
319
|
+
resolved.relative_to(root)
|
|
320
|
+
except ValueError:
|
|
321
|
+
continue
|
|
322
|
+
assert_safe_resolved_chain(root, resolved)
|
|
323
|
+
return resolved
|
|
324
|
+
raise ValueError(
|
|
325
|
+
f"Managed skills alias does not resolve into a managed registry root: "
|
|
326
|
+
f"{alias} -> {resolved}"
|
|
327
|
+
)
|
|
328
|
+
|
|
168
329
|
def cli_skill_dirs(scope):
|
|
169
330
|
try:
|
|
170
331
|
return CLI_SKILL_DIRS[scope]
|
|
@@ -187,6 +348,41 @@ def lexical_symlink_target(link):
|
|
|
187
348
|
return Path(os.path.normpath(str(lexical)))
|
|
188
349
|
|
|
189
350
|
|
|
351
|
+
def assert_nonrecursive_skill_link(destination, source):
|
|
352
|
+
"""Reject a link that would point from inside a skill back to that skill.
|
|
353
|
+
|
|
354
|
+
A repository may legitimately be the source of a globally distributed
|
|
355
|
+
skill. When fanout runs inside that same repository, however, projecting
|
|
356
|
+
``<repo>/.claude/skills/<name> -> <repo>`` creates an unbounded filesystem
|
|
357
|
+
cycle for every symlink-following walker. Compare the lexical destination
|
|
358
|
+
against the resolved source so the check also catches a catalog alias that
|
|
359
|
+
points at the repository root.
|
|
360
|
+
|
|
361
|
+
The one safe equality is an existing real directory in the shared managed
|
|
362
|
+
projection. That directory is already in its final location and the
|
|
363
|
+
caller preserves it instead of creating a symlink.
|
|
364
|
+
"""
|
|
365
|
+
destination = Path(destination).absolute()
|
|
366
|
+
source = Path(source).resolve(strict=True)
|
|
367
|
+
if destination == source and is_real_directory(destination):
|
|
368
|
+
return
|
|
369
|
+
try:
|
|
370
|
+
destination.relative_to(source)
|
|
371
|
+
except ValueError:
|
|
372
|
+
return
|
|
373
|
+
raise ValueError(
|
|
374
|
+
"Refusing recursive skill symlink: destination "
|
|
375
|
+
f"{destination} is inside its resolved source {source}"
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def assert_nonrecursive_skill_topology(active_cli_dirs, skill_sources):
|
|
380
|
+
"""Validate every proposed source/destination pair before any mutation."""
|
|
381
|
+
for _cli_dir, expected_cli in active_cli_dirs:
|
|
382
|
+
for name, source in skill_sources.items():
|
|
383
|
+
assert_nonrecursive_skill_link(expected_cli / validate_skill_name(name), source)
|
|
384
|
+
|
|
385
|
+
|
|
190
386
|
def preflight_cli_dirs(
|
|
191
387
|
cli_dirs_base,
|
|
192
388
|
skill_names,
|
|
@@ -232,17 +428,35 @@ def preflight_cli_dirs(
|
|
|
232
428
|
canonical_alias = lexical_symlink_target(cli_dir) == managed_skills
|
|
233
429
|
if not canonical_alias:
|
|
234
430
|
raise ValueError(f"Refusing symlinked CLI skills directory: {cli_dir}")
|
|
235
|
-
assert_real_directory_chain(base, managed_skills)
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
431
|
+
assert_real_directory_chain(base, managed_skills.parent)
|
|
432
|
+
# In the canonical Skillex topology the projection directory IS the
|
|
433
|
+
# registry checkout, reached through a symlink
|
|
434
|
+
# (`~/.agents/skills -> ~/code/skillex/skill-sets/global`), so
|
|
435
|
+
# demanding a real directory here made `--scope global` unrunnable on
|
|
436
|
+
# the only machine that has the topology. Admit exactly that shape
|
|
437
|
+
# through resolve_managed_alias() and nothing else.
|
|
438
|
+
if managed_skills.is_symlink():
|
|
439
|
+
if scope != "global":
|
|
440
|
+
# A generated PROJECT always owns a real `.agents/skills` --
|
|
441
|
+
# pjangler's TypeScript mirror (projectSkillTopologyIssues in
|
|
442
|
+
# src/parity/rules.ts) says so too. Relaxing only the global
|
|
443
|
+
# scope keeps every project destination on the strict rule.
|
|
444
|
+
raise ValueError(
|
|
445
|
+
f"Managed skills alias target is not a real directory: {managed_skills}"
|
|
446
|
+
)
|
|
447
|
+
resolved_alias = resolve_managed_alias(managed_skills, canonical_alias_roots())
|
|
448
|
+
else:
|
|
449
|
+
if (
|
|
450
|
+
not managed_skills.exists()
|
|
451
|
+
or not managed_skills.is_dir()
|
|
452
|
+
):
|
|
453
|
+
raise ValueError(
|
|
454
|
+
f"Managed skills alias target is not a real directory: {managed_skills}"
|
|
455
|
+
)
|
|
456
|
+
assert_real_directory_chain(base, managed_skills)
|
|
457
|
+
resolved_alias = managed_skills.resolve(strict=True)
|
|
244
458
|
resolved_cli = cli_dir.resolve(strict=True)
|
|
245
|
-
if resolved_cli !=
|
|
459
|
+
if resolved_cli != resolved_alias:
|
|
246
460
|
raise ValueError(
|
|
247
461
|
f"Managed skills alias escapes managed project skills: {cli_dir}"
|
|
248
462
|
)
|
|
@@ -251,7 +465,12 @@ def preflight_cli_dirs(
|
|
|
251
465
|
# instead would silently drop every `skills[]` entry on a project
|
|
252
466
|
# where every supported CLI is aliased -- `provision-packs.py`
|
|
253
467
|
# only ever materializes pack members.
|
|
254
|
-
|
|
468
|
+
# Pin the RESOLVED directory as the expected destination. Every later
|
|
469
|
+
# mutation then addresses the real path directly instead of
|
|
470
|
+
# re-traversing the alias, so re-pointing the alias mid-run cannot
|
|
471
|
+
# redirect a write; revalidate_cli_dir() re-checks it still resolves
|
|
472
|
+
# here before each one.
|
|
473
|
+
managed_expected = resolved_alias
|
|
255
474
|
# Containment first, so an escaping name is reported as an escape
|
|
256
475
|
# rather than as whatever it happens to collide with.
|
|
257
476
|
assert_destinations_contained(managed_expected)
|
|
@@ -259,7 +478,7 @@ def preflight_cli_dirs(
|
|
|
259
478
|
# real, hand-authored skill directories. Never let the fanout
|
|
260
479
|
# rmtree one of those; fail here, before anything is mutated.
|
|
261
480
|
for name in skill_names:
|
|
262
|
-
destination =
|
|
481
|
+
destination = resolved_alias / name
|
|
263
482
|
if not is_real_directory(destination):
|
|
264
483
|
continue
|
|
265
484
|
source = skill_sources.get(name) if skill_sources is not None else None
|
|
@@ -284,6 +503,8 @@ def preflight_cli_dirs(
|
|
|
284
503
|
if cli_dir.exists() and cli_dir.resolve(strict=True) != expected_cli:
|
|
285
504
|
raise ValueError(f"CLI skills directory escapes its parent: {cli_dir}")
|
|
286
505
|
add_target(cli_dir, expected_cli)
|
|
506
|
+
if skill_sources is not None:
|
|
507
|
+
assert_nonrecursive_skill_topology(active, skill_sources)
|
|
287
508
|
return active
|
|
288
509
|
|
|
289
510
|
|
|
@@ -293,11 +514,22 @@ def revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli):
|
|
|
293
514
|
assert_real_directory_chain(base, cli_dir.parent)
|
|
294
515
|
if cli_dir.parent.is_symlink() or not cli_dir.parent.is_dir():
|
|
295
516
|
raise ValueError(f"Unsafe CLI destination parent after preflight: {cli_dir.parent}")
|
|
517
|
+
if cli_dir.is_symlink():
|
|
518
|
+
# Exactly ONE symlinked destination is admissible -- the canonical
|
|
519
|
+
# managed-skills alias -- and only while it still resolves to the very
|
|
520
|
+
# directory preflight pinned as `expected_cli`. `expected_cli != cli_dir`
|
|
521
|
+
# is what tells the global alias (pinned to its RESOLVED target) apart
|
|
522
|
+
# from a project's real `.agents/skills`, where the two are the same
|
|
523
|
+
# path; a project destination that turned into a symlink after preflight
|
|
524
|
+
# therefore still fails here.
|
|
525
|
+
if cli_dir != managed_skills_dir(base) or expected_cli == cli_dir:
|
|
526
|
+
raise ValueError(f"CLI skills directory changed to a symlink after preflight: {cli_dir}")
|
|
527
|
+
if resolve_managed_alias(cli_dir, canonical_alias_roots()) != expected_cli:
|
|
528
|
+
raise ValueError(f"Managed skills alias re-pointed after preflight: {cli_dir}")
|
|
529
|
+
return
|
|
296
530
|
current_expected = cli_dir.parent.resolve(strict=True) / cli_dir.name
|
|
297
531
|
if current_expected != expected_cli:
|
|
298
532
|
raise ValueError(f"CLI destination parent changed after preflight: {cli_dir}")
|
|
299
|
-
if cli_dir.is_symlink():
|
|
300
|
-
raise ValueError(f"CLI skills directory changed to a symlink after preflight: {cli_dir}")
|
|
301
533
|
if cli_dir.exists():
|
|
302
534
|
if not cli_dir.is_dir() or cli_dir.resolve(strict=True) != expected_cli:
|
|
303
535
|
raise ValueError(f"Unsafe CLI skills directory after preflight: {cli_dir}")
|
|
@@ -776,7 +1008,7 @@ def select_pack_version(pack_dir):
|
|
|
776
1008
|
The discriminator is what those children ARE: a child holding a regular
|
|
777
1009
|
SKILL.md is a skill, so its parent cannot be a version root. Contrast
|
|
778
1010
|
`packs/bmad/`, also pack.toml-less and also all real directories, but whose
|
|
779
|
-
children (
|
|
1011
|
+
children (1.2.0-next.3/, 1.3.0/) hold no top-level SKILL.md -- that IS a
|
|
780
1012
|
version layout and the highest version is selected.
|
|
781
1013
|
|
|
782
1014
|
`packs/Kurzgesagt/` is NOT an example of this: its twelve children are all
|
|
@@ -1330,7 +1562,7 @@ def resolve_pack(
|
|
|
1330
1562
|
|
|
1331
1563
|
{"name", "root", "family_root", "declared"}
|
|
1332
1564
|
|
|
1333
|
-
`root` is the exact pack root (e.g. `packs/bmad/
|
|
1565
|
+
`root` is the exact pack root (e.g. `packs/bmad/1.3.0`); `family_root` is
|
|
1334
1566
|
`packs/bmad` when the pack lives under a version directory, else None. The
|
|
1335
1567
|
two are reported SEPARATELY on purpose: a sibling version under the same
|
|
1336
1568
|
family root is NOT this pack, so callers must never treat the family root
|
|
@@ -1526,12 +1758,81 @@ def handle_retired_dirs(cli_dirs_base, managed_roots, prune=False):
|
|
|
1526
1758
|
# --------------------------------------------------------------------------- #
|
|
1527
1759
|
|
|
1528
1760
|
|
|
1761
|
+
def reconcile_projection(real_cli_dir, skills_map, managed_roots, project_root=None, apply=True):
|
|
1762
|
+
"""Remove managed projection links this sync does not account for.
|
|
1763
|
+
|
|
1764
|
+
`fanout_to_cli` only ever added and overwrote. Nothing removed a link, so a
|
|
1765
|
+
projection was a monotonically growing record of every skill the repo ever
|
|
1766
|
+
declared: rename a skill, retire a pack, or move a registry cache and the
|
|
1767
|
+
old link stayed forever, dangling. Measured on the reporting machine: 81 of
|
|
1768
|
+
pjangler's 132 `.claude/skills` links pointed at BMAD pack versions that no
|
|
1769
|
+
longer exist, and a hand-planted broken link -- including one whose name IS
|
|
1770
|
+
a declared entry -- survived a re-run untouched.
|
|
1771
|
+
|
|
1772
|
+
Deliberately narrow. Only a SYMLINK is ever removed, and only when its
|
|
1773
|
+
LEXICAL target (never a resolved one -- resolving a hostile link would walk
|
|
1774
|
+
outside the project first) lies inside a managed registry root. So:
|
|
1775
|
+
|
|
1776
|
+
- a real directory is never touched: that is BMAD's installer output in
|
|
1777
|
+
`<cli>/skills`, or a hand-authored skill in `.agents/skills`;
|
|
1778
|
+
- a link pointing anywhere outside the managed roots is the operator's
|
|
1779
|
+
own and is left alone;
|
|
1780
|
+
- a pack member survives, because `skills_map` already contains every
|
|
1781
|
+
name the declared packs resolved to before fanout runs.
|
|
1782
|
+
"""
|
|
1783
|
+
removed = []
|
|
1784
|
+
try:
|
|
1785
|
+
entries = sorted(os.listdir(real_cli_dir))
|
|
1786
|
+
except OSError:
|
|
1787
|
+
return removed
|
|
1788
|
+
for name in entries:
|
|
1789
|
+
if name in skills_map:
|
|
1790
|
+
continue
|
|
1791
|
+
candidate = real_cli_dir / name
|
|
1792
|
+
if not candidate.is_symlink():
|
|
1793
|
+
continue
|
|
1794
|
+
try:
|
|
1795
|
+
target = lexical_symlink_target(candidate)
|
|
1796
|
+
except OSError:
|
|
1797
|
+
continue
|
|
1798
|
+
dangling = not candidate.exists()
|
|
1799
|
+
# A DANGLING link is removed wherever it points. It names a skill and
|
|
1800
|
+
# resolves to nothing, so it cannot be serving anyone, and the managed-root
|
|
1801
|
+
# test would miss exactly the ones that hurt most: the relics of a retired
|
|
1802
|
+
# intermediate hop. `<repo>/.claude/skills/hindsight ->
|
|
1803
|
+
# 33GOD/skills/hindsight` outlived that farm entry and is not inside any
|
|
1804
|
+
# registry root, so it would have rotted here forever. A link that still
|
|
1805
|
+
# resolves is only removed inside a managed root, where this engine is the
|
|
1806
|
+
# only writer.
|
|
1807
|
+
# A live link is removable inside a managed registry root -- and inside the
|
|
1808
|
+
# PROJECT ROOT itself, which is equally this engine's territory. Seven
|
|
1809
|
+
# links in pjangler pointed at `<repo>/skills/<name>` for names the global
|
|
1810
|
+
# scope already provides: leftovers from the era when inherit_global
|
|
1811
|
+
# materialized the whole global manifest into every project. They resolve,
|
|
1812
|
+
# so the managed-root test left them, and they are precisely the duplicate
|
|
1813
|
+
# copy this engine now exists to stop making. A repo's own skill is not
|
|
1814
|
+
# caught by this: discovery puts it in skills_map, so it is declared.
|
|
1815
|
+
own_territory = is_inside_managed_root(target, managed_roots)
|
|
1816
|
+
if not own_territory and project_root is not None:
|
|
1817
|
+
own_territory = is_inside_managed_root(target, {Path(project_root).resolve()})
|
|
1818
|
+
if not dangling and not own_territory:
|
|
1819
|
+
continue
|
|
1820
|
+
state = "dangling" if dangling else "undeclared"
|
|
1821
|
+
if apply:
|
|
1822
|
+
candidate.unlink()
|
|
1823
|
+
removed.append((candidate, target, state))
|
|
1824
|
+
print(f"{'✗' if apply else 'would remove'} {candidate} ({state} -> {target})")
|
|
1825
|
+
return removed
|
|
1826
|
+
|
|
1529
1827
|
def fanout_to_cli(
|
|
1530
1828
|
cli_dirs_base,
|
|
1531
1829
|
skills_map,
|
|
1532
1830
|
active_cli_dirs=None,
|
|
1533
1831
|
before_mutation=None,
|
|
1534
1832
|
scope="project",
|
|
1833
|
+
managed_roots=None,
|
|
1834
|
+
reconcile=True,
|
|
1835
|
+
reconcile_apply=True,
|
|
1535
1836
|
):
|
|
1536
1837
|
"""
|
|
1537
1838
|
Creates symlinks in each of the supported CLI skill dirs relative to
|
|
@@ -1539,7 +1840,12 @@ def fanout_to_cli(
|
|
|
1539
1840
|
"""
|
|
1540
1841
|
skill_names = [validate_skill_name(name) for name in skills_map]
|
|
1541
1842
|
if active_cli_dirs is None:
|
|
1542
|
-
active_cli_dirs = preflight_cli_dirs(
|
|
1843
|
+
active_cli_dirs = preflight_cli_dirs(
|
|
1844
|
+
cli_dirs_base,
|
|
1845
|
+
skill_names,
|
|
1846
|
+
scope,
|
|
1847
|
+
skill_sources=skills_map,
|
|
1848
|
+
)
|
|
1543
1849
|
if skill_names and not active_cli_dirs:
|
|
1544
1850
|
# A sync that resolves skills but has nowhere to put them has FAILED.
|
|
1545
1851
|
# Reporting success here is how a topology change silently unprojects
|
|
@@ -1564,6 +1870,7 @@ def fanout_to_cli(
|
|
|
1564
1870
|
symlink_target = real_cli_dir / name
|
|
1565
1871
|
if symlink_target.parent != real_cli_dir:
|
|
1566
1872
|
raise ValueError(f"Skill destination escapes CLI directory: {symlink_target}")
|
|
1873
|
+
assert_nonrecursive_skill_link(symlink_target, actual_path)
|
|
1567
1874
|
|
|
1568
1875
|
# A project-local source may already be the exact destination in
|
|
1569
1876
|
# the shared `.agents/skills` projection. Preserve that real
|
|
@@ -1591,16 +1898,103 @@ def fanout_to_cli(
|
|
|
1591
1898
|
symlink_target.unlink()
|
|
1592
1899
|
|
|
1593
1900
|
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
1901
|
+
assert_nonrecursive_skill_link(symlink_target, actual_path)
|
|
1594
1902
|
os.symlink(actual_path, symlink_target)
|
|
1595
1903
|
linked_total += 1
|
|
1596
1904
|
print(f"→ {symlink_target} -> {actual_path}")
|
|
1597
1905
|
|
|
1906
|
+
removed_total = 0
|
|
1907
|
+
if reconcile and managed_roots:
|
|
1908
|
+
for cli_dir, expected_cli in active_cli_dirs:
|
|
1909
|
+
revalidate_cli_dir(cli_dirs_base, cli_dir, expected_cli)
|
|
1910
|
+
removed_total += len(
|
|
1911
|
+
reconcile_projection(
|
|
1912
|
+
expected_cli.resolve(strict=True),
|
|
1913
|
+
skills_map,
|
|
1914
|
+
managed_roots,
|
|
1915
|
+
# `cli_dirs_base` is the REPO at project scope -- and $HOME at
|
|
1916
|
+
# global scope, where "inside the project root" would mean
|
|
1917
|
+
# "anywhere under $HOME", i.e. every skill source on the
|
|
1918
|
+
# machine. That is not a narrow rule, it is no rule: the
|
|
1919
|
+
# global farm would reclaim every undeclared link it holds.
|
|
1920
|
+
# Global scope keeps the managed-root test only.
|
|
1921
|
+
project_root=None if scope == "global" else cli_dirs_base,
|
|
1922
|
+
apply=reconcile_apply,
|
|
1923
|
+
)
|
|
1924
|
+
)
|
|
1925
|
+
|
|
1598
1926
|
print(
|
|
1599
|
-
f"sync-skills: {linked_total} new/updated symlink(s) "
|
|
1600
|
-
f"
|
|
1927
|
+
f"sync-skills: {linked_total} new/updated symlink(s), "
|
|
1928
|
+
f"{removed_total} stale link(s) "
|
|
1929
|
+
+ ("removed " if reconcile_apply else "to remove (dry run) ")
|
|
1930
|
+
+ f"across CLIs in {cli_dirs_base}"
|
|
1601
1931
|
)
|
|
1602
1932
|
|
|
1603
1933
|
|
|
1934
|
+
def report_global_inheritance(global_manifest_path):
|
|
1935
|
+
"""Verify that the user scope is reachable; project NOTHING from it.
|
|
1936
|
+
|
|
1937
|
+
`inherit_global: true` used to prepend the whole global manifest as layer 0
|
|
1938
|
+
of a PROJECT run, so every global skill was materialized as a fresh symlink
|
|
1939
|
+
inside every project CLI skill dir -- 38 resolvable global skills x2 present
|
|
1940
|
+
CLI dirs = 76 links per project, re-created on every `cd`.
|
|
1941
|
+
|
|
1942
|
+
That work is dead. Every agent CLI installed on a machine like this one
|
|
1943
|
+
reads the user scope implicitly, and each of its global skill roots is
|
|
1944
|
+
already a single dir-level symlink to ~/.agents/skills:
|
|
1945
|
+
|
|
1946
|
+
~/.claude/skills ~/.codex/skills ~/.gemini/skills ~/.copilot/skills
|
|
1947
|
+
~/.kimi-code/skills ~/.config/opencode/skills ~/.openclaw/skills
|
|
1948
|
+
|
|
1949
|
+
One projection, N aliases -- so a global skill is visible in a project
|
|
1950
|
+
because the CLI inherits the user scope, not because someone copied a link
|
|
1951
|
+
into the repo. A project projection holds only that repo's own declared
|
|
1952
|
+
skills and its declared packs.
|
|
1953
|
+
|
|
1954
|
+
What is left here is the check: if the user-scope alias is missing or points
|
|
1955
|
+
somewhere else, global skills are NOT reachable and the operator should hear
|
|
1956
|
+
about it rather than have this script quietly paper over it with copies.
|
|
1957
|
+
"""
|
|
1958
|
+
home = Path(os.path.expanduser("~"))
|
|
1959
|
+
managed = managed_global_skills_dir(home)
|
|
1960
|
+
reachable, broken = [], []
|
|
1961
|
+
for cli_rel_path in cli_skill_dirs("global"):
|
|
1962
|
+
alias = home / cli_rel_path
|
|
1963
|
+
if not alias.exists() and not alias.is_symlink():
|
|
1964
|
+
continue
|
|
1965
|
+
try:
|
|
1966
|
+
if alias.resolve(strict=True) == managed.resolve(strict=True):
|
|
1967
|
+
reachable.append(cli_rel_path)
|
|
1968
|
+
continue
|
|
1969
|
+
except OSError:
|
|
1970
|
+
pass
|
|
1971
|
+
broken.append(cli_rel_path)
|
|
1972
|
+
# This runs from an enter hook on every `cd`, so say nothing when the user
|
|
1973
|
+
# scope is intact. Report only what an operator has to act on.
|
|
1974
|
+
if not broken and reachable:
|
|
1975
|
+
return
|
|
1976
|
+
declared = len(load_manifest(global_manifest_path).get("skills", []))
|
|
1977
|
+
for cli_rel_path in broken:
|
|
1978
|
+
print(
|
|
1979
|
+
f"Warning: ~/{cli_rel_path} does not resolve to {managed}; global "
|
|
1980
|
+
f"skills are not reachable for that CLI. Fix the alias "
|
|
1981
|
+
f"(ln -sfn {managed} ~/{cli_rel_path}); do not copy links into "
|
|
1982
|
+
f"projects.",
|
|
1983
|
+
file=sys.stderr,
|
|
1984
|
+
)
|
|
1985
|
+
if not reachable:
|
|
1986
|
+
print(
|
|
1987
|
+
f"Warning: no user-scope alias of {managed} was found, so none of "
|
|
1988
|
+
f"the {declared} global skill(s) declared in {global_manifest_path} "
|
|
1989
|
+
f"are reachable from any CLI.",
|
|
1990
|
+
file=sys.stderr,
|
|
1991
|
+
)
|
|
1992
|
+
|
|
1993
|
+
|
|
1994
|
+
def managed_global_skills_dir(home):
|
|
1995
|
+
"""The single user-scope projection every global CLI root aliases."""
|
|
1996
|
+
return home.joinpath(*MANAGED_SKILLS_RELATIVE)
|
|
1997
|
+
|
|
1604
1998
|
def manifest_layer(manifest_path):
|
|
1605
1999
|
manifest = load_manifest(manifest_path)
|
|
1606
2000
|
validate_manifest_skill_names(manifest)
|
|
@@ -1613,30 +2007,88 @@ def manifest_layer(manifest_path):
|
|
|
1613
2007
|
}
|
|
1614
2008
|
|
|
1615
2009
|
|
|
2010
|
+
def repo_local_skill_layer(project_root, inherited_names):
|
|
2011
|
+
"""Project `<repo>/skills/<name>/SKILL.md` without anyone declaring it.
|
|
2012
|
+
|
|
2013
|
+
"Repo-specific skills live in the repo" is only true if the repo's own
|
|
2014
|
+
skills reach an agent working in it. They did not: pjangler authored
|
|
2015
|
+
`pjangler-dev` and `pjangler-parity-rules` and projected neither into any CLI
|
|
2016
|
+
directory at any scope, so the skills describing how to develop pjangler were
|
|
2017
|
+
invisible to every agent developing pjangler.
|
|
2018
|
+
|
|
2019
|
+
Discovered rather than declared, on purpose. `.agents/skills.json` is
|
|
2020
|
+
generated and gitignored in these repos, so a hand-written entry there does
|
|
2021
|
+
not survive a fresh clone; and a declaration that merely restates the
|
|
2022
|
+
contents of a directory is a second copy of the truth that can drift from the
|
|
2023
|
+
first. A directory holding a SKILL.md IS the declaration.
|
|
2024
|
+
|
|
2025
|
+
A name the GLOBAL scope already provides is skipped: every CLI inherits the
|
|
2026
|
+
user scope implicitly, so projecting it here would be the copy this engine
|
|
2027
|
+
exists to stop making. That is why pjangler gets exactly its two repo-only
|
|
2028
|
+
skills and not the seven it authors for machine-wide use.
|
|
2029
|
+
|
|
2030
|
+
Lowest precedence. A declared pack or an explicit skills[] entry of the same
|
|
2031
|
+
name still wins, because explicit beats discovered.
|
|
2032
|
+
"""
|
|
2033
|
+
skills_root = project_root / "skills"
|
|
2034
|
+
if not skills_root.is_dir() or skills_root.is_symlink():
|
|
2035
|
+
return None
|
|
2036
|
+
entries = []
|
|
2037
|
+
for child in sorted(skills_root.iterdir()):
|
|
2038
|
+
if child.name.startswith("."):
|
|
2039
|
+
continue
|
|
2040
|
+
if child.is_symlink() or not child.is_dir():
|
|
2041
|
+
continue
|
|
2042
|
+
if not (child / "SKILL.md").is_file():
|
|
2043
|
+
continue
|
|
2044
|
+
name = validate_skill_name(child.name)
|
|
2045
|
+
if name in inherited_names:
|
|
2046
|
+
continue
|
|
2047
|
+
entries.append({"name": name, "source": child.resolve(strict=True).as_uri()})
|
|
2048
|
+
if not entries:
|
|
2049
|
+
return None
|
|
2050
|
+
print(f"Discovered {len(entries)} repo-local skill(s) in {skills_root}")
|
|
2051
|
+
return {
|
|
2052
|
+
"manifest": {"skills": entries},
|
|
2053
|
+
"packs": [],
|
|
2054
|
+
"base_dir": project_root,
|
|
2055
|
+
"registry": DEFAULT_REGISTRY,
|
|
2056
|
+
}
|
|
2057
|
+
|
|
1616
2058
|
def main():
|
|
1617
2059
|
args = parse_args()
|
|
1618
2060
|
|
|
2061
|
+
project_root = resolved_project_root(args)
|
|
1619
2062
|
global_manifest_path = Path(os.path.expanduser("~/.agents/skills.json"))
|
|
1620
|
-
project_manifest_path =
|
|
2063
|
+
project_manifest_path = project_root / ".agents" / "skills.json"
|
|
1621
2064
|
|
|
1622
2065
|
# Destination topology is a security boundary. Validate every active CLI
|
|
1623
2066
|
# directory before cloning/updating registries, creating caches, or changing
|
|
1624
2067
|
# any skill link so one unsafe/broken symlink produces zero mutation.
|
|
1625
2068
|
#
|
|
1626
2069
|
# Precedence, lowest to highest (contract section 5):
|
|
1627
|
-
# global packs[] -> global skills[] ->
|
|
2070
|
+
# global packs[] -> global skills[] -> <repo>/skills discovery
|
|
2071
|
+
# -> project packs[] -> project skills[]
|
|
2072
|
+
# Discovery sits below the project manifest: explicit beats discovered.
|
|
1628
2073
|
layers = []
|
|
1629
2074
|
if args.scope == "global":
|
|
1630
2075
|
preflight_base = Path(os.path.expanduser("~"))
|
|
1631
2076
|
print(f"Loading global manifest from {global_manifest_path}")
|
|
1632
2077
|
layers.append(manifest_layer(global_manifest_path))
|
|
1633
2078
|
else:
|
|
1634
|
-
preflight_base =
|
|
2079
|
+
preflight_base = project_root
|
|
1635
2080
|
print(f"Loading project manifest from {project_manifest_path}")
|
|
1636
2081
|
project_layer = manifest_layer(project_manifest_path)
|
|
2082
|
+
inherited_names = set()
|
|
1637
2083
|
if project_layer["manifest"].get("inherit_global", False):
|
|
1638
|
-
|
|
1639
|
-
|
|
2084
|
+
report_global_inheritance(global_manifest_path)
|
|
2085
|
+
inherited_names = {
|
|
2086
|
+
manifest_skill_name(skill)
|
|
2087
|
+
for skill in load_manifest(global_manifest_path).get("skills", [])
|
|
2088
|
+
}
|
|
2089
|
+
discovered = repo_local_skill_layer(project_root, inherited_names)
|
|
2090
|
+
if discovered is not None:
|
|
2091
|
+
layers.append(discovered)
|
|
1640
2092
|
layers.append(project_layer)
|
|
1641
2093
|
|
|
1642
2094
|
preflight_names = []
|
|
@@ -1719,6 +2171,9 @@ def main():
|
|
|
1719
2171
|
skills_to_sync,
|
|
1720
2172
|
active_cli_dirs=active_cli_dirs,
|
|
1721
2173
|
scope=args.scope,
|
|
2174
|
+
managed_roots=managed_roots,
|
|
2175
|
+
reconcile=not args.no_reconcile,
|
|
2176
|
+
reconcile_apply=not args.reconcile_dry_run,
|
|
1722
2177
|
)
|
|
1723
2178
|
|
|
1724
2179
|
handle_retired_dirs(preflight_base, managed_roots, prune=args.prune_retired)
|