@delorenj/pjangler 1.2.21 → 1.2.25

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 (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -10,7 +10,7 @@ set -euo pipefail
10
10
  # contract runtime/profile.yaml opts into config.inherit_from: default
11
11
  # registry ~/.hermes/profiles/<profile_name> -> <role_dir>/runtime
12
12
  # role <role_dir>/role.yaml profile: <profile_name>
13
- # services systemd gateway/consumer units restarted when something changed
13
+ # services stale per-profile Bloodbank consumers retired; gateways restarted
14
14
  #
15
15
  # Default is a DRY-RUN drift report (exit 1 when drift exists, 0 when clean).
16
16
  # --apply writes the fixes and restarts the changed agents' services.
@@ -74,7 +74,7 @@ done
74
74
  [[ -f "$REGISTRY_FILE" ]] || { echo "fleet-sync: registry not found: $REGISTRY_FILE" >&2; exit 2; }
75
75
  [[ -f "$WRAPPER_TEMPLATE" ]] || { echo "fleet-sync: wrapper template not found: $WRAPPER_TEMPLATE" >&2; exit 2; }
76
76
 
77
- # Registry -> TSV: agent_id, role_dir, profile_name, gateway_unit, consumer_unit
77
+ # Registry -> TSV: agent_id, role_dir, profile_name, gateway_unit, legacy_consumer_unit
78
78
  read_registry() {
79
79
  python3 - "$REGISTRY_FILE" <<'PYEOF'
80
80
  import sys
@@ -91,7 +91,7 @@ for agent_id, a in sorted((data.get("agents") or {}).items()):
91
91
  if not isinstance(a, dict):
92
92
  continue
93
93
  systemd = a.get("systemd") or {}
94
- print("\t".join([
94
+ print("\x1f".join([
95
95
  agent_id,
96
96
  str(a.get("role_dir") or ""),
97
97
  str(a.get("profile_name") or agent_id),
@@ -111,12 +111,113 @@ declare -a RESTART_UNITS=()
111
111
 
112
112
  # Registry agents sharing a role_dir would overwrite each other's wrapper
113
113
  # and role.yaml on --apply; refuse to write into contested dirs.
114
- CONTESTED_DIRS="$(read_registry | cut -f2 | sort | uniq -d)"
114
+ CONTESTED_DIRS="$(read_registry | cut -d $'\x1f' -f2 | sort | uniq -d)"
115
115
 
116
116
  note() { # note <agent> <status> <message>
117
117
  printf '%-34s %-7s %s\n' "$1" "$2" "$3"
118
118
  }
119
119
 
120
+ systemctl_user_unit_state() { # systemctl_user_unit_state <is-active|is-enabled> <unit>
121
+ local query="$1" unit="$2" output rc first_line
122
+ command -v systemctl >/dev/null 2>&1 \
123
+ || { printf 'error|systemctl unavailable'; return 0; }
124
+ set +e
125
+ output="$(LC_ALL=C systemctl --user "$query" "$unit" 2>&1)"
126
+ rc=$?
127
+ set -e
128
+ first_line="${output%%$'\n'*}"
129
+ first_line="${first_line//$'\t'/ }"
130
+ first_line="${first_line//|/}"
131
+ case "$query:$rc:$first_line" in
132
+ is-active:0:active|is-active:0:reloading|is-active:0:activating|is-active:0:deactivating)
133
+ printf 'ok|%s' "$first_line" ;;
134
+ is-active:3:inactive|is-active:3:failed)
135
+ printf 'ok|%s' "$first_line" ;;
136
+ is-active:4:inactive)
137
+ printf 'ok|not-found' ;;
138
+ is-enabled:0:enabled|is-enabled:0:enabled-runtime|is-enabled:0:linked|is-enabled:0:linked-runtime|is-enabled:0:alias|is-enabled:0:static|is-enabled:0:indirect|is-enabled:0:generated|is-enabled:0:transient)
139
+ printf 'ok|%s' "$first_line" ;;
140
+ is-enabled:1:disabled|is-enabled:1:masked|is-enabled:1:masked-runtime)
141
+ printf 'ok|%s' "$first_line" ;;
142
+ is-enabled:4:not-found)
143
+ printf 'ok|not-found' ;;
144
+ *)
145
+ [[ -n "$first_line" ]] || first_line="exit $rc with no state"
146
+ printf 'error|%s' "$first_line" ;;
147
+ esac
148
+ }
149
+
150
+ retire_registry_consumer_metadata() { # retire_registry_consumer_metadata <agent_id>
151
+ local target_agent="$1" lock_file="${REGISTRY_FILE}.lock" registry_lock_fd=""
152
+ command -v flock >/dev/null 2>&1 \
153
+ || { echo "fleet-sync: flock is required for registry updates" >&2; return 1; }
154
+ [[ ! -L "$lock_file" ]] \
155
+ || { echo "fleet-sync: refusing registry lock symlink: $lock_file" >&2; return 1; }
156
+ exec {registry_lock_fd}>"$lock_file"
157
+ chmod 600 "$lock_file"
158
+ flock -w 30 "$registry_lock_fd" \
159
+ || { echo "fleet-sync: timed out waiting for registry lock" >&2; return 1; }
160
+ python3 - "$REGISTRY_FILE" "$target_agent" <<'PYEOF'
161
+ import errno
162
+ import os
163
+ import pathlib
164
+ import sys
165
+ import tempfile
166
+
167
+ try:
168
+ import yaml
169
+ except ImportError:
170
+ raise SystemExit("fleet-sync: python3-yaml is required")
171
+
172
+ path = pathlib.Path(sys.argv[1])
173
+ agent_id = sys.argv[2]
174
+ if path.is_symlink():
175
+ raise SystemExit(f"fleet-sync: refusing registry symlink: {path}")
176
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
177
+ entry = (data.get("agents") or {}).get(agent_id)
178
+ if isinstance(entry, dict) and isinstance(entry.get("systemd"), dict):
179
+ entry["systemd"].pop("consumer_unit", None)
180
+ rendered = yaml.safe_dump(data, sort_keys=False)
181
+
182
+ def fsync_parent(target):
183
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
184
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
185
+ try:
186
+ directory_fd = os.open(target.parent, flags)
187
+ except OSError as exc:
188
+ if exc.errno in unsupported:
189
+ return
190
+ raise
191
+ try:
192
+ try:
193
+ os.fsync(directory_fd)
194
+ except OSError as exc:
195
+ if exc.errno not in unsupported:
196
+ raise
197
+ finally:
198
+ os.close(directory_fd)
199
+
200
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.fleet-sync-", dir=path.parent)
201
+ try:
202
+ os.fchmod(fd, 0o600)
203
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
204
+ handle.write(rendered)
205
+ handle.flush()
206
+ os.fsync(handle.fileno())
207
+ os.replace(temporary, path)
208
+ os.chmod(path, 0o600)
209
+ fsync_parent(path)
210
+ except BaseException:
211
+ try:
212
+ os.unlink(temporary)
213
+ except FileNotFoundError:
214
+ pass
215
+ raise
216
+ PYEOF
217
+ flock -u "$registry_lock_fd"
218
+ eval "exec ${registry_lock_fd}>&-"
219
+ }
220
+
120
221
  ensure_env_key() { # ensure_env_key <path> <key> <value>
121
222
  python3 - "$1" "$2" "$3" <<'PYEOF'
122
223
  import pathlib, sys
@@ -143,7 +244,7 @@ wanted_agent() {
143
244
  return 1
144
245
  }
145
246
 
146
- while IFS=$'\t' read -r agent_id role_dir profile_name gateway_unit consumer_unit; do
247
+ while IFS=$'\x1f' read -r agent_id role_dir profile_name gateway_unit consumer_unit; do
147
248
  wanted_agent "$agent_id" || continue
148
249
  if [[ -z "$role_dir" || ! -d "$role_dir" ]]; then
149
250
  note "$agent_id" DRIFT "role_dir missing: ${role_dir:-<unset>} (MANUAL: deprovision or fix registry)"
@@ -158,6 +259,63 @@ while IFS=$'\t' read -r agent_id role_dir profile_name gateway_unit consumer_uni
158
259
  runtime="$role_dir/runtime"
159
260
  changed=0
160
261
 
262
+ # Legacy per-profile Bloodbank consumers are unhealthy until fully retired.
263
+ legacy_consumer_unit="${consumer_unit:-hermes-${agent_id}-consumer.service}"
264
+ if [[ ! "$legacy_consumer_unit" =~ ^hermes-[A-Za-z0-9._-]+-consumer\.service$ ]]; then
265
+ note "$agent_id" DRIFT "unsafe legacy consumer unit name in registry: $legacy_consumer_unit (MANUAL: fix registry)"
266
+ DRIFT=$((DRIFT + 1))
267
+ else
268
+ legacy_consumer_path="$HOME/.config/systemd/user/$legacy_consumer_unit"
269
+ legacy_consumer_present=0
270
+ [[ -n "$consumer_unit" || -e "$legacy_consumer_path" || -L "$legacy_consumer_path" ]] \
271
+ && legacy_consumer_present=1
272
+ if command -v systemctl >/dev/null 2>&1; then
273
+ legacy_active_result="$(systemctl_user_unit_state is-active "$legacy_consumer_unit")"
274
+ legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$legacy_consumer_unit")"
275
+ if [[ "$legacy_active_result" == error\|* || "$legacy_enabled_result" == error\|* ]]; then
276
+ note "$agent_id" DRIFT "legacy consumer state query failed; unit and metadata preserved"
277
+ DRIFT=$((DRIFT + 1))
278
+ legacy_consumer_query_failed=1
279
+ else
280
+ legacy_consumer_query_failed=0
281
+ legacy_active_state="${legacy_active_result#*|}"
282
+ legacy_enabled_state="${legacy_enabled_result#*|}"
283
+ [[ "$legacy_active_state" == "not-found" && "$legacy_enabled_state" == "not-found" ]] \
284
+ || legacy_consumer_present=1
285
+ fi
286
+ elif [[ $legacy_consumer_present -eq 1 ]]; then
287
+ note "$agent_id" DRIFT "systemctl unavailable; legacy unit and metadata preserved"
288
+ DRIFT=$((DRIFT + 1))
289
+ legacy_consumer_query_failed=1
290
+ else
291
+ legacy_consumer_query_failed=0
292
+ fi
293
+ if [[ $legacy_consumer_query_failed -eq 0 && $legacy_consumer_present -eq 1 ]]; then
294
+ if [[ $APPLY -eq 0 ]]; then
295
+ note "$agent_id" DRIFT "legacy per-profile Bloodbank consumer remains: $legacy_consumer_unit"
296
+ DRIFT=$((DRIFT + 1))
297
+ else
298
+ if ! systemctl --user disable --now "$legacy_consumer_unit" >/dev/null 2>&1; then
299
+ note "$agent_id" DRIFT "legacy consumer disable failed; unit and metadata preserved"
300
+ DRIFT=$((DRIFT + 1))
301
+ else
302
+ legacy_active_result="$(systemctl_user_unit_state is-active "$legacy_consumer_unit")"
303
+ legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$legacy_consumer_unit")"
304
+ if [[ "$legacy_active_result" != "ok|inactive" || "$legacy_enabled_result" != "ok|disabled" ]]; then
305
+ note "$agent_id" DRIFT "legacy consumer is not proven inactive and disabled; unit and metadata preserved"
306
+ DRIFT=$((DRIFT + 1))
307
+ else
308
+ rm -f -- "$legacy_consumer_path"
309
+ systemctl --user daemon-reload >/dev/null 2>&1 || true
310
+ retire_registry_consumer_metadata "$agent_id"
311
+ note "$agent_id" FIXED "legacy per-profile Bloodbank consumer retired"
312
+ FIXED=$((FIXED + 1))
313
+ fi
314
+ fi
315
+ fi
316
+ fi
317
+ fi
318
+
161
319
  # 1. Launcher wrapper regenerated from the current template.
162
320
  expected_wrapper="$(render_wrapper "$agent_id")"
163
321
  if [[ ! -f "$role_dir/hermes" ]] || ! diff -q <(printf '%s\n' "$expected_wrapper") "$role_dir/hermes" >/dev/null 2>&1; then
@@ -334,13 +492,12 @@ PYEOF
334
492
 
335
493
  if [[ $changed -eq 1 && $RESTART -eq 1 ]]; then
336
494
  [[ -n "$gateway_unit" ]] && RESTART_UNITS+=("$gateway_unit")
337
- [[ -n "$consumer_unit" ]] && RESTART_UNITS+=("$consumer_unit")
338
495
  fi
339
496
  done < <(read_registry)
340
497
 
341
498
  # 5. Stale registry symlinks: entries pointing into fleet runtimes under a
342
499
  # name the registry does not know. Reported only — removal is a human call.
343
- known_names="$(read_registry | cut -f3 | sort -u)"
500
+ known_names="$(read_registry | cut -d $'\x1f' -f3 | sort -u)"
344
501
  if [[ -d "$PROFILES_DIR" ]]; then
345
502
  for link in "$PROFILES_DIR"/*; do
346
503
  [[ -L "$link" ]] || continue
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env python3
2
+ """hermes-runtime-templatize — dedup a runtime's skills onto the shared
3
+ hermes-base pack, at PER-SKILL granularity, with scoped-name overrides.
4
+
5
+ Why per-skill: 14 of the 18 base dirs are CATEGORY dirs (apple, creative,
6
+ github, …) holding 73 sub-skills total. hermes keys skill identity on each
7
+ SKILL.md's frontmatter `name:` (and the containing dir name), so dedup and
8
+ override must happen at the sub-skill level, not the category-dir level.
9
+
10
+ Policy (fleet decision, 2026-07-23):
11
+ * a base sub-skill IDENTICAL to the pack -> delete the local copy (it then
12
+ resolves read-only from the pack via config.yaml skills.external_dirs).
13
+ * a base sub-skill that was OVERWRITTEN -> SCOPED-NAME override: rename its
14
+ dir AND its frontmatter `name:` to `<name>-<slug>`, promoting it from a
15
+ shared-base skill to an unambiguous agent-scoped copy. The pristine base
16
+ stays in the pack for every other agent; no collision, no data loss.
17
+ * an agent-ADDED skill (not in the pack) -> left untouched in the overlay.
18
+ Then the pack is wired into config.yaml (if not already). Empty category dirs
19
+ left after deletions are pruned.
20
+
21
+ A runtime whose config has no skills.external_dirs (bare-layout agents) is
22
+ BLOCKED (can't resolve deleted skills from the pack) and left untouched.
23
+
24
+ Dry-run by default; --apply mutates. Idempotent (a second run finds nothing to
25
+ do). `verify` checks a runtime is name-disjoint from the pack.
26
+
27
+ Usage:
28
+ hermes-runtime-templatize.py dedup [--apply] [--root DIR] [--slug S] [--pack DIR]
29
+ hermes-runtime-templatize.py verify [--root DIR] [--pack DIR]
30
+ """
31
+ from __future__ import annotations
32
+ import argparse, hashlib, os, re, shutil, sys
33
+ from pathlib import Path
34
+
35
+ DEFAULT_PACK = Path("/home/delorenj/code/skillex/packs/hermes-base/0.18.2")
36
+
37
+ # The canonical fleet skills.external_dirs (in precedence order). Every agent
38
+ # should carry exactly these; `provision` normalizes to them (preserving any
39
+ # agent-specific extras).
40
+ MAINLINE_EXTERNAL_DIRS = [
41
+ "/home/delorenj/code/skillex/skill-sets/global/.system",
42
+ "/home/delorenj/code/skillex/packs/bmad/6.10.1-next.31",
43
+ str(DEFAULT_PACK),
44
+ ]
45
+
46
+ # Mirror hermes agent/skill_utils.py: only these dirs are pruned from skill
47
+ # scanning, and a SKILL.md inside a skill's support dir is progressive-
48
+ # disclosure data, not an active skill root. Keeping this identical means we
49
+ # classify EXACTLY the skills hermes treats as active (never .archive, .git, …).
50
+ EXCLUDED_SKILL_DIRS = frozenset((
51
+ ".archive", ".git", ".github", ".hub", ".mypy_cache", ".nox", ".pytest_cache",
52
+ ".ruff_cache", ".tox", ".venv", "__pycache__", "node_modules", "site-packages", "venv",
53
+ ))
54
+ SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
55
+
56
+
57
+ def iter_skill_roots(skills_dir: Path):
58
+ """Yield each ACTIVE SKILL.md path (exact copy of hermes iter_skill_index_files)."""
59
+ matches = []
60
+ for root, dirs, files in os.walk(str(skills_dir)):
61
+ has = "SKILL.md" in files
62
+ dirs[:] = [d for d in dirs if d not in EXCLUDED_SKILL_DIRS and not (has and d in SKILL_SUPPORT_DIRS)]
63
+ if "SKILL.md" in files:
64
+ matches.append(os.path.join(root, "SKILL.md"))
65
+ for p in sorted(matches):
66
+ yield Path(p)
67
+
68
+
69
+ def treehash(d: Path) -> str:
70
+ lines = []
71
+ for p in sorted(d.rglob("*")):
72
+ if p.is_file():
73
+ lines.append(hashlib.sha256(p.read_bytes()).hexdigest() + " " + str(p.relative_to(d)))
74
+ lines.sort()
75
+ return hashlib.sha256("\n".join(lines).encode()).hexdigest()
76
+
77
+
78
+ def frontmatter_name(smd: Path) -> str:
79
+ try:
80
+ t = smd.read_text(encoding="utf-8")
81
+ except Exception:
82
+ return smd.parent.name
83
+ m = re.match(r"^---\s*\n(.*?)\n---", t, re.S)
84
+ if m:
85
+ mm = re.search(r"^name:\s*(.+?)\s*$", m.group(1), re.M)
86
+ if mm:
87
+ return mm.group(1).strip().strip("'\"")
88
+ return smd.parent.name
89
+
90
+
91
+ def set_frontmatter_name(smd: Path, newname: str) -> None:
92
+ t = smd.read_text(encoding="utf-8")
93
+ m = re.match(r"^(---\s*\n)(.*?)(\n---)", t, re.S)
94
+ if m:
95
+ head, fm, fence = m.group(1), m.group(2), m.group(3)
96
+ if re.search(r"^name:\s*.+$", fm, re.M):
97
+ fm = re.sub(r"^name:\s*.+$", f"name: {newname}", fm, count=1, flags=re.M)
98
+ else:
99
+ fm = f"name: {newname}\n" + fm
100
+ smd.write_text(head + fm + fence + t[m.end():], encoding="utf-8")
101
+ else:
102
+ smd.write_text(f"---\nname: {newname}\n---\n" + t, encoding="utf-8")
103
+
104
+
105
+ def pack_index(pack: Path) -> dict[str, str]:
106
+ """frontmatter-name -> treehash of the skill's own dir, across the pack."""
107
+ idx: dict[str, str] = {}
108
+ for smd in iter_skill_roots(pack):
109
+ idx[frontmatter_name(smd)] = treehash(smd.parent)
110
+ return idx
111
+
112
+
113
+ def runtimes(root: Path):
114
+ base = root / "agents" / "hermes"
115
+ if not base.is_dir():
116
+ return []
117
+ return sorted(p for p in base.glob("*/runtime") if p.is_dir())
118
+
119
+
120
+ def config_wireable(cfg: Path, pack: Path) -> tuple[bool, bool]:
121
+ """(wireable, already). wireable=False for a config with no external_dirs."""
122
+ if not cfg.is_file():
123
+ return (False, False)
124
+ txt = cfg.read_text(encoding="utf-8")
125
+ if str(pack) in txt:
126
+ return (True, True)
127
+ return (bool(re.search(r"^\s*external_dirs:", txt, re.M)), False)
128
+
129
+
130
+ def wire(cfg: Path, pack: Path, apply: bool) -> bool:
131
+ ok, already = config_wireable(cfg, pack)
132
+ if already:
133
+ return True
134
+ if not ok:
135
+ return False
136
+ if not apply:
137
+ return True
138
+ lines = cfg.read_text(encoding="utf-8").splitlines()
139
+ out, i, done = [], 0, False
140
+ while i < len(lines):
141
+ m = re.match(r"^(\s*)external_dirs:\s*(.*)$", lines[i])
142
+ if not done and m:
143
+ indent, inline = m.group(1), m.group(2).strip()
144
+ if inline == "": # block form: keep existing items, append ours
145
+ out.append(lines[i])
146
+ j, item_indent = i + 1, None
147
+ while j < len(lines) and lines[j].strip().startswith("- "):
148
+ if item_indent is None:
149
+ item_indent = len(lines[j]) - len(lines[j].lstrip())
150
+ out.append(lines[j]); j += 1
151
+ out.append(" " * (item_indent if item_indent is not None else indent) + "- " + str(pack))
152
+ done, i = True, j
153
+ continue
154
+ else: # inline form ([] or [a, b]) -> convert to block
155
+ inner = inline.strip("[]").strip()
156
+ items = [x.strip() for x in inner.split(",") if x.strip()] if inner else []
157
+ out.append(f"{indent}external_dirs:")
158
+ for it in items:
159
+ out.append(f"{indent}- {it}")
160
+ out.append(f"{indent}- {pack}")
161
+ done, i = True, i + 1
162
+ continue
163
+ out.append(lines[i]); i += 1
164
+ if not done:
165
+ return False
166
+ cfg.write_text("\n".join(out) + "\n", encoding="utf-8")
167
+ return True
168
+
169
+
170
+ def set_external_dirs(cfg: Path, dirs: list[str], apply: bool) -> str:
171
+ """Normalize skills.external_dirs to `dirs` (canonical order) + preserve any
172
+ agent-specific extras. Creates skills:/external_dirs: if absent. Idempotent."""
173
+ if not cfg.is_file():
174
+ return "no config.yaml"
175
+ lines = cfg.read_text(encoding="utf-8").splitlines()
176
+ skills_i = next((i for i, l in enumerate(lines) if re.match(r"^skills:\s*$", l)), None)
177
+ items = [f" - {d}" for d in dirs]
178
+ if skills_i is None:
179
+ new = lines[:]
180
+ if new and new[-1].strip() != "":
181
+ new.append("")
182
+ new += ["skills:", " external_dirs:"] + items
183
+ action = "created skills.external_dirs"
184
+ else:
185
+ end = next((j for j in range(skills_i + 1, len(lines)) if lines[j] and not lines[j][0].isspace()), len(lines))
186
+ ext_i = next((j for j in range(skills_i + 1, end) if re.match(r"^\s*external_dirs:", lines[j])), None)
187
+ if ext_i is None:
188
+ new = lines[:skills_i + 1] + [" external_dirs:"] + items + lines[skills_i + 1:]
189
+ action = "added external_dirs under skills:"
190
+ else:
191
+ m = re.match(r"^(\s*)external_dirs:\s*(.*)$", lines[ext_i])
192
+ indent, inline = m.group(1), m.group(2).strip()
193
+ existing, item_end = [], ext_i + 1
194
+ if inline == "":
195
+ while item_end < end and lines[item_end].strip().startswith("- "):
196
+ existing.append(re.sub(r"^-\s*", "", lines[item_end].strip()))
197
+ item_end += 1
198
+ elif inline != "[]":
199
+ existing = [x.strip() for x in inline.strip("[]").split(",") if x.strip()]
200
+ merged = list(dirs) + [x for x in existing if x not in dirs]
201
+ new = lines[:ext_i] + [f"{indent}external_dirs:"] + [f"{indent}- {d}" for d in merged] + lines[item_end:]
202
+ action = "normalized external_dirs"
203
+ if new == lines:
204
+ return "ok (already mainline)"
205
+ if apply:
206
+ cfg.write_text("\n".join(new) + "\n", encoding="utf-8")
207
+ return action
208
+
209
+
210
+ def provision(root: Path, dirs: list[str], apply: bool) -> None:
211
+ for rt in runtimes(root):
212
+ print(f"-- {rt}")
213
+ print(" " + set_external_dirs(rt / "config.yaml", dirs, apply))
214
+
215
+
216
+ def prune_empty_categories(sk: Path, apply: bool) -> int:
217
+ """Remove immediate child dirs of skills/ that hold no SKILL.md anymore."""
218
+ n = 0
219
+ for child in sorted(p for p in sk.iterdir() if p.is_dir()):
220
+ if not any(child.rglob("SKILL.md")):
221
+ n += 1
222
+ if apply:
223
+ shutil.rmtree(child)
224
+ return n
225
+
226
+
227
+ def dedup(root: Path, slug: str, pack: Path, apply: bool) -> None:
228
+ idx = pack_index(pack)
229
+ for rt in runtimes(root):
230
+ sk = rt / "skills"
231
+ print(f"-- {rt}")
232
+ if not sk.is_dir():
233
+ print(" no skills/ — nothing to do"); continue
234
+ ok, already = config_wireable(rt / "config.yaml", pack)
235
+ if not ok:
236
+ print(" BLOCKED: config.yaml has no skills.external_dirs (bare-layout) — provision a skills block first; untouched")
237
+ continue
238
+ ident, scoped, adds = [], [], []
239
+ for smd in iter_skill_roots(sk):
240
+ d = smd.parent
241
+ name = frontmatter_name(smd)
242
+ if name in idx:
243
+ (ident if treehash(d) == idx[name] else scoped).append((d, name))
244
+ else:
245
+ adds.append(name)
246
+ print(f" classify (per-skill): base-identical={len(ident)} overwritten={len(scoped)} agent-adds={len(adds)}")
247
+ if not apply:
248
+ for d, name in scoped:
249
+ print(f" [would] scoped-name override: {d.relative_to(sk)} -> name '{name}-{slug}'")
250
+ for d, name in ident:
251
+ print(f" [would] rm {d.relative_to(sk)} (identical → pack)")
252
+ print(f" [would] wire pack into config.yaml{' (already)' if already else ''}")
253
+ continue
254
+ if not wire(rt / "config.yaml", pack, True):
255
+ print(" ABORT: wire failed — leaving skills untouched"); continue
256
+ for d, name in scoped:
257
+ newdir = d.parent / f"{d.name}-{slug}"
258
+ if newdir.exists():
259
+ print(f" SKIP scoped rename (target exists): {newdir.relative_to(sk)}"); continue
260
+ set_frontmatter_name(d / "SKILL.md", f"{name}-{slug}")
261
+ d.rename(newdir)
262
+ for d, name in ident:
263
+ shutil.rmtree(d)
264
+ pruned = prune_empty_categories(sk, True)
265
+ print(f" reconciled: {len(ident)} identical removed, {len(scoped)} scoped→*-{slug}, {pruned} empty categories pruned; base resolves from pack")
266
+
267
+
268
+ def verify(root: Path, pack: Path) -> int:
269
+ idx = pack_index(pack)
270
+ bad = 0
271
+ for rt in runtimes(root):
272
+ sk = rt / "skills"
273
+ if not sk.is_dir():
274
+ continue
275
+ for smd in iter_skill_roots(sk):
276
+ name = frontmatter_name(smd)
277
+ if name in idx and treehash(smd.parent) != idx[name]:
278
+ print(f" NOT DISJOINT: {rt.name}: local '{name}' shares a pack name but differs (scope-rename it)")
279
+ bad += 1
280
+ if name in idx and treehash(smd.parent) == idx[name]:
281
+ print(f" REDUNDANT: {rt.name}: local '{name}' is an identical copy of the pack (delete it)")
282
+ bad += 1
283
+ print("verify:", "CLEAN ✓ (runtime is name-disjoint from the pack)" if bad == 0 else f"{bad} issue(s)")
284
+ return 1 if bad else 0
285
+
286
+
287
+ def main() -> int:
288
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
289
+ sub = ap.add_subparsers(dest="cmd", required=True)
290
+ for name in ("dedup", "verify", "provision"):
291
+ s = sub.add_parser(name)
292
+ s.add_argument("--root", default=".")
293
+ s.add_argument("--pack", default=str(DEFAULT_PACK))
294
+ if name in ("dedup", "provision"):
295
+ s.add_argument("--apply", action="store_true")
296
+ if name == "dedup":
297
+ s.add_argument("--slug", default=None, help="agent scope token (default: repo dir basename, lowercased)")
298
+ a = ap.parse_args()
299
+ root = Path(a.root).resolve()
300
+ pack = Path(a.pack)
301
+ if not (pack / "SKILL.md").exists() and not any(pack.rglob("SKILL.md")):
302
+ print(f"ERROR: no skills under pack {pack}", file=sys.stderr); return 2
303
+ if a.cmd == "verify":
304
+ return verify(root, pack)
305
+ if a.cmd == "provision":
306
+ mode = "APPLY" if a.apply else "dry-run"
307
+ print(f"== provision external_dirs ({mode}) :: {root} ==")
308
+ provision(root, MAINLINE_EXTERNAL_DIRS, a.apply)
309
+ print("\nRESULT:", "applied." if a.apply else "dry-run. --apply to write the mainline external_dirs.")
310
+ return 0
311
+ slug = (a.slug or root.name).lower()
312
+ mode = "APPLY" if a.apply else "dry-run"
313
+ print(f"== templatize dedup ({mode}) :: {root} :: slug={slug} :: pack={pack} ==")
314
+ dedup(root, slug, pack, a.apply)
315
+ print("\nRESULT:", "applied where safe (BLOCKED runtimes untouched)." if a.apply
316
+ else "dry-run. --apply to reconcile. Bare-config runtimes are reported BLOCKED.")
317
+ return 0
318
+
319
+
320
+ if __name__ == "__main__":
321
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """Move chat-platform credentials out of a fleet-wide dotenv file safely.
3
+
4
+ The Hermes fleet shares provider credentials, but polling/socket credentials
5
+ belong to one gateway profile. This helper performs a dry-run by default and
6
+ moves only named assignments without ever printing their values.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import datetime as dt
13
+ import os
14
+ import re
15
+ import shutil
16
+ import stat
17
+ import tempfile
18
+ from pathlib import Path
19
+
20
+
21
+ DEFAULT_KEYS = ("SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_ALLOWED_USERS")
22
+ ASSIGNMENT = re.compile(r"^(?P<prefix>\s*)(?P<key>[A-Za-z_][A-Za-z0-9_]*)=(?P<value>.*)$")
23
+
24
+
25
+ def _read(path: Path) -> list[str]:
26
+ return path.read_text(encoding="utf-8").splitlines(keepends=True)
27
+
28
+
29
+ def _assignments(lines: list[str], keys: set[str]) -> dict[str, tuple[int, str]]:
30
+ found: dict[str, tuple[int, str]] = {}
31
+ for index, line in enumerate(lines):
32
+ match = ASSIGNMENT.match(line.rstrip("\n"))
33
+ if match and match.group("key") in keys:
34
+ found[match.group("key")] = (index, line)
35
+ return found
36
+
37
+
38
+ def _same_file(left: Path, right: Path) -> bool:
39
+ try:
40
+ return left.resolve(strict=True) == right.resolve(strict=True)
41
+ except FileNotFoundError:
42
+ return left.resolve() == right.resolve()
43
+
44
+
45
+ def _atomic_write(path: Path, lines: list[str]) -> None:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
48
+ try:
49
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
50
+ handle.writelines(lines)
51
+ handle.flush()
52
+ os.fsync(handle.fileno())
53
+ os.chmod(temporary, stat.S_IRUSR | stat.S_IWUSR)
54
+ os.replace(temporary, path)
55
+ finally:
56
+ if os.path.exists(temporary):
57
+ os.unlink(temporary)
58
+
59
+
60
+ def migrate(central: Path, target: Path, keys: tuple[str, ...], *, apply: bool) -> list[str]:
61
+ if not central.is_file():
62
+ raise ValueError(f"central env file does not exist: {central}")
63
+ if target.is_symlink():
64
+ raise ValueError(f"target env must be a profile-local file, not a symlink: {target}")
65
+ if _same_file(central, target):
66
+ raise ValueError("central and target env resolve to the same file")
67
+
68
+ selected = set(keys)
69
+ central_lines = _read(central)
70
+ target_lines = _read(target) if target.exists() else []
71
+ source_values = _assignments(central_lines, selected)
72
+ target_values = _assignments(target_lines, selected)
73
+
74
+ missing = [key for key in keys if key not in source_values]
75
+ if missing:
76
+ raise ValueError(f"central env is missing required keys: {', '.join(missing)}")
77
+
78
+ conflicts = [
79
+ key
80
+ for key in keys
81
+ if key in target_values and target_values[key][1].rstrip("\n") != source_values[key][1].rstrip("\n")
82
+ ]
83
+ if conflicts:
84
+ raise ValueError(f"target env already has different values for: {', '.join(conflicts)}")
85
+
86
+ if not apply:
87
+ return list(keys)
88
+
89
+ timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
90
+ central_backup = central.with_name(f"{central.name}.bak-{timestamp}-before-chat-scope")
91
+ shutil.copy2(central, central_backup)
92
+ os.chmod(central_backup, stat.S_IRUSR | stat.S_IWUSR)
93
+ if target.exists():
94
+ target_backup = target.with_name(f"{target.name}.bak-{timestamp}-before-chat-scope")
95
+ shutil.copy2(target, target_backup)
96
+ os.chmod(target_backup, stat.S_IRUSR | stat.S_IWUSR)
97
+
98
+ remove_indexes = {source_values[key][0] for key in keys}
99
+ next_central = [line for index, line in enumerate(central_lines) if index not in remove_indexes]
100
+ next_target = list(target_lines)
101
+ if next_target and not next_target[-1].endswith("\n"):
102
+ next_target[-1] += "\n"
103
+ if next_target and next_target[-1].strip():
104
+ next_target.append("\n")
105
+ next_target.append("# Profile-scoped chat gateway credentials.\n")
106
+ for key in keys:
107
+ if key not in target_values:
108
+ line = source_values[key][1]
109
+ next_target.append(line if line.endswith("\n") else f"{line}\n")
110
+
111
+ _atomic_write(target, next_target)
112
+ _atomic_write(central, next_central)
113
+ return list(keys)
114
+
115
+
116
+ def _parser() -> argparse.ArgumentParser:
117
+ parser = argparse.ArgumentParser(description=__doc__)
118
+ parser.add_argument("--central-env", type=Path, default=Path.home() / ".hermes" / ".env")
119
+ parser.add_argument("--target-env", type=Path, required=True)
120
+ parser.add_argument("--keys", nargs="+", default=list(DEFAULT_KEYS))
121
+ parser.add_argument("--apply", action="store_true", help="write the migration; default is dry-run")
122
+ return parser
123
+
124
+
125
+ def main() -> int:
126
+ args = _parser().parse_args()
127
+ moved = migrate(args.central_env.expanduser(), args.target_env.expanduser(), tuple(args.keys), apply=args.apply)
128
+ action = "moved" if args.apply else "would move"
129
+ print(f"{action} {', '.join(moved)} from {args.central_env} to {args.target_env}")
130
+ if not args.apply:
131
+ print("dry-run only; rerun with --apply after reviewing the target paths")
132
+ return 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ raise SystemExit(main())