@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.
- package/.mise/scripts/link-agentfiles.sh +9 -0
- package/dist/index.js +1492 -358
- package/dist/mcp-server.js +2391 -290
- package/package.json +5 -2
- package/templates/commonproject/.mise/scripts/create-plane-project.sh +26 -0
- package/templates/commonproject/copier.yml +2 -0
- package/templates/commonproject/template/.agents/hooks/README.md +151 -0
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +63 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-journal-write.sh +327 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-recall.sh +180 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-retain.sh +74 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/hindsight-session-end.sh +57 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-bank.sh +57 -0
- package/templates/commonproject/template/.agents/hooks/hindsight/lib/hindsight-journal.sh +48 -0
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +97 -0
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +30 -0
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +51 -0
- package/templates/commonproject/template/.agents/hooks/reminder-for-skill-check.sh +20 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +618 -0
- package/templates/commonproject/template/.agents/local.example.json +15 -0
- package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +62 -0
- package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +110 -0
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +45 -0
- package/templates/commonproject/template/mise.toml.jinja +42 -0
- package/templates/hermes-agent/.codegraph/daemon.pid +6 -0
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +10 -0
- package/templates/hermes-agent/README.md +16 -14
- package/templates/hermes-agent/copier.yml +7 -25
- package/templates/hermes-agent/docs/architecture.md +16 -18
- package/templates/hermes-agent/docs/fleet-control-plane/README.md +35 -0
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +253 -0
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +280 -0
- package/templates/hermes-agent/docs/fleet-control-plane/implementation-readiness-report-2026-06-27.md +118 -0
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +141 -0
- package/templates/hermes-agent/docs/operations.md +17 -19
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +17 -13
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/README.md +47 -46
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/architecture.md +39 -38
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/development.md +63 -99
- package/templates/hermes-agent/docs/{scrum-master → sentinel}/providers.md +2 -2
- package/templates/hermes-agent/docs/sentinel.md +18 -0
- package/templates/hermes-agent/install-local.sh +22 -20
- package/templates/hermes-agent/runtime-scaffold/README.md +10 -9
- package/templates/hermes-agent/scripts/backfill-fleet-sot.sh +12 -21
- package/templates/hermes-agent/scripts/fleet-sync.sh +369 -0
- package/templates/hermes-agent/scripts/migrate-unify.sh +252 -0
- package/templates/hermes-agent/scripts/unify-pm.sh +148 -0
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +10 -9
- package/templates/hermes-agent/template/.scripts/01-config.sh +2 -3
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -2
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +47 -19
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +71 -27
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +3 -3
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +3 -3
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +48 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +2 -2
- package/templates/hermes-agent/template/.scripts/99-summary.sh +3 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +4 -19
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +36 -0
- package/templates/hermes-agent/template/.scripts/config.example.toml +11 -5
- package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.sh → heartbeat.sh} +84 -31
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +1 -1
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +9 -3
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/emit-event.py +5 -5
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-autonomous-review.sh +6 -6
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/bin/issue-close-gate.sh +2 -2
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/autonomous-delegated-review.md +7 -7
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/bloodbank-events.md +3 -3
- package/templates/hermes-agent/template/.scripts/{scrum-master → sentinel}/docs/continuous-ticket-orchestration.md +6 -6
- package/templates/hermes-agent/template/.scripts/{scrum-master/continuous-ticket-sentinel.prompt.md.jinja → sentinel.prompt.md.jinja} +5 -4
- package/templates/hermes-agent/template/SOUL.md.jinja +3 -6
- package/templates/hermes-agent/template/hermes.jinja +6 -15
- package/templates/hermes-agent/template/role.yaml.jinja +9 -8
- package/templates/commonproject/.claude/commands/bmad/bmb/agents/agent-builder.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmb/agents/module-builder.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmb/agents/workflow-builder.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmb/workflows/agent.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmb/workflows/module.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmb/workflows/workflow.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/analyst.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/architect.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/dev.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/pm.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/quick-flow-solo-dev.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/sm.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/tea.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/tech-writer.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/agents/ux-designer.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/check-implementation-readiness.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/code-review.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/correct-course.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-architecture.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-epics-and-stories.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-dataflow.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-diagram.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-flowchart.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-excalidraw-wireframe.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-product-brief.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-story.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/create-ux-design.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/dev-story.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/document-project.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/generate-project-context.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/prd.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-dev.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/quick-spec.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/research.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/retrospective.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-planning.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/sprint-status.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-atdd.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-automate.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-ci.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-framework.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-nfr.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-design.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-test-review.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/testarch-trace.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-init.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/bmm/workflows/workflow-status.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/agents/brainstorming-coach.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/creative-problem-solver.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/design-thinking-coach.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/innovation-strategist.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/presentation-master.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/agents/storyteller.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/design-thinking.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/innovation-strategy.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/problem-solving.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/cis/workflows/storytelling.md +0 -13
- package/templates/commonproject/.claude/commands/bmad/core/agents/bmad-master.md +0 -14
- package/templates/commonproject/.claude/commands/bmad/core/tasks/index-docs.md +0 -9
- package/templates/commonproject/.claude/commands/bmad/core/tasks/shard-doc.md +0 -9
- package/templates/commonproject/.claude/commands/bmad/core/workflows/brainstorming.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/core/workflows/party-mode.md +0 -5
- package/templates/commonproject/.claude/commands/bmad/custom/workflows/ticket-lifecycle.md +0 -5
- package/templates/hermes-agent/docs/scrum-master-sentinel.md +0 -17
- package/templates/hermes-agent/template/.scripts/75-scrum-master.sh +0 -117
- 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
|
+
}
|