@delorenj/pjangler 1.3.7 → 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.
Files changed (27) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +20 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +14320 -7847
  16. package/dist/mcp-server.js +13482 -8146
  17. package/package.json +4 -3
  18. package/templates/commonproject/copier.yml +14 -5
  19. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  20. package/templates/commonproject/template/.mise/scripts/provision-packs.py +60 -2
  21. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  22. package/templates/commonproject/template/mise.toml.jinja +12 -6
  23. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  24. package/templates/hermes-agent/template/.scripts/70-systemd.sh +11 -7
  25. package/templates/hermes-agent/template/.scripts/_lib.sh +54 -10
  26. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +29 -2
  27. package/templates/hermes-agent/template/.scripts/providers/plane.sh +18 -2
@@ -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
- if (
237
- not managed_skills.exists()
238
- or managed_skills.is_symlink()
239
- or not managed_skills.is_dir()
240
- ):
241
- raise ValueError(
242
- f"Managed skills alias target is not a real directory: {managed_skills}"
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 != managed_skills.resolve(strict=True):
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
- managed_expected = managed_skills.parent.resolve(strict=True) / managed_skills.name
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 = managed_skills / name
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 (6.10.1-next.31/, 6.10.2/) hold no top-level SKILL.md -- that IS a
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/6.10.2`); `family_root` is
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(cli_dirs_base, skill_names, scope)
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"across CLIs in {cli_dirs_base}"
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 = Path(os.getcwd()) / ".agents" / "skills.json"
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[] -> project packs[] -> project 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 = Path(os.getcwd())
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
- print("Inheriting global skills...")
1639
- layers.append(manifest_layer(global_manifest_path))
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)