@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
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env bash
2
+ # Opt-in, profile-local Slack Socket Mode provisioning.
3
+ #
4
+ # Slack credentials are invocation-only inputs. Capture them before _lib.sh
5
+ # sources fleet.env so an accidentally shared fleet token can never provision a
6
+ # profile. The non-secret allowed-user policy may still come from fleet.env.
7
+ INVOCATION_SLACK_BOT_TOKEN="${SLACK_BOT_TOKEN-}"
8
+ INVOCATION_SLACK_APP_TOKEN="${SLACK_APP_TOKEN-}"
9
+ INVOCATION_SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
10
+ INVOCATION_ENABLE_SLACK="${ENABLE_SLACK-${WIRE_SLACK-}}"
11
+
12
+ # shellcheck source=_lib.sh
13
+ source "$(dirname "$0")/_lib.sh"
14
+ load_role_env
15
+
16
+ SLACK_BOT_TOKEN="$INVOCATION_SLACK_BOT_TOKEN"
17
+ SLACK_APP_TOKEN="$INVOCATION_SLACK_APP_TOKEN"
18
+ if [[ -n "$INVOCATION_SLACK_ALLOWED_USERS" ]]; then
19
+ SLACK_ALLOWED_USERS="$INVOCATION_SLACK_ALLOWED_USERS"
20
+ else
21
+ SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS-}"
22
+ fi
23
+ ENABLE_SLACK="$INVOCATION_ENABLE_SLACK"
24
+ unset INVOCATION_SLACK_BOT_TOKEN INVOCATION_SLACK_APP_TOKEN
25
+
26
+ slack_yaml_update() {
27
+ python3 - "$ROLE_YAML" "$@" <<'PYEOF'
28
+ import json
29
+ import pathlib
30
+ import re
31
+ import sys
32
+
33
+ path = pathlib.Path(sys.argv[1])
34
+ updates = dict(zip(sys.argv[2::2], sys.argv[3::2]))
35
+ text = path.read_text(encoding="utf-8")
36
+ match = re.search(r"(?ms)^slack:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text)
37
+ if not match:
38
+ raise SystemExit(f"slack metadata block missing from {path}")
39
+ body = match.group("body")
40
+ for key, value in updates.items():
41
+ replacement = f" {key}: {json.dumps(value)}"
42
+ body, count = re.subn(
43
+ rf"(?m)^\s+{re.escape(key)}:\s*.*$", lambda _: replacement, body, count=1
44
+ )
45
+ if count != 1:
46
+ raise SystemExit(f"Slack metadata key {key!r} missing from {path}")
47
+ path.write_text(text[: match.start("body")] + body + text[match.end("body") :], encoding="utf-8")
48
+ PYEOF
49
+ }
50
+
51
+ truthy() {
52
+ case "${1,,}" in
53
+ 1|true|yes|on) return 0 ;;
54
+ *) return 1 ;;
55
+ esac
56
+ }
57
+
58
+ if [[ "${SKIP_SLACK:-0}" == "1" ]]; then
59
+ slack_yaml_update provisioning_status disabled
60
+ log "[31] slack — DISABLED (SKIP_SLACK=1)"
61
+ mark_done 31-slack
62
+ exit 0
63
+ fi
64
+
65
+ already_done 31-slack && { log "[31] slack already wired — skipping"; exit 0; }
66
+
67
+ have_bot=0
68
+ have_app=0
69
+ [[ -n "$SLACK_BOT_TOKEN" ]] && have_bot=1
70
+ [[ -n "$SLACK_APP_TOKEN" ]] && have_app=1
71
+
72
+ if ! truthy "${ENABLE_SLACK:-0}" && (( ! have_bot && ! have_app )); then
73
+ slack_yaml_update provisioning_status deferred
74
+ log "[31] slack — deferred (opt in with ENABLE_SLACK=1 or supply both Slack tokens)"
75
+ exit 0
76
+ fi
77
+
78
+ if (( have_bot != have_app )) && ! truthy "${ENABLE_SLACK:-0}"; then
79
+ die "Slack provisioning requires a dedicated SLACK_BOT_TOKEN and SLACK_APP_TOKEN pair"
80
+ fi
81
+
82
+ if truthy "${ENABLE_SLACK:-0}" && [[ -t 0 ]]; then
83
+ if [[ -z "$SLACK_BOT_TOKEN" ]]; then
84
+ read -r -s -p "Slack Bot User OAuth Token (xoxb-...): " SLACK_BOT_TOKEN
85
+ echo >&2
86
+ fi
87
+ if [[ -z "$SLACK_APP_TOKEN" ]]; then
88
+ read -r -s -p "Slack App-Level Socket Mode Token (xapp-...): " SLACK_APP_TOKEN
89
+ echo >&2
90
+ fi
91
+ fi
92
+
93
+ [[ -n "$SLACK_BOT_TOKEN" && -n "$SLACK_APP_TOKEN" ]] \
94
+ || die "Slack provisioning requires both SLACK_BOT_TOKEN and SLACK_APP_TOKEN"
95
+ [[ "$SLACK_BOT_TOKEN" == xoxb-* ]] || die "SLACK_BOT_TOKEN must be a Bot User OAuth token (xoxb-...)"
96
+ [[ "$SLACK_APP_TOKEN" == xapp-* ]] || die "SLACK_APP_TOKEN must be an App-Level Socket Mode token (xapp-...)"
97
+
98
+ SLACK_ALLOWED_USERS="${SLACK_ALLOWED_USERS//[[:space:]]/}"
99
+ if [[ -n "$SLACK_ALLOWED_USERS" && "$SLACK_ALLOWED_USERS" != "*" \
100
+ && ! "$SLACK_ALLOWED_USERS" =~ ^[UW][A-Z0-9]+(,[UW][A-Z0-9]+)*$ ]]; then
101
+ die "SLACK_ALLOWED_USERS must be '*' or comma-separated Slack member IDs"
102
+ fi
103
+
104
+ RUNTIME="$ROLE_DIR/runtime"
105
+ ENVF="$RUNTIME/.env"
106
+ mkdir -p "$RUNTIME"
107
+ [[ ! -L "$ENVF" ]] || die "refusing to write Slack credentials through symlink: $ENVF"
108
+
109
+ log "[31] verifying Slack bot identity via auth.test"
110
+ auth_response=$(curl -fsS \
111
+ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
112
+ -H "Content-Type: application/x-www-form-urlencoded" \
113
+ -X POST "https://slack.com/api/auth.test") \
114
+ || die "Slack auth.test request failed"
115
+
116
+ identity=$(printf '%s' "$auth_response" | python3 -c '
117
+ import json, sys
118
+ try:
119
+ data = json.load(sys.stdin)
120
+ except Exception:
121
+ raise SystemExit("Slack auth.test returned an invalid response")
122
+ if not data.get("ok"):
123
+ error = str(data.get("error") or "unknown_error")
124
+ safe = "".join(c for c in error if c.isalnum() or c in "_-.")[:80]
125
+ raise SystemExit("Slack auth.test rejected the bot token ({})".format(safe or "unknown_error"))
126
+ values = [data.get(k, "") for k in ("team_id", "team", "user_id", "bot_id", "user")]
127
+ if not values[0] or not values[2]:
128
+ raise SystemExit("Slack auth.test response omitted required identity fields")
129
+ print("\t".join(str(v).replace("\t", " ").replace("\n", " ") for v in values))
130
+ ') || die "Slack bot identity verification failed"
131
+ IFS=$'\t' read -r slack_team_id slack_team_name slack_bot_user_id slack_bot_id slack_bot_username <<< "$identity"
132
+
133
+ # Reject credential reuse, token rotation onto an identity owned by another
134
+ # agent, and credentials parked in shared env files. The scan, durable identity
135
+ # claim, and profile credential write share one fleet-wide flock.
136
+ export SLACK_BOT_TOKEN SLACK_APP_TOKEN SLACK_ALLOWED_USERS
137
+ fleet_lock_acquire
138
+ trap 'fleet_lock_release' EXIT
139
+ python3 - "$REGISTRY_FILE" "$FLEET_ENV" "$ENVF" "$AGENT_ID" \
140
+ "$slack_team_id" "$slack_bot_user_id" "$slack_bot_id" \
141
+ "$ROLE_DIR" "$PROFILE_NAME" "$slack_team_name" "$slack_bot_username" <<'PYEOF'
142
+ import errno
143
+ import os
144
+ import pathlib
145
+ import re
146
+ import sys
147
+ import tempfile
148
+ try:
149
+ import yaml # type: ignore
150
+ except ImportError:
151
+ raise SystemExit("PyYAML is required for Slack fleet claims")
152
+
153
+ (
154
+ registry_path,
155
+ fleet_path,
156
+ target_path,
157
+ agent_id,
158
+ team_id,
159
+ user_id,
160
+ bot_id,
161
+ role_dir,
162
+ profile_name,
163
+ team_name,
164
+ bot_username,
165
+ ) = sys.argv[1:]
166
+ bot_token = os.environ["SLACK_BOT_TOKEN"]
167
+ app_token = os.environ["SLACK_APP_TOKEN"]
168
+ target = pathlib.Path(target_path).resolve(strict=False)
169
+
170
+ def env_values(path):
171
+ try:
172
+ text = path.read_text(encoding="utf-8")
173
+ except (OSError, UnicodeError):
174
+ return {}
175
+ values = {}
176
+ for key in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"):
177
+ match = re.search(rf"(?m)^\s*(?:export\s+)?{key}\s*=\s*(.*)$", text)
178
+ if match:
179
+ value = match.group(1).strip()
180
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
181
+ value = value[1:-1]
182
+ values[key] = value
183
+ return values
184
+
185
+ owners = [("shared fleet environment", pathlib.Path(fleet_path))]
186
+ registry = pathlib.Path(registry_path)
187
+ if registry.is_symlink():
188
+ raise SystemExit(f"refusing to update registry symlink: {registry}")
189
+ data = {"schema_version": 1, "agents": {}}
190
+ if registry.is_file():
191
+ try:
192
+ data = yaml.safe_load(registry.read_text(encoding="utf-8")) or data
193
+ except Exception as exc:
194
+ raise SystemExit(f"cannot safely inspect Slack ownership registry: {type(exc).__name__}")
195
+ if not isinstance(data, dict) or not isinstance(data.get("agents", {}), dict):
196
+ raise SystemExit("cannot safely inspect Slack ownership registry: invalid agents mapping")
197
+ agents = data.setdefault("agents", {})
198
+ for other_id, entry in agents.items():
199
+ if other_id == agent_id or not isinstance(entry, dict):
200
+ continue
201
+ slack = entry.get("slack") or {}
202
+ if isinstance(slack, dict):
203
+ same_user = user_id and slack.get("bot_user_id") == user_id
204
+ same_bot = bot_id and slack.get("bot_id") == bot_id
205
+ same_team_user = team_id and same_user and slack.get("team_id") == team_id
206
+ if same_bot or same_team_user:
207
+ raise SystemExit(f"Slack bot identity is already assigned to agent {other_id}")
208
+ other_role_dir = entry.get("role_dir")
209
+ if other_role_dir:
210
+ owners.append((f"agent {other_id}", pathlib.Path(str(other_role_dir)) / "runtime" / ".env"))
211
+
212
+ home_value = os.environ.get("HOME", "")
213
+ if home_value:
214
+ home = pathlib.Path(home_value)
215
+ owners.append(("shared Hermes root", home / ".hermes" / ".env"))
216
+ profiles = home / ".hermes" / "profiles"
217
+ if profiles.is_dir():
218
+ for profile in profiles.iterdir():
219
+ owners.append((f"profile {profile.name}", profile / ".env"))
220
+
221
+ seen = set()
222
+ for owner, path in owners:
223
+ resolved = path.resolve(strict=False)
224
+ if resolved == target or resolved in seen:
225
+ continue
226
+ seen.add(resolved)
227
+ values = env_values(path)
228
+ if values.get("SLACK_BOT_TOKEN") == bot_token:
229
+ raise SystemExit(f"Slack bot token is already assigned to {owner}")
230
+ if values.get("SLACK_APP_TOKEN") == app_token:
231
+ raise SystemExit(f"Slack app token is already assigned to {owner}")
232
+
233
+ claim = agents.setdefault(agent_id, {})
234
+ if not isinstance(claim, dict):
235
+ raise SystemExit(f"registry entry for {agent_id} is not a mapping")
236
+ claim["role_dir"] = role_dir
237
+ claim["profile_name"] = profile_name
238
+ claim["slack"] = {
239
+ "provisioning_status": "verified",
240
+ "team_id": team_id,
241
+ "team_name": team_name,
242
+ "bot_user_id": user_id,
243
+ "bot_id": bot_id,
244
+ "bot_username": bot_username,
245
+ }
246
+ registry.parent.mkdir(parents=True, exist_ok=True)
247
+ rendered = yaml.safe_dump(data, sort_keys=False)
248
+
249
+ def fsync_parent(target):
250
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
251
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
252
+ try:
253
+ directory_fd = os.open(target.parent, flags)
254
+ except OSError as exc:
255
+ if exc.errno in unsupported:
256
+ return
257
+ raise
258
+ try:
259
+ try:
260
+ os.fsync(directory_fd)
261
+ except OSError as exc:
262
+ if exc.errno not in unsupported:
263
+ raise
264
+ finally:
265
+ os.close(directory_fd)
266
+
267
+ fd, temporary = tempfile.mkstemp(prefix=f".{registry.name}.slack-", dir=registry.parent)
268
+ try:
269
+ os.fchmod(fd, 0o600)
270
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
271
+ handle.write(rendered)
272
+ handle.flush()
273
+ os.fsync(handle.fileno())
274
+ os.replace(temporary, registry)
275
+ os.chmod(registry, 0o600)
276
+ fsync_parent(registry)
277
+ except BaseException:
278
+ try:
279
+ os.unlink(temporary)
280
+ except FileNotFoundError:
281
+ pass
282
+ raise
283
+ PYEOF
284
+
285
+ # Atomically replace only the Slack fields in the profile-local runtime file.
286
+ # Secrets stay out of argv and are never written to root .env or fleet.env.
287
+ python3 - "$ENVF" <<'PYEOF'
288
+ import errno
289
+ import json
290
+ import os
291
+ import pathlib
292
+ import re
293
+ import tempfile
294
+
295
+ path = pathlib.Path(__import__("sys").argv[1])
296
+ if path.is_symlink():
297
+ raise SystemExit(f"refusing to write Slack credentials through symlink: {path}")
298
+ path.parent.mkdir(parents=True, exist_ok=True)
299
+ text = path.read_text(encoding="utf-8") if path.exists() else ""
300
+ for key in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"):
301
+ text = re.sub(rf"(?m)^\s*(?:export\s+)?#?\s*{key}\s*=.*(?:\n|$)", "", text)
302
+ values = {key: os.environ.get(key, "") for key in (
303
+ "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"
304
+ )}
305
+ text = text.rstrip("\n")
306
+ if text:
307
+ text += "\n"
308
+ text += "".join(f"{key}={json.dumps(value)}\n" for key, value in values.items())
309
+
310
+ def fsync_parent(target):
311
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
312
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
313
+ try:
314
+ directory_fd = os.open(target.parent, flags)
315
+ except OSError as exc:
316
+ if exc.errno in unsupported:
317
+ return
318
+ raise
319
+ try:
320
+ try:
321
+ os.fsync(directory_fd)
322
+ except OSError as exc:
323
+ if exc.errno not in unsupported:
324
+ raise
325
+ finally:
326
+ os.close(directory_fd)
327
+
328
+ fd, temporary = tempfile.mkstemp(prefix=".env.slack-", dir=path.parent)
329
+ try:
330
+ os.fchmod(fd, 0o600)
331
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
332
+ handle.write(text)
333
+ handle.flush()
334
+ os.fsync(handle.fileno())
335
+ os.replace(temporary, path)
336
+ os.chmod(path, 0o600)
337
+ fsync_parent(path)
338
+ except BaseException:
339
+ try:
340
+ os.unlink(temporary)
341
+ except FileNotFoundError:
342
+ pass
343
+ raise
344
+ PYEOF
345
+ unset SLACK_BOT_TOKEN SLACK_APP_TOKEN
346
+
347
+ slack_yaml_update \
348
+ provisioning_status verified \
349
+ team_id "$slack_team_id" \
350
+ team_name "$slack_team_name" \
351
+ bot_user_id "$slack_bot_user_id" \
352
+ bot_id "$slack_bot_id" \
353
+ bot_username "$slack_bot_username"
354
+
355
+ fleet_lock_release
356
+ trap - EXIT
357
+
358
+ if [[ -z "$SLACK_ALLOWED_USERS" ]]; then
359
+ warn " Slack is wired but denies all inbound users until SLACK_ALLOWED_USERS is set"
360
+ fi
361
+ log " verified Slack bot $slack_bot_username in $slack_team_name (profile-local credentials)"
362
+ mark_done 31-slack
@@ -1,76 +1,18 @@
1
1
  #!/usr/bin/env bash
2
- # Install the bloodbank consumer + envelope helper into the runtime submodule.
2
+ # Compatibility checkpoint for the fleet-shared Bloodbank gateway contract.
3
3
  # shellcheck source=_lib.sh
4
4
  source "$(dirname "$0")/_lib.sh"
5
5
  load_role_env
6
6
 
7
- already_done 60-bloodbank && { log "[60] bloodbank already installed — skipping"; exit 0; }
8
- [[ "${SKIP_BLOODBANK:-0}" == "1" ]] && { log "[60] bloodbank — SKIPPED"; mark_done 60-bloodbank; exit 0; }
7
+ already_done 60-bloodbank && { log "[60] bloodbank fleet-routing checkpoint already complete — skipping"; exit 0; }
9
8
 
10
- RUNTIME="$ROLE_DIR/runtime"
11
- [[ -d "$RUNTIME" ]] || die "runtime not initialized; run 20-runtime-repo.sh first"
12
-
13
- log "[60] installing bloodbank consumer for $AGENT_ID"
14
-
15
- # Ensure the runtime consumer exists AND is rendered.
16
- #
17
- # Step 20 (20-runtime-repo.sh) only renders into a TMP dir during the
18
- # initial GH-repo push — the on-disk $RUNTIME_SCAFFOLD_DIR copy that lands
19
- # in the project repo keeps its `{{agent_id}}`/`{{repo}}`/`{{role}}`
20
- # placeholders. If $RUNTIME/bloodbank-consumer.py was ever lost (runtime
21
- # submodule wiped, manual rm, etc.) and then someone restored it by
22
- # copying from $RUNTIME_SCAFFOLD_DIR, they'd end up with an un-rendered
23
- # consumer whose envelope routing filter compares against literal `{{repo}}`
24
- # and `{{agent_id}}` values.
25
- # This block handles both failure modes idempotently.
26
- CONSUMER="$RUNTIME/bloodbank-consumer.py"
27
- SCAFFOLD_CONSUMER="$RUNTIME_SCAFFOLD_DIR/bloodbank-consumer.py"
28
-
29
- needs_restore=0
30
- if [[ ! -f "$CONSUMER" ]]; then
31
- log " consumer missing in runtime — restoring from scaffold"
32
- needs_restore=1
33
- elif grep -qE '\{\{(agent_id|repo|role|display_name)\}\}' "$CONSUMER"; then
34
- warn " consumer in runtime has un-rendered placeholders — re-rendering"
35
- needs_restore=1
36
- fi
37
-
38
- if [[ "$needs_restore" == "1" ]]; then
39
- [[ -f "$SCAFFOLD_CONSUMER" ]] || die "scaffold source missing: $SCAFFOLD_CONSUMER"
40
- cp "$SCAFFOLD_CONSUMER" "$CONSUMER"
41
- # Same placeholder set + values as 20-runtime-repo.sh's render pass.
42
- python3 - "$CONSUMER" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" <<'PYEOF'
43
- import pathlib, sys
44
- p = pathlib.Path(sys.argv[1])
45
- agent_id, repo, role, display = sys.argv[2:6]
46
- mapping = {
47
- "{{agent_id}}": agent_id, "{{repo}}": repo, "{{role}}": role,
48
- "{{display_name}}": display,
49
- }
50
- t = p.read_text()
51
- for k, v in mapping.items():
52
- t = t.replace(k, v)
53
- p.write_text(t)
54
- PYEOF
55
- fi
56
- chmod +x "$CONSUMER"
57
-
58
- # Health check: is NATS up?
59
- if (echo > /dev/tcp/$BLOODBANK_NATS_HOST/$BLOODBANK_NATS_PORT) 2>/dev/null; then
60
- log " NATS reachable at $BLOODBANK_NATS_HOST:$BLOODBANK_NATS_PORT"
9
+ # SKIP_BLOODBANK used to suppress per-profile consumer installation. Keep the
10
+ # flag harmless for existing unattended provisioning commands; there is no
11
+ # profile-local process, dependency, broker probe, or file to skip anymore.
12
+ if [[ "${SKIP_BLOODBANK:-0}" == "1" ]]; then
13
+ log "[60] bloodbank — SKIP_BLOODBANK accepted as a compatibility no-op"
61
14
  else
62
- warn " NATS not reachable; consumer will retry on start"
63
- warn " bring up: cd $BLOODBANK_COMPOSE_DIR && docker compose -f compose/docker-compose.yml up -d"
64
- fi
65
-
66
- # Ensure nats-py is available in the hermes venv (uv-managed, no pip binary)
67
- if ! "$HERMES_AGENT_REPO/.venv/bin/python" -c "import nats" 2>/dev/null; then
68
- warn " python nats-py not installed in hermes venv; installing via uv"
69
- if command -v uv >/dev/null 2>&1; then
70
- (cd "$HERMES_AGENT_REPO" && uv pip install --quiet --python .venv/bin/python nats-py 2>&1 | tail -3) || true
71
- else
72
- warn " uv not available either — install nats-py manually: cd $HERMES_AGENT_REPO && uv pip install nats-py"
73
- fi
15
+ log "[60] bloodbank fleet-shared gateway routes target_agent_id=$AGENT_ID"
74
16
  fi
75
17
 
76
18
  mark_done 60-bloodbank
@@ -1,18 +1,57 @@
1
1
  #!/usr/bin/env bash
2
- # Install systemd --user units: gateway, consumer, and a fused heartbeat timer
2
+ # Install systemd --user units: profile gateway and fused heartbeat timer
3
3
  # (board-reconciliation sentinel pass + gated runtime checkpoint, one tick).
4
4
  # shellcheck source=_lib.sh
5
5
  source "$(dirname "$0")/_lib.sh"
6
6
  load_role_env
7
7
 
8
- already_done 70-systemd && { log "[70] systemd already installed — skipping"; exit 0; }
9
- [[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
10
-
11
8
  RUNTIME="$ROLE_DIR/runtime"
12
9
  REPO_ROOT="$(project_repo_path)" || REPO_ROOT="$ROLE_DIR"
13
10
  SYS_DIR="$HOME/.config/systemd/user"
14
11
  mkdir -p "$SYS_DIR" "$RUNTIME/logs"
15
12
 
13
+ # Upgrade remediation must run before honoring a legacy done marker. Older
14
+ # templates installed one per-profile Bloodbank consumer; leaving even one
15
+ # enabled races the fleet-shared durable gateway and can duplicate execution.
16
+ LEGACY_CONSUMER_UNIT="hermes-${AGENT_ID}-consumer.service"
17
+ LEGACY_CONSUMER_PATH="$SYS_DIR/$LEGACY_CONSUMER_UNIT"
18
+ legacy_consumer_present=0
19
+ [[ -e "$LEGACY_CONSUMER_PATH" || -L "$LEGACY_CONSUMER_PATH" ]] \
20
+ && legacy_consumer_present=1
21
+ if command -v systemctl >/dev/null 2>&1; then
22
+ legacy_active_result="$(systemctl_user_unit_state is-active "$LEGACY_CONSUMER_UNIT")"
23
+ legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$LEGACY_CONSUMER_UNIT")"
24
+ [[ "$legacy_active_result" != error\|* ]] \
25
+ || die "cannot safely query legacy consumer activity; preserving unit: ${legacy_active_result#*|}"
26
+ [[ "$legacy_enabled_result" != error\|* ]] \
27
+ || die "cannot safely query legacy consumer enablement; preserving unit: ${legacy_enabled_result#*|}"
28
+ legacy_active_state="${legacy_active_result#*|}"
29
+ legacy_enabled_state="${legacy_enabled_result#*|}"
30
+ [[ "$legacy_active_state" == "not-found" && "$legacy_enabled_state" == "not-found" ]] \
31
+ || legacy_consumer_present=1
32
+ elif [[ $legacy_consumer_present -eq 1 ]]; then
33
+ die "systemctl is unavailable; cannot safely retire legacy consumer: $LEGACY_CONSUMER_UNIT"
34
+ fi
35
+ if [[ $legacy_consumer_present -eq 1 ]]; then
36
+ systemctl --user disable --now "$LEGACY_CONSUMER_UNIT" >/dev/null 2>&1 \
37
+ || die "legacy consumer disable failed; preserving unit: $LEGACY_CONSUMER_UNIT"
38
+ legacy_active_result="$(systemctl_user_unit_state is-active "$LEGACY_CONSUMER_UNIT")"
39
+ legacy_enabled_result="$(systemctl_user_unit_state is-enabled "$LEGACY_CONSUMER_UNIT")"
40
+ [[ "$legacy_active_result" == "ok|inactive" ]] \
41
+ || die "legacy consumer is not proven inactive; preserving unit: ${legacy_active_result#*|}"
42
+ [[ "$legacy_enabled_result" == "ok|disabled" ]] \
43
+ || die "legacy consumer is not proven disabled; preserving unit: ${legacy_enabled_result#*|}"
44
+ rm -f -- "$LEGACY_CONSUMER_PATH"
45
+ if systemd_user_available; then
46
+ systemctl --user daemon-reload >/dev/null 2>&1 \
47
+ || warn " systemd daemon-reload failed after legacy consumer retirement"
48
+ fi
49
+ log " retired legacy per-profile Bloodbank consumer: $LEGACY_CONSUMER_UNIT"
50
+ fi
51
+
52
+ already_done 70-systemd && { log "[70] systemd already installed — legacy cleanup checked"; exit 0; }
53
+ [[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
54
+
16
55
  # The heartbeat runner (board-reconciliation sentinel pass + gated checkpoint)
17
56
  # and the checkpoint helper both render into the role dir; just ensure they are
18
57
  # executable. heartbeat.sh calls checkpoint.sh internally.
@@ -43,31 +82,6 @@ StandardError=append:$RUNTIME/logs/gateway.systemd.log
43
82
  WantedBy=default.target
44
83
  UNIT
45
84
 
46
- # Consumer unit
47
- CSM_UNIT="hermes-${AGENT_ID}-consumer.service"
48
- cat > "$SYS_DIR/$CSM_UNIT" <<UNIT
49
- [Unit]
50
- Description=Bloodbank Consumer — $DISPLAY_NAME
51
- After=network-online.target $GW_UNIT
52
- Wants=network-online.target
53
-
54
- [Service]
55
- Type=simple
56
- WorkingDirectory=$RUNTIME
57
- Environment=HERMES_HOME=$RUNTIME
58
- Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
59
- Environment=CODEX_HOME=$CODEX_HOME
60
- EnvironmentFile=-$RUNTIME/.env
61
- ExecStart=$HERMES_AGENT_REPO/.venv/bin/python $RUNTIME/bloodbank-consumer.py
62
- Restart=on-failure
63
- RestartSec=5
64
- StandardOutput=append:$RUNTIME/logs/consumer.log
65
- StandardError=append:$RUNTIME/logs/consumer.log
66
-
67
- [Install]
68
- WantedBy=default.target
69
- UNIT
70
-
71
85
  # Fused heartbeat: board-reconciliation sentinel pass + gated runtime checkpoint.
72
86
  # Frequent ticks (1 min); heartbeat.sh's own cooldown/lock logic rate-limits the
73
87
  # full Hermes pass, and the checkpoint is gated to ~hourly inside the runner.
@@ -115,7 +129,7 @@ if systemd_user_available; then
115
129
  # `enable --now` both enables (persist across login) AND starts the unit now, so a
116
130
  # freshly provisioned agent comes up live instead of dormant. Units with missing
117
131
  # creds (e.g. a gateway with no Telegram token yet) fail softly via Restart=on-failure.
118
- for u in "$GW_UNIT" "$CSM_UNIT" "$HB_TIMER"; do
132
+ for u in "$GW_UNIT" "$HB_TIMER"; do
119
133
  systemctl --user enable --now "$u" >/dev/null 2>&1 && log " enabled + started: $u" || warn " failed to enable/start: $u"
120
134
  done
121
135
  else
@@ -4,6 +4,10 @@
4
4
  source "$(dirname "$0")/_lib.sh"
5
5
  load_role_env
6
6
 
7
+ fleet_lock_acquire
8
+ trap 'fleet_lock_release' EXIT
9
+
10
+ [[ ! -L "$REGISTRY_FILE" ]] || die "refusing to update registry symlink: $REGISTRY_FILE"
7
11
  mkdir -p "$(dirname "$REGISTRY_FILE")"
8
12
  if [[ ! -f "$REGISTRY_FILE" ]]; then
9
13
  cat > "$REGISTRY_FILE" <<'YAML'
@@ -21,33 +25,109 @@ log "[80] appending to fleet registry: $REGISTRY_FILE"
21
25
 
22
26
  python3 - "$REGISTRY_FILE" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" \
23
27
  "$PROJECT_PATH" "$ROLE_DIR" "$PROFILE_NAME" \
24
- "$BOT_HANDLE" \
28
+ "$(yaml_get telegram.provisioning_status)" "$BOT_HANDLE" "$(yaml_get telegram.bot_id)" \
29
+ "$(yaml_get slack.provisioning_status)" "$(yaml_get slack.team_id)" \
30
+ "$(yaml_get slack.team_name)" "$(yaml_get slack.bot_user_id)" \
31
+ "$(yaml_get slack.bot_id)" "$(yaml_get slack.bot_username)" \
32
+ "$(yaml_get bloodbank.gateway_scope)" "$(yaml_get bloodbank.target_agent_id)" \
25
33
  "$PLANE_WORKSPACE" "$PLANE_PROJECT_ID" "$(yaml_get plane.identifier)" \
26
- "$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$FLEET_ENV" \
27
- "hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-consumer.service" \
28
- "hermes-${AGENT_ID}-heartbeat.timer" <<'PYEOF'
29
- import sys, pathlib, datetime
34
+ "$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$HERMES_RUNTIME_GIT_URL" \
35
+ "$HERMES_RUNTIME_GIT_REF" "$HERMES_RUNTIME_GIT_SHA" "$FLEET_ENV" \
36
+ "hermes-${AGENT_ID}-gateway.service" "hermes-${AGENT_ID}-heartbeat.timer" <<'PYEOF'
37
+ import datetime
38
+ import errno
39
+ import os
40
+ import pathlib
41
+ import sys
42
+ import tempfile
30
43
  try:
31
44
  import yaml # type: ignore
32
45
  except ImportError:
33
46
  sys.exit("PyYAML required; pip install pyyaml")
34
- (path, agent_id, repo, role, display, project, role_dir, profile, bot,
35
- plane_ws, plane_id, plane_ident, runtime_repo, hermes_bin, hermes_repo,
36
- fleet_env, gw, csm, ckpt) = sys.argv[1:20]
47
+ (path, agent_id, repo, role, display, project, role_dir, profile,
48
+ telegram_status, bot, telegram_bot_id,
49
+ slack_status, slack_team_id, slack_team_name, slack_user_id, slack_bot_id,
50
+ slack_username, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
51
+ plane_ident, runtime_repo, hermes_bin, hermes_repo, hermes_git_url,
52
+ hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat) = sys.argv[1:32]
37
53
  p = pathlib.Path(path)
54
+ if p.is_symlink():
55
+ raise SystemExit(f"refusing to update registry symlink: {p}")
38
56
  data = yaml.safe_load(p.read_text()) or {"schema_version": 1, "agents": {}}
39
57
  data.setdefault("agents", {})[agent_id] = {
40
58
  "repo": repo, "role": role, "display_name": display,
41
59
  "project_path": project, "role_dir": role_dir,
42
60
  "profile_name": profile,
43
- "telegram": {"bot_username": bot},
61
+ "telegram": {
62
+ "provisioning_status": telegram_status,
63
+ "bot_username": bot,
64
+ "bot_id": telegram_bot_id,
65
+ },
66
+ "slack": {
67
+ "provisioning_status": slack_status,
68
+ "team_id": slack_team_id,
69
+ "team_name": slack_team_name,
70
+ "bot_user_id": slack_user_id,
71
+ "bot_id": slack_bot_id,
72
+ "bot_username": slack_username,
73
+ },
74
+ "bloodbank": {
75
+ "gateway_scope": bloodbank_scope,
76
+ "target_agent_id": bloodbank_target,
77
+ },
44
78
  "plane": {"workspace": plane_ws, "project_id": plane_id, "identifier": plane_ident},
45
79
  "runtime_repo": runtime_repo,
46
- "hermes": {"bin": hermes_bin, "repo": hermes_repo, "fleet_env": fleet_env},
47
- "systemd": {"gateway_unit": gw, "consumer_unit": csm, "heartbeat_timer": ckpt},
48
- "provisioned_at": datetime.datetime.utcnow().isoformat() + "Z",
80
+ "hermes": {
81
+ "bin": hermes_bin,
82
+ "repo": hermes_repo,
83
+ "git_url": hermes_git_url,
84
+ "git_ref": hermes_git_ref,
85
+ "git_sha": hermes_git_sha,
86
+ "fleet_env": fleet_env,
87
+ },
88
+ "systemd": {"gateway_unit": gw, "heartbeat_timer": heartbeat},
89
+ "provisioned_at": datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z"),
49
90
  }
50
- p.write_text(yaml.safe_dump(data, sort_keys=False))
91
+ rendered = yaml.safe_dump(data, sort_keys=False)
92
+ p.parent.mkdir(parents=True, exist_ok=True)
93
+
94
+ def fsync_parent(target):
95
+ unsupported = {errno.EINVAL, getattr(errno, "ENOTSUP", errno.EINVAL), errno.ENOSYS}
96
+ flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
97
+ try:
98
+ directory_fd = os.open(target.parent, flags)
99
+ except OSError as exc:
100
+ if exc.errno in unsupported:
101
+ return
102
+ raise
103
+ try:
104
+ try:
105
+ os.fsync(directory_fd)
106
+ except OSError as exc:
107
+ if exc.errno not in unsupported:
108
+ raise
109
+ finally:
110
+ os.close(directory_fd)
111
+
112
+ fd, temporary = tempfile.mkstemp(prefix=f".{p.name}.registry-", dir=p.parent)
113
+ try:
114
+ os.fchmod(fd, 0o600)
115
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
116
+ handle.write(rendered)
117
+ handle.flush()
118
+ os.fsync(handle.fileno())
119
+ os.replace(temporary, p)
120
+ os.chmod(p, 0o600)
121
+ fsync_parent(p)
122
+ except BaseException:
123
+ try:
124
+ os.unlink(temporary)
125
+ except FileNotFoundError:
126
+ pass
127
+ raise
51
128
  PYEOF
52
129
 
130
+ fleet_lock_release
131
+ trap - EXIT
132
+
53
133
  mark_done 80-registry