@event4u/agent-config 1.18.0 → 1.19.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 (126) hide show
  1. package/.agent-src/commands/council/default.md +74 -76
  2. package/.agent-src/commands/feature/roadmap.md +22 -0
  3. package/.agent-src/commands/roadmap/create.md +38 -6
  4. package/.agent-src/commands/roadmap/execute.md +36 -9
  5. package/.agent-src/rules/agent-authority.md +1 -0
  6. package/.agent-src/rules/agent-docs.md +1 -0
  7. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  8. package/.agent-src/rules/architecture.md +1 -0
  9. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  10. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  11. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  12. package/.agent-src/rules/augment-portability.md +1 -0
  13. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  14. package/.agent-src/rules/autonomous-execution.md +1 -0
  15. package/.agent-src/rules/capture-learnings.md +1 -0
  16. package/.agent-src/rules/chat-history-cadence.md +34 -0
  17. package/.agent-src/rules/chat-history-ownership.md +1 -0
  18. package/.agent-src/rules/chat-history-visibility.md +1 -0
  19. package/.agent-src/rules/cli-output-handling.md +2 -2
  20. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  21. package/.agent-src/rules/commit-conventions.md +1 -0
  22. package/.agent-src/rules/commit-policy.md +1 -0
  23. package/.agent-src/rules/context-hygiene.md +22 -0
  24. package/.agent-src/rules/direct-answers.md +1 -0
  25. package/.agent-src/rules/docker-commands.md +1 -0
  26. package/.agent-src/rules/docs-sync.md +1 -0
  27. package/.agent-src/rules/downstream-changes.md +1 -0
  28. package/.agent-src/rules/e2e-testing.md +1 -0
  29. package/.agent-src/rules/guidelines.md +1 -0
  30. package/.agent-src/rules/improve-before-implement.md +1 -0
  31. package/.agent-src/rules/language-and-tone.md +1 -0
  32. package/.agent-src/rules/laravel-translations.md +1 -0
  33. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  34. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  35. package/.agent-src/rules/missing-tool-handling.md +1 -0
  36. package/.agent-src/rules/model-recommendation.md +1 -0
  37. package/.agent-src/rules/no-cheap-questions.md +1 -0
  38. package/.agent-src/rules/no-roadmap-references.md +1 -0
  39. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  40. package/.agent-src/rules/onboarding-gate.md +26 -0
  41. package/.agent-src/rules/package-ci-checks.md +1 -0
  42. package/.agent-src/rules/php-coding.md +1 -0
  43. package/.agent-src/rules/preservation-guard.md +1 -0
  44. package/.agent-src/rules/review-routing-awareness.md +1 -0
  45. package/.agent-src/rules/reviewer-awareness.md +1 -0
  46. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +1 -0
  49. package/.agent-src/rules/runtime-safety.md +1 -0
  50. package/.agent-src/rules/scope-control.md +1 -0
  51. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  52. package/.agent-src/rules/size-enforcement.md +1 -0
  53. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  54. package/.agent-src/rules/skill-quality.md +1 -0
  55. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  56. package/.agent-src/rules/think-before-action.md +1 -0
  57. package/.agent-src/rules/token-efficiency.md +1 -0
  58. package/.agent-src/rules/tool-safety.md +1 -0
  59. package/.agent-src/rules/ui-audit-gate.md +1 -0
  60. package/.agent-src/rules/upstream-proposal.md +1 -0
  61. package/.agent-src/rules/user-interaction.md +1 -0
  62. package/.agent-src/rules/verify-before-complete.md +1 -0
  63. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  64. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  65. package/.agent-src/templates/agent-settings.md +16 -0
  66. package/.agent-src/templates/roadmaps.md +8 -3
  67. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
  68. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
  69. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
  70. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  71. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
  72. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  73. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  74. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  75. package/.claude-plugin/marketplace.json +1 -1
  76. package/CHANGELOG.md +62 -0
  77. package/README.md +19 -19
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/catalog.md +5 -2
  80. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  81. package/docs/contracts/decision-trace-v1.md +146 -0
  82. package/docs/contracts/file-ownership-matrix.json +7 -0
  83. package/docs/contracts/hook-architecture-v1.md +213 -0
  84. package/docs/contracts/memory-visibility-v1.md +138 -0
  85. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  86. package/docs/contracts/rule-interactions.yml +22 -0
  87. package/docs/customization.md +1 -0
  88. package/docs/development.md +4 -1
  89. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  90. package/package.json +1 -1
  91. package/scripts/agent-config +44 -0
  92. package/scripts/ai_council/bundler.py +3 -3
  93. package/scripts/ai_council/clients.py +24 -8
  94. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  95. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  96. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  97. package/scripts/ai_council/session.py +92 -0
  98. package/scripts/capture_showcase_session.py +361 -0
  99. package/scripts/chat_history.py +11 -1
  100. package/scripts/check_always_budget.py +7 -2
  101. package/scripts/context_hygiene_hook.py +14 -6
  102. package/scripts/council_cli.py +357 -0
  103. package/scripts/hook_manifest.yaml +184 -0
  104. package/scripts/hooks/__init__.py +1 -0
  105. package/scripts/hooks/augment-dispatcher.sh +72 -0
  106. package/scripts/hooks/cline-dispatcher.sh +86 -0
  107. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  108. package/scripts/hooks/dispatch_hook.py +348 -0
  109. package/scripts/hooks/envelope.py +98 -0
  110. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  111. package/scripts/hooks/state_io.py +122 -0
  112. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  113. package/scripts/hooks_status.py +146 -0
  114. package/scripts/install.py +725 -87
  115. package/scripts/install.sh +1 -1
  116. package/scripts/lint_hook_manifest.py +216 -0
  117. package/scripts/lint_one_off_age.py +184 -0
  118. package/scripts/lint_rule_tiers.py +78 -0
  119. package/scripts/lint_showcase_sessions.py +148 -0
  120. package/scripts/minimal_safe_diff_hook.py +245 -0
  121. package/scripts/onboarding_gate_hook.py +13 -8
  122. package/scripts/readme_linter.py +12 -3
  123. package/scripts/roadmap_progress_hook.py +5 -0
  124. package/scripts/sync_agent_settings.py +32 -129
  125. package/scripts/sync_yaml_rt.py +734 -0
  126. package/scripts/verify_before_complete_hook.py +216 -0
@@ -29,6 +29,8 @@ import copy
29
29
  import json
30
30
  import os
31
31
  import re
32
+ import shlex
33
+ import subprocess
32
34
  import sys
33
35
  from pathlib import Path
34
36
 
@@ -464,23 +466,31 @@ def ensure_augment_bridge(project_root: Path, force: bool) -> None:
464
466
  # .augment/settings.json is plugin enablement, not hooks.
465
467
  AUGMENT_USER_DIR = Path.home() / ".augment"
466
468
  AUGMENT_USER_HOOKS_DIR = AUGMENT_USER_DIR / "hooks"
467
- AUGMENT_CHAT_HISTORY_TRAMPOLINE = "augment-chat-history.sh"
468
- AUGMENT_ROADMAP_PROGRESS_TRAMPOLINE = "augment-roadmap-progress.sh"
469
- AUGMENT_ONBOARDING_GATE_TRAMPOLINE = "augment-onboarding-gate.sh"
470
- AUGMENT_CONTEXT_HYGIENE_TRAMPOLINE = "augment-context-hygiene.sh"
471
- # (trampoline name, list of events it should fire on). Each trampoline
472
- # is a self-contained workspace router; mapping them per-event keeps the
473
- # wiring explicit and lets a future hook bind to a different surface
474
- # without touching the chat-history one.
475
- AUGMENT_HOOK_BINDINGS = (
476
- (AUGMENT_CHAT_HISTORY_TRAMPOLINE,
477
- ("SessionStart", "SessionEnd", "Stop", "PostToolUse")),
478
- (AUGMENT_ROADMAP_PROGRESS_TRAMPOLINE,
479
- ("PostToolUse",)),
480
- (AUGMENT_ONBOARDING_GATE_TRAMPOLINE,
481
- ("SessionStart",)),
482
- (AUGMENT_CONTEXT_HYGIENE_TRAMPOLINE,
483
- ("PostToolUse",)),
469
+
470
+ # Phase 7.3 (hook-architecture-v1.md): one universal trampoline per
471
+ # platform replaces the per-concern fan-out. The trampoline cd's into
472
+ # the consumer workspace and pipes stdin into
473
+ # `./agent-config dispatch:hook`, which reads scripts/hook_manifest.yaml
474
+ # to resolve which concerns fire on (platform, event).
475
+ AUGMENT_DISPATCHER_TRAMPOLINE = "augment-dispatcher.sh"
476
+
477
+ # Pre-Phase-7 trampolines deployed at ~/.augment/hooks/ — install removes
478
+ # them on rerun so the manifest stays the single source of truth.
479
+ AUGMENT_LEGACY_TRAMPOLINES = (
480
+ "augment-chat-history.sh",
481
+ "augment-roadmap-progress.sh",
482
+ "augment-onboarding-gate.sh",
483
+ "augment-context-hygiene.sh",
484
+ )
485
+
486
+ # (agent-config event, Augment native event). Augment fires the same
487
+ # trampoline once per binding; the trampoline forwards both names to the
488
+ # dispatcher so concerns can branch on either.
489
+ AUGMENT_DISPATCHER_BINDINGS = (
490
+ ("session_start", "SessionStart"),
491
+ ("session_end", "SessionEnd"),
492
+ ("stop", "Stop"),
493
+ ("post_tool_use", "PostToolUse"),
484
494
  )
485
495
 
486
496
 
@@ -501,38 +511,60 @@ def _deploy_augment_trampoline(package_root: Path, name: str, force: bool) -> Pa
501
511
  return dst
502
512
 
503
513
 
504
- def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
505
- """Deploy the Augment lifecycle-hook trampolines at user scope.
506
-
507
- Augment hook scripts must use the .sh extension and be referenced by
508
- absolute path; user scope is the only surface that fires for both the
509
- CLI and the IDE plugins. This installs once per developer (not per
510
- project) each trampoline reads workspace_roots from the event
511
- payload and dispatches into whichever project is active at hook-fire
512
- time.
513
-
514
- Trampolines deployed (see AUGMENT_HOOK_BINDINGS for the source of
515
- truth):
516
- - augment-chat-history.sh → SessionStart/SessionEnd/Stop/PostToolUse
517
- - augment-roadmap-progress.sh → PostToolUse (path-filtered to
518
- agents/roadmaps/ — see scripts/roadmap_progress_hook.py)
519
- - augment-onboarding-gate.sh → SessionStart (refresh
520
- agents/state/onboarding-gate.json from .agent-settings.yml)
521
- - augment-context-hygiene.sh → PostToolUse (per-turn counter,
522
- loop detection, freshness milestones)
514
+ def _remove_legacy_augment_trampolines() -> None:
515
+ """Phase 7.3 cleanup: drop pre-dispatcher trampolines on rerun.
516
+
517
+ The manifest is now the single source of truth; leaving the old
518
+ per-concern .sh files at ~/.augment/hooks/ would not break anything
519
+ (settings.json no longer references them), but it produces stale
520
+ artefacts that confuse `task hooks-status` and look like a partial
521
+ install. Removal is best-effort and silent on missing files.
523
522
  """
524
- per_event: dict[str, list] = {}
525
- for name, events in AUGMENT_HOOK_BINDINGS:
526
- dst = _deploy_augment_trampoline(package_root, name, force)
527
- if dst is None:
528
- continue
529
- entry = {"hooks": [{"type": "command", "command": str(dst)}]}
530
- for event in events:
531
- per_event.setdefault(event, []).append(entry)
523
+ for name in AUGMENT_LEGACY_TRAMPOLINES:
524
+ legacy = AUGMENT_USER_HOOKS_DIR / name
525
+ try:
526
+ if legacy.is_file():
527
+ legacy.unlink()
528
+ skip(f"removed legacy ~/.augment/hooks/{name}")
529
+ except OSError:
530
+ pass
531
+
532
532
 
533
- if not per_event:
533
+ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
534
+ """Deploy the Augment universal-dispatcher trampoline at user scope.
535
+
536
+ Phase 7.3 (hook-architecture-v1.md): one trampoline replaces the
537
+ four per-concern .sh files. The trampoline reads the event JSON
538
+ from stdin, extracts workspace_roots[0], cd's there, and pipes the
539
+ payload into `./agent-config dispatch:hook --platform augment
540
+ --event <agent-config-event> --native-event <native>`. The
541
+ dispatcher then loads scripts/hook_manifest.yaml and runs the
542
+ resolved concern chain.
543
+
544
+ Augment hook scripts must use the .sh extension and be referenced
545
+ by absolute path; user scope is the only surface that fires for
546
+ both the CLI and the IDE plugins. Installs once per developer.
547
+
548
+ Settings entries (Phase 7.3, see AUGMENT_DISPATCHER_BINDINGS):
549
+ - SessionStart → augment-dispatcher.sh session_start SessionStart
550
+ - SessionEnd → augment-dispatcher.sh session_end SessionEnd
551
+ - Stop → augment-dispatcher.sh stop Stop
552
+ - PostToolUse → augment-dispatcher.sh post_tool_use PostToolUse
553
+ """
554
+ dst = _deploy_augment_trampoline(package_root, AUGMENT_DISPATCHER_TRAMPOLINE, force)
555
+ if dst is None:
534
556
  return
535
557
 
558
+ _remove_legacy_augment_trampolines()
559
+
560
+ per_event: dict[str, list] = {}
561
+ for ac_event, native in AUGMENT_DISPATCHER_BINDINGS:
562
+ # Augment's `command` is a shell line — pass agent-config event
563
+ # and Augment-native event as positional args.
564
+ cmd = f"{dst} {ac_event} {native}"
565
+ entry = {"hooks": [{"type": "command", "command": cmd}]}
566
+ per_event.setdefault(native, []).append(entry)
567
+
536
568
  settings_patch: dict = {"hooks": per_event}
537
569
  merge_json_file(
538
570
  AUGMENT_USER_DIR / "settings.json",
@@ -542,60 +574,56 @@ def ensure_augment_user_hooks(package_root: Path, force: bool) -> None:
542
574
  )
543
575
 
544
576
 
545
- def _claude_hook_block(subcommand: str) -> dict:
546
- """Single hook entry that calls ./agent-config <subcommand> --platform claude."""
577
+ # Claude Code lifecycle events → agent-config event vocabulary.
578
+ # Phase 7.3: one universal dispatch:hook entry per event replaces the
579
+ # per-concern subcommand fan-out. The dispatcher reads
580
+ # scripts/hook_manifest.yaml to resolve which concerns fire on each
581
+ # (platform, event) tuple. Mirrors AUGMENT_DISPATCHER_BINDINGS so each
582
+ # concern fires on the same logical surface across platforms — the
583
+ # contract from agents/contexts/hardening-pattern.md § Cross-platform
584
+ # parity.
585
+ CLAUDE_DISPATCHER_BINDINGS = (
586
+ ("session_start", "SessionStart"),
587
+ ("session_end", "SessionEnd"),
588
+ ("stop", "Stop"),
589
+ ("user_prompt_submit", "UserPromptSubmit"),
590
+ ("post_tool_use", "PostToolUse"),
591
+ )
592
+
593
+
594
+ def _claude_dispatch_block(ac_event: str, native: str) -> dict:
595
+ """Single hook entry routing the event through the universal dispatcher."""
547
596
  return {
548
597
  "hooks": [
549
598
  {
550
599
  "type": "command",
551
- "command": f"./agent-config {subcommand} --platform claude",
600
+ "command": (
601
+ f"./agent-config dispatch:hook "
602
+ f"--platform claude --event {ac_event} "
603
+ f"--native-event {native}"
604
+ ),
552
605
  },
553
606
  ],
554
607
  }
555
608
 
556
609
 
557
- # Claude Code Tier 1 hook bindings — keep in sync with AUGMENT_HOOK_BINDINGS.
558
- # `chat-history:hook` is the cross-cutting transcript hook; the three
559
- # rule-specific hooks are the Phase 4 Tier 1 set from
560
- # `road-to-rule-hardening.md`.
561
- CLAUDE_HOOK_SUBCOMMANDS = {
562
- "chat-history": "chat-history:hook",
563
- "roadmap-progress": "roadmap-progress:hook",
564
- "onboarding-gate": "onboarding-gate:hook",
565
- "context-hygiene": "context-hygiene:hook",
566
- }
567
- # (subcommand-key, list of Claude Code lifecycle events). Mirrors
568
- # AUGMENT_HOOK_BINDINGS so each rule fires on the same logical surface
569
- # on both platforms — the contract from
570
- # `agents/contexts/hardening-pattern.md` § Cross-platform parity.
571
- CLAUDE_HOOK_BINDINGS = (
572
- ("chat-history",
573
- ("SessionStart", "UserPromptSubmit", "PostToolUse", "Stop", "SessionEnd")),
574
- ("roadmap-progress",
575
- ("PostToolUse",)),
576
- ("onboarding-gate",
577
- ("SessionStart",)),
578
- ("context-hygiene",
579
- ("PostToolUse",)),
580
- )
581
-
582
-
583
610
  def ensure_claude_bridge(project_root: Path, force: bool) -> None:
584
- """Deploy .claude/settings.json with plugin enablement and Tier 1 hooks.
585
-
586
- Hooks dispatch to the project-root ./agent-config wrapper, which routes
587
- to the per-rule Python implementation (chat_history.py,
588
- roadmap_progress_hook.py, onboarding_gate_hook.py,
589
- context_hygiene_hook.py). They are no-ops when the relevant feature is
590
- disabled in .agent-settings.yml. Idempotent: reruns merge cleanly
591
- without duplicating entries (deep_merge replaces hook arrays rather
592
- than appending).
611
+ """Deploy .claude/settings.json with plugin enablement and the Phase 7
612
+ universal dispatcher hooks.
613
+
614
+ Each Claude Code lifecycle event is wired to a single
615
+ `./agent-config dispatch:hook` invocation. The dispatcher reads
616
+ scripts/hook_manifest.yaml at runtime and runs the resolved concern
617
+ chain concerns are no-ops when the relevant feature is disabled
618
+ in .agent-settings.yml. Idempotent: reruns merge cleanly without
619
+ duplicating entries (deep_merge replaces hook arrays rather than
620
+ appending).
593
621
  """
594
622
  per_event: dict[str, list] = {}
595
- for key, events in CLAUDE_HOOK_BINDINGS:
596
- block = _claude_hook_block(CLAUDE_HOOK_SUBCOMMANDS[key])
597
- for event in events:
598
- per_event.setdefault(event, []).append(block)
623
+ for ac_event, native in CLAUDE_DISPATCHER_BINDINGS:
624
+ per_event.setdefault(native, []).append(
625
+ _claude_dispatch_block(ac_event, native)
626
+ )
599
627
 
600
628
  bridge = {
601
629
  "enabledPlugins": {"agent-conf@event4u": True},
@@ -604,6 +632,467 @@ def ensure_claude_bridge(project_root: Path, force: bool) -> None:
604
632
  merge_json_file(project_root / ".claude" / "settings.json", bridge, force, ".claude/settings.json")
605
633
 
606
634
 
635
+ # Cursor lifecycle events → agent-config event vocabulary.
636
+ # Phase 7.5 (hook-architecture-v1.md, scripts/hook_manifest.yaml):
637
+ # Cursor's project-scope `.cursor/hooks.json` fires hooks with the
638
+ # project as cwd, so the dispatch:hook command runs directly with no
639
+ # trampoline. User-scope `~/.cursor/hooks.json` is a separate opt-in
640
+ # (--cursor-user-hooks) and routes through cursor-dispatcher.sh because
641
+ # the user-scope hooks fire across all projects.
642
+ #
643
+ # Native event names per https://cursor.com/docs/reference/third-party-hooks
644
+ # (camelCase). UserPromptSubmit lives at `beforeSubmitPrompt`. Stop is
645
+ # IDE-only — CLI-only Cursor users get the rule-only checkpoint
646
+ # fallback per agents/contexts/chat-history-platform-hooks.md.
647
+ CURSOR_DISPATCHER_BINDINGS = (
648
+ ("session_start", "sessionStart"),
649
+ ("session_end", "sessionEnd"),
650
+ ("stop", "stop"),
651
+ ("user_prompt_submit", "beforeSubmitPrompt"),
652
+ ("post_tool_use", "postToolUse"),
653
+ )
654
+
655
+
656
+ def _cursor_dispatch_command(ac_event: str, native: str) -> str:
657
+ return (
658
+ f"./agent-config dispatch:hook "
659
+ f"--platform cursor --event {ac_event} "
660
+ f"--native-event {native}"
661
+ )
662
+
663
+
664
+ def ensure_cursor_bridge(project_root: Path, force: bool) -> None:
665
+ """Deploy `.cursor/hooks.json` (project scope) with the Phase 7
666
+ universal dispatcher hooks.
667
+
668
+ Each Cursor lifecycle event is wired to a single
669
+ `./agent-config dispatch:hook` invocation. Cursor fires project
670
+ hooks with the project as cwd, so no trampoline is needed at this
671
+ scope — concerns are no-ops when disabled in .agent-settings.yml.
672
+ Idempotent: deep_merge replaces hook arrays on rerun rather than
673
+ appending duplicates.
674
+ """
675
+ hooks: dict[str, list] = {}
676
+ for ac_event, native in CURSOR_DISPATCHER_BINDINGS:
677
+ hooks.setdefault(native, []).append(
678
+ {"command": _cursor_dispatch_command(ac_event, native)}
679
+ )
680
+
681
+ bridge = {"version": 1, "hooks": hooks}
682
+ merge_json_file(project_root / ".cursor" / "hooks.json", bridge, force, ".cursor/hooks.json")
683
+
684
+
685
+ # Cursor user-scope hooks fire across every project the developer opens
686
+ # in the Cursor IDE / CLI. The trampoline reads `workspace_roots[0]`
687
+ # from the event payload (per https://cursor.com/docs/hooks) and routes
688
+ # the JSON into the active project's `./agent-config dispatch:hook`,
689
+ # silent no-op when the workspace is not an agent-config consumer.
690
+ CURSOR_USER_DIR = Path.home() / ".cursor"
691
+ CURSOR_USER_HOOKS_DIR = CURSOR_USER_DIR / "hooks"
692
+ CURSOR_DISPATCHER_TRAMPOLINE = "cursor-dispatcher.sh"
693
+
694
+
695
+ def ensure_cursor_user_hooks(package_root: Path, force: bool) -> None:
696
+ """Deploy the Cursor universal-dispatcher trampoline at user scope.
697
+
698
+ Phase 7.5 (hook-architecture-v1.md): mirrors ensure_augment_user_hooks
699
+ for the Cursor surface. Writes:
700
+ - ~/.cursor/hooks/cursor-dispatcher.sh (trampoline)
701
+ - ~/.cursor/hooks.json (event → trampoline call)
702
+
703
+ Each hooks.json command line is `<dispatcher> <ac_event> <native>`
704
+ so the trampoline can forward both names to the dispatcher for
705
+ traceability. Hooks fire across all projects the developer opens.
706
+ """
707
+ src = package_root / "scripts" / "hooks" / CURSOR_DISPATCHER_TRAMPOLINE
708
+ if not src.exists():
709
+ skip(f"cursor trampoline missing in package: {src}")
710
+ return
711
+
712
+ CURSOR_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
713
+ dst = CURSOR_USER_HOOKS_DIR / CURSOR_DISPATCHER_TRAMPOLINE
714
+ src_text = src.read_text(encoding="utf-8")
715
+ if dst.exists() and dst.read_text(encoding="utf-8") == src_text and not force:
716
+ skip(f"~/.cursor/hooks/{CURSOR_DISPATCHER_TRAMPOLINE} already up to date")
717
+ else:
718
+ dst.write_text(src_text, encoding="utf-8")
719
+ dst.chmod(0o755)
720
+ success(f"~/.cursor/hooks/{CURSOR_DISPATCHER_TRAMPOLINE} installed")
721
+
722
+ hooks: dict[str, list] = {}
723
+ for ac_event, native in CURSOR_DISPATCHER_BINDINGS:
724
+ hooks.setdefault(native, []).append(
725
+ {"command": f"{dst} {ac_event} {native}"}
726
+ )
727
+
728
+ settings_patch: dict = {"version": 1, "hooks": hooks}
729
+ merge_json_file(
730
+ CURSOR_USER_DIR / "hooks.json",
731
+ settings_patch,
732
+ force,
733
+ "~/.cursor/hooks.json",
734
+ )
735
+
736
+
737
+ # Cline lifecycle events → agent-config event vocabulary.
738
+ # Phase 7.6 (hook-architecture-v1.md, scripts/hook_manifest.yaml):
739
+ # Cline reads scripts at `.clinerules/hooks/<HookName>` (project) or
740
+ # `~/Documents/Cline/Hooks/<HookName>` (global) — file names match
741
+ # the hook type exactly, no extension, executable bit required.
742
+ # Both TaskStart (new) and TaskResume (resumed) map to session_start;
743
+ # TaskCancel maps to stop because the session is interrupted with
744
+ # partial state (mirrors Augment Stop semantics).
745
+ CLINE_DISPATCHER_BINDINGS = (
746
+ ("session_start", "TaskStart"),
747
+ ("session_start", "TaskResume"),
748
+ ("session_end", "TaskComplete"),
749
+ ("stop", "TaskCancel"),
750
+ ("user_prompt_submit", "UserPromptSubmit"),
751
+ ("post_tool_use", "PostToolUse"),
752
+ )
753
+
754
+ # Each project-scope script is generated from this template — one file
755
+ # per native hook name. The script reads stdin (Cline's payload), forwards
756
+ # it into `./agent-config dispatch:hook`, then emits the empty JSON
757
+ # envelope Cline expects (`{}` = no cancel, no context modification).
758
+ # `cd "$WORKSPACE_ROOT"` is intentional even though Cline fires project
759
+ # hooks with cwd already set: the workspace path lands in $WORKSPACE_ROOT
760
+ # at install time and the cd guards against future Cline behaviour
761
+ # changes (cline#8073-class shifts in cwd handling).
762
+ CLINE_PROJECT_HOOK_TEMPLATE = """\
763
+ #!/usr/bin/env bash
764
+ # Generated by event4u/agent-config install.py — DO NOT EDIT.
765
+ # Project-scope Cline hook for {native_event} → agent-config {ac_event}.
766
+ # Phase 7.6 (docs/contracts/hook-architecture-v1.md).
767
+ set -u
768
+ EVENT_DATA="$(cat)"
769
+ WORKSPACE_ROOT={workspace_quoted}
770
+ cd "$WORKSPACE_ROOT" 2>/dev/null || {{ printf '%s\\n' '{{}}'; exit 0; }}
771
+ if [ ! -x ./agent-config ]; then
772
+ printf '%s\\n' '{{}}'
773
+ exit 0
774
+ fi
775
+ printf '%s' "$EVENT_DATA" \\
776
+ | ./agent-config dispatch:hook \\
777
+ --platform cline \\
778
+ --event {ac_event} \\
779
+ --native-event {native_event} \\
780
+ >/dev/null 2>&1 || true
781
+ printf '%s\\n' '{{}}'
782
+ exit 0
783
+ """
784
+
785
+
786
+ def ensure_cline_bridge(project_root: Path, force: bool) -> None:
787
+ """Deploy `.clinerules/hooks/<HookName>` per-event scripts.
788
+
789
+ Phase 7.6: Cline project hooks are individual executable scripts
790
+ named exactly after the hook (no extension). install writes one
791
+ script per (ac_event, native_event) tuple in
792
+ CLINE_DISPATCHER_BINDINGS; rerunning is idempotent — the script
793
+ body is overwritten only when content differs (or --force).
794
+ """
795
+ hooks_dir = project_root / ".clinerules" / "hooks"
796
+ hooks_dir.mkdir(parents=True, exist_ok=True)
797
+
798
+ workspace_quoted = shlex.quote(str(project_root.resolve()))
799
+ written = 0
800
+ for ac_event, native_event in CLINE_DISPATCHER_BINDINGS:
801
+ target = hooks_dir / native_event
802
+ body = CLINE_PROJECT_HOOK_TEMPLATE.format(
803
+ native_event=native_event,
804
+ ac_event=ac_event,
805
+ workspace_quoted=workspace_quoted,
806
+ )
807
+ if target.exists() and target.read_text(encoding="utf-8") == body and not force:
808
+ continue
809
+ if target.exists() and not force:
810
+ skip(f".clinerules/hooks/{native_event} exists, needs update (use --force)")
811
+ continue
812
+ target.write_text(body, encoding="utf-8")
813
+ target.chmod(0o755)
814
+ written += 1
815
+ if written:
816
+ success(f".clinerules/hooks/ — {written} script(s) installed")
817
+ else:
818
+ skip(".clinerules/hooks/ already up to date")
819
+
820
+
821
+ # Cline user-scope hooks live at `~/Documents/Cline/Hooks/<HookName>`
822
+ # (per docs.cline.bot/customization/hooks) and fire across every
823
+ # project the developer opens. The trampoline reads `workspaceRoots[0]`
824
+ # from the event payload and routes the JSON into the active project's
825
+ # `./agent-config dispatch:hook`. Silent no-op when the workspace is
826
+ # not an agent-config consumer.
827
+ CLINE_USER_DIR = Path.home() / "Documents" / "Cline" / "Hooks"
828
+ CLINE_DISPATCHER_TRAMPOLINE = "cline-dispatcher.sh"
829
+
830
+
831
+ def ensure_cline_user_hooks(package_root: Path, force: bool) -> None:
832
+ """Deploy the Cline universal-dispatcher trampoline at user scope.
833
+
834
+ Phase 7.6 (hook-architecture-v1.md): mirrors ensure_cursor_user_hooks
835
+ for Cline. Writes:
836
+ - ~/Documents/Cline/Hooks/cline-dispatcher.sh (shared trampoline)
837
+ - ~/Documents/Cline/Hooks/<HookName> (per-event wrapper)
838
+
839
+ Each per-event wrapper is a tiny shim that exec's the trampoline
840
+ with `<ac_event> <native_event>` arguments and re-pipes stdin —
841
+ this matches Cline's "file name == hook name, no extension"
842
+ convention while still routing through one shared dispatcher.
843
+ """
844
+ src = package_root / "scripts" / "hooks" / CLINE_DISPATCHER_TRAMPOLINE
845
+ if not src.exists():
846
+ skip(f"cline trampoline missing in package: {src}")
847
+ return
848
+
849
+ CLINE_USER_DIR.mkdir(parents=True, exist_ok=True)
850
+ trampoline = CLINE_USER_DIR / CLINE_DISPATCHER_TRAMPOLINE
851
+ src_text = src.read_text(encoding="utf-8")
852
+ if trampoline.exists() and trampoline.read_text(encoding="utf-8") == src_text and not force:
853
+ skip(f"~/Documents/Cline/Hooks/{CLINE_DISPATCHER_TRAMPOLINE} already up to date")
854
+ else:
855
+ trampoline.write_text(src_text, encoding="utf-8")
856
+ trampoline.chmod(0o755)
857
+ success(f"~/Documents/Cline/Hooks/{CLINE_DISPATCHER_TRAMPOLINE} installed")
858
+
859
+ trampoline_quoted = shlex.quote(str(trampoline))
860
+ for ac_event, native_event in CLINE_DISPATCHER_BINDINGS:
861
+ wrapper = CLINE_USER_DIR / native_event
862
+ body = (
863
+ "#!/usr/bin/env bash\n"
864
+ "# Generated by event4u/agent-config install.py — DO NOT EDIT.\n"
865
+ f"# User-scope Cline hook for {native_event} → agent-config {ac_event}.\n"
866
+ f"exec {trampoline_quoted} {ac_event} {native_event}\n"
867
+ )
868
+ if wrapper.exists() and wrapper.read_text(encoding="utf-8") == body and not force:
869
+ continue
870
+ wrapper.write_text(body, encoding="utf-8")
871
+ wrapper.chmod(0o755)
872
+
873
+
874
+ # Windsurf (Cascade) lifecycle events → agent-config event vocabulary.
875
+ # Phase 7.7 (hook-architecture-v1.md, scripts/hook_manifest.yaml):
876
+ # Windsurf reads `.windsurf/hooks.json` (project) or
877
+ # `~/.codeium/windsurf/hooks.json` (user). Cascade has no generic
878
+ # post-tool-use surface — concerns gated to that slot don't fire on
879
+ # Windsurf (documented platform limitation in chat-history-platform-hooks.md).
880
+ WINDSURF_DISPATCHER_BINDINGS = (
881
+ ("session_start", "post_setup_worktree"),
882
+ ("user_prompt_submit", "pre_user_prompt"),
883
+ ("stop", "post_cascade_response"),
884
+ )
885
+
886
+
887
+ def _windsurf_dispatch_command(ac_event: str, native: str) -> str:
888
+ return (
889
+ f"./agent-config dispatch:hook "
890
+ f"--platform windsurf --event {ac_event} "
891
+ f"--native-event {native}"
892
+ )
893
+
894
+
895
+ def ensure_windsurf_bridge(project_root: Path, force: bool) -> None:
896
+ """Deploy `.windsurf/hooks.json` (project scope) with the Phase 7
897
+ universal dispatcher hooks.
898
+
899
+ Each Windsurf lifecycle event is wired to a single
900
+ `./agent-config dispatch:hook` invocation. Cascade fires project
901
+ hooks with the workspace as cwd, so no trampoline is needed at this
902
+ scope. Idempotent via deep_merge — rerunning replaces hook arrays
903
+ rather than appending duplicates. `show_output: false` keeps post
904
+ hooks silent (per Windsurf docs); concerns stream their own output
905
+ via agents/state/.dispatcher/.
906
+ """
907
+ hooks: dict[str, list] = {}
908
+ for ac_event, native in WINDSURF_DISPATCHER_BINDINGS:
909
+ hooks.setdefault(native, []).append({
910
+ "command": _windsurf_dispatch_command(ac_event, native),
911
+ "show_output": False,
912
+ })
913
+
914
+ bridge = {"hooks": hooks}
915
+ merge_json_file(
916
+ project_root / ".windsurf" / "hooks.json",
917
+ bridge,
918
+ force,
919
+ ".windsurf/hooks.json",
920
+ )
921
+
922
+
923
+ # Windsurf user-scope hooks live at `~/.codeium/windsurf/hooks.json`
924
+ # (per docs.windsurf.com/windsurf/cascade/hooks). The trampoline
925
+ # resolves the active workspace from $PWD / .agent-settings.yml /
926
+ # tool_info.cwd|file_path / $ROOT_WORKSPACE_PATH and routes the JSON
927
+ # into that project's `./agent-config dispatch:hook`. Silent no-op
928
+ # when the workspace is not an agent-config consumer.
929
+ WINDSURF_USER_DIR = Path.home() / ".codeium" / "windsurf"
930
+ WINDSURF_USER_HOOKS_DIR = WINDSURF_USER_DIR / "hooks"
931
+ WINDSURF_DISPATCHER_TRAMPOLINE = "windsurf-dispatcher.sh"
932
+
933
+
934
+ def ensure_windsurf_user_hooks(package_root: Path, force: bool) -> None:
935
+ """Deploy the Windsurf universal-dispatcher trampoline at user scope.
936
+
937
+ Phase 7.7 (hook-architecture-v1.md): mirrors ensure_cursor_user_hooks
938
+ for the Windsurf surface. Writes:
939
+ - ~/.codeium/windsurf/hooks/windsurf-dispatcher.sh (trampoline)
940
+ - ~/.codeium/windsurf/hooks.json (event → trampoline call)
941
+
942
+ Each hooks.json command line is `<dispatcher> <ac_event> <native>`
943
+ so the trampoline forwards both names to the dispatcher for
944
+ traceability. Hooks fire across all projects the developer opens.
945
+ """
946
+ src = package_root / "scripts" / "hooks" / WINDSURF_DISPATCHER_TRAMPOLINE
947
+ if not src.exists():
948
+ skip(f"windsurf trampoline missing in package: {src}")
949
+ return
950
+
951
+ WINDSURF_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
952
+ dst = WINDSURF_USER_HOOKS_DIR / WINDSURF_DISPATCHER_TRAMPOLINE
953
+ src_text = src.read_text(encoding="utf-8")
954
+ if dst.exists() and dst.read_text(encoding="utf-8") == src_text and not force:
955
+ skip(f"~/.codeium/windsurf/hooks/{WINDSURF_DISPATCHER_TRAMPOLINE} already up to date")
956
+ else:
957
+ dst.write_text(src_text, encoding="utf-8")
958
+ dst.chmod(0o755)
959
+ success(f"~/.codeium/windsurf/hooks/{WINDSURF_DISPATCHER_TRAMPOLINE} installed")
960
+
961
+ hooks: dict[str, list] = {}
962
+ for ac_event, native in WINDSURF_DISPATCHER_BINDINGS:
963
+ hooks.setdefault(native, []).append({
964
+ "command": f"{dst} {ac_event} {native}",
965
+ "show_output": False,
966
+ })
967
+
968
+ settings_patch: dict = {"hooks": hooks}
969
+ merge_json_file(
970
+ WINDSURF_USER_DIR / "hooks.json",
971
+ settings_patch,
972
+ force,
973
+ "~/.codeium/windsurf/hooks.json",
974
+ )
975
+
976
+
977
+ # Gemini CLI lifecycle events → agent-config event vocabulary.
978
+ # Phase 7.8 (hook-architecture-v1.md, scripts/hook_manifest.yaml):
979
+ # Gemini reads `.gemini/settings.json` (project) or
980
+ # `~/.gemini/settings.json` (user). Each event maps to an array of
981
+ # hook groups; each group has a `matcher` (exact string for lifecycle,
982
+ # regex for tool events) and a `hooks` array of `{type: "command",
983
+ # command: "..."}`.
984
+ #
985
+ # Native event names per geminicli.com/docs/hooks/reference/
986
+ # (PascalCase). BeforeAgent fires after the user submits a prompt
987
+ # and before agent planning — our user_prompt_submit slot. AfterAgent
988
+ # fires when the agent loop ends — our stop slot. SessionStart /
989
+ # SessionEnd are advisory (continue/decision ignored). For lifecycle
990
+ # events the matcher filters on `source` ("startup"|"resume"|"clear"
991
+ # for SessionStart, etc.); empty matcher == match all.
992
+ GEMINI_DISPATCHER_BINDINGS = (
993
+ ("session_start", "SessionStart", ""),
994
+ ("session_end", "SessionEnd", ""),
995
+ ("stop", "AfterAgent", ""),
996
+ ("user_prompt_submit", "BeforeAgent", ""),
997
+ ("post_tool_use", "AfterTool", ".*"),
998
+ )
999
+
1000
+
1001
+ def _gemini_dispatch_command(ac_event: str, native: str) -> str:
1002
+ return (
1003
+ f"./agent-config dispatch:hook "
1004
+ f"--platform gemini --event {ac_event} "
1005
+ f"--native-event {native}"
1006
+ )
1007
+
1008
+
1009
+ def _gemini_hooks_dict(command_factory) -> dict[str, list]:
1010
+ """Build the nested {event: [{matcher, hooks: [{type, command}]}]}
1011
+ payload Gemini expects. command_factory(ac_event, native) returns
1012
+ the command string for one binding."""
1013
+ out: dict[str, list] = {}
1014
+ for ac_event, native, matcher in GEMINI_DISPATCHER_BINDINGS:
1015
+ out.setdefault(native, []).append({
1016
+ "matcher": matcher,
1017
+ "hooks": [
1018
+ {
1019
+ "type": "command",
1020
+ "command": command_factory(ac_event, native),
1021
+ },
1022
+ ],
1023
+ })
1024
+ return out
1025
+
1026
+
1027
+ def ensure_gemini_bridge(project_root: Path, force: bool) -> None:
1028
+ """Deploy `.gemini/settings.json` (project scope) with the Phase 7
1029
+ universal dispatcher hooks.
1030
+
1031
+ Each Gemini lifecycle event is wired to a single
1032
+ `./agent-config dispatch:hook` invocation. Project-scope hooks
1033
+ fire with the project as cwd, so no trampoline is needed at this
1034
+ scope. Idempotent via deep_merge — rerunning replaces hook arrays
1035
+ rather than appending duplicates.
1036
+ """
1037
+ bridge = {"hooks": _gemini_hooks_dict(_gemini_dispatch_command)}
1038
+ merge_json_file(
1039
+ project_root / ".gemini" / "settings.json",
1040
+ bridge,
1041
+ force,
1042
+ ".gemini/settings.json",
1043
+ )
1044
+
1045
+
1046
+ # Gemini user-scope hooks live at `~/.gemini/settings.json` and fire
1047
+ # across every project the developer opens. The trampoline resolves
1048
+ # the active workspace from $PWD / .agent-settings.yml / payload.cwd
1049
+ # and routes the JSON into that project's `./agent-config dispatch:hook`.
1050
+ # Silent no-op when the workspace is not an agent-config consumer.
1051
+ GEMINI_USER_DIR = Path.home() / ".gemini"
1052
+ GEMINI_USER_HOOKS_DIR = GEMINI_USER_DIR / "hooks"
1053
+ GEMINI_DISPATCHER_TRAMPOLINE = "gemini-dispatcher.sh"
1054
+
1055
+
1056
+ def ensure_gemini_user_hooks(package_root: Path, force: bool) -> None:
1057
+ """Deploy the Gemini universal-dispatcher trampoline at user scope.
1058
+
1059
+ Phase 7.8 (hook-architecture-v1.md): mirrors ensure_windsurf_user_hooks
1060
+ for the Gemini surface. Writes:
1061
+ - ~/.gemini/hooks/gemini-dispatcher.sh (trampoline)
1062
+ - ~/.gemini/settings.json (event → trampoline call)
1063
+
1064
+ Each settings.json command line is `<dispatcher> <ac_event> <native>`
1065
+ so the trampoline forwards both names to the dispatcher for
1066
+ traceability. Hooks fire across all projects the developer opens.
1067
+ """
1068
+ src = package_root / "scripts" / "hooks" / GEMINI_DISPATCHER_TRAMPOLINE
1069
+ if not src.exists():
1070
+ skip(f"gemini trampoline missing in package: {src}")
1071
+ return
1072
+
1073
+ GEMINI_USER_HOOKS_DIR.mkdir(parents=True, exist_ok=True)
1074
+ dst = GEMINI_USER_HOOKS_DIR / GEMINI_DISPATCHER_TRAMPOLINE
1075
+ src_text = src.read_text(encoding="utf-8")
1076
+ if dst.exists() and dst.read_text(encoding="utf-8") == src_text and not force:
1077
+ skip(f"~/.gemini/hooks/{GEMINI_DISPATCHER_TRAMPOLINE} already up to date")
1078
+ else:
1079
+ dst.write_text(src_text, encoding="utf-8")
1080
+ dst.chmod(0o755)
1081
+ success(f"~/.gemini/hooks/{GEMINI_DISPATCHER_TRAMPOLINE} installed")
1082
+
1083
+ settings_patch = {
1084
+ "hooks": _gemini_hooks_dict(
1085
+ lambda ac_event, native: f"{dst} {ac_event} {native}",
1086
+ ),
1087
+ }
1088
+ merge_json_file(
1089
+ GEMINI_USER_DIR / "settings.json",
1090
+ settings_patch,
1091
+ force,
1092
+ "~/.gemini/settings.json",
1093
+ )
1094
+
1095
+
607
1096
  def ensure_copilot_bridge(project_root: Path, force: bool) -> None:
608
1097
  target = project_root / ".github" / "plugin" / "marketplace.json"
609
1098
 
@@ -627,6 +1116,107 @@ def ensure_copilot_bridge(project_root: Path, force: bool) -> None:
627
1116
  success(".github/plugin/marketplace.json created")
628
1117
 
629
1118
 
1119
+ # --- Post-install smoke test ---
1120
+
1121
+ # (platform, native event used for the dry-fire). Probe events are
1122
+ # chosen so the dispatcher resolves at least one concern per platform
1123
+ # from the canonical manifest. Copilot is intentionally excluded —
1124
+ # rule-only fallback per Phase 7.9.
1125
+ SMOKE_PROBE_EVENTS = (
1126
+ ("augment", "session_start"),
1127
+ ("claude", "SessionStart"),
1128
+ ("cursor", "beforeShellExecution"),
1129
+ ("cline", "session_start"),
1130
+ ("windsurf", "post_setup_worktree"),
1131
+ ("gemini", "SessionStart"),
1132
+ )
1133
+
1134
+ # Map platform → bridge file/dir we expect to exist before probing.
1135
+ # Mirrors PLATFORM_BRIDGES in scripts/hooks_status.py.
1136
+ SMOKE_BRIDGE_PATHS = {
1137
+ "augment": ".augment/settings.json",
1138
+ "claude": ".claude/settings.json",
1139
+ "cursor": ".cursor/hooks.json",
1140
+ "cline": ".clinerules/hooks",
1141
+ "windsurf": ".windsurf/hooks.json",
1142
+ "gemini": ".gemini/settings.json",
1143
+ }
1144
+
1145
+
1146
+ def _smoke_test_hooks(project_root: Path, package_root: Path) -> int:
1147
+ """Dry-fire dispatch_hook.py against every installed bridge.
1148
+
1149
+ Per Phase 7.12: uses `--dry-run` so resolution-only — no concern
1150
+ invocation, no state writes outside the dispatcher's own report.
1151
+ Failure is non-fatal (warn only); install always exits 0 even
1152
+ when smoke fails so consumers in restricted CI sandboxes are not
1153
+ blocked. CI-side strict mode lives in `hooks_status --strict`.
1154
+ """
1155
+ dispatcher = package_root / "scripts" / "hooks" / "dispatch_hook.py"
1156
+ manifest = package_root / "scripts" / "hook_manifest.yaml"
1157
+ if not dispatcher.is_file() or not manifest.is_file():
1158
+ return 0 # package layout doesn't ship the dispatcher; skip silently
1159
+
1160
+ failed: list[str] = []
1161
+ skipped: list[str] = []
1162
+ passed: list[str] = []
1163
+
1164
+ for platform, native in SMOKE_PROBE_EVENTS:
1165
+ rel_bridge = SMOKE_BRIDGE_PATHS.get(platform, "")
1166
+ bridge_path = project_root / rel_bridge if rel_bridge else None
1167
+ bridge_present = bool(
1168
+ bridge_path and (bridge_path.is_file() or
1169
+ (bridge_path.is_dir() and any(bridge_path.iterdir())))
1170
+ )
1171
+ if not bridge_present:
1172
+ skipped.append(platform)
1173
+ continue
1174
+ # Map native → agent-config event using the dispatcher's own
1175
+ # alias resolution. We re-use the dispatcher in --dry-run mode,
1176
+ # passing both --platform + --event=<canonical>. Since the
1177
+ # canonical event is what the manifest binds against, we feed
1178
+ # it directly: 'session_start' is the cross-platform anchor
1179
+ # that every bridge wires up. This avoids re-implementing
1180
+ # alias resolution here.
1181
+ cmd = [
1182
+ sys.executable, str(dispatcher),
1183
+ "--manifest", str(manifest),
1184
+ "--platform", platform,
1185
+ "--event", "session_start",
1186
+ "--native-event", native,
1187
+ "--dry-run",
1188
+ ]
1189
+ try:
1190
+ proc = subprocess.run(
1191
+ cmd, input="{}", capture_output=True, text=True,
1192
+ cwd=str(project_root), timeout=10, check=False,
1193
+ )
1194
+ except (OSError, subprocess.TimeoutExpired) as exc:
1195
+ failed.append(f"{platform}: {exc}")
1196
+ continue
1197
+ if proc.returncode != 0:
1198
+ failed.append(f"{platform}: exit={proc.returncode} {proc.stderr.strip()[:120]}")
1199
+ continue
1200
+ try:
1201
+ plan = json.loads(proc.stdout or "{}")
1202
+ except json.JSONDecodeError:
1203
+ failed.append(f"{platform}: dispatcher did not emit JSON plan")
1204
+ continue
1205
+ if not isinstance(plan.get("concerns"), list):
1206
+ failed.append(f"{platform}: plan.concerns missing or not a list")
1207
+ continue
1208
+ passed.append(platform)
1209
+
1210
+ if not QUIET:
1211
+ if passed:
1212
+ success(f"hook smoke passed: {', '.join(passed)}")
1213
+ if skipped:
1214
+ skip(f"hook smoke skipped (bridge not installed): {', '.join(skipped)}")
1215
+ for line in failed:
1216
+ warn(f"hook smoke failed — {line}")
1217
+ return 1 if failed else 0
1218
+
1219
+
630
1220
  # --- Argument parsing ---
631
1221
 
632
1222
  def parse_options(argv: list[str]) -> argparse.Namespace:
@@ -647,9 +1237,34 @@ def parse_options(argv: list[str]) -> argparse.Namespace:
647
1237
  action="store_true",
648
1238
  help="also deploy ~/.augment/settings.json + ~/.augment/hooks/ (user-scope, all projects)",
649
1239
  )
1240
+ parser.add_argument(
1241
+ "--cursor-user-hooks",
1242
+ action="store_true",
1243
+ help="also deploy ~/.cursor/hooks.json + ~/.cursor/hooks/cursor-dispatcher.sh (user-scope, all projects)",
1244
+ )
1245
+ parser.add_argument(
1246
+ "--cline-user-hooks",
1247
+ action="store_true",
1248
+ help="also deploy ~/Documents/Cline/Hooks/ trampoline + per-event wrappers (user-scope, all projects)",
1249
+ )
1250
+ parser.add_argument(
1251
+ "--windsurf-user-hooks",
1252
+ action="store_true",
1253
+ help="also deploy ~/.codeium/windsurf/hooks.json + hooks/windsurf-dispatcher.sh (user-scope, all projects)",
1254
+ )
1255
+ parser.add_argument(
1256
+ "--gemini-user-hooks",
1257
+ action="store_true",
1258
+ help="also deploy ~/.gemini/settings.json + ~/.gemini/hooks/gemini-dispatcher.sh (user-scope, all projects)",
1259
+ )
650
1260
  parser.add_argument("--project", default=None, help="project root (default: cwd or PROJECT_ROOT env)")
651
1261
  parser.add_argument("--package", default=None, help="package root (default: auto-detect under project)")
652
1262
  parser.add_argument("--quiet", action="store_true", help="suppress info/success output (warnings/errors still shown)")
1263
+ parser.add_argument(
1264
+ "--no-smoke",
1265
+ action="store_true",
1266
+ help="skip the post-install hook smoke test (default: dry-fire dispatch:hook against every installed bridge)",
1267
+ )
653
1268
  return parser.parse_args(argv)
654
1269
 
655
1270
 
@@ -690,11 +1305,33 @@ def main(argv: list[str]) -> int:
690
1305
  ensure_vscode_bridge(project_root, package_type, opts.force)
691
1306
  ensure_augment_bridge(project_root, opts.force)
692
1307
  ensure_claude_bridge(project_root, opts.force)
1308
+ ensure_cursor_bridge(project_root, opts.force)
1309
+ ensure_cline_bridge(project_root, opts.force)
1310
+ ensure_windsurf_bridge(project_root, opts.force)
1311
+ ensure_gemini_bridge(project_root, opts.force)
693
1312
  ensure_copilot_bridge(project_root, opts.force)
694
1313
 
695
1314
  if opts.augment_user_hooks:
696
1315
  ensure_augment_user_hooks(package_root, opts.force)
697
1316
 
1317
+ if opts.cursor_user_hooks:
1318
+ ensure_cursor_user_hooks(package_root, opts.force)
1319
+
1320
+ if opts.cline_user_hooks:
1321
+ ensure_cline_user_hooks(package_root, opts.force)
1322
+
1323
+ if opts.windsurf_user_hooks:
1324
+ ensure_windsurf_user_hooks(package_root, opts.force)
1325
+
1326
+ if opts.gemini_user_hooks:
1327
+ ensure_gemini_user_hooks(package_root, opts.force)
1328
+
1329
+ if not opts.skip_bridges and not opts.no_smoke:
1330
+ if not QUIET:
1331
+ print()
1332
+ info("Smoke-testing installed hook bridges (dry-run)")
1333
+ _smoke_test_hooks(project_root, package_root)
1334
+
698
1335
  if not QUIET:
699
1336
  print()
700
1337
  success("Done.")
@@ -707,6 +1344,7 @@ def main(argv: list[str]) -> int:
707
1344
  print(" Next steps:")
708
1345
  print(" • Commit .agent-settings.yml and bridge files to your repo")
709
1346
  print(" • New team members just run composer install / npm install — done")
1347
+ print(" • Inspect hook coverage: ./agent-config hooks:status")
710
1348
  print(" • Full walkthrough: https://github.com/event4u-app/agent-config/blob/main/docs/getting-started.md")
711
1349
  print()
712
1350
  return 0