@delorenj/pjangler 1.1.5 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/.mise/scripts/link-agentfiles.sh +9 -0
  2. package/dist/index.js +1492 -358
  3. package/dist/mcp-server.js +2391 -290
  4. package/package.json +5 -2
  5. package/templates/commonproject/.mise/scripts/create-plane-project.sh +26 -0
  6. package/templates/commonproject/copier.yml +2 -0
  7. package/templates/commonproject/template/.agents/hooks/README.md +151 -0
  8. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +63 -0
  9. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-journal-write.sh +327 -0
  10. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-recall.sh +180 -0
  11. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-retain.sh +74 -0
  12. package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-session-end.sh +57 -0
  13. package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-bank.sh +57 -0
  14. package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-journal.sh +48 -0
  15. package/templates/commonproject/template/.agents/hooks/hooks.master.json +97 -0
  16. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +30 -0
  17. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +51 -0
  18. package/templates/commonproject/template/.agents/hooks/reminder-for-skill-check.sh +20 -0
  19. package/templates/commonproject/template/.agents/hooks/sync.py +618 -0
  20. package/templates/commonproject/template/.agents/local.example.json +15 -0
  21. package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +62 -0
  22. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +110 -0
  23. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +45 -0
  24. package/templates/commonproject/template/mise.toml.jinja +42 -0
  25. package/templates/hermes-agent/.codegraph/daemon.pid +6 -0
  26. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +10 -0
  27. package/templates/hermes-agent/README.md +16 -14
  28. package/templates/hermes-agent/copier.yml +7 -25
  29. package/templates/hermes-agent/docs/architecture.md +16 -18
  30. package/templates/hermes-agent/docs/fleet-control-plane/README.md +35 -0
  31. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +253 -0
  32. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +280 -0
  33. package/templates/hermes-agent/docs/fleet-control-plane/implementation-readiness-report-2026-06-27.md +118 -0
  34. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +141 -0
  35. package/templates/hermes-agent/docs/operations.md +17 -19
  36. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +17 -13
  37. package/templates/hermes-agent/docs/{scrum-master → sentinel}/README.md +47 -46
  38. package/templates/hermes-agent/docs/{scrum-master → sentinel}/architecture.md +39 -38
  39. package/templates/hermes-agent/docs/{scrum-master → sentinel}/development.md +63 -99
  40. package/templates/hermes-agent/docs/{scrum-master → sentinel}/providers.md +2 -2
  41. package/templates/hermes-agent/docs/sentinel.md +18 -0
  42. package/templates/hermes-agent/install-local.sh +22 -20
  43. package/templates/hermes-agent/runtime-scaffold/README.md +10 -9
  44. package/templates/hermes-agent/scripts/backfill-fleet-sot.sh +12 -21
  45. package/templates/hermes-agent/scripts/fleet-sync.sh +369 -0
  46. package/templates/hermes-agent/scripts/migrate-unify.sh +252 -0
  47. package/templates/hermes-agent/scripts/unify-pm.sh +148 -0
  48. package/templates/hermes-agent/template/.runtime-scaffold/README.md +10 -9
  49. package/templates/hermes-agent/template/.scripts/01-config.sh +2 -3
  50. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -2
  51. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +47 -19
  52. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +71 -27
  53. package/templates/hermes-agent/template/.scripts/30-telegram.sh +3 -3
  54. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +3 -3
  55. package/templates/hermes-agent/template/.scripts/70-systemd.sh +48 -43
  56. package/templates/hermes-agent/template/.scripts/80-registry.sh +2 -2
  57. package/templates/hermes-agent/template/.scripts/99-summary.sh +3 -4
  58. package/templates/hermes-agent/template/.scripts/_lib.sh +4 -19
  59. package/templates/hermes-agent/template/.scripts/checkpoint.sh +36 -0
  60. package/templates/hermes-agent/template/.scripts/config.example.toml +11 -5
  61. package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.sh → heartbeat.sh} +84 -31
  62. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +1 -1
  63. package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -3
  64. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/emit-event.py +5 -5
  65. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-autonomous-review.sh +6 -6
  66. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-close-gate.sh +2 -2
  67. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/autonomous-delegated-review.md +7 -7
  68. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/bloodbank-events.md +3 -3
  69. package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/continuous-ticket-orchestration.md +6 -6
  70. package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.prompt.md.jinja → sentinel.prompt.md.jinja} +5 -4
  71. package/templates/hermes-agent/template/SOUL.md.jinja +3 -6
  72. package/templates/hermes-agent/template/hermes.jinja +6 -15
  73. package/templates/hermes-agent/template/role.yaml.jinja +9 -8
  74. package/templates/commonproject/.claude/commands/bmad/bmb/agents/agent-builder.md +0 -14
  75. package/templates/commonproject/.claude/commands/bmad/bmb/agents/module-builder.md +0 -14
  76. package/templates/commonproject/.claude/commands/bmad/bmb/agents/workflow-builder.md +0 -14
  77. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/agent.md +0 -5
  78. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/module.md +0 -5
  79. package/templates/commonproject/.claude/commands/bmad/bmb/workflows/workflow.md +0 -5
  80. package/templates/commonproject/.claude/commands/bmad/bmm/agents/analyst.md +0 -14
  81. package/templates/commonproject/.claude/commands/bmad/bmm/agents/architect.md +0 -14
  82. package/templates/commonproject/.claude/commands/bmad/bmm/agents/dev.md +0 -14
  83. package/templates/commonproject/.claude/commands/bmad/bmm/agents/pm.md +0 -14
  84. package/templates/commonproject/.claude/commands/bmad/bmm/agents/quick-flow-solo-dev.md +0 -14
  85. package/templates/commonproject/.claude/commands/bmad/bmm/agents/sm.md +0 -14
  86. package/templates/commonproject/.claude/commands/bmad/bmm/agents/tea.md +0 -14
  87. package/templates/commonproject/.claude/commands/bmad/bmm/agents/tech-writer.md +0 -14
  88. package/templates/commonproject/.claude/commands/bmad/bmm/agents/ux-designer.md +0 -14
  89. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/check-implementation-readiness.md +0 -5
  90. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/code-review.md +0 -13
  91. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/correct-course.md +0 -13
  92. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-architecture.md +0 -5
  93. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-epics-and-stories.md +0 -5
  94. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-dataflow.md +0 -13
  95. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-diagram.md +0 -13
  96. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-flowchart.md +0 -13
  97. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-wireframe.md +0 -13
  98. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-product-brief.md +0 -5
  99. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-story.md +0 -13
  100. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-ux-design.md +0 -5
  101. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/dev-story.md +0 -13
  102. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/document-project.md +0 -13
  103. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/generate-project-context.md +0 -5
  104. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/prd.md +0 -5
  105. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-dev.md +0 -5
  106. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-spec.md +0 -5
  107. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/research.md +0 -5
  108. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/retrospective.md +0 -13
  109. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-planning.md +0 -13
  110. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-status.md +0 -13
  111. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-atdd.md +0 -13
  112. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-automate.md +0 -13
  113. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-ci.md +0 -13
  114. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-framework.md +0 -13
  115. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-nfr.md +0 -13
  116. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-design.md +0 -13
  117. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-review.md +0 -13
  118. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-trace.md +0 -13
  119. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-init.md +0 -13
  120. package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-status.md +0 -13
  121. package/templates/commonproject/.claude/commands/bmad/cis/agents/brainstorming-coach.md +0 -14
  122. package/templates/commonproject/.claude/commands/bmad/cis/agents/creative-problem-solver.md +0 -14
  123. package/templates/commonproject/.claude/commands/bmad/cis/agents/design-thinking-coach.md +0 -14
  124. package/templates/commonproject/.claude/commands/bmad/cis/agents/innovation-strategist.md +0 -14
  125. package/templates/commonproject/.claude/commands/bmad/cis/agents/presentation-master.md +0 -14
  126. package/templates/commonproject/.claude/commands/bmad/cis/agents/storyteller.md +0 -14
  127. package/templates/commonproject/.claude/commands/bmad/cis/workflows/design-thinking.md +0 -13
  128. package/templates/commonproject/.claude/commands/bmad/cis/workflows/innovation-strategy.md +0 -13
  129. package/templates/commonproject/.claude/commands/bmad/cis/workflows/problem-solving.md +0 -13
  130. package/templates/commonproject/.claude/commands/bmad/cis/workflows/storytelling.md +0 -13
  131. package/templates/commonproject/.claude/commands/bmad/core/agents/bmad-master.md +0 -14
  132. package/templates/commonproject/.claude/commands/bmad/core/tasks/index-docs.md +0 -9
  133. package/templates/commonproject/.claude/commands/bmad/core/tasks/shard-doc.md +0 -9
  134. package/templates/commonproject/.claude/commands/bmad/core/workflows/brainstorming.md +0 -5
  135. package/templates/commonproject/.claude/commands/bmad/core/workflows/party-mode.md +0 -5
  136. package/templates/commonproject/.claude/commands/bmad/custom/workflows/ticket-lifecycle.md +0 -5
  137. package/templates/hermes-agent/docs/scrum-master-sentinel.md +0 -17
  138. package/templates/hermes-agent/template/.scripts/75-scrum-master.sh +0 -117
  139. package/templates/hermes-agent/template/.scripts/90-chain-scrum-master.sh +0 -45
@@ -0,0 +1,618 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ sync.py — project-scoped agent-hooks fan-out engine for CoachingAgentFramework.
4
+
5
+ ONE source of truth (`hooks.master.json`) is propagated to each agent CLI's
6
+ native hook-config dialect. This is the /ssot-fanout pattern, right-sized for a
7
+ handful of hooks: the master is the only hand-edited artifact; every per-agent
8
+ config is GENERATED and idempotent (re-running with an unchanged master writes
9
+ zero bytes).
10
+
11
+ Targets (v1):
12
+ - claude : the repo's committed `.claude/settings.json` `hooks` key
13
+ (uses $CLAUDE_PROJECT_DIR; all devs get it with no bootstrap)
14
+ - codex : injected into the per-user `~/.codex/hooks.json` on enter,
15
+ removed on leave (absolute paths; no project-scoped codex file exists)
16
+ - hermes : STUB — bindings are printed only (payload adaptation deferred)
17
+
18
+ Commands:
19
+ --install Regenerate claude settings + inject codex hooks (mise `enter`).
20
+ --uninstall Remove the codex injection (mise `leave`). Claude's committed
21
+ settings are left in place.
22
+ --check Read-only drift gate: nonzero exit if the committed claude
23
+ settings don't match the master, or (if codex is installed) the
24
+ codex injection is missing/stale. For CI.
25
+ --quiet Suppress normal output (errors still print to stderr).
26
+
27
+ Env:
28
+ CAF_HOOKS_SKIP_CODEX=1 Skip the codex target entirely.
29
+
30
+ install/uninstall never hard-fail the shell (exit 0 on internal error, warn to
31
+ stderr); --check returns nonzero on drift so it can gate CI.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import json
37
+ import os
38
+ import re
39
+ import sys
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+
43
+ HOOK_DIR = Path(__file__).resolve().parent # <repo>/.agents/hooks
44
+ REPO_ROOT = HOOK_DIR.parent.parent # <repo>
45
+ MASTER = HOOK_DIR / "hooks.master.json"
46
+
47
+ QUIET = False
48
+
49
+
50
+ def log(msg: str) -> None:
51
+ if not QUIET:
52
+ print(msg)
53
+
54
+
55
+ def warn(msg: str) -> None:
56
+ print(f"[caf-hooks] {msg}", file=sys.stderr)
57
+
58
+
59
+ def load_master() -> dict:
60
+ with MASTER.open() as fh:
61
+ return json.load(fh)
62
+
63
+
64
+ def _now_iso() -> str:
65
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
66
+
67
+
68
+ def load_local() -> dict:
69
+ """Per-dev overrides from .agents/local.json (gitignored). Fails open.
70
+
71
+ Note: individual disabled HOOKS are enforced at RUNTIME by lib/hook-guard.sh
72
+ (so even Claude's committed hooks honor them); this only needs disabled
73
+ AGENTS, which gate install. CAF_HOOKS_SKIP_CODEX=1 is an env shortcut.
74
+ """
75
+ disabled_agents: set[str] = set()
76
+ p = REPO_ROOT / ".agents" / "local.json"
77
+ if p.exists():
78
+ try:
79
+ data = json.loads(p.read_text() or "{}")
80
+ disabled_agents = set((data.get("hooks") or {}).get("disabled_agents") or [])
81
+ except (json.JSONDecodeError, OSError) as exc:
82
+ warn(f"ignoring malformed .agents/local.json: {exc}")
83
+ if os.environ.get("CAF_HOOKS_SKIP_CODEX") == "1":
84
+ disabled_agents.add("codex")
85
+ return {"disabled_agents": disabled_agents}
86
+
87
+
88
+ # --------------------------------------------------------------------------- #
89
+ # Shared: turn the master into per-event hook groups for a given agent.
90
+ # Returns {event_name: [ {matcher?, hooks:[{type,command,timeout}]} ]}.
91
+ # Hooks sharing an event + matcher are merged into one group (Claude/Codex shape).
92
+ # --------------------------------------------------------------------------- #
93
+ def build_event_groups(master: dict, agent_key: str) -> dict:
94
+ agent = master["agents"][agent_key]
95
+ base_dir = agent["base_dir"].replace("{repo}", str(REPO_ROOT))
96
+ unit = agent.get("timeout_unit", "s")
97
+ ev_map = agent["lifecycle_events"]
98
+
99
+ # Preserve master order; bucket by (event, matcher).
100
+ buckets: "dict[tuple[str, str | None], list[dict]]" = {}
101
+ order: list[tuple[str, str | None]] = []
102
+
103
+ for hook in master["hooks"]:
104
+ lifecycle = hook["lifecycle"]
105
+ event = ev_map.get(lifecycle)
106
+ if not event: # this agent doesn't map this lifecycle (e.g. hermes stub)
107
+ continue
108
+ matcher = hook.get("matcher")
109
+ timeout = hook["timeout_s"] * (1000 if unit == "ms" else 1)
110
+ # Guard wrapper => per-dev runtime opt-out via .agents/local.json. The
111
+ # guard execs the real script (stdin passes through) unless the hook id
112
+ # is disabled, in which case it exits 0 silently (no error noise).
113
+ command = f"{base_dir}/lib/hook-guard.sh {hook['id']} {base_dir}/{hook['script']}"
114
+ key = (event, matcher)
115
+ if key not in buckets:
116
+ buckets[key] = []
117
+ order.append(key)
118
+ buckets[key].append(
119
+ {"type": "command", "command": command, "timeout": timeout}
120
+ )
121
+
122
+ groups: dict[str, list[dict]] = {}
123
+ for (event, matcher) in order:
124
+ group: dict = {}
125
+ if matcher is not None:
126
+ group["matcher"] = matcher
127
+ group["hooks"] = buckets[(event, matcher)]
128
+ groups.setdefault(event, []).append(group)
129
+ return groups
130
+
131
+
132
+ # --------------------------------------------------------------------------- #
133
+ # Claude dialect: own the `hooks` key of the committed .claude/settings.json.
134
+ # --------------------------------------------------------------------------- #
135
+ def render_claude(master: dict) -> dict:
136
+ target = REPO_ROOT / master["agents"]["claude"]["config_target"]
137
+ settings = {}
138
+ if target.exists():
139
+ try:
140
+ settings = json.loads(target.read_text() or "{}")
141
+ except json.JSONDecodeError:
142
+ warn(f"{target} is not valid JSON; refusing to overwrite")
143
+ return {"target": target, "changed": False, "error": True}
144
+ settings["hooks"] = build_event_groups(master, "claude")
145
+ return {"target": target, "settings": settings, "changed": True}
146
+
147
+
148
+ def claude_serialized(master: dict) -> str | None:
149
+ r = render_claude(master)
150
+ if r.get("error"):
151
+ return None
152
+ return json.dumps(r["settings"], indent=2) + "\n"
153
+
154
+
155
+ def install_claude(master: dict) -> None:
156
+ target = REPO_ROOT / master["agents"]["claude"]["config_target"]
157
+ desired = claude_serialized(master)
158
+ if desired is None:
159
+ return
160
+ current = target.read_text() if target.exists() else None
161
+ if current == desired:
162
+ log(f"claude: up to date ({target.relative_to(REPO_ROOT)})")
163
+ return
164
+ target.parent.mkdir(parents=True, exist_ok=True)
165
+ target.write_text(desired)
166
+ log(f"claude: wrote {target.relative_to(REPO_ROOT)}")
167
+
168
+
169
+ # --------------------------------------------------------------------------- #
170
+ # Codex dialect: inject/remove absolute-path entries in ~/.codex/hooks.json.
171
+ # Our entries are identified by the command containing the repo marker path.
172
+ # --------------------------------------------------------------------------- #
173
+ def codex_target(master: dict) -> Path:
174
+ return Path(os.path.expanduser(master["agents"]["codex"]["config_target"]))
175
+
176
+
177
+ def codex_marker() -> str:
178
+ return f"{REPO_ROOT}/.agents/hooks/"
179
+
180
+
181
+ def _strip_caf_hooks(hooks_obj: dict, marker: str) -> bool:
182
+ """Remove our hooks from every event; drop emptied groups. Returns changed."""
183
+ changed = False
184
+ for event, groups in list(hooks_obj.items()):
185
+ new_groups = []
186
+ for group in groups:
187
+ kept = [h for h in group.get("hooks", []) if marker not in h.get("command", "")]
188
+ if len(kept) != len(group.get("hooks", [])):
189
+ changed = True
190
+ if kept:
191
+ g = dict(group)
192
+ g["hooks"] = kept
193
+ new_groups.append(g)
194
+ elif not group.get("hooks"):
195
+ new_groups.append(group) # preserve foreign empty groups untouched
196
+ if new_groups:
197
+ hooks_obj[event] = new_groups
198
+ else:
199
+ del hooks_obj[event]
200
+ changed = True
201
+ return changed
202
+
203
+
204
+ def _codex_load(target: Path) -> dict:
205
+ if target.exists():
206
+ try:
207
+ return json.loads(target.read_text() or "{}")
208
+ except json.JSONDecodeError:
209
+ warn(f"{target} is not valid JSON; skipping codex")
210
+ return {"__error__": True}
211
+ return {"hooks": {}}
212
+
213
+
214
+ def install_codex(master: dict) -> None:
215
+ if os.environ.get("CAF_HOOKS_SKIP_CODEX") == "1":
216
+ log("codex: skipped (CAF_HOOKS_SKIP_CODEX=1)")
217
+ return
218
+ target = codex_target(master)
219
+ if not target.parent.exists():
220
+ log("codex: ~/.codex not present, skipping")
221
+ return
222
+ data = _codex_load(target)
223
+ if data.get("__error__"):
224
+ return
225
+ hooks_obj = data.setdefault("hooks", {})
226
+ marker = codex_marker()
227
+
228
+ # Idempotent: remove any prior CAF entries, then append fresh ones.
229
+ _strip_caf_hooks(hooks_obj, marker)
230
+ desired = build_event_groups(master, "codex")
231
+ for event, groups in desired.items():
232
+ hooks_obj.setdefault(event, [])
233
+ hooks_obj[event].extend(groups)
234
+
235
+ serialized = json.dumps(data, indent=2) + "\n"
236
+ if target.exists() and target.read_text() == serialized:
237
+ log(f"codex: up to date ({target})")
238
+ return
239
+ backup = target.with_suffix(target.suffix + ".caf-bak")
240
+ if target.exists() and not backup.exists():
241
+ backup.write_text(target.read_text())
242
+ log(f"codex: backed up -> {backup}")
243
+ target.write_text(serialized)
244
+ log(f"codex: injected project hooks into {target}")
245
+
246
+
247
+ def uninstall_codex(master: dict) -> None:
248
+ target = codex_target(master)
249
+ if not target.exists():
250
+ return
251
+ data = _codex_load(target)
252
+ if data.get("__error__"):
253
+ return
254
+ hooks_obj = data.get("hooks", {})
255
+ if _strip_caf_hooks(hooks_obj, codex_marker()):
256
+ data["hooks"] = hooks_obj
257
+ target.write_text(json.dumps(data, indent=2) + "\n")
258
+ log(f"codex: removed project hooks from {target}")
259
+ else:
260
+ log("codex: nothing to remove")
261
+
262
+
263
+ # --------------------------------------------------------------------------- #
264
+ # Kimi dialect: a sentinel-bounded [[hooks]] block in ~/.kimi-code/config.toml.
265
+ # Kimi runs hooks shell:true with the payload JSON on stdin and uses Claude-
266
+ # identical events + payload (incl. .prompt / .tool_input), so it reuses the SAME
267
+ # guard-wrapped scripts as claude/codex. Pure-text injection — no TOML library,
268
+ # preserves the rest of config.toml byte-for-byte, fully reversible.
269
+ # --------------------------------------------------------------------------- #
270
+ def kimi_target(master: dict) -> Path:
271
+ agent = master["agents"]["kimi"]
272
+ home = os.environ.get(agent.get("config_home_env") or "")
273
+ if home:
274
+ return Path(home) / "config.toml"
275
+ return Path(os.path.expanduser(agent["config_target"]))
276
+
277
+
278
+ def _kimi_markers(master: dict) -> tuple[str, str]:
279
+ name = REPO_ROOT.name
280
+ return (
281
+ f"# >>> {master['marker']} BEGIN ({name}) — generated by .agents/hooks/sync.py; do not edit",
282
+ f"# <<< {master['marker']} END ({name})",
283
+ )
284
+
285
+
286
+ def kimi_block(master: dict) -> str:
287
+ agent = master["agents"]["kimi"]
288
+ base = agent["base_dir"].replace("{repo}", str(REPO_ROOT))
289
+ unit = agent.get("timeout_unit", "s")
290
+ ev = agent["lifecycle_events"]
291
+ begin, end = _kimi_markers(master)
292
+ lines = [begin]
293
+ for hook in master["hooks"]:
294
+ event = ev.get(hook["lifecycle"])
295
+ if not event:
296
+ continue
297
+ timeout = hook["timeout_s"] * (1000 if unit == "ms" else 1)
298
+ # Guard wrapper => per-dev opt-out; shell:true so the two-path command works.
299
+ cmd = f"{base}/lib/hook-guard.sh {hook['id']} {base}/{hook['script']}"
300
+ lines.append("[[hooks]]")
301
+ lines.append(f'event = "{event}"')
302
+ if hook.get("matcher"):
303
+ lines.append(f'matcher = "{hook["matcher"]}"')
304
+ lines.append(f'command = "{cmd}"') # paths have no quotes/backslashes -> safe TOML string
305
+ lines.append(f"timeout = {timeout}")
306
+ lines.append("")
307
+ lines.append(end)
308
+ return "\n".join(lines) + "\n"
309
+
310
+
311
+ def _strip_kimi_block(text: str, master: dict) -> tuple[str, bool]:
312
+ # Removes exactly the one "\n" separator install prepends + our block + its
313
+ # trailing newline, leaving the rest of the file byte-for-byte intact.
314
+ begin, end = _kimi_markers(master)
315
+ pattern = re.compile(r"\n" + re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
316
+ new, n = pattern.subn("", text)
317
+ return new, n > 0
318
+
319
+
320
+ def install_kimi(master: dict) -> None:
321
+ target = kimi_target(master)
322
+ if not target.parent.exists():
323
+ log("kimi: ~/.kimi-code not present, skipping")
324
+ return
325
+ text = target.read_text() if target.exists() else ""
326
+ body, _ = _strip_kimi_block(text, master) # work against config minus our block
327
+ # Refuse if a foreign hooks definition exists (would collide with [[hooks]]).
328
+ if re.search(r"(?m)^\s*hooks\s*=\s*\[[^\]]", body) or re.search(r"(?m)^\s*\[\[hooks\]\]", body):
329
+ warn("kimi: existing non-CAF hooks found in config.toml; skipping to avoid a TOML conflict")
330
+ return
331
+ body = re.sub(r"(?m)^\s*hooks\s*=\s*\[\s*\]\s*\n", "", body) # drop an empty `hooks = []`
332
+ if body and not body.endswith("\n"):
333
+ body += "\n"
334
+ # Append "\n" + block (block ends with "\n"); the lone "\n" is what strip removes,
335
+ # so the body above is preserved verbatim on uninstall.
336
+ new = body + "\n" + kimi_block(master)
337
+ if text == new:
338
+ log(f"kimi: up to date ({target})")
339
+ return
340
+ backup = target.with_suffix(target.suffix + ".caf-bak")
341
+ if target.exists() and not backup.exists():
342
+ backup.write_text(text)
343
+ log(f"kimi: backed up -> {backup}")
344
+ target.write_text(new)
345
+ log(f"kimi: injected project hooks into {target}")
346
+
347
+
348
+ def uninstall_kimi(master: dict) -> None:
349
+ target = kimi_target(master)
350
+ if not target.exists():
351
+ return
352
+ text = target.read_text()
353
+ new, changed = _strip_kimi_block(text, master)
354
+ if changed:
355
+ target.write_text(new if new.endswith("\n") else new + "\n")
356
+ log(f"kimi: removed project hooks from {target}")
357
+ else:
358
+ log("kimi: nothing to remove")
359
+
360
+
361
+ # --------------------------------------------------------------------------- #
362
+ # Hermes dialect: merge the adapter into the per-deployment runtime config.yaml
363
+ # + shell-hooks-allowlist.json. Hermes runs hooks shell=False with the payload
364
+ # on stdin, so it uses the ADAPTER (hermes/hindsight-hook.sh <event>), not the
365
+ # Claude-shaped scripts. Our entries are tagged by the repo marker in the command.
366
+ # --------------------------------------------------------------------------- #
367
+ def hermes_commands(master: dict) -> list[tuple[str, str, int]]:
368
+ h = master["agents"]["hermes"]
369
+ runner = h["runner"].replace("{repo}", str(REPO_ROOT))
370
+ timeout = h.get("timeout_s", 5)
371
+ return [
372
+ (event, f"{runner} {event}", timeout)
373
+ for _lifecycle, event in h["lifecycle_events"].items()
374
+ ]
375
+
376
+
377
+ def _backup_once(path: Path) -> None:
378
+ backup = path.with_suffix(path.suffix + ".caf-bak")
379
+ if path.exists() and not backup.exists():
380
+ backup.write_text(path.read_text())
381
+ log(f"hermes: backed up -> {backup.name}")
382
+
383
+
384
+ def _load_yaml(path: Path):
385
+ import yaml # lazy: only hermes needs it
386
+ return yaml.safe_load(path.read_text()) or {}
387
+
388
+
389
+ def install_hermes(master: dict) -> None:
390
+ h = master["agents"]["hermes"]
391
+ cfg = REPO_ROOT / h["config_target"]
392
+ allow = REPO_ROOT / h["allowlist_target"]
393
+ if not cfg.exists():
394
+ log("hermes: runtime config.yaml not present, skipping")
395
+ return
396
+ try:
397
+ import yaml
398
+ except ImportError:
399
+ warn("hermes: pyyaml not available, skipping (pip install pyyaml)")
400
+ return
401
+ marker = codex_marker()
402
+ cmds = hermes_commands(master)
403
+
404
+ # --- config.yaml hooks block ---
405
+ try:
406
+ data = _load_yaml(cfg)
407
+ except Exception as exc: # noqa: BLE001
408
+ warn(f"hermes: could not parse {cfg}: {exc}")
409
+ return
410
+ hooks = data.setdefault("hooks", {})
411
+ cfg_changed = False
412
+ for event, command, timeout in cmds:
413
+ entries = hooks.setdefault(event, [])
414
+ ours = [e for e in entries if marker in e.get("command", "")]
415
+ if not ours:
416
+ entries.append({"command": command, "timeout": timeout})
417
+ cfg_changed = True
418
+ elif ours[0].get("command") != command: # drifted -> refresh
419
+ ours[0]["command"] = command
420
+ ours[0]["timeout"] = timeout
421
+ cfg_changed = True
422
+
423
+ # --- shell-hooks-allowlist.json (pre-approve so Hermes won't prompt) ---
424
+ allow_data = {"approvals": []}
425
+ if allow.exists():
426
+ try:
427
+ allow_data = json.loads(allow.read_text() or '{"approvals": []}')
428
+ except json.JSONDecodeError:
429
+ allow_data = {"approvals": []}
430
+ approvals = allow_data.setdefault("approvals", [])
431
+ allow_changed = False
432
+ for event, command, _timeout in cmds:
433
+ if not any(a.get("command") == command and a.get("event") == event for a in approvals):
434
+ approvals.append({
435
+ "approved_at": _now_iso(),
436
+ "approved_by": master["marker"],
437
+ "command": command,
438
+ "event": event,
439
+ })
440
+ allow_changed = True
441
+
442
+ if cfg_changed:
443
+ _backup_once(cfg)
444
+ cfg.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=False, allow_unicode=True))
445
+ log(f"hermes: merged adapter into {cfg.relative_to(REPO_ROOT)}")
446
+ else:
447
+ log("hermes: config.yaml up to date")
448
+ if allow_changed:
449
+ _backup_once(allow)
450
+ allow.write_text(json.dumps(allow_data, indent=2) + "\n")
451
+ log(f"hermes: pre-approved adapter in {allow.relative_to(REPO_ROOT)}")
452
+
453
+
454
+ def uninstall_hermes(master: dict) -> None:
455
+ h = master["agents"]["hermes"]
456
+ cfg = REPO_ROOT / h["config_target"]
457
+ allow = REPO_ROOT / h["allowlist_target"]
458
+ marker = codex_marker()
459
+ try:
460
+ import yaml
461
+ except ImportError:
462
+ return
463
+ if cfg.exists():
464
+ try:
465
+ data = _load_yaml(cfg)
466
+ except Exception: # noqa: BLE001
467
+ data = None
468
+ if data and isinstance(data.get("hooks"), dict):
469
+ changed = False
470
+ for event, entries in list(data["hooks"].items()):
471
+ kept = [e for e in entries if marker not in e.get("command", "")]
472
+ if len(kept) != len(entries):
473
+ changed = True
474
+ if kept:
475
+ data["hooks"][event] = kept
476
+ else:
477
+ del data["hooks"][event]
478
+ if changed:
479
+ cfg.write_text(yaml.safe_dump(data, sort_keys=False, default_flow_style=False, allow_unicode=True))
480
+ log(f"hermes: removed adapter from {cfg.relative_to(REPO_ROOT)}")
481
+ if allow.exists():
482
+ try:
483
+ allow_data = json.loads(allow.read_text() or '{"approvals": []}')
484
+ except json.JSONDecodeError:
485
+ allow_data = None
486
+ if allow_data and isinstance(allow_data.get("approvals"), list):
487
+ kept = [a for a in allow_data["approvals"] if marker not in a.get("command", "")]
488
+ if len(kept) != len(allow_data["approvals"]):
489
+ allow_data["approvals"] = kept
490
+ allow.write_text(json.dumps(allow_data, indent=2) + "\n")
491
+ log(f"hermes: removed adapter approvals from {allow.relative_to(REPO_ROOT)}")
492
+
493
+
494
+ # --------------------------------------------------------------------------- #
495
+ # Check (drift gate)
496
+ # --------------------------------------------------------------------------- #
497
+ def cmd_check(master: dict) -> int:
498
+ rc = 0
499
+ # Claude: committed settings must match the master.
500
+ target = REPO_ROOT / master["agents"]["claude"]["config_target"]
501
+ desired = claude_serialized(master)
502
+ if desired is None:
503
+ rc = 1
504
+ else:
505
+ current = target.read_text() if target.exists() else None
506
+ if current != desired:
507
+ warn(f"DRIFT: {target.relative_to(REPO_ROOT)} differs from hooks.master.json "
508
+ f"(run `mise run hooks-sync`)")
509
+ rc = 1
510
+ else:
511
+ log(f"claude: in sync ({target.relative_to(REPO_ROOT)})")
512
+ disabled = load_local()["disabled_agents"]
513
+
514
+ # Codex: only checked if installed locally (CI has no ~/.codex) and not opted out.
515
+ if "codex" not in disabled:
516
+ ct = codex_target(master)
517
+ if ct.exists():
518
+ data = _codex_load(ct)
519
+ if not data.get("__error__"):
520
+ present = json.dumps(data)
521
+ want = build_event_groups(master, "codex")
522
+ missing = [
523
+ h["command"]
524
+ for groups in want.values()
525
+ for g in groups
526
+ for h in g["hooks"]
527
+ if h["command"] not in present
528
+ ]
529
+ if missing:
530
+ warn(f"DRIFT: codex hooks.json missing {len(missing)} project hook(s) "
531
+ f"(run `mise run hooks-sync`)")
532
+ rc = 1
533
+ else:
534
+ log(f"codex: in sync ({ct})")
535
+
536
+ # Kimi: only checked if its per-user config exists and not opted out.
537
+ if "kimi" not in disabled:
538
+ kt = kimi_target(master)
539
+ if kt.exists():
540
+ present = kt.read_text()
541
+ begin, _end = _kimi_markers(master)
542
+ base = master["agents"]["kimi"]["base_dir"].replace("{repo}", str(REPO_ROOT))
543
+ want_cmds = [
544
+ f"{base}/lib/hook-guard.sh {h['id']} {base}/{h['script']}"
545
+ for h in master["hooks"]
546
+ if master["agents"]["kimi"]["lifecycle_events"].get(h["lifecycle"])
547
+ ]
548
+ missing = [c for c in want_cmds if c not in present]
549
+ if begin not in present or missing:
550
+ warn(f"DRIFT: kimi config.toml missing the project hooks block "
551
+ f"(run `mise run hooks-sync`)")
552
+ rc = 1
553
+ else:
554
+ log(f"kimi: in sync ({kt})")
555
+
556
+ # Hermes: only checked if its runtime config exists and not opted out.
557
+ if "hermes" not in disabled:
558
+ hcfg = REPO_ROOT / master["agents"]["hermes"]["config_target"]
559
+ if hcfg.exists():
560
+ try:
561
+ present = json.dumps(_load_yaml(hcfg))
562
+ except Exception: # noqa: BLE001
563
+ present = ""
564
+ missing = [c for _ev, c, _t in hermes_commands(master) if c not in present]
565
+ if missing:
566
+ warn(f"DRIFT: hermes config.yaml missing {len(missing)} adapter hook(s) "
567
+ f"(run `mise run hooks-sync`)")
568
+ rc = 1
569
+ else:
570
+ log(f"hermes: in sync ({hcfg.relative_to(REPO_ROOT)})")
571
+
572
+ if rc == 0:
573
+ log("hooks: all targets in sync.")
574
+ return rc
575
+
576
+
577
+ def main() -> int:
578
+ global QUIET
579
+ ap = argparse.ArgumentParser(description="CAF project-scoped agent-hooks fan-out")
580
+ mode = ap.add_mutually_exclusive_group(required=True)
581
+ mode.add_argument("--install", action="store_true", help="generate claude + inject codex")
582
+ mode.add_argument("--uninstall", action="store_true", help="remove codex injection")
583
+ mode.add_argument("--check", action="store_true", help="drift gate (read-only)")
584
+ ap.add_argument("--quiet", action="store_true")
585
+ args = ap.parse_args()
586
+ QUIET = args.quiet
587
+
588
+ try:
589
+ master = load_master()
590
+ except Exception as exc: # noqa: BLE001
591
+ warn(f"could not load {MASTER}: {exc}")
592
+ return 0 if not args.check else 1
593
+
594
+ if args.check:
595
+ return cmd_check(master)
596
+
597
+ disabled = load_local()["disabled_agents"]
598
+ try:
599
+ if args.install:
600
+ if "claude" in disabled:
601
+ log("claude: skipped (disabled in local.json)")
602
+ else:
603
+ install_claude(master)
604
+ # codex/kimi/hermes are per-dev injections, so opting out actively removes them.
605
+ (uninstall_codex if "codex" in disabled else install_codex)(master)
606
+ (uninstall_kimi if "kimi" in disabled else install_kimi)(master)
607
+ (uninstall_hermes if "hermes" in disabled else install_hermes)(master)
608
+ elif args.uninstall:
609
+ uninstall_codex(master)
610
+ uninstall_kimi(master)
611
+ uninstall_hermes(master)
612
+ except Exception as exc: # noqa: BLE001 — never break the shell on enter/leave
613
+ warn(f"non-fatal error during {'install' if args.install else 'uninstall'}: {exc}")
614
+ return 0
615
+
616
+
617
+ if __name__ == "__main__":
618
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ {
2
+ "$comment": "PER-DEV LOCAL OVERRIDES — copy this file to .agents/local.json (gitignored) and edit. Lets you opt out of individual hooks, whole agents, or specific auto-linked skills, and tune how project skills fan out to your agent CLIs — without touching committed config. Hooks self-skip at RUNTIME via .agents/hooks/lib/hook-guard.sh (so even Claude's committed hooks honor it); codex/hermes injections additionally skip at INSTALL time. Re-run `mise run hooks-sync` / `mise run skills-relink` (or re-cd into the repo) after editing.",
3
+ "hooks": {
4
+ "$disabled_help": "Hook ids from .agents/hooks/hooks.master.json: skill-check-reminder, hindsight-recall, hindsight-retain, hindsight-session-end. Listed ids are skipped at runtime across ALL agents.",
5
+ "disabled": [],
6
+ "$disabled_agents_help": "Agent keys: claude, codex, kimi, hermes. Listed agents are NOT installed (codex/kimi/hermes injections are skipped/removed). Claude's hooks are committed and can't be fully removed per-dev — disable them individually via `disabled` instead.",
7
+ "disabled_agents": []
8
+ },
9
+ "skills": {
10
+ "$disabled_help": "Skill directory names under .agents/skills/. Listed skills are NOT symlinked into any agent CLI dir for you, and are pruned from your managed mirrors.",
11
+ "disabled": [],
12
+ "$defer_to_global_help": "Set true ONLY if you run a global agent system at ~/.agents/skills. When true, the linker skips any project skill whose name also exists in your global SSOT, so your global copy wins and you get ZERO duplicates/conflicts (and yields the slot in shared dirs like ~/.codex/skills). Teammates with no global layer leave this false (the default) and inherit the FULL project skill set. Override the global dir with the AGENTS_GLOBAL_SKILLS_DIR env var.",
13
+ "defer_to_global": false
14
+ }
15
+ }