@delorenj/pjangler 1.2.19 → 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 (72) hide show
  1. package/dist/index.js +2353 -518
  2. package/dist/mcp-server.js +2331 -494
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +6 -4
  5. package/templates/commonproject/template/.agents/hooks/README.md +13 -26
  6. package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
  7. package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
  8. package/templates/commonproject/template/.agents/local.example.json +2 -8
  9. package/templates/commonproject/template/.agents/skills.json +6 -0
  10. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  11. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  12. package/templates/commonproject/template/mise.toml.jinja +16 -16
  13. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  14. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  15. package/templates/hermes-agent/README.md +70 -29
  16. package/templates/hermes-agent/copier.yml +9 -6
  17. package/templates/hermes-agent/docs/architecture.md +75 -51
  18. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  19. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  20. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  21. package/templates/hermes-agent/docs/operations.md +179 -81
  22. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  23. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  24. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  25. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  26. package/templates/hermes-agent/docs/sentinel.md +1 -1
  27. package/templates/hermes-agent/install-local.sh +41 -4
  28. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  29. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  30. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  31. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  32. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  33. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  34. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  35. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  36. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  38. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  39. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  40. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  41. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  42. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  43. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  44. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  45. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  46. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  47. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  48. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  49. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  50. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  51. package/templates/hermes-agent/template/.scripts/heartbeat.sh +12 -1
  52. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
  53. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
  54. package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
  55. package/templates/hermes-agent/template/hermes.jinja +6 -3
  56. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  57. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  58. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  59. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  60. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  61. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  62. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  63. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  64. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  65. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  66. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  67. package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
  68. package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
  69. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  70. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  71. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  72. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ # fleet-prune-debris — remove evolutionary debris from the Hermes fleet's
3
+ # systemd USER units, and retire now-dead runtime-checkpoint units for agents
4
+ # whose runtime is already pure-local (see degit-runtime.sh, fleet decision D2).
5
+ #
6
+ # Conservative by design. It only AUTO-removes things that are unambiguously
7
+ # dead:
8
+ # - a hardcoded allowlist of DISABLED stale units (old singleton gateways,
9
+ # the delodocs per-agent dashboard) — and only while they are NOT active;
10
+ # - *.bak-* backup copies of hermes unit files;
11
+ # - hermes-<id>-pm-checkpoint.{service,timer} whose runtime has NO .git
12
+ # (already pure-local -> the checkpoint unit can never do anything again).
13
+ # Everything ambiguous (active/enabled redundant units, the carrie persona, the
14
+ # delonet-company-reporter archetype, FAILED units still git-backed) is REPORTED
15
+ # as MANUAL and never touched.
16
+ #
17
+ # Dry-run by default. --apply performs the removals + one daemon-reload.
18
+ #
19
+ # Usage: fleet-prune-debris.sh [--apply]
20
+ set -euo pipefail
21
+
22
+ APPLY=0
23
+ for a in "$@"; do
24
+ case "$a" in
25
+ --apply) APPLY=1 ;;
26
+ -h|--help) sed -n '2,26p' "$0"; exit 0 ;;
27
+ *) printf 'fleet-prune-debris: unknown arg: %s\n' "$a" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+
31
+ SD="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
32
+ [ -d "$SD" ] || { printf 'ERROR: no systemd user dir at %s\n' "$SD" >&2; exit 1; }
33
+
34
+ MODE="dry-run"; [ "$APPLY" -eq 1 ] && MODE="APPLY"
35
+ printf '== fleet-prune-debris (%s) :: %s ==\n' "$MODE" "$SD"
36
+ note(){ printf ' %s\n' "$*"; }
37
+ uc(){ systemctl --user "$@" 2>/dev/null; } # user systemctl, quiet
38
+ is_active(){ uc is-active "$1" >/dev/null; }
39
+ exists(){ [ -e "$SD/$1" ]; }
40
+ DID=0
41
+
42
+ # remove_unit BASENAME... — disable+stop (if loaded) then rm the file(s).
43
+ remove_unit(){
44
+ local u
45
+ for u in "$@"; do
46
+ exists "$u" || { note "absent (ok): $u"; continue; }
47
+ if [ "$APPLY" -eq 1 ]; then
48
+ uc disable --now "$u" || true
49
+ rm -f "$SD/$u"
50
+ note "removed: $u"
51
+ else
52
+ note "[would] disable --now + rm: $u"
53
+ fi
54
+ DID=1
55
+ done
56
+ }
57
+
58
+ printf '\n[1] disabled stale units (old singleton gateways / stale dashboard)\n'
59
+ # From recon: these are DISABLED + superseded. Auto-remove only if not active.
60
+ for u in hermes-gateway.service hermes-gateway-de01182b.service hermes-dashboard-delodocs-pm.service; do
61
+ if exists "$u"; then
62
+ if is_active "$u"; then note "MANUAL: $u is ACTIVE — expected disabled; confirm before removing";
63
+ else remove_unit "$u"; fi
64
+ else note "absent (ok): $u"; fi
65
+ done
66
+
67
+ printf '\n[2] *.bak-* backup copies of hermes unit files\n'
68
+ shopt -s nullglob
69
+ BAKS=("$SD"/hermes-*.bak-*) # matches *.service.bak-* / *.timer.bak-* too (glob * spans dots)
70
+ shopt -u nullglob
71
+ if [ "${#BAKS[@]}" -eq 0 ]; then note "none"; else
72
+ for f in "${BAKS[@]}"; do
73
+ if [ "$APPLY" -eq 1 ]; then rm -f "$f"; note "removed: $(basename "$f")"; else note "[would] rm: $(basename "$f")"; fi
74
+ DID=1
75
+ done
76
+ fi
77
+
78
+ printf '\n[3] dead checkpoint units for pure-local runtimes\n'
79
+ shopt -s nullglob
80
+ CKPTS=("$SD"/hermes-*-pm-checkpoint.service)
81
+ shopt -u nullglob
82
+ if [ "${#CKPTS[@]}" -eq 0 ]; then note "none"; else
83
+ for svc in "${CKPTS[@]}"; do
84
+ base="$(basename "$svc")"
85
+ # Derive the runtime from ExecStart=<role_dir>/.scripts/checkpoint.sh
86
+ exec_line="$(grep -m1 '^ExecStart=' "$svc" 2>/dev/null | sed 's/^ExecStart=//')"
87
+ script="${exec_line%% *}" # strip any args
88
+ role_dir="$(dirname "$(dirname "$script")")" # .../pm/.scripts/x.sh -> .../pm
89
+ runtime="$role_dir/runtime"
90
+ if [ ! -d "$runtime" ]; then
91
+ note "MANUAL: $base -> runtime not found ($runtime); leaving"
92
+ continue
93
+ fi
94
+ if [ -e "$runtime/.git" ]; then
95
+ note "SKIP: $base -> runtime still git-backed (degit-runtime.sh first): $runtime"
96
+ else
97
+ note "pure-local -> retiring: $base (+ .timer)"
98
+ remove_unit "$base" "${base%.service}.timer"
99
+ fi
100
+ done
101
+ fi
102
+
103
+ printf '\n[4] MANUAL review (not auto-touched)\n'
104
+ # Running-redundant / ambiguous units surfaced for a human decision.
105
+ for u in hermes-gateway-intelliforia-voice-agent.service hermes-carrie-backend.service hermes-carrie-telegram-gateway.service; do
106
+ exists "$u" && note "MANUAL: $u ($(uc is-active "$u" || true)/$(uc is-enabled "$u" || true)) — redundant/legacy persona; decide keep vs retire"
107
+ done
108
+ # delonet-company-reporter is a different archetype (not a PM).
109
+ shopt -s nullglob
110
+ for u in "$SD"/hermes-delonet-company-reporter-*.service; do
111
+ b="$(basename "$u")"; note "MANUAL: $b ($(uc is-active "$b" || true)) — non-PM reporter archetype; repair or remove as a unit"
112
+ done
113
+ shopt -u nullglob
114
+ # Any remaining failed hermes units.
115
+ FAILED="$(uc --plain --no-legend list-units --state=failed 'hermes-*' | awk '{print $1}')"
116
+ if [ -n "$FAILED" ]; then
117
+ printf '%s\n' "$FAILED" | while read -r f; do [ -n "$f" ] && note "FAILED: $f — investigate (degit fixes git-push failures; missing-script units are orphans to remove)"; done
118
+ else note "no failed hermes units"; fi
119
+
120
+ echo
121
+ if [ "$APPLY" -eq 1 ]; then
122
+ uc daemon-reload || true
123
+ printf 'RESULT: applied + daemon-reload. Re-run to confirm idempotent (should be all "absent/none/SKIP").\n'
124
+ elif [ "$DID" -eq 1 ]; then
125
+ printf 'RESULT: dry-run. Re-run with --apply to remove the [would] items above.\n'
126
+ else
127
+ printf 'RESULT: nothing to prune (already clean) — only MANUAL items remain.\n'
128
+ fi
@@ -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())