@event4u/agent-config 2.2.2 → 2.4.0

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 (68) hide show
  1. package/.agent-src/commands/onboard.md +14 -9
  2. package/.agent-src/rules/external-reference-deep-dive.md +69 -0
  3. package/.agent-src/skills/ai-council/SKILL.md +5 -3
  4. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  5. package/.agent-src/templates/agents/agent-project-settings.example.yml +4 -3
  6. package/.agent-src/templates/copilot-instructions.md +7 -0
  7. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +29 -7
  8. package/.agent-src/templates/scripts/work_engine/_lib/user_global_paths.py +249 -0
  9. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +8 -5
  10. package/.claude-plugin/marketplace.json +27 -1
  11. package/CHANGELOG.md +79 -0
  12. package/README.md +1 -8
  13. package/config/agent-settings.template.yml +5 -3
  14. package/docs/architecture.md +1 -1
  15. package/docs/catalog.md +5 -3
  16. package/docs/contracts/installed-tools-lockfile.md +142 -0
  17. package/docs/customization.md +23 -17
  18. package/docs/decisions/ADR-007-agent-discovery-scopes.md +6 -0
  19. package/docs/decisions/ADR-009-event4u-namespace.md +188 -0
  20. package/docs/decisions/INDEX.md +1 -0
  21. package/docs/development.md +37 -0
  22. package/docs/getting-started.md +1 -1
  23. package/docs/guidelines/agent-infra/installed-tools-manifest.md +1 -1
  24. package/docs/guidelines/agent-infra/layered-settings.md +6 -4
  25. package/docs/installation.md +17 -2
  26. package/docs/migration/v1-to-v2.md +45 -0
  27. package/docs/setup/per-ide/antigravity.md +63 -0
  28. package/docs/setup/per-ide/augment.md +77 -0
  29. package/docs/setup/per-ide/claude-desktop.md +107 -65
  30. package/docs/setup/per-ide/codebuddy.md +63 -0
  31. package/docs/setup/per-ide/continue.md +68 -0
  32. package/docs/setup/per-ide/droid.md +65 -0
  33. package/docs/setup/per-ide/jetbrains.md +76 -0
  34. package/docs/setup/per-ide/kilocode.md +66 -0
  35. package/docs/setup/per-ide/kiro.md +72 -0
  36. package/docs/setup/per-ide/opencode.md +62 -0
  37. package/docs/setup/per-ide/qoder.md +63 -0
  38. package/docs/setup/per-ide/roocode.md +68 -0
  39. package/docs/setup/per-ide/trae.md +63 -0
  40. package/docs/setup/per-ide/warp.md +63 -0
  41. package/docs/setup/per-ide/zed.md +73 -0
  42. package/package.json +1 -1
  43. package/scripts/_cli/cmd_doctor.py +351 -0
  44. package/scripts/_cli/cmd_prune.py +317 -0
  45. package/scripts/_cli/cmd_uninstall.py +465 -0
  46. package/scripts/_cli/cmd_update.py +30 -4
  47. package/scripts/_cli/cmd_versions.py +147 -0
  48. package/scripts/_lib/agent_settings.py +29 -7
  49. package/scripts/_lib/agents_overlay.py +15 -4
  50. package/scripts/_lib/claude_desktop_bundler.py +150 -0
  51. package/scripts/_lib/fs_atomic.py +116 -0
  52. package/scripts/_lib/installed_lock.py +37 -4
  53. package/scripts/_lib/installed_tools.py +189 -45
  54. package/scripts/_lib/json_pointers.py +260 -0
  55. package/scripts/_lib/update_check.py +29 -5
  56. package/scripts/_lib/user_global_paths.py +249 -0
  57. package/scripts/agent-config +69 -0
  58. package/scripts/ai_council/__init__.py +4 -3
  59. package/scripts/ai_council/budget_guard.py +34 -4
  60. package/scripts/ai_council/bundler.py +2 -0
  61. package/scripts/ai_council/clients.py +28 -7
  62. package/scripts/compress.py +78 -15
  63. package/scripts/install +8 -0
  64. package/scripts/install-hooks.sh +54 -1
  65. package/scripts/install.py +1149 -53
  66. package/scripts/install_anthropic_key.sh +5 -3
  67. package/scripts/install_openai_key.sh +5 -3
  68. package/scripts/skill_trigger_eval.py +13 -2
@@ -26,18 +26,32 @@ from __future__ import annotations
26
26
 
27
27
  import argparse
28
28
  import copy
29
+ import hashlib
29
30
  import json
30
31
  import os
31
32
  import re
32
33
  import shlex
34
+ import shutil
33
35
  import subprocess
34
36
  import sys
35
37
  from pathlib import Path
38
+ from typing import Any, Optional
39
+
40
+ try:
41
+ from scripts._lib.json_pointers import build_merge_entries # noqa: PLC0415
42
+ except ImportError: # pragma: no cover — alt sys.path layout
43
+ from _lib.json_pointers import build_merge_entries # type: ignore[no-redef] # noqa: PLC0415
36
44
 
37
45
  DEFAULT_PROFILE = "minimal"
38
46
  SUPPORTED_PROFILES = ("minimal", "balanced", "full")
39
47
  COST_PROFILE_PLACEHOLDER = "__COST_PROFILE__"
40
48
 
49
+ # Env-var equivalent of --force for CI / scripted installs (P3.4).
50
+ # When set to "1" the install run treats every conflict as
51
+ # force-overwrite; never enabled by default to keep destructive writes
52
+ # explicit.
53
+ ALLOW_OVERWRITE_ENV = "AGENT_CONFIG_ALLOW_OVERWRITE"
54
+
41
55
  SETTINGS_FILE = ".agent-settings.yml"
42
56
  LEGACY_SETTINGS_FILE = ".agent-settings"
43
57
  LEGACY_BACKUP_FILE = ".agent-settings.backup.key-value"
@@ -128,6 +142,224 @@ def detect_package_type_for_project(project_root: Path, package_root: Path) -> s
128
142
  return detect_package_type(package_root)
129
143
 
130
144
 
145
+ # --- Conflict detection (P3.1 / P3.3) ---
146
+
147
+ class ConflictAbort(SystemExit):
148
+ """Raised when a conflict resolution chose 'abort'.
149
+
150
+ Inherits ``SystemExit`` so an unhandled abort terminates the
151
+ install with a non-zero exit code without an opaque traceback.
152
+ """
153
+
154
+ def __init__(self, message: str):
155
+ super().__init__(1)
156
+ self.message = message
157
+
158
+
159
+ class ConflictPolicy:
160
+ """Per-install conflict resolution policy (P3.1).
161
+
162
+ Aggregates the inputs the resolver needs to decide whether to
163
+ overwrite a target that exists on disk:
164
+
165
+ * ``force`` — true when ``--force`` was passed OR the
166
+ ``AGENT_CONFIG_ALLOW_OVERWRITE=1`` env-var is set (P3.4).
167
+ * ``interactive`` — true when stdin AND stdout are TTYs; the
168
+ only context where the 3-option prompt is meaningful.
169
+ * ``known_paths`` — absolute path strings recorded as ours by
170
+ the project-scope manifest (``files[]`` entries). A target at a
171
+ known path is **not** a foreign collision — we own it and the
172
+ existing skip/force behaviour applies.
173
+ * ``known_pointers``— ``(file_label, json_pointer)`` pairs we
174
+ previously merged into shared JSON files (P3.3). A pointer in
175
+ this set is ours; one not in it that exists in the target is a
176
+ foreign merge collision.
177
+ """
178
+
179
+ __slots__ = ("force", "interactive", "known_paths", "known_pointers")
180
+
181
+ def __init__(
182
+ self,
183
+ *,
184
+ force: bool,
185
+ interactive: bool,
186
+ known_paths: set[str],
187
+ known_pointers: set[tuple[str, str]],
188
+ ) -> None:
189
+ self.force = force
190
+ self.interactive = interactive
191
+ self.known_paths = known_paths
192
+ self.known_pointers = known_pointers
193
+
194
+
195
+ # Module-level singleton: configured once in main() (after --force +
196
+ # env-var resolution), consulted by every writer. When ``None`` the
197
+ # install runs in **legacy mode**: writers honor their local ``force``
198
+ # flag and skip-otherwise, no foreign-pointer detection. Set only by
199
+ # :func:`main` after loading the manifest so test callers that exercise
200
+ # writers directly keep the pre-P3 contract.
201
+ _CONFLICT_POLICY: Optional[ConflictPolicy] = None
202
+
203
+
204
+ def _conflict_policy_active() -> bool:
205
+ return _CONFLICT_POLICY is not None
206
+
207
+
208
+ def _get_conflict_policy() -> ConflictPolicy:
209
+ if _CONFLICT_POLICY is None:
210
+ # Legacy-mode fallback: no manifest loaded, no foreign detection
211
+ # surface. ``force=False`` here so the local ``force_hint`` from
212
+ # the caller is the only signal; ``known_*`` stay empty.
213
+ return ConflictPolicy(
214
+ force=False, interactive=False,
215
+ known_paths=set(), known_pointers=set(),
216
+ )
217
+ return _CONFLICT_POLICY
218
+
219
+
220
+ def _set_conflict_policy(policy: Optional[ConflictPolicy]) -> None:
221
+ global _CONFLICT_POLICY
222
+ _CONFLICT_POLICY = policy
223
+
224
+
225
+ def _allow_overwrite_env() -> bool:
226
+ return os.environ.get(ALLOW_OVERWRITE_ENV) == "1"
227
+
228
+
229
+ def _is_interactive() -> bool:
230
+ try:
231
+ return sys.stdin.isatty() and sys.stdout.isatty()
232
+ except (AttributeError, ValueError): # pragma: no cover — closed streams
233
+ return False
234
+
235
+
236
+ def _load_conflict_policy(project_root: Path, force: bool) -> ConflictPolicy:
237
+ """Build a :class:`ConflictPolicy` from the on-disk manifest.
238
+
239
+ Reads ``agents/installed-tools.lock`` once and folds every recorded
240
+ ``files[].path`` into ``known_paths`` and every
241
+ ``merged_keys[].{file, json_pointer}`` into ``known_pointers``. The
242
+ manifest is the only source of truth for "this is ours"; if it's
243
+ missing both sets stay empty and every existing target is treated
244
+ as foreign.
245
+ """
246
+ known_paths: set[str] = set()
247
+ known_pointers: set[tuple[str, str]] = set()
248
+ try:
249
+ tools_mod = _load_installed_tools_module()
250
+ target = tools_mod.manifest_path(project_root)
251
+ existing = tools_mod.read_manifest(target) or {}
252
+ for tool in existing.get("tools", []) or []:
253
+ for entry in tool.get("files", []) or []:
254
+ path_val = entry.get("path")
255
+ if isinstance(path_val, str) and path_val:
256
+ # Manifest paths may be absolute (production writers
257
+ # use ``str(Path)`` for ``files[].path``) or relative
258
+ # (portable manifests). Writers always pass absolute
259
+ # ``Path`` objects to ``_resolve_file_conflict``, so
260
+ # normalise here against ``project_root`` to keep the
261
+ # known-path silent-skip branch reachable.
262
+ p = Path(path_val)
263
+ if not p.is_absolute():
264
+ p = (project_root / p).resolve()
265
+ known_paths.add(str(p))
266
+ for entry in tool.get("merged_keys", []) or []:
267
+ file_label = entry.get("file")
268
+ pointer = entry.get("json_pointer")
269
+ if isinstance(file_label, str) and isinstance(pointer, str):
270
+ known_pointers.add((file_label, pointer))
271
+ except Exception: # pragma: no cover — fail-open on corrupt manifest
272
+ # Don't block the install if the manifest is malformed; just
273
+ # report nothing as ours so foreign-file detection stays strict.
274
+ pass
275
+ return ConflictPolicy(
276
+ force=force or _allow_overwrite_env(),
277
+ interactive=_is_interactive(),
278
+ known_paths=known_paths,
279
+ known_pointers=known_pointers,
280
+ )
281
+
282
+
283
+ def prompt_file_conflict_choice(path: Path) -> str:
284
+ """3-option resolution prompt for a foreign file at ``path``.
285
+
286
+ Returns ``"force"`` / ``"skip"`` / ``"abort"``. Mirrors
287
+ :func:`prompt_collision_choice` (loops on invalid input, aborts on
288
+ EOF or 3 invalid replies). Only called when the policy is
289
+ interactive AND ``--force`` was not specified.
290
+ """
291
+ print()
292
+ warn(f"Foreign file at {path}")
293
+ info("This path exists but is not recorded as ours in the manifest.")
294
+ info("Choose how to handle the conflict:")
295
+ print(" 1) Force — overwrite the file with our content")
296
+ print(" 2) Skip — leave the file untouched, continue install")
297
+ print(" 3) Abort — stop the install, exit non-zero")
298
+ print()
299
+ attempts = 0
300
+ while attempts < 3:
301
+ try:
302
+ reply = _read_line("Choose [1/2/3]: ")
303
+ except EOFError:
304
+ fail(f"File-conflict prompt aborted (EOF on stdin) for {path}")
305
+ if reply in ("1", "force", "f"):
306
+ return "force"
307
+ if reply in ("2", "skip", "s"):
308
+ return "skip"
309
+ if reply in ("3", "abort", "a"):
310
+ return "abort"
311
+ attempts += 1
312
+ warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
313
+ fail(f"File-conflict prompt aborted (3 invalid replies) for {path}")
314
+ return "abort" # unreachable
315
+
316
+
317
+ def _resolve_file_conflict(target: Path, *, force_hint: bool) -> str:
318
+ """Decide what to do when ``target`` already exists on disk.
319
+
320
+ Returns ``"write"`` (proceed with overwrite), ``"skip"`` (leave the
321
+ target alone), or raises :class:`ConflictAbort`. ``force_hint`` is
322
+ the caller's local ``force`` flag — typically the install-level
323
+ ``--force``; we OR it with the global policy's ``force`` to honor
324
+ ``AGENT_CONFIG_ALLOW_OVERWRITE=1`` in callers that have not yet
325
+ been refactored to read the policy directly.
326
+
327
+ Decision matrix:
328
+
329
+ * target does not exist → ``"write"``
330
+ * target IS in ``known_paths`` → ``"write"`` if force else ``"skip"``
331
+ (legacy behaviour — we own it, skip silently without --force)
332
+ * target NOT in ``known_paths`` (foreign):
333
+ * force → ``"write"`` (overwrite)
334
+ * interactive → prompt → translate to write/skip/abort
335
+ * non-interactive → raise ``ConflictAbort``
336
+ """
337
+ if not target.exists():
338
+ return "write"
339
+ if not _conflict_policy_active():
340
+ # Legacy mode (no manifest loaded): preserve the pre-P3 contract
341
+ # — force overwrites, otherwise skip. No prompt, no abort.
342
+ return "write" if force_hint else "skip"
343
+ policy = _get_conflict_policy()
344
+ effective_force = force_hint or policy.force
345
+ if str(target) in policy.known_paths:
346
+ return "write" if effective_force else "skip"
347
+ if effective_force:
348
+ return "write"
349
+ if policy.interactive:
350
+ choice = prompt_file_conflict_choice(target)
351
+ if choice == "force":
352
+ return "write"
353
+ if choice == "skip":
354
+ return "skip"
355
+ raise ConflictAbort(f"User aborted on foreign file at {target}")
356
+ raise ConflictAbort(
357
+ f"Foreign file at {target}: refusing to overwrite. "
358
+ f"Re-run with --force or set {ALLOW_OVERWRITE_ENV}=1 to allow. "
359
+ f"Run `agent-config doctor` to inspect orphaned files first."
360
+ )
361
+
362
+
131
363
  # --- File utilities ---
132
364
 
133
365
  def ensure_directory(path: Path) -> None:
@@ -167,25 +399,178 @@ def deep_merge(base: dict, overlay: dict) -> dict:
167
399
  return result
168
400
 
169
401
 
170
- def merge_json_file(path: Path, new_data: dict, force: bool, label: str) -> None:
402
+ def _pointer_target_exists(doc: dict, pointer: str) -> bool:
403
+ """Return True when ``pointer`` resolves to an existing key in ``doc``.
404
+
405
+ Walks the RFC-6901 segments without descending into lists (per the
406
+ array-index ban in :mod:`scripts._lib.json_pointers`). Missing
407
+ intermediate segments short-circuit to False.
408
+ """
409
+ if not pointer.startswith("/"):
410
+ return False
411
+ cursor: Any = doc
412
+ segments = pointer.split("/")[1:]
413
+ segments = [s.replace("~1", "/").replace("~0", "~") for s in segments]
414
+ for seg in segments[:-1]:
415
+ if not isinstance(cursor, dict) or seg not in cursor:
416
+ return False
417
+ cursor = cursor[seg]
418
+ if not isinstance(cursor, dict):
419
+ return False
420
+ return segments[-1] in cursor
421
+
422
+
423
+ def _detect_foreign_pointers(
424
+ existing: dict,
425
+ overlay_entries: list[dict[str, Any]],
426
+ label: str,
427
+ policy: ConflictPolicy,
428
+ ) -> list[str]:
429
+ """Return overlay pointers that exist in ``existing`` but aren't ours.
430
+
431
+ P3.3 — pointer-level foreign-merge detection. A pointer is foreign
432
+ when it would overwrite a value already on disk that the manifest
433
+ does NOT record as ours (``(label, pointer) not in known_pointers``).
434
+ Returns the list of foreign pointer strings (sorted, deduped) for
435
+ use in the conflict-resolution prompt. In legacy mode (no manifest
436
+ loaded) the function returns an empty list so callers fall back to
437
+ the pre-P3 update flow.
438
+ """
439
+ if not _conflict_policy_active():
440
+ return []
441
+ foreign: list[str] = []
442
+ seen: set[str] = set()
443
+ for entry in overlay_entries:
444
+ pointer = entry.get("json_pointer")
445
+ if not isinstance(pointer, str) or pointer in seen:
446
+ continue
447
+ seen.add(pointer)
448
+ if not _pointer_target_exists(existing, pointer):
449
+ continue
450
+ if (label, pointer) in policy.known_pointers:
451
+ continue
452
+ foreign.append(pointer)
453
+ foreign.sort()
454
+ return foreign
455
+
456
+
457
+ def prompt_json_conflict_choice(path: Path, foreign: list[str]) -> str:
458
+ """3-option resolution prompt for foreign JSON pointers at ``path``.
459
+
460
+ Returns ``"force"`` / ``"skip"`` / ``"abort"``. Shows the foreign
461
+ pointer list so the user knows what will be overwritten.
462
+ """
463
+ print()
464
+ warn(f"Foreign JSON keys at {path}")
465
+ info("The following pointers exist in the file but are not recorded as ours:")
466
+ for pointer in foreign[:10]:
467
+ print(f" {pointer}")
468
+ if len(foreign) > 10:
469
+ print(f" ... and {len(foreign) - 10} more")
470
+ info("Choose how to handle the conflict:")
471
+ print(" 1) Force — overwrite the listed pointers with our values")
472
+ print(" 2) Skip — leave the file untouched, continue install")
473
+ print(" 3) Abort — stop the install, exit non-zero")
474
+ print()
475
+ attempts = 0
476
+ while attempts < 3:
477
+ try:
478
+ reply = _read_line("Choose [1/2/3]: ")
479
+ except EOFError:
480
+ fail(f"JSON-conflict prompt aborted (EOF on stdin) for {path}")
481
+ if reply in ("1", "force", "f"):
482
+ return "force"
483
+ if reply in ("2", "skip", "s"):
484
+ return "skip"
485
+ if reply in ("3", "abort", "a"):
486
+ return "abort"
487
+ attempts += 1
488
+ warn(f"Invalid choice '{reply}'. Enter 1, 2, or 3.")
489
+ fail(f"JSON-conflict prompt aborted (3 invalid replies) for {path}")
490
+ return "abort" # unreachable
491
+
492
+
493
+ def _resolve_json_conflict(
494
+ path: Path, label: str, foreign: list[str], *, force_hint: bool,
495
+ ) -> str:
496
+ """Decide what to do when ``label`` has foreign pointers (P3.3).
497
+
498
+ Returns ``"write"`` or ``"skip"``; raises :class:`ConflictAbort`.
499
+ Same resolution matrix as :func:`_resolve_file_conflict` but with a
500
+ pointer-aware prompt.
501
+ """
502
+ policy = _get_conflict_policy()
503
+ effective_force = force_hint or policy.force
504
+ if effective_force:
505
+ return "write"
506
+ if policy.interactive:
507
+ choice = prompt_json_conflict_choice(path, foreign)
508
+ if choice == "force":
509
+ return "write"
510
+ if choice == "skip":
511
+ return "skip"
512
+ raise ConflictAbort(f"User aborted on foreign JSON pointers at {path}")
513
+ raise ConflictAbort(
514
+ f"Foreign JSON pointers at {path}: refusing to overwrite "
515
+ f"({len(foreign)} key(s)). Re-run with --force or set "
516
+ f"{ALLOW_OVERWRITE_ENV}=1 to allow. "
517
+ f"Run `agent-config doctor` to inspect orphaned pointers first."
518
+ )
519
+
520
+
521
+ def merge_json_file(
522
+ path: Path, new_data: dict, force: bool, label: str,
523
+ ) -> list[dict[str, Any]]:
524
+ """Merge ``new_data`` into ``path``; return v2 ``merged_keys[]`` entries.
525
+
526
+ P1.5 + P3.2 + P3.3 of road-to-multi-package-coexistence: every JSON
527
+ pointer the install writes lands in the manifest so uninstall can
528
+ subtract it cleanly. The merge uses leaf-level pointer-replace
529
+ semantics (``deep_merge`` recurses into dicts, replaces at leaves)
530
+ so sibling keys owned by neighbour packages survive. Before any
531
+ write that would overwrite a pre-existing pointer NOT recorded as
532
+ ours, the conflict policy is consulted (force / interactive prompt
533
+ / non-interactive abort).
534
+
535
+ Returns the v2 entries on a successful create / update; returns
536
+ ``[]`` when the file was already in sync or the update was
537
+ suppressed without ``--force`` / on a skip choice.
538
+ """
539
+ new_entries = build_merge_entries(label, new_data)
540
+
171
541
  if not path.exists():
172
542
  write_json_file(path, new_data)
173
543
  success(f"{label} created")
174
- return
544
+ return new_entries
175
545
 
176
546
  existing = read_json_file(path)
177
547
  merged = deep_merge(existing, new_data)
178
548
 
179
549
  if merged == existing:
180
550
  skip(f"{label} already configured")
181
- return
182
-
183
- if not force:
551
+ return new_entries
552
+
553
+ policy = _get_conflict_policy()
554
+ foreign = _detect_foreign_pointers(existing, new_entries, label, policy)
555
+
556
+ if foreign:
557
+ # Foreign-pointer collision: ask the policy. On "write" we fall
558
+ # through and let deep_merge produce the leaf-level pointer-
559
+ # replace; on "skip" we bail without changing the file.
560
+ decision = _resolve_json_conflict(path, label, foreign, force_hint=force)
561
+ if decision == "skip":
562
+ skip(f"{label} has foreign keys, skipped")
563
+ return []
564
+ elif not (force or policy.force):
565
+ # No foreign collision but file needs an update — preserve the
566
+ # legacy "needs --force" contract so existing test expectations
567
+ # and the project-bridge flow stay intact.
184
568
  skip(f"{label} exists, needs update (use --force)")
185
- return
569
+ return []
186
570
 
187
571
  write_json_file(path, merged)
188
572
  success(f"{label} updated")
573
+ return new_entries
189
574
 
190
575
 
191
576
  # --- Legacy settings migration ---
@@ -442,12 +827,16 @@ def ensure_vscode_bridge(project_root: Path, package_type: str, force: bool) ->
442
827
  plugin_path = plugin_paths.get(package_type, "./plugin/agent-config")
443
828
 
444
829
  bridge = {"chat.pluginLocations": {plugin_path: True}}
830
+ # Substrate bridge — not tracked in the manifest, so merged_keys
831
+ # are computed but discarded.
445
832
  merge_json_file(project_root / ".vscode" / "settings.json", bridge, force, ".vscode/settings.json")
446
833
 
447
834
 
448
- def ensure_augment_bridge(project_root: Path, force: bool) -> None:
835
+ def ensure_augment_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
449
836
  bridge = {"enabledPlugins": {"agent-config@event4u": True}}
450
- merge_json_file(project_root / ".augment" / "settings.json", bridge, force, ".augment/settings.json")
837
+ return merge_json_file(
838
+ project_root / ".augment" / "settings.json", bridge, force, ".augment/settings.json",
839
+ )
451
840
 
452
841
 
453
842
  # Augment lifecycle hooks live at user scope (~/.augment/settings.json) per
@@ -520,7 +909,7 @@ def _remove_legacy_augment_trampolines() -> None:
520
909
  pass
521
910
 
522
911
 
523
- def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
912
+ def ensure_augment_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
524
913
  """Deploy the Augment universal-dispatcher trampoline at user scope.
525
914
 
526
915
  Phase 7.3 (hook-architecture-v1.md): one trampoline replaces the
@@ -543,7 +932,7 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
543
932
  """
544
933
  dst = _deploy_augment_trampoline(package_root, AUGMENT_DISPATCHER_TRAMPOLINE, force)
545
934
  if dst is None:
546
- return
935
+ return []
547
936
 
548
937
  _remove_legacy_augment_trampolines()
549
938
 
@@ -556,7 +945,7 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
556
945
  per_event.setdefault(native, []).append(entry)
557
946
 
558
947
  settings_patch: dict = {"hooks": per_event}
559
- merge_json_file(
948
+ return merge_json_file(
560
949
  AUGMENT_USER_DIR / "settings.json",
561
950
  settings_patch,
562
951
  force,
@@ -597,7 +986,7 @@ def _claude_dispatch_block(ac_event: str, native: str) -> dict:
597
986
  }
598
987
 
599
988
 
600
- def ensure_claude_bridge(project_root: Path, force: bool) -> None:
989
+ def ensure_claude_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
601
990
  """Deploy .claude/settings.json with plugin enablement and the Phase 7
602
991
  universal dispatcher hooks.
603
992
 
@@ -619,7 +1008,9 @@ def ensure_claude_bridge(project_root: Path, force: bool) -> None:
619
1008
  "enabledPlugins": {"agent-conf@event4u": True},
620
1009
  "hooks": per_event,
621
1010
  }
622
- merge_json_file(project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json")
1011
+ return merge_json_file(
1012
+ project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json",
1013
+ )
623
1014
 
624
1015
 
625
1016
  # Cursor lifecycle events → agent-config event vocabulary.
@@ -651,7 +1042,7 @@ def _cursor_dispatch_command(ac_event: str, native: str) -> str:
651
1042
  )
652
1043
 
653
1044
 
654
- def ensure_cursor_bridge(project_root: Path, force: bool) -> None:
1045
+ def ensure_cursor_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
655
1046
  """Deploy `.cursor/hooks.json` (project scope) with the Phase 7
656
1047
  universal dispatcher hooks.
657
1048
 
@@ -669,7 +1060,9 @@ def ensure_cursor_bridge(project_root: Path, force: bool) -> None:
669
1060
  )
670
1061
 
671
1062
  bridge = {"version": 1, "hooks": hooks}
672
- merge_json_file(project_root / ".cursor" / "hooks.json", bridge, force, ".cursor/hooks.json")
1063
+ return merge_json_file(
1064
+ project_root / ".cursor" / "hooks.json", bridge, force, ".cursor/hooks.json",
1065
+ )
673
1066
 
674
1067
 
675
1068
  # Cursor user-scope hooks fire across every project the developer opens
@@ -682,7 +1075,7 @@ CURSOR_USER_HOOKS_DIR = CURSOR_USER_DIR / "hooks"
682
1075
  CURSOR_DISPATCHER_TRAMPOLINE = "cursor-dispatcher.sh"
683
1076
 
684
1077
 
685
- def ensure_cursor_user_hooks(package_root: Path, force: bool) -> None:
1078
+ def ensure_cursor_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
686
1079
  """Deploy the Cursor universal-dispatcher trampoline at user scope.
687
1080
 
688
1081
  Phase 7.5 (hook-architecture-v1.md): mirrors ensure_augment_user_hooks
@@ -697,7 +1090,7 @@ def ensure_cursor_user_hooks(package_root: Path, force: bool) -> None:
697
1090
  src = package_root / "scripts" / "hooks" / CURSOR_DISPATCHER_TRAMPOLINE
698
1091
  if not src.exists():
699
1092
  skip(f"cursor trampoline missing in package: {src}")
700
- return
1093
+ return []
701
1094
 
702
1095
  CURSOR_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
703
1096
  dst = CURSOR_USER_HOOKS_DIR / CURSOR_DISPATCHER_TRAMPOLINE
@@ -716,7 +1109,7 @@ def ensure_cursor_user_hooks(package_root: Path, force: bool) -> None:
716
1109
  )
717
1110
 
718
1111
  settings_patch: dict = {"version": 1, "hooks": hooks}
719
- merge_json_file(
1112
+ return merge_json_file(
720
1113
  CURSOR_USER_DIR / "hooks.json",
721
1114
  settings_patch,
722
1115
  force,
@@ -882,7 +1275,7 @@ def _windsurf_dispatch_command(ac_event: str, native: str) -> str:
882
1275
  )
883
1276
 
884
1277
 
885
- def ensure_windsurf_bridge(project_root: Path, force: bool) -> None:
1278
+ def ensure_windsurf_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
886
1279
  """Deploy `.windsurf/hooks.json` (project scope) with the Phase 7
887
1280
  universal dispatcher hooks.
888
1281
 
@@ -902,7 +1295,7 @@ def ensure_windsurf_bridge(project_root: Path, force: bool) -> None:
902
1295
  })
903
1296
 
904
1297
  bridge = {"hooks": hooks}
905
- merge_json_file(
1298
+ return merge_json_file(
906
1299
  project_root / ".windsurf" / "hooks.json",
907
1300
  bridge,
908
1301
  force,
@@ -921,7 +1314,7 @@ WINDSURF_USER_HOOKS_DIR = WINDSURF_USER_DIR / "hooks"
921
1314
  WINDSURF_DISPATCHER_TRAMPOLINE = "windsurf-dispatcher.sh"
922
1315
 
923
1316
 
924
- def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> None:
1317
+ def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
925
1318
  """Deploy the Windsurf universal-dispatcher trampoline at user scope.
926
1319
 
927
1320
  Phase 7.7 (hook-architecture-v1.md): mirrors ensure_cursor_user_hooks
@@ -936,7 +1329,7 @@ def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> None:
936
1329
  src = package_root / "scripts" / "hooks" / WINDSURF_DISPATCHER_TRAMPOLINE
937
1330
  if not src.exists():
938
1331
  skip(f"windsurf trampoline missing in package: {src}")
939
- return
1332
+ return []
940
1333
 
941
1334
  WINDSURF_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
942
1335
  dst = WINDSURF_USER_HOOKS_DIR / WINDSURF_DISPATCHER_TRAMPOLINE
@@ -956,7 +1349,7 @@ def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> None:
956
1349
  })
957
1350
 
958
1351
  settings_patch: dict = {"hooks": hooks}
959
- merge_json_file(
1352
+ return merge_json_file(
960
1353
  WINDSURF_USER_DIR / "hooks.json",
961
1354
  settings_patch,
962
1355
  force,
@@ -1014,7 +1407,7 @@ def _gemini_hooks_dict(command_factory) -> dict[str, list]:
1014
1407
  return out
1015
1408
 
1016
1409
 
1017
- def ensure_gemini_bridge(project_root: Path, force: bool) -> None:
1410
+ def ensure_gemini_bridge(project_root: Path, force: bool) -> list[dict[str, Any]]:
1018
1411
  """Deploy `.gemini/settings.json` (project scope) with the Phase 7
1019
1412
  universal dispatcher hooks.
1020
1413
 
@@ -1025,7 +1418,7 @@ def ensure_gemini_bridge(project_root: Path, force: bool) -> None:
1025
1418
  rather than appending duplicates.
1026
1419
  """
1027
1420
  bridge = {"hooks": _gemini_hooks_dict(_gemini_dispatch_command)}
1028
- merge_json_file(
1421
+ return merge_json_file(
1029
1422
  project_root / ".gemini" / "settings.json",
1030
1423
  bridge,
1031
1424
  force,
@@ -1043,7 +1436,7 @@ GEMINI_USER_HOOKS_DIR = GEMINI_USER_DIR / "hooks"
1043
1436
  GEMINI_DISPATCHER_TRAMPOLINE = "gemini-dispatcher.sh"
1044
1437
 
1045
1438
 
1046
- def ensure_gemini_user_hooks(package_root: Path, force: bool) -> None:
1439
+ def ensure_gemini_user_hooks(package_root: Path, force: bool) -> list[dict[str, Any]]:
1047
1440
  """Deploy the Gemini universal-dispatcher trampoline at user scope.
1048
1441
 
1049
1442
  Phase 7.8 (hook-architecture-v1.md): mirrors ensure_windsurf_user_hooks
@@ -1058,7 +1451,7 @@ def ensure_gemini_user_hooks(package_root: Path, force: bool) -> None:
1058
1451
  src = package_root / "scripts" / "hooks" / GEMINI_DISPATCHER_TRAMPOLINE
1059
1452
  if not src.exists():
1060
1453
  skip(f"gemini trampoline missing in package: {src}")
1061
- return
1454
+ return []
1062
1455
 
1063
1456
  GEMINI_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
1064
1457
  dst = GEMINI_USER_HOOKS_DIR / GEMINI_DISPATCHER_TRAMPOLINE
@@ -1075,7 +1468,7 @@ def ensure_gemini_user_hooks(package_root: Path, force: bool) -> None:
1075
1468
  lambda ac_event, native: f"{dst} {ac_event} {native}",
1076
1469
  ),
1077
1470
  }
1078
- merge_json_file(
1471
+ return merge_json_file(
1079
1472
  GEMINI_USER_DIR / "settings.json",
1080
1473
  settings_patch,
1081
1474
  force,
@@ -1120,6 +1513,19 @@ Roo Code reads `.roo/rules/*.md` as system-level instructions. The
1120
1513
  canonical rule and skill source lives under `.augment/` (Augment
1121
1514
  portability mirror — see `AGENTS.md` for orientation).
1122
1515
 
1516
+ ## How to use
1517
+
1518
+ - These rules load automatically on every Roo Code session — no
1519
+ manual action required.
1520
+ - Switch Roo Code modes (Architect / Code / Ask / Debug / Custom)
1521
+ via the mode switcher to invoke different cognition profiles;
1522
+ every mode still sees these rules.
1523
+ - Slash commands and skills live under `.augment/commands/` and
1524
+ `.augment/skills/`. Roo Code does not register them natively —
1525
+ invoke them by name in chat (e.g. *"run the create-pr command"*).
1526
+
1527
+ See `docs/setup/per-ide/roocode.md` for the full activation guide.
1528
+
1123
1529
  Run `./agent-config --help` for available commands.
1124
1530
  """
1125
1531
 
@@ -1296,6 +1702,19 @@ Kilo Code auto-discovers `.kilocode/rules/*.md` as system-level rules
1296
1702
  per session. The canonical rule and skill source lives under
1297
1703
  `.augment/` (Augment portability mirror — see `AGENTS.md` for
1298
1704
  orientation).
1705
+
1706
+ ## How to use
1707
+
1708
+ - These rules load automatically on every Kilo Code session — no
1709
+ manual action required.
1710
+ - Switch Kilo Code modes (Architect / Code / Ask / Debug /
1711
+ Orchestrator) via the mode switcher to invoke different
1712
+ cognition profiles; every mode still sees these rules.
1713
+ - Slash commands and skills live under `.augment/commands/` and
1714
+ `.augment/skills/`. Kilo Code does not register them natively —
1715
+ invoke them by name in chat (e.g. *"run the create-pr command"*).
1716
+
1717
+ See `docs/setup/per-ide/kilocode.md` for the full activation guide.
1299
1718
  """
1300
1719
 
1301
1720
 
@@ -1406,6 +1825,20 @@ This file marks the project as an `event4u/agent-config` consumer.
1406
1825
  Kiro auto-discovers `.kiro/steering/*.md` as steering documents per
1407
1826
  session. The canonical rule and skill source lives under `.augment/`
1408
1827
  (Augment portability mirror — see `AGENTS.md` for orientation).
1828
+
1829
+ ## How to use
1830
+
1831
+ - Steering documents load automatically on every Kiro session — no
1832
+ manual action required.
1833
+ - For structured, plan-first work, use Kiro's **Spec** workflow
1834
+ (the agent produces a spec → tasks → implementation under your
1835
+ review). For free-form work, use **Vibe**. Both honor these
1836
+ steering documents.
1837
+ - Slash commands and skills live under `.augment/commands/` and
1838
+ `.augment/skills/`. Kiro does not register them natively —
1839
+ invoke them by name in chat (e.g. *"run the create-pr command"*).
1840
+
1841
+ See `docs/setup/per-ide/kiro.md` for the full activation guide.
1409
1842
  """
1410
1843
 
1411
1844
 
@@ -1556,6 +1989,16 @@ USER_SCOPE_PATHS = {
1556
1989
  "zed": "~/.config/zed/",
1557
1990
  "jetbrains": "~/.config/JetBrains/",
1558
1991
  "kiro": "~/.kiro/",
1992
+ # Phase 2.4 expansion — anchors lifted from
1993
+ # nextlevelbuilder/ui-ux-pro-max-skill (cli/assets/templates/platforms/*.json)
1994
+ # so `--global` covers every tool that ships a markdown-skills convention.
1995
+ "qoder": "~/.qoder/",
1996
+ "opencode": "~/.opencode/",
1997
+ "trae": "~/.trae/",
1998
+ "antigravity": "~/.agents/",
1999
+ "codebuddy": "~/.codebuddy/",
2000
+ "droid": "~/.factory/",
2001
+ "warp": "~/.warp/",
1559
2002
  }
1560
2003
 
1561
2004
 
@@ -1583,12 +2026,24 @@ SCOPE_SUPPORT = {
1583
2026
  "augment": "both",
1584
2027
  "aider": "both",
1585
2028
  "codex": "both",
1586
- "roocode": "project",
2029
+ # Phase 2.4: roocode / kilocode lifted to "both" — global deploys
2030
+ # write to `~/.roo/skills/` and `~/.kilocode/skills/` matching the
2031
+ # nextlevelbuilder/ui-ux-pro-max-skill anchors.
2032
+ "roocode": "both",
1587
2033
  "continue": "both",
1588
- "kilocode": "project",
2034
+ "kilocode": "both",
1589
2035
  "zed": "both",
1590
2036
  "jetbrains": "global",
1591
2037
  "kiro": "both",
2038
+ # Phase 2.4 expansion — global-only for new anchors; project bridges
2039
+ # are not yet implemented for these IDs.
2040
+ "qoder": "global",
2041
+ "opencode": "global",
2042
+ "trae": "global",
2043
+ "antigravity": "global",
2044
+ "codebuddy": "global",
2045
+ "droid": "global",
2046
+ "warp": "global",
1592
2047
  }
1593
2048
 
1594
2049
 
@@ -1618,6 +2073,120 @@ PROJECT_BRIDGE_MARKERS = {
1618
2073
  }
1619
2074
 
1620
2075
 
2076
+ # Per-tool content deployment plan for `--global` installs. Each entry is a
2077
+ # list of ``(package_src_relative, dest_subpath)`` tuples. ``package_src_relative``
2078
+ # resolves against the agent-config package root; ``dest_subpath`` is appended
2079
+ # to ``USER_SCOPE_PATHS[tool_id]`` (expanded). Symlinks in the source are
2080
+ # dereferenced so the user-scope copy stays valid after npx cache eviction
2081
+ # (Council Round 3 Q1 rejected cross-scope symlinks).
2082
+ #
2083
+ # Tools absent from this map have no deployable content yet in global scope:
2084
+ # - ``copilot`` has no user-scope convention (rules live in
2085
+ # ``.github/copilot-instructions.md`` per project); users export per-project
2086
+ # via ``agent-config export --tool=copilot``.
2087
+ # - ``aider`` config is a single YAML file (``~/.aider.conf.yml``), not a
2088
+ # directory; --global prints a hint rather than synthesizing a file.
2089
+ # - ``zed`` / ``jetbrains`` have no markdown-skills convention; --global
2090
+ # prints a hint.
2091
+ # - ``claude-desktop`` is a marker-only deployment, handled in
2092
+ # ``_write_claude_desktop_marker`` rather than via this map.
2093
+ #
2094
+ # Tools that follow the markdown-skills convention (anchors lifted from
2095
+ # nextlevelbuilder/ui-ux-pro-max-skill) deploy ``.claude/skills`` —
2096
+ # the universal Anthropic-shaped skill bundle — into ``<anchor>/skills/``
2097
+ # (or ``<anchor>/steering/`` for kiro). ``.claude/rules`` is also copied
2098
+ # where the destination is a true rules-aware tool root.
2099
+ _CLAUDE_SKILL_BUNDLE: list[tuple[str, str]] = [
2100
+ (".claude/rules", "rules"),
2101
+ (".claude/skills", "skills"),
2102
+ (".claude/personas", "personas"),
2103
+ ]
2104
+ GLOBAL_DEPLOY_SOURCES: dict[str, list[tuple[str, str]]] = {
2105
+ "claude-code": _CLAUDE_SKILL_BUNDLE,
2106
+ "augment": [
2107
+ (".augment/rules", "rules"),
2108
+ (".augment/skills", "skills"),
2109
+ (".augment/commands", "commands"),
2110
+ (".augment/contexts", "contexts"),
2111
+ (".augment/personas", "personas"),
2112
+ (".augment/templates", "templates"),
2113
+ ],
2114
+ "cursor": [
2115
+ (".cursor/rules", "rules"),
2116
+ (".cursor/commands", "commands"),
2117
+ (".cursor/personas", "personas"),
2118
+ ],
2119
+ "windsurf": [
2120
+ (".windsurf/rules", "rules"),
2121
+ (".windsurf/workflows", "workflows"),
2122
+ ],
2123
+ "cline": [
2124
+ (".clinerules", ""),
2125
+ ],
2126
+ # Markdown-skills tools — mirror the universal skill bundle into the
2127
+ # tool-specific anchor. Subpath matches the reference repo's
2128
+ # platform JSON `folderStructure.skillPath` (with the skill-name
2129
+ # tail stripped — we deploy the entire bundle, not a single skill).
2130
+ "gemini-cli": _CLAUDE_SKILL_BUNDLE,
2131
+ "codex": _CLAUDE_SKILL_BUNDLE,
2132
+ "continue": _CLAUDE_SKILL_BUNDLE,
2133
+ "roocode": _CLAUDE_SKILL_BUNDLE,
2134
+ "kilocode": _CLAUDE_SKILL_BUNDLE,
2135
+ "qoder": _CLAUDE_SKILL_BUNDLE,
2136
+ "opencode": _CLAUDE_SKILL_BUNDLE,
2137
+ "trae": _CLAUDE_SKILL_BUNDLE,
2138
+ "antigravity": _CLAUDE_SKILL_BUNDLE,
2139
+ "codebuddy": _CLAUDE_SKILL_BUNDLE,
2140
+ "droid": _CLAUDE_SKILL_BUNDLE,
2141
+ "warp": _CLAUDE_SKILL_BUNDLE,
2142
+ # Kiro reads from `steering/` not `skills/` (per
2143
+ # platforms/kiro.json#folderStructure.skillPath).
2144
+ "kiro": [
2145
+ (".claude/rules", "rules"),
2146
+ (".claude/skills", "steering"),
2147
+ (".claude/personas", "personas"),
2148
+ ],
2149
+ }
2150
+
2151
+
2152
+ # Marker body written to the Claude Desktop user-scope directory. Claude
2153
+ # Desktop has no rules/skills filesystem convention; the marker advertises
2154
+ # the agent-config install for downstream tooling and gives users a stable
2155
+ # pointer to the lockfile.
2156
+ _CLAUDE_DESKTOP_MARKER_TEMPLATE = """\
2157
+ # agent-config — Claude Desktop marker
2158
+
2159
+ Installed by `@event4u/agent-config` (user scope, ADR-007).
2160
+
2161
+ - Lockfile: `{lockfile}`
2162
+ - Anchor: `{anchor}`
2163
+ - Skill bundles: `{bundles_dir}` ({bundle_count} ZIPs)
2164
+
2165
+ ## Import skills into Claude Desktop
2166
+
2167
+ Claude Desktop has no filesystem skill-discovery convention — skills are
2168
+ imported manually via the Customize → Skills UI.
2169
+
2170
+ 1. Open Claude Desktop → **Settings → Customize → Skills**.
2171
+ 2. Click the **Upload skill** button.
2172
+ 3. Browse to `{bundles_dir}` and pick the `<skill-name>.zip` files you
2173
+ want to install. One ZIP = one skill.
2174
+ 4. Repeat per skill. Claude Desktop keeps each upload until you remove it.
2175
+
2176
+ The bundle directory is regenerated on every
2177
+ `npx @event4u/agent-config init --tools=claude-desktop` run (only
2178
+ changed skills are rewritten — content-hash idempotency).
2179
+
2180
+ To remove this marker, delete this file.
2181
+ """
2182
+
2183
+ #: Subpath under ``~/.event4u/agent-config/`` where Claude Desktop ZIP
2184
+ #: bundles are written. Kept separate from the per-tool USER_SCOPE_PATHS
2185
+ #: anchor (which is the Claude Desktop config dir) because bundles are
2186
+ #: package-owned, not Claude-owned, content.
2187
+ _CLAUDE_DESKTOP_BUNDLES_SUBPATH = "claude-desktop/bundles"
2188
+
2189
+
1621
2190
  def _bridge_marker(tool_id: str, scope: str) -> str:
1622
2191
  """Return the canonical bridge-marker path for ``(tool_id, scope)``.
1623
2192
 
@@ -1925,11 +2494,116 @@ def _load_installed_tools_module():
1925
2494
  return installed_tools
1926
2495
 
1927
2496
 
2497
+ def _load_user_global_paths_module():
2498
+ """Lazy-import ``scripts._lib.user_global_paths`` (Phase 3 migration shim)."""
2499
+ pkg_root = str(Path(__file__).resolve().parents[1])
2500
+ if pkg_root not in sys.path:
2501
+ sys.path.insert(0, pkg_root)
2502
+ from scripts._lib import user_global_paths # noqa: WPS433 — lazy by design
2503
+ return user_global_paths
2504
+
2505
+
2506
+ def _load_claude_desktop_bundler_module():
2507
+ """Lazy-import ``scripts._lib.claude_desktop_bundler`` (Phase 4 ZIP bundler)."""
2508
+ pkg_root = str(Path(__file__).resolve().parents[1])
2509
+ if pkg_root not in sys.path:
2510
+ sys.path.insert(0, pkg_root)
2511
+ from scripts._lib import claude_desktop_bundler # noqa: WPS433 — lazy by design
2512
+ return claude_desktop_bundler
2513
+
2514
+
2515
+ def _sha256_of_file(path: Path) -> Optional[str]:
2516
+ """Return the hex SHA-256 of ``path`` content, or ``None`` if unreadable.
2517
+
2518
+ Used by the v2 manifest (P1.4) to record content hashes for
2519
+ ``deployed`` and ``marker`` files so drift can be detected later.
2520
+ Bridge files intentionally pass ``None`` (their content is a
2521
+ pointer, not committed bytes).
2522
+ """
2523
+ try:
2524
+ data = path.read_bytes()
2525
+ except OSError:
2526
+ return None
2527
+ return hashlib.sha256(data).hexdigest()
2528
+
2529
+
2530
+ def _file_entry(path: Path, kind: str, *, hash_content: bool) -> dict[str, Any]:
2531
+ """Build a v2 ``files[]`` entry from an absolute path.
2532
+
2533
+ ``hash_content`` toggles SHA-256 computation; bridges pass ``False``
2534
+ (sha256 stays ``None``), deployed / marker files pass ``True``.
2535
+ The manifest is path-only at the wire level — we serialise the
2536
+ absolute path because user-scope files are not under ``project_root``.
2537
+ """
2538
+ return {
2539
+ "path": str(path),
2540
+ "kind": kind,
2541
+ "sha256": _sha256_of_file(path) if hash_content else None,
2542
+ }
2543
+
2544
+
2545
+ def _files_by_tool_from_deploy(
2546
+ deploy_results: dict[str, tuple[int, int, str, list[Path]]],
2547
+ project_root: Path,
2548
+ ) -> dict[str, list[dict[str, Any]]]:
2549
+ """Translate ``_deploy_global_content`` output into v2 manifest entries.
2550
+
2551
+ Returns ``{tool_id: [files[]]}``. ``status=deployed`` paths get
2552
+ ``kind=deployed``; ``status=marker`` paths get ``kind=marker``.
2553
+ ``hint`` / ``unsupported`` tools produce no entries (nothing was
2554
+ written). Empty path lists are emitted as empty lists so the
2555
+ inventory replaces rather than preserves a stale prior set.
2556
+ """
2557
+ out: dict[str, list[dict[str, Any]]] = {}
2558
+ for tool_id, (_, _, status, paths) in deploy_results.items():
2559
+ if status == "deployed":
2560
+ out[tool_id] = [
2561
+ _file_entry(p, "deployed", hash_content=True) for p in paths
2562
+ ]
2563
+ elif status == "marker":
2564
+ out[tool_id] = [
2565
+ _file_entry(p, "marker", hash_content=True) for p in paths
2566
+ ]
2567
+ else:
2568
+ # No files written — record empty list so a re-install with
2569
+ # a smaller set actually shrinks the recorded inventory.
2570
+ out[tool_id] = []
2571
+ return out
2572
+
2573
+
2574
+ def _files_by_tool_from_bridges(
2575
+ tools: set[str],
2576
+ project_root: Path,
2577
+ scope: str,
2578
+ ) -> dict[str, list[dict[str, Any]]]:
2579
+ """Build v2 ``files[]`` entries from project-scope bridge markers.
2580
+
2581
+ Each project-scope tool contributes a single ``kind=bridge`` entry
2582
+ pointing at its marker file. Bridges are pointers (not content we
2583
+ own bytes-for-bytes), so ``sha256`` stays ``None`` per the schema.
2584
+ """
2585
+ out: dict[str, list[dict[str, Any]]] = {}
2586
+ for tool_id in sorted(tools):
2587
+ marker = _bridge_marker(tool_id, scope)
2588
+ if not marker:
2589
+ continue
2590
+ marker_path = Path(marker)
2591
+ if not marker_path.is_absolute():
2592
+ marker_path = project_root / marker_path
2593
+ out[tool_id] = [
2594
+ _file_entry(marker_path, "bridge", hash_content=False),
2595
+ ]
2596
+ return out
2597
+
2598
+
1928
2599
  def _update_installed_tools_manifest(
1929
2600
  project_root: Path,
1930
2601
  tools: set[str],
1931
2602
  scope: str,
1932
2603
  force: bool,
2604
+ *,
2605
+ files_by_tool: Optional[dict[str, list[dict[str, Any]]]] = None,
2606
+ merged_keys_by_tool: Optional[dict[str, list[dict[str, Any]]]] = None,
1933
2607
  ) -> int:
1934
2608
  """Append / refresh project-scope manifest entries (ADR-008 Phase 3.2).
1935
2609
 
@@ -1939,6 +2613,14 @@ def _update_installed_tools_manifest(
1939
2613
  (behaviour). Idempotent on (name, scope) match; refuses scope changes
1940
2614
  without ``--force`` per ADR-008 § Lifecycle.
1941
2615
 
2616
+ ``files_by_tool`` (P1.4) is the per-tool inventory of paths the
2617
+ install just wrote. When omitted the manifest preserves any prior
2618
+ ``files[]`` on idempotent re-installs and emits none on first write.
2619
+
2620
+ ``merged_keys_by_tool`` (P1.5) is the per-tool inventory of JSON
2621
+ pointers the install merged into shared files (e.g. ``.cursor/hooks.json``).
2622
+ Same idempotency contract as ``files_by_tool``.
2623
+
1942
2624
  Returns 0 on success, 1 on refusal (scope mismatch without ``--force``).
1943
2625
  """
1944
2626
  tools_mod = _load_installed_tools_module()
@@ -1954,6 +2636,10 @@ def _update_installed_tools_manifest(
1954
2636
  if not marker:
1955
2637
  # Substrate (vscode) or unknown — not tracked in the manifest.
1956
2638
  continue
2639
+ files = files_by_tool.get(tool_id) if files_by_tool else None
2640
+ merged_keys = (
2641
+ merged_keys_by_tool.get(tool_id) if merged_keys_by_tool else None
2642
+ )
1957
2643
  try:
1958
2644
  entries = tools_mod.upsert_tool(
1959
2645
  entries,
@@ -1961,6 +2647,8 @@ def _update_installed_tools_manifest(
1961
2647
  scope=scope,
1962
2648
  bridge_marker=marker,
1963
2649
  force=force,
2650
+ files=files,
2651
+ merged_keys=merged_keys,
1964
2652
  )
1965
2653
  except tools_mod.ScopeMismatchError as exc:
1966
2654
  if not QUIET:
@@ -1975,6 +2663,290 @@ def _update_installed_tools_manifest(
1975
2663
  return 0
1976
2664
 
1977
2665
 
2666
+ # --- Global content deployment (ADR-007 user-scope file writes) ---
2667
+
2668
+
2669
+ def _resolve_package_root_for_global() -> Path:
2670
+ """Locate the agent-config package root for global content deployment.
2671
+
2672
+ Resolves relative to ``scripts/install.py`` (one level up). Verified by
2673
+ the presence of ``config/profiles/minimal.ini`` so a misplaced copy of
2674
+ install.py outside the package fails loudly instead of writing nothing.
2675
+ """
2676
+ here = Path(__file__).resolve()
2677
+ candidate = here.parent.parent
2678
+ if not (candidate / "config" / "profiles" / "minimal.ini").exists():
2679
+ fail(
2680
+ f"Could not locate agent-config package root from {here}. "
2681
+ "Expected config/profiles/minimal.ini at the parent directory."
2682
+ )
2683
+ return candidate
2684
+
2685
+
2686
+ #: Inline package identifier injected into deployed Markdown
2687
+ #: frontmatter (P5.1). Human-readable provenance only; the manifest
2688
+ #: remains the authoritative ownership source (see P5.3).
2689
+ PACKAGE_TAG_ID = "event4u/agent-config"
2690
+
2691
+
2692
+ def _inject_package_tag(
2693
+ target: Path, source: Path | None, package_root: Path | None,
2694
+ ) -> None:
2695
+ """Inject ``package:`` / ``source_path:`` keys into existing frontmatter.
2696
+
2697
+ No-ops for files that don't end in ``.md`` or that lack a leading
2698
+ ``---`` frontmatter block (P5.1: don't synthesise frontmatter where
2699
+ none exists). Idempotent: running on an already-tagged file
2700
+ rewrites the same values without growing the block.
2701
+
2702
+ ``source`` is the file we copied **from** (post-symlink-resolution
2703
+ when applicable); when ``package_root`` is supplied and contains
2704
+ ``source``, the recorded value is the path relative to the package
2705
+ root, otherwise the absolute path. ``source=None`` skips the
2706
+ ``source_path`` key but still maintains the ``package`` key.
2707
+
2708
+ The injected key is ``source_path:`` (not ``source:``) to avoid
2709
+ colliding with the established ``source: package`` origin-type
2710
+ convention used by 200+ rule files in this and downstream packages.
2711
+ """
2712
+ if target.suffix != ".md":
2713
+ return
2714
+ try:
2715
+ text = target.read_text(encoding="utf-8")
2716
+ except OSError:
2717
+ return
2718
+ if not text.startswith("---\n") and not text.startswith("---\r\n"):
2719
+ return
2720
+ # Locate the closing fence — second ``---`` on its own line.
2721
+ lines = text.splitlines(keepends=True)
2722
+ close_idx: int | None = None
2723
+ for i in range(1, len(lines)):
2724
+ if lines[i].rstrip("\r\n") == "---":
2725
+ close_idx = i
2726
+ break
2727
+ if close_idx is None:
2728
+ return
2729
+ fm_lines = lines[1:close_idx]
2730
+ body_lines = lines[close_idx:]
2731
+
2732
+ source_value: str | None = None
2733
+ if source is not None:
2734
+ try:
2735
+ resolved_src = source.resolve()
2736
+ except OSError:
2737
+ resolved_src = source
2738
+ if package_root is not None:
2739
+ try:
2740
+ source_value = str(
2741
+ resolved_src.relative_to(package_root.resolve()),
2742
+ )
2743
+ except ValueError:
2744
+ source_value = str(resolved_src)
2745
+ else:
2746
+ source_value = str(resolved_src)
2747
+
2748
+ def _set_key(block: list[str], key: str, value: str) -> list[str]:
2749
+ prefix = f"{key}:"
2750
+ rendered = f"{key}: {value}\n"
2751
+ for idx, line in enumerate(block):
2752
+ if line.startswith(prefix):
2753
+ block[idx] = rendered
2754
+ return block
2755
+ block.append(rendered)
2756
+ return block
2757
+
2758
+ fm_lines = _set_key(fm_lines, "package", PACKAGE_TAG_ID)
2759
+ if source_value is not None:
2760
+ fm_lines = _set_key(fm_lines, "source_path", source_value)
2761
+ new_text = "".join(lines[:1] + fm_lines + body_lines)
2762
+ if new_text != text:
2763
+ target.write_text(new_text, encoding="utf-8")
2764
+
2765
+
2766
+ def _copy_dir_dereferencing_symlinks(
2767
+ src: Path, dest: Path, force: bool,
2768
+ *,
2769
+ package_root: Path | None = None,
2770
+ ) -> tuple[int, int, list[Path]]:
2771
+ """Recursively copy ``src`` into ``dest``, dereferencing every symlink.
2772
+
2773
+ Returns ``(files_written, files_skipped, written_paths)``. The third
2774
+ element is the absolute path list of every file the copy actually
2775
+ wrote (P1.4 — manifest needs to record the inventory). ``dest`` is
2776
+ created if missing. Existing files at ``dest`` are overwritten only
2777
+ when ``force=True``; otherwise skipped silently and counted as
2778
+ ``skipped``. Symlinks in ``src`` are resolved so the user-scope copy
2779
+ survives npx cache eviction (the source tree under
2780
+ ``~/.npm/_npx/<hash>/`` is transient).
2781
+
2782
+ When ``package_root`` is supplied, deployed ``.md`` files get an
2783
+ inline package tag injected into their frontmatter (P5.1).
2784
+ """
2785
+ written = 0
2786
+ skipped = 0
2787
+ written_paths: list[Path] = []
2788
+ if not src.exists():
2789
+ return (0, 0, written_paths)
2790
+ if not src.is_dir():
2791
+ # Single-file source (e.g. .windsurfrules): copy as one file.
2792
+ dest.parent.mkdir(parents=True, exist_ok=True)
2793
+ decision = _resolve_file_conflict(dest, force_hint=force)
2794
+ if decision == "skip":
2795
+ return (0, 1, written_paths)
2796
+ shutil.copyfile(src, dest, follow_symlinks=True)
2797
+ _inject_package_tag(dest, src, package_root)
2798
+ written_paths.append(dest)
2799
+ return (1, 0, written_paths)
2800
+ dest.mkdir(parents=True, exist_ok=True)
2801
+ for entry in src.rglob("*"):
2802
+ rel = entry.relative_to(src)
2803
+ target = dest / rel
2804
+ if entry.is_dir() and not entry.is_symlink():
2805
+ target.mkdir(parents=True, exist_ok=True)
2806
+ continue
2807
+ # Resolve symlinks to their real targets. ``follow_symlinks=True``
2808
+ # on copyfile produces a real file at the destination.
2809
+ resolved = entry.resolve()
2810
+ if resolved.is_dir():
2811
+ # Symlinked subdir — recurse into the resolved path.
2812
+ target.mkdir(parents=True, exist_ok=True)
2813
+ sub_w, sub_s, sub_p = _copy_dir_dereferencing_symlinks(
2814
+ resolved, target, force, package_root=package_root,
2815
+ )
2816
+ written += sub_w
2817
+ skipped += sub_s
2818
+ written_paths.extend(sub_p)
2819
+ continue
2820
+ decision = _resolve_file_conflict(target, force_hint=force)
2821
+ if decision == "skip":
2822
+ skipped += 1
2823
+ continue
2824
+ target.parent.mkdir(parents=True, exist_ok=True)
2825
+ shutil.copyfile(resolved, target, follow_symlinks=True)
2826
+ _inject_package_tag(target, resolved, package_root)
2827
+ written += 1
2828
+ written_paths.append(target)
2829
+ return (written, skipped, written_paths)
2830
+
2831
+
2832
+ def _claude_desktop_bundles_dir() -> Path:
2833
+ """Return the canonical bundle output dir under the event4u namespace.
2834
+
2835
+ Located via :func:`user_global_paths.write_target` so the path
2836
+ honours the ``EVENT4U_HOME`` env override used by tests.
2837
+ """
2838
+ paths_mod = _load_user_global_paths_module()
2839
+ return paths_mod.write_target(_CLAUDE_DESKTOP_BUNDLES_SUBPATH)
2840
+
2841
+
2842
+ def _write_claude_desktop_marker(
2843
+ force: bool,
2844
+ lockfile_path: Path,
2845
+ *,
2846
+ bundles_dir: Path,
2847
+ bundle_count: int,
2848
+ ) -> tuple[int, int, list[Path]]:
2849
+ """Write the Claude Desktop user-scope marker file.
2850
+
2851
+ Returns ``(written, skipped, written_paths)`` for symmetry with the
2852
+ tree copier (P1.4). The marker points users at ``bundles_dir`` for
2853
+ the Customize → Skills import flow (Phase 4). Existing markers are
2854
+ overwritten unconditionally because the bundle count is part of the
2855
+ body and we want it to stay current.
2856
+ """
2857
+ anchor = Path(USER_SCOPE_PATHS["claude-desktop"]).expanduser()
2858
+ target = anchor / "agent-config.md"
2859
+ anchor.mkdir(parents=True, exist_ok=True)
2860
+ body = _CLAUDE_DESKTOP_MARKER_TEMPLATE.format(
2861
+ lockfile=str(lockfile_path),
2862
+ anchor=str(anchor),
2863
+ bundles_dir=str(bundles_dir),
2864
+ bundle_count=bundle_count,
2865
+ )
2866
+ target.write_text(body, encoding="utf-8")
2867
+ return (1, 0, [target])
2868
+
2869
+
2870
+ def _deploy_claude_desktop(
2871
+ force: bool, package_root: Path, lockfile_path: Path,
2872
+ ) -> tuple[int, int, str, list[Path]]:
2873
+ """Build skill ZIP bundles + write the marker for ``claude-desktop``.
2874
+
2875
+ Phase 4 of road-to-event4u-namespace-and-claude-desktop. Bundles
2876
+ land in ``~/.event4u/agent-config/claude-desktop/bundles/``; the
2877
+ marker file in the Claude Desktop config dir points at them with
2878
+ Customize → Skills import instructions.
2879
+
2880
+ Returns ``(bundle_count, 0, "deployed", [bundles_dir, marker])``.
2881
+ The ``deployed`` status replaces the v2.3 ``marker``-only status.
2882
+ """
2883
+ bundler = _load_claude_desktop_bundler_module()
2884
+ bundles_dir = _claude_desktop_bundles_dir()
2885
+ bundler.build_skill_bundles(package_root, bundles_dir, force=force)
2886
+ # Count total existing ZIPs (idempotent runs may not rewrite any).
2887
+ bundle_count = sum(1 for _ in bundles_dir.glob("*.zip")) if bundles_dir.is_dir() else 0
2888
+ _, _, marker_paths = _write_claude_desktop_marker(
2889
+ force, lockfile_path, bundles_dir=bundles_dir, bundle_count=bundle_count,
2890
+ )
2891
+ return (bundle_count, 0, "deployed", [bundles_dir, *marker_paths])
2892
+
2893
+
2894
+ def _deploy_global_content(
2895
+ tools: set[str],
2896
+ force: bool,
2897
+ package_root: Path,
2898
+ lockfile_path: Path,
2899
+ ) -> dict[str, tuple[int, int, str, list[Path]]]:
2900
+ """Deploy per-tool content into user-scope anchors for ``tools``.
2901
+
2902
+ For each tool in ``tools`` that has a ``GLOBAL_DEPLOY_SOURCES`` entry,
2903
+ copies the listed package subtrees into ``USER_SCOPE_PATHS[tool_id]``
2904
+ (expanded). For ``claude-desktop`` builds per-skill ZIP bundles under
2905
+ ``~/.event4u/agent-config/claude-desktop/bundles/`` and writes the
2906
+ marker file pointing at them (Phase 4). For tools without a deployment
2907
+ plan (e.g. ``copilot``), records a ``hint`` status so the caller can
2908
+ print an actionable next step.
2909
+
2910
+ Returns ``{tool_id: (written, skipped, status, written_paths)}``
2911
+ where ``status`` is one of ``deployed``, ``hint``, ``unsupported``
2912
+ and ``written_paths`` is the absolute path list of every file the
2913
+ deploy actually wrote (P1.4).
2914
+ """
2915
+ results: dict[str, tuple[int, int, str, list[Path]]] = {}
2916
+ for tool_id in sorted(tools):
2917
+ if tool_id == "claude-desktop":
2918
+ results[tool_id] = _deploy_claude_desktop(force, package_root, lockfile_path)
2919
+ continue
2920
+ plan = GLOBAL_DEPLOY_SOURCES.get(tool_id)
2921
+ if plan is None:
2922
+ # No deployable content yet for this tool in global scope.
2923
+ # `copilot` has no user-scope convention. `aider` is a single
2924
+ # YAML file (not a directory). `zed` / `jetbrains` have no
2925
+ # markdown-skills convention. Each prints an actionable hint.
2926
+ status = "hint" if tool_id in {"copilot", "aider", "zed", "jetbrains"} else "unsupported"
2927
+ results[tool_id] = (0, 0, status, [])
2928
+ continue
2929
+ anchor_raw = USER_SCOPE_PATHS.get(tool_id)
2930
+ if not anchor_raw:
2931
+ results[tool_id] = (0, 0, "unsupported", [])
2932
+ continue
2933
+ anchor = Path(anchor_raw).expanduser()
2934
+ written_total = 0
2935
+ skipped_total = 0
2936
+ written_paths: list[Path] = []
2937
+ for src_rel, dest_sub in plan:
2938
+ src = package_root / src_rel
2939
+ dest = anchor / dest_sub if dest_sub else anchor
2940
+ w, s, paths = _copy_dir_dereferencing_symlinks(
2941
+ src, dest, force, package_root=package_root,
2942
+ )
2943
+ written_total += w
2944
+ skipped_total += s
2945
+ written_paths.extend(paths)
2946
+ results[tool_id] = (written_total, skipped_total, "deployed", written_paths)
2947
+ return results
2948
+
2949
+
1978
2950
  def install_global(
1979
2951
  tools: set[str],
1980
2952
  force: bool,
@@ -1982,19 +2954,38 @@ def install_global(
1982
2954
  ) -> int:
1983
2955
  """User-scope install path (ADR-007 + Phase 1.6 lockfile lifecycle).
1984
2956
 
1985
- Reads ``~/.config/agent-config/installed.lock`` first. A recorded
1986
- version that does not match the running package version refuses the
2957
+ Reads ``~/.event4u/agent-config/installed.lock`` first (with a read
2958
+ fallback to the legacy ``~/.config/agent-config/installed.lock``). A
2959
+ recorded version that does not match the running package version refuses the
1987
2960
  install with a remediation hint unless ``--force`` is passed. On
1988
2961
  success the lockfile is rewritten atomically with the current
1989
2962
  package version + the union of previously-recorded and now-installed
1990
- tool IDs. Concrete per-tool file writes still belong to later tasks
1991
- (export remains the documented escape for project-local content).
2963
+ tool IDs, then per-tool content (rules / skills / personas / etc.) is
2964
+ copied from the agent-config package into each tool's user-scope
2965
+ anchor (``GLOBAL_DEPLOY_SOURCES``). ``copilot`` is the lone headline
2966
+ exception — it has no user-scope convention, so it is reported with a
2967
+ hint pointing at ``agent-config export --tool=copilot``.
1992
2968
 
1993
2969
  When ``project_root`` points at a project tree (detected by the
1994
2970
  presence of ``.agent-settings.yml``), the project-scope manifest at
1995
2971
  ``agents/installed-tools.lock`` is also refreshed with ``scope=global``
1996
2972
  entries per ADR-008 Phase 3.2.
2973
+
2974
+ Phase 3 namespace migration: before any lockfile read, the legacy
2975
+ ``~/.config/agent-config/`` tree (pre-2.4 installs) is migrated into
2976
+ ``~/.event4u/agent-config/`` so subsequent reads land on the canonical
2977
+ path. The migration is idempotent and leaves a ``MIGRATED.md``
2978
+ breadcrumb behind; the legacy tree is never auto-deleted.
1997
2979
  """
2980
+ paths_mod = _load_user_global_paths_module()
2981
+ migrated = paths_mod.migrate_legacy_namespace()
2982
+ if migrated and not QUIET:
2983
+ info(
2984
+ "🔁 Migrated user-global config to "
2985
+ f"{paths_mod.event4u_root()} (legacy "
2986
+ f"{paths_mod.legacy_xdg_root()} preserved as fallback)"
2987
+ )
2988
+
1998
2989
  lock_mod = _load_installed_lock_module()
1999
2990
  installed_version = lock_mod.current_package_version()
2000
2991
  lock_path = lock_mod.lockfile_path()
@@ -2015,7 +3006,7 @@ def install_global(
2015
3006
  if not QUIET:
2016
3007
  print()
2017
3008
  info("Agent Config — Global (user-scope) install [ADR-007]")
2018
- info("Planned per-tool anchor paths:")
3009
+ info("Per-tool anchor paths:")
2019
3010
  for tool_id in sorted(tools):
2020
3011
  anchor = USER_SCOPE_PATHS.get(tool_id)
2021
3012
  if anchor is None:
@@ -2033,6 +3024,32 @@ def install_global(
2033
3024
  info(f" schema_version=1, agent_config_version={installed_version}")
2034
3025
  info(f" tools={','.join(merged_tools)}")
2035
3026
 
3027
+ # Deploy per-tool content into user-scope anchors. Sources resolve from
3028
+ # the agent-config package root (located via `__file__`, not the
3029
+ # caller's CWD); destinations are `USER_SCOPE_PATHS[tool_id]` (expanded).
3030
+ package_root = _resolve_package_root_for_global()
3031
+ deploy_results = _deploy_global_content(tools, force, package_root, written)
3032
+
3033
+ if not QUIET:
3034
+ print()
3035
+ info("Deployed per-tool content:")
3036
+ for tool_id in sorted(deploy_results):
3037
+ w, s, status, _ = deploy_results[tool_id]
3038
+ anchor = USER_SCOPE_PATHS.get(tool_id, "")
3039
+ if status == "deployed" and tool_id == "claude-desktop":
3040
+ bundles_dir = _claude_desktop_bundles_dir()
3041
+ print(f" {tool_id:<15} → {bundles_dir} ({w} bundles)")
3042
+ elif status == "deployed":
3043
+ print(f" {tool_id:<15} → {anchor} ({w} files, {s} skipped)")
3044
+ elif status == "marker":
3045
+ print(f" {tool_id:<15} → {anchor}agent-config.md ({'written' if w else 'skipped'})")
3046
+ elif status == "hint":
3047
+ print(f" {tool_id:<15} → no user-scope convention; use `agent-config export --tool={tool_id}`")
3048
+ else:
3049
+ print(f" {tool_id:<15} → no global-scope content yet (project-scope install supported)")
3050
+ if not force and any(s > 0 for _, s, _, _ in deploy_results.values()):
3051
+ info(" Re-run with --force to overwrite existing files.")
3052
+
2036
3053
  # Refresh the project-scope manifest when running inside a project tree
2037
3054
  # (ADR-008 Phase 3.2). Outside a project (e.g. plain `~/`) there is no
2038
3055
  # manifest to write and the global lockfile alone is the source of truth.
@@ -2044,13 +3061,21 @@ def install_global(
2044
3061
  and (project_root / SETTINGS_FILE).exists()
2045
3062
  and not (project_root / ".agent-src.uncompressed").is_dir()
2046
3063
  ):
2047
- rc = _update_installed_tools_manifest(project_root, tools, "global", force)
3064
+ # Collect deployed/marker paths per tool so the manifest records
3065
+ # the v2 ``files[]`` inventory (P1.4).
3066
+ files_by_tool = _files_by_tool_from_deploy(
3067
+ deploy_results, project_root,
3068
+ )
3069
+ rc = _update_installed_tools_manifest(
3070
+ project_root, tools, "global", force,
3071
+ files_by_tool=files_by_tool,
3072
+ )
2048
3073
  if rc != 0:
2049
3074
  return rc
2050
3075
 
2051
3076
  if not QUIET:
2052
3077
  print()
2053
- success("Global install recorded.")
3078
+ success("Global install completed.")
2054
3079
  print()
2055
3080
  return 0
2056
3081
 
@@ -2167,6 +3192,18 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
2167
3192
  "for --scope=global)."
2168
3193
  ),
2169
3194
  )
3195
+ parser.add_argument(
3196
+ "--offline",
3197
+ action="store_true",
3198
+ help=(
3199
+ "skip every network call: suppress the post-install update "
3200
+ "banner and set AGENT_CONFIG_OFFLINE=1 for downstream "
3201
+ "subprocesses (versions / update / future fetchers). "
3202
+ "All bridge content is bundled in the package, so install "
3203
+ "itself never touches the network — this flag is the "
3204
+ "explicit guarantee for air-gapped / CI runs."
3205
+ ),
3206
+ )
2170
3207
  opts = parser.parse_args(argv)
2171
3208
  opts.tools = _merge_tools_aliases(opts.tools, opts.ai)
2172
3209
  if opts.scope == "global" and opts.custom_path:
@@ -2184,7 +3221,10 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
2184
3221
  _VALID_TOOLS = {
2185
3222
  "claude-code", "claude-desktop", "cursor", "windsurf", "cline",
2186
3223
  "gemini-cli", "copilot", "augment", "aider", "codex", "roocode",
2187
- "continue", "kilocode", "zed", "jetbrains", "kiro", "all",
3224
+ "continue", "kilocode", "zed", "jetbrains", "kiro",
3225
+ # Phase 2.4 expansion (nextlevelbuilder/ui-ux-pro-max-skill anchors).
3226
+ "qoder", "opencode", "trae", "antigravity", "codebuddy", "droid", "warp",
3227
+ "all",
2188
3228
  }
2189
3229
 
2190
3230
 
@@ -2231,6 +3271,15 @@ def main(argv: list[str]) -> int:
2231
3271
  opts = parse_options(argv)
2232
3272
  QUIET = opts.quiet
2233
3273
 
3274
+ # --offline: propagate via env so child subprocesses (versions /
3275
+ # update / check_update_banner) honor the air-gap guarantee
3276
+ # without each one needing its own flag. AGENT_CONFIG_NO_UPDATE_CHECK
3277
+ # is the canonical kill-switch for the post-install banner; the
3278
+ # broader AGENT_CONFIG_OFFLINE signals intent to future fetchers.
3279
+ if opts.offline:
3280
+ os.environ["AGENT_CONFIG_OFFLINE"] = "1"
3281
+ os.environ["AGENT_CONFIG_NO_UPDATE_CHECK"] = "1"
3282
+
2234
3283
  if opts.profile not in SUPPORTED_PROFILES:
2235
3284
  fail(f"Unsupported profile: {opts.profile}. Supported: {', '.join(SUPPORTED_PROFILES)}")
2236
3285
 
@@ -2252,14 +3301,42 @@ def main(argv: list[str]) -> int:
2252
3301
  tools_was_all = _tools_was_all(opts.tools)
2253
3302
  parsed_tools = _validate_scope(parsed_tools, scope, tools_was_all)
2254
3303
 
2255
- if scope == "global":
2256
- # Pass detect_root so the manifest refresh runs when --global is
2257
- # invoked from within a project tree (ADR-008 Phase 3.2).
2258
- return install_global(parsed_tools, opts.force, project_root=detect_root)
3304
+ # Conflict policy: load known paths/pointers from the manifest once
3305
+ # so every writer can ask "is this ours?" before clobbering (P3.1 /
3306
+ # P3.3). Built from the project-scope manifest because that's the
3307
+ # only on-disk source of truth across both install scopes.
3308
+ policy_root = detect_root if scope == "global" else (
3309
+ custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
3310
+ )
3311
+ _set_conflict_policy(_load_conflict_policy(policy_root, opts.force))
3312
+
3313
+ try:
3314
+ if scope == "global":
3315
+ # Pass detect_root so the manifest refresh runs when --global is
3316
+ # invoked from within a project tree (ADR-008 Phase 3.2).
3317
+ return install_global(parsed_tools, opts.force, project_root=detect_root)
3318
+
3319
+ project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
3320
+ is_first_run = not (project_root / SETTINGS_FILE).exists()
3321
+ return _main_project_install(opts, project_root, parsed_tools, is_first_run)
3322
+ except ConflictAbort as exc:
3323
+ warn(exc.message)
3324
+ return 1
3325
+ finally:
3326
+ _set_conflict_policy(None)
3327
+
2259
3328
 
2260
- project_root = custom_path or Path(opts.project or os.environ.get("PROJECT_ROOT") or os.getcwd()).resolve()
2261
- is_first_run = not (project_root / SETTINGS_FILE).exists()
3329
+ def _main_project_install(
3330
+ opts: argparse.Namespace,
3331
+ project_root: Path,
3332
+ parsed_tools: set[str],
3333
+ is_first_run: bool,
3334
+ ) -> int:
3335
+ """Project-scope install body extracted from :func:`main`.
2262
3336
 
3337
+ Kept as a private helper so ``main()`` can wrap the entire install
3338
+ in a ``try/except ConflictAbort`` without rewriting indentation.
3339
+ """
2263
3340
  if opts.package:
2264
3341
  package_root = Path(opts.package).resolve()
2265
3342
  if not (package_root / "config" / "profiles" / "minimal.ini").exists():
@@ -2282,21 +3359,24 @@ def main(argv: list[str]) -> int:
2282
3359
 
2283
3360
  tools = parsed_tools
2284
3361
 
3362
+ # Per-tool merged_keys collected from JSON bridge merges (P1.5).
3363
+ merged_keys_by_tool: dict[str, list[dict[str, Any]]] = {}
3364
+
2285
3365
  if not opts.skip_bridges:
2286
3366
  # Substrate bridges (always written; other tools symlink/depend on them).
2287
3367
  ensure_vscode_bridge(project_root, package_type, opts.force)
2288
- ensure_augment_bridge(project_root, opts.force)
3368
+ merged_keys_by_tool["augment"] = ensure_augment_bridge(project_root, opts.force)
2289
3369
  # Tool-specific bridges (gated by --tools selection).
2290
3370
  if _is_tool_enabled(tools, "claude-code"):
2291
- ensure_claude_bridge(project_root, opts.force)
3371
+ merged_keys_by_tool["claude-code"] = ensure_claude_bridge(project_root, opts.force)
2292
3372
  if _is_tool_enabled(tools, "cursor"):
2293
- ensure_cursor_bridge(project_root, opts.force)
3373
+ merged_keys_by_tool["cursor"] = ensure_cursor_bridge(project_root, opts.force)
2294
3374
  if _is_tool_enabled(tools, "cline"):
2295
3375
  ensure_cline_bridge(project_root, opts.force)
2296
3376
  if _is_tool_enabled(tools, "windsurf"):
2297
- ensure_windsurf_bridge(project_root, opts.force)
3377
+ merged_keys_by_tool["windsurf"] = ensure_windsurf_bridge(project_root, opts.force)
2298
3378
  if _is_tool_enabled(tools, "gemini-cli"):
2299
- ensure_gemini_bridge(project_root, opts.force)
3379
+ merged_keys_by_tool["gemini-cli"] = ensure_gemini_bridge(project_root, opts.force)
2300
3380
  if _is_tool_enabled(tools, "copilot"):
2301
3381
  ensure_copilot_bridge(project_root, opts.force)
2302
3382
  if _is_tool_enabled(tools, "roocode"):
@@ -2318,20 +3398,31 @@ def main(argv: list[str]) -> int:
2318
3398
  if _is_tool_enabled(tools, "kiro"):
2319
3399
  ensure_kiro_bridge(project_root, opts.force)
2320
3400
 
3401
+ # User-scope hook bridges contribute additional merged_keys to the
3402
+ # same tool entry (P1.5) — the manifest tracks every JSON pointer
3403
+ # the install wrote, regardless of which file owns it.
2321
3404
  if opts.augment_user_hooks:
2322
- ensure_augment_user_hooks(package_root, opts.force)
3405
+ merged_keys_by_tool.setdefault("augment", []).extend(
3406
+ ensure_augment_user_hooks(package_root, opts.force),
3407
+ )
2323
3408
 
2324
3409
  if opts.cursor_user_hooks and _is_tool_enabled(tools, "cursor"):
2325
- ensure_cursor_user_hooks(package_root, opts.force)
3410
+ merged_keys_by_tool.setdefault("cursor", []).extend(
3411
+ ensure_cursor_user_hooks(package_root, opts.force),
3412
+ )
2326
3413
 
2327
3414
  if opts.cline_user_hooks and _is_tool_enabled(tools, "cline"):
2328
3415
  ensure_cline_user_hooks(package_root, opts.force)
2329
3416
 
2330
3417
  if opts.windsurf_user_hooks and _is_tool_enabled(tools, "windsurf"):
2331
- ensure_windsurf_user_hooks(package_root, opts.force)
3418
+ merged_keys_by_tool.setdefault("windsurf", []).extend(
3419
+ ensure_windsurf_user_hooks(package_root, opts.force),
3420
+ )
2332
3421
 
2333
3422
  if opts.gemini_user_hooks and _is_tool_enabled(tools, "gemini-cli"):
2334
- ensure_gemini_user_hooks(package_root, opts.force)
3423
+ merged_keys_by_tool.setdefault("gemini-cli", []).extend(
3424
+ ensure_gemini_user_hooks(package_root, opts.force),
3425
+ )
2335
3426
 
2336
3427
  if not opts.skip_bridges and not opts.no_smoke:
2337
3428
  if not QUIET:
@@ -2344,8 +3435,13 @@ def main(argv: list[str]) -> int:
2344
3435
  # markers actually exist. Skipped when `--skip-bridges` was used (the
2345
3436
  # caller is exercising the install plan, not committing to it).
2346
3437
  if not opts.skip_bridges:
3438
+ files_by_tool = _files_by_tool_from_bridges(
3439
+ parsed_tools, project_root, "project",
3440
+ )
2347
3441
  rc = _update_installed_tools_manifest(
2348
3442
  project_root, parsed_tools, "project", opts.force,
3443
+ files_by_tool=files_by_tool,
3444
+ merged_keys_by_tool=merged_keys_by_tool,
2349
3445
  )
2350
3446
  if rc != 0:
2351
3447
  return rc