@delorenj/pjangler 1.4.2 → 1.4.3

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 (39) hide show
  1. package/dist/index.js +3336 -1359
  2. package/dist/index.js.map +7 -0
  3. package/dist/mcp-server.js +1520 -838
  4. package/dist/mcp-server.js.map +7 -0
  5. package/dist/prompt.js +2 -1
  6. package/dist/prompt.js.map +7 -0
  7. package/package.json +8 -4
  8. package/templates/hermes-agent/copier.yml +16 -3
  9. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  10. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  11. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  12. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  13. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  14. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  15. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  16. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  17. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  18. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  19. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  20. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  21. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  22. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  23. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  25. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  26. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +9 -0
  27. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  28. package/templates/hermes-agent/template/.scripts/providers/linear.sh +24 -1
  29. package/templates/hermes-agent/template/.scripts/providers/plane.sh +54 -8
  30. package/templates/hermes-agent/template/.scripts/providers/trello.sh +25 -2
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +5 -16
  32. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +2 -16
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +11 -4
@@ -106,6 +106,562 @@ p.write_text("".join(lines), encoding="utf-8")
106
106
  PYEOF
107
107
  }
108
108
 
109
+ # Upsert one scalar below a top-level mapping without requiring the mapping to
110
+ # have existed in an older rendered role.yaml. This is intentionally narrower
111
+ # than a general YAML editor: lifecycle scripts use it only for safe, generated
112
+ # status metadata such as service_state.gateway.
113
+ yaml_upsert_block_value() {
114
+ # yaml_upsert_block_value PARENT KEY VALUE [string|bool]
115
+ python3 - "$ROLE_YAML" "$1" "$2" "$3" "${4:-string}" <<'PYEOF'
116
+ import json, pathlib, re, sys
117
+
118
+ path, parent, key, value, kind = sys.argv[1:6]
119
+ if not all(re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", item) for item in (parent, key)):
120
+ raise SystemExit("yaml_upsert_block_value: unsafe key")
121
+ if kind == "bool":
122
+ if value not in {"true", "false"}:
123
+ raise SystemExit("yaml_upsert_block_value: invalid boolean")
124
+ rendered_value = value
125
+ elif kind == "string":
126
+ rendered_value = json.dumps(value, ensure_ascii=False)
127
+ else:
128
+ raise SystemExit("yaml_upsert_block_value: unsupported scalar type")
129
+ p = pathlib.Path(path)
130
+ text = p.read_text(encoding="utf-8")
131
+ match = re.search(
132
+ rf"(?ms)^{re.escape(parent)}:\s*\n(?P<body>(?:^[ \t]+.*\n?)*)", text
133
+ )
134
+ replacement = f" {key}: {rendered_value}"
135
+ if match:
136
+ body = match.group("body")
137
+ body, count = re.subn(
138
+ rf"(?m)^[ \t]+{re.escape(key)}:\s*.*$", replacement, body, count=1
139
+ )
140
+ if count == 0:
141
+ if body and not body.endswith("\n"):
142
+ body += "\n"
143
+ body += replacement + "\n"
144
+ text = text[: match.start("body")] + body + text[match.end("body") :]
145
+ else:
146
+ if text and not text.endswith("\n"):
147
+ text += "\n"
148
+ text += f"\n{parent}:\n{replacement}\n"
149
+ p.write_text(text, encoding="utf-8")
150
+ PYEOF
151
+ }
152
+
153
+ # Test whether an ignored dotenv file contains one non-empty exact assignment.
154
+ # Values remain inside the child process and are never printed or imported into
155
+ # the provisioning shell.
156
+ dotenv_has_nonempty() {
157
+ python3 - "$1" "$2" <<'PYEOF'
158
+ import pathlib, re, sys
159
+
160
+ path = pathlib.Path(sys.argv[1])
161
+ key = sys.argv[2]
162
+ if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) or not path.is_file() or path.is_symlink():
163
+ raise SystemExit(1)
164
+ for raw in path.read_text(encoding="utf-8").splitlines():
165
+ line = raw.strip()
166
+ if not line or line.startswith("#"):
167
+ continue
168
+ if line.startswith("export "):
169
+ line = line[7:].lstrip()
170
+ name, sep, value = line.partition("=")
171
+ if not sep or name.strip() != key:
172
+ continue
173
+ value = value.strip()
174
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
175
+ value = value[1:-1]
176
+ raise SystemExit(0 if value else 1)
177
+ raise SystemExit(1)
178
+ PYEOF
179
+ }
180
+
181
+ # Persist one process-only value to 1Password. The helper prints only the
182
+ # resulting op:// reference; the value crosses stdin and an anonymous pipe.
183
+ store_onepassword_secret() {
184
+ # store_onepassword_secret ITEM_NAME VALUE
185
+ local item="$1" value="$2" vault
186
+ vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
187
+ [[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
188
+ [[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
189
+ || die "trusted 1Password storage helper is missing"
190
+ printf '%s' "$value" \
191
+ | python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" "$vault" "$item"
192
+ }
193
+
194
+ # Stage one credential in a new immutable 1Password item. Output is two lines:
195
+ # item id, then op:// reference. Nothing is active until the channel transaction
196
+ # commits that reference.
197
+ stage_onepassword_secret() {
198
+ # stage_onepassword_secret ITEM_PREFIX FIELD VALUE
199
+ local item="$1" field="$2" value="$3" vault
200
+ vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
201
+ [[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
202
+ [[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
203
+ || die "trusted 1Password storage helper is missing"
204
+ printf '%s' "$value" \
205
+ | python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
206
+ --store-staged "$vault" "$item" "$field"
207
+ }
208
+
209
+ # Stage a credential pair with one atomic 1Password item create. Output is
210
+ # immutable item id followed by both verified op:// references.
211
+ stage_onepassword_secret_pair() {
212
+ # stage_onepassword_secret_pair ITEM_NAME FIELD_ONE VALUE_ONE FIELD_TWO VALUE_TWO
213
+ local item="$1" field_one="$2" value_one="$3" field_two="$4" value_two="$5" vault
214
+ vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
215
+ [[ -n "$vault" ]] || die "fleet.onepassword_vault is required for channel credentials"
216
+ [[ -f "$ROLE_DIR/.scripts/store-onepassword-secret.py" ]] \
217
+ || die "trusted 1Password storage helper is missing"
218
+ printf '%s\n%s' "$value_one" "$value_two" \
219
+ | python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
220
+ --store-pair "$vault" "$item" "$field_one" "$field_two"
221
+ }
222
+
223
+ delete_staged_onepassword_item() {
224
+ # delete_staged_onepassword_item IMMUTABLE_ITEM_ID
225
+ local item_id="$1" vault
226
+ vault="${HERMES_ONEPASSWORD_VAULT:-$(config_get fleet.onepassword_vault 'DeLoSecrets')}"
227
+ [[ -n "$vault" && -n "$item_id" ]] || return 1
228
+ python3 -I "$ROLE_DIR/.scripts/store-onepassword-secret.py" \
229
+ --delete-item-id "$vault" "$item_id"
230
+ }
231
+
232
+ # Update one supported profile override and immediately regenerate config.yaml
233
+ # from fleet base + delta. Keeping both operations in one helper prevents the
234
+ # first deploy and fleet-sync paths from disagreeing about which file is the
235
+ # source of truth.
236
+ profile_config_delta_update() {
237
+ # profile_config_delta_update PROFILE_HOME secret-ref ENV_NAME OP_REFERENCE
238
+ # profile_config_delta_update PROFILE_HOME secret-ref-pair ENV REF ENV REF
239
+ # profile_config_delta_update PROFILE_HOME channel-enabled telegram|slack true|false
240
+ # profile_config_delta_update PROFILE_HOME voice PLUGIN VOICE
241
+ local profile_lock_tool="$ROLE_DIR/.scripts/lib/profile-config-lock.py"
242
+ [[ -f "$profile_lock_tool" && ! -L "$profile_lock_tool" ]] \
243
+ || die "trusted PM profile config lock helper is unavailable"
244
+ if [[ "${2:-}" == "voice" ]]; then
245
+ [[ $# -eq 4 ]] || die "voice requires a profile, plugin, and voice"
246
+ local voice_tool="$ROLE_DIR/.scripts/lib/voice-config.py"
247
+ [[ -f "$voice_tool" && ! -L "$voice_tool" ]] \
248
+ || die "trusted PM voice config helper is unavailable"
249
+ python3 -I "$voice_tool" reconcile \
250
+ --base "$1/../../config.yaml" \
251
+ --delta "$1/config.delta.yaml" \
252
+ --generated "$1/config.yaml" \
253
+ --plugin "$3" \
254
+ --voice "$4"
255
+ return
256
+ fi
257
+ python3 - "$profile_lock_tool" "$@" <<'PYEOF'
258
+ import atexit, copy, importlib.util, os, pathlib, re, sys, tempfile
259
+ try:
260
+ import yaml
261
+ except ImportError:
262
+ raise SystemExit("PyYAML is required for Hermes profile config")
263
+
264
+ lock_source = pathlib.Path(sys.argv[1])
265
+ spec = importlib.util.spec_from_file_location("pjangler_profile_config_lock", lock_source)
266
+ if spec is None or spec.loader is None:
267
+ raise SystemExit(f"cannot load profile config lock helper: {lock_source}")
268
+ profile_lock_module = importlib.util.module_from_spec(spec)
269
+ spec.loader.exec_module(profile_lock_module)
270
+ profile = pathlib.Path(sys.argv[2])
271
+ profile_lock = profile_lock_module.ProfileConfigLock(profile)
272
+ try:
273
+ profile_lock.acquire()
274
+ except profile_lock_module.ProfileConfigLockError as exc:
275
+ raise SystemExit(str(exc)) from exc
276
+ atexit.register(profile_lock.release)
277
+ # A profile root is a security boundary. Never follow the legacy symlink form:
278
+ # even a read would escape the named profile and a later atomic replace could
279
+ # mutate shared/runtime state. Migration must happen in the lifecycle step.
280
+ if profile.is_symlink():
281
+ raise SystemExit(
282
+ f"refusing symlinked profile root: {profile}; "
283
+ "run the pjangler Hermes runtime-singleton migration before provisioning channels"
284
+ )
285
+ if not profile.is_dir():
286
+ raise SystemExit(f"required profile root is unavailable: {profile}")
287
+ args = sys.argv[3:]
288
+ if not args:
289
+ raise SystemExit("profile config update mode is required")
290
+ mode = args[0]
291
+ base_path = profile.parent.parent / "config.yaml"
292
+ delta_path = profile / "config.delta.yaml"
293
+ generated_path = profile / "config.yaml"
294
+
295
+ def load(path):
296
+ if not path.is_file() or path.is_symlink():
297
+ raise SystemExit(f"required config source is unavailable: {path}")
298
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
299
+ if not isinstance(data, dict):
300
+ raise SystemExit(f"config source must be a mapping: {path}")
301
+ return data
302
+
303
+ LIST_PATCH_KEY = "x-pjangler-merge"
304
+
305
+ def plain_merge(base, override):
306
+ result = copy.deepcopy(base)
307
+ for key, value in override.items():
308
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
309
+ result[key] = plain_merge(result[key], value)
310
+ elif key in result and isinstance(result[key], dict) and value is None:
311
+ continue
312
+ else:
313
+ result[key] = copy.deepcopy(value)
314
+ return result
315
+
316
+ def apply_list_patches(result, directive):
317
+ if directive is None:
318
+ return
319
+ if not isinstance(directive, dict) or not isinstance(directive.get("list_patches", {}), dict):
320
+ raise SystemExit(f"{LIST_PATCH_KEY}.list_patches must be a mapping")
321
+ for dotted, rule in directive.get("list_patches", {}).items():
322
+ if not isinstance(dotted, str) or not dotted or not isinstance(rule, dict):
323
+ raise SystemExit("invalid list patch")
324
+ additions = rule.get("add", []) or []
325
+ removals = rule.get("remove", []) or []
326
+ if not isinstance(additions, list) or not isinstance(removals, list) or not all(
327
+ isinstance(item, str) for item in [*additions, *removals]
328
+ ):
329
+ raise SystemExit(f"list patch for {dotted} must contain string lists")
330
+ cursor = result
331
+ parts = dotted.split(".")
332
+ for part in parts[:-1]:
333
+ child = cursor.setdefault(part, {})
334
+ if not isinstance(child, dict):
335
+ raise SystemExit(f"list patch parent for {dotted} is not a mapping")
336
+ cursor = child
337
+ current = cursor.get(parts[-1], []) or []
338
+ if not isinstance(current, list):
339
+ raise SystemExit(f"list patch target {dotted} is not a list")
340
+ removed = set(removals)
341
+ merged = [item for item in current if item not in removed]
342
+ for item in additions:
343
+ if item not in merged:
344
+ merged.append(item)
345
+ cursor[parts[-1]] = merged
346
+
347
+ def merge(base, override):
348
+ directive = override.get(LIST_PATCH_KEY)
349
+ ordinary = {key: value for key, value in override.items() if key != LIST_PATCH_KEY}
350
+ result = plain_merge(base, ordinary)
351
+ apply_list_patches(result, directive)
352
+ return result
353
+
354
+ def atomic_write(path, content):
355
+ path.parent.mkdir(parents=True, exist_ok=True)
356
+ fd, temporary = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
357
+ try:
358
+ os.fchmod(fd, 0o600)
359
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
360
+ handle.write(content)
361
+ handle.flush()
362
+ os.fsync(handle.fileno())
363
+ if path.is_symlink():
364
+ path.unlink()
365
+ os.replace(temporary, path)
366
+ os.chmod(path, 0o600)
367
+ except BaseException:
368
+ try: os.unlink(temporary)
369
+ except FileNotFoundError: pass
370
+ raise
371
+
372
+ base = load(base_path)
373
+ if delta_path.exists():
374
+ delta = load(delta_path)
375
+ else:
376
+ delta = {}
377
+ if mode == "secret-ref":
378
+ if len(args) != 3:
379
+ raise SystemExit("secret-ref requires an environment name and reference")
380
+ name, value = args[1:]
381
+ if not re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
382
+ raise SystemExit("invalid secret environment variable name")
383
+ if not value.startswith("op://") or any(ch in value for ch in "\r\n\0"):
384
+ raise SystemExit("invalid 1Password reference")
385
+ onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
386
+ if not isinstance(onepassword, dict):
387
+ raise SystemExit("secrets.onepassword delta must be a mapping")
388
+ onepassword["enabled"] = True
389
+ env = onepassword.setdefault("env", {})
390
+ if not isinstance(env, dict):
391
+ raise SystemExit("secrets.onepassword.env delta must be a mapping")
392
+ env[name] = value
393
+ elif mode == "secret-ref-pair":
394
+ if len(args) != 5:
395
+ raise SystemExit("secret-ref-pair requires two environment/reference pairs")
396
+ name_one, value_one, name_two, value_two = args[1:]
397
+ pairs = ((name_one, value_one), (name_two, value_two))
398
+ if name_one == name_two:
399
+ raise SystemExit("secret-ref-pair environment names must be distinct")
400
+ for env_name, reference in pairs:
401
+ if not re.fullmatch(r"[A-Z][A-Z0-9_]*", env_name):
402
+ raise SystemExit("invalid secret environment variable name")
403
+ if not reference.startswith("op://") or any(ch in reference for ch in "\r\n\0"):
404
+ raise SystemExit("invalid 1Password reference")
405
+ onepassword = delta.setdefault("secrets", {}).setdefault("onepassword", {})
406
+ if not isinstance(onepassword, dict):
407
+ raise SystemExit("secrets.onepassword delta must be a mapping")
408
+ onepassword["enabled"] = True
409
+ env = onepassword.setdefault("env", {})
410
+ if not isinstance(env, dict):
411
+ raise SystemExit("secrets.onepassword.env delta must be a mapping")
412
+ # Both refs enter the same in-memory document and one atomic delta write.
413
+ for env_name, reference in pairs:
414
+ env[env_name] = reference
415
+ elif mode == "channel-enabled":
416
+ if len(args) != 3:
417
+ raise SystemExit("channel-enabled requires a channel and boolean")
418
+ name, value = args[1:]
419
+ if name not in {"telegram", "slack"}:
420
+ raise SystemExit("unsupported channel enablement key")
421
+ if value not in {"true", "false"}:
422
+ raise SystemExit("channel enablement must be true or false")
423
+ platforms = delta.setdefault("platforms", {})
424
+ if not isinstance(platforms, dict):
425
+ raise SystemExit("platforms delta must be a mapping")
426
+ channel = platforms.setdefault(name, {})
427
+ if not isinstance(channel, dict):
428
+ raise SystemExit(f"platforms.{name} delta must be a mapping")
429
+ channel["enabled"] = value == "true"
430
+ else:
431
+ raise SystemExit("unsupported profile config delta update")
432
+ existing_comments = []
433
+ if delta_path.is_file() and not delta_path.is_symlink():
434
+ for line in delta_path.read_text(encoding="utf-8").splitlines():
435
+ if line.lstrip().startswith("#") and line not in existing_comments:
436
+ existing_comments.append(line)
437
+ standard_comments = [
438
+ "# Override-only delta for this Hermes profile.",
439
+ "# Contains configuration and secret references only; secret values remain in 1Password.",
440
+ ]
441
+ comments = [*standard_comments, *(line for line in existing_comments if line not in standard_comments)]
442
+ atomic_write(delta_path, "\n".join(comments) + "\n" + yaml.safe_dump(delta, sort_keys=False))
443
+ header = (
444
+ "# GENERATED FILE -- DO NOT EDIT.\n"
445
+ "# source: fleet config.yaml + profile config.delta.yaml\n"
446
+ )
447
+ atomic_write(generated_path, header + yaml.safe_dump(merge(base, delta), sort_keys=False))
448
+ PYEOF
449
+ }
450
+
451
+ profile_voice_contract_set() {
452
+ # profile_voice_contract_set PROFILE_HOME PLUGIN VOICE
453
+ profile_config_delta_update "$1" voice "$2" "$3"
454
+ }
455
+
456
+ profile_root_require_real() {
457
+ # profile_root_require_real PROFILE_HOME
458
+ [[ ! -L "$1" ]] \
459
+ || die "refusing symlinked profile root: $1; run the pjangler Hermes runtime-singleton migration before provisioning channels"
460
+ [[ -d "$1" ]] || die "required profile root is unavailable: $1"
461
+ }
462
+
463
+ profile_onepassword_ref_exists() {
464
+ # profile_onepassword_ref_exists PROFILE_HOME ENV_NAME
465
+ # This is a read-only service eligibility probe. Channel transactions do not
466
+ # use it: their refs and identity are captured under registry -> profile lock.
467
+ python3 - "$1/config.delta.yaml" "$2" <<'PYEOF'
468
+ import pathlib, sys
469
+ try:
470
+ import yaml
471
+ except ImportError:
472
+ raise SystemExit(1)
473
+ path = pathlib.Path(sys.argv[1])
474
+ if not path.is_file() or path.is_symlink():
475
+ raise SystemExit(1)
476
+ try:
477
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
478
+ ref = data["secrets"]["onepassword"]["env"].get(sys.argv[2], "")
479
+ except (KeyError, TypeError, ValueError, yaml.YAMLError):
480
+ raise SystemExit(1)
481
+ raise SystemExit(0 if isinstance(ref, str) and ref.startswith("op://") else 1)
482
+ PYEOF
483
+ }
484
+
485
+ profile_onepassword_ref_get() {
486
+ # profile_onepassword_ref_get PROFILE_HOME ENV_NAME
487
+ python3 - "$1/config.delta.yaml" "$2" <<'PYEOF'
488
+ import pathlib, sys
489
+ try:
490
+ import yaml
491
+ data = yaml.safe_load(pathlib.Path(sys.argv[1]).read_text(encoding="utf-8")) or {}
492
+ reference = data["secrets"]["onepassword"]["env"].get(sys.argv[2], "")
493
+ except Exception:
494
+ reference = ""
495
+ if not isinstance(reference, str) or not reference.startswith("op://"):
496
+ raise SystemExit(1)
497
+ print(reference)
498
+ PYEOF
499
+ }
500
+
501
+ profile_onepassword_ref_validate() {
502
+ # profile_onepassword_ref_validate PROFILE_HOME ENV_NAME
503
+ # Return 0 when the reference resolves, 2 when no syntactically valid
504
+ # mapping exists, and 75 when a valid mapping cannot currently be checked.
505
+ # This is a read-only service eligibility probe, never a transaction input.
506
+ local reference helper="$ROLE_DIR/.scripts/store-onepassword-secret.py"
507
+ [[ -f "$helper" ]] || return 75
508
+ reference="$(profile_onepassword_ref_get "$1" "$2")" || return 2
509
+ if python3 -I "$helper" --validate-reference "$reference" >/dev/null 2>&1; then
510
+ return 0
511
+ fi
512
+ return 75
513
+ }
514
+
515
+ channel_transaction_telegram() {
516
+ # channel_transaction_telegram PROFILE_HOME RUNTIME_ENV REF USERNAME BOT_ID ALLOWED
517
+ local helper="$ROLE_DIR/.scripts/channel-transaction.py"
518
+ [[ -f "$helper" && ! -L "$helper" ]] || die "trusted channel transaction helper is missing"
519
+ [[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
520
+ && ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
521
+ || die "trusted PM profile config lock helper is unavailable"
522
+ python3 -I "$helper" \
523
+ --channel telegram \
524
+ --profile "$1" \
525
+ --role-yaml "$ROLE_YAML" \
526
+ --registry "$REGISTRY_FILE" \
527
+ --runtime-env "$2" \
528
+ --done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
529
+ --agent-id "$AGENT_ID" \
530
+ --role-dir "$ROLE_DIR" \
531
+ --profile-name "$PROFILE_NAME" \
532
+ --allowed-value "$6" \
533
+ --reference TELEGRAM_BOT_TOKEN "$3" \
534
+ --metadata provisioning_status verified \
535
+ --metadata bot_username "$4" \
536
+ --metadata bot_id "$5"
537
+ }
538
+
539
+ channel_transaction_telegram_existing() {
540
+ # channel_transaction_telegram_existing PROFILE_HOME RUNTIME_ENV
541
+ # Caller holds the fleet registry lock; the helper acquires the profile lock
542
+ # and reads refs plus role metadata only after both locks are held.
543
+ local helper="$ROLE_DIR/.scripts/channel-transaction.py"
544
+ local validator="$ROLE_DIR/.scripts/store-onepassword-secret.py"
545
+ [[ -f "$helper" && ! -L "$helper" ]] \
546
+ || die "trusted channel transaction helper is missing"
547
+ [[ -f "$validator" && ! -L "$validator" ]] \
548
+ || die "trusted 1Password reference validator is missing"
549
+ [[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
550
+ && ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
551
+ || die "trusted PM profile config lock helper is unavailable"
552
+ python3 -I "$helper" \
553
+ --channel telegram \
554
+ --profile "$1" \
555
+ --role-yaml "$ROLE_YAML" \
556
+ --registry "$REGISTRY_FILE" \
557
+ --runtime-env "$2" \
558
+ --done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
559
+ --agent-id "$AGENT_ID" \
560
+ --role-dir "$ROLE_DIR" \
561
+ --profile-name "$PROFILE_NAME" \
562
+ --reconcile-existing \
563
+ --reference-validator "$validator"
564
+ }
565
+
566
+ channel_transaction_telegram_prepare_unconfigured() {
567
+ # Caller holds the fleet registry lock. The helper disables only a channel
568
+ # whose locked role snapshot is not verified; exit 3 means verified/no-op.
569
+ local helper="$ROLE_DIR/.scripts/channel-transaction.py"
570
+ [[ -f "$helper" && ! -L "$helper" ]] \
571
+ || die "trusted channel transaction helper is missing"
572
+ [[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
573
+ && ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
574
+ || die "trusted PM profile config lock helper is unavailable"
575
+ python3 -I "$helper" \
576
+ --channel telegram \
577
+ --profile "$1" \
578
+ --role-yaml "$ROLE_YAML" \
579
+ --registry "$REGISTRY_FILE" \
580
+ --runtime-env "$2" \
581
+ --done-marker "$ROLE_DIR/.scripts/.done-30-telegram" \
582
+ --agent-id "$AGENT_ID" \
583
+ --role-dir "$ROLE_DIR" \
584
+ --profile-name "$PROFILE_NAME" \
585
+ --prepare-unconfigured
586
+ }
587
+
588
+ channel_transaction_slack() {
589
+ # channel_transaction_slack PROFILE ENV BOT_REF APP_REF TEAM_ID TEAM_NAME USER_ID BOT_ID USERNAME ALLOWED
590
+ local helper="$ROLE_DIR/.scripts/channel-transaction.py"
591
+ [[ -f "$helper" && ! -L "$helper" ]] || die "trusted channel transaction helper is missing"
592
+ [[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
593
+ && ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
594
+ || die "trusted PM profile config lock helper is unavailable"
595
+ python3 -I "$helper" \
596
+ --channel slack \
597
+ --profile "$1" \
598
+ --role-yaml "$ROLE_YAML" \
599
+ --registry "$REGISTRY_FILE" \
600
+ --runtime-env "$2" \
601
+ --done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
602
+ --agent-id "$AGENT_ID" \
603
+ --role-dir "$ROLE_DIR" \
604
+ --profile-name "$PROFILE_NAME" \
605
+ --allowed-value "${10}" \
606
+ --reference SLACK_BOT_TOKEN "$3" \
607
+ --reference SLACK_APP_TOKEN "$4" \
608
+ --metadata provisioning_status verified \
609
+ --metadata team_id "$5" \
610
+ --metadata team_name "$6" \
611
+ --metadata bot_user_id "$7" \
612
+ --metadata bot_id "$8" \
613
+ --metadata bot_username "$9"
614
+ }
615
+
616
+ channel_transaction_slack_existing() {
617
+ # channel_transaction_slack_existing PROFILE_HOME RUNTIME_ENV
618
+ # Caller holds the fleet registry lock; the helper acquires the profile lock
619
+ # and reads refs plus role metadata only after both locks are held.
620
+ local helper="$ROLE_DIR/.scripts/channel-transaction.py"
621
+ local validator="$ROLE_DIR/.scripts/store-onepassword-secret.py"
622
+ [[ -f "$helper" && ! -L "$helper" ]] \
623
+ || die "trusted channel transaction helper is missing"
624
+ [[ -f "$validator" && ! -L "$validator" ]] \
625
+ || die "trusted 1Password reference validator is missing"
626
+ [[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
627
+ && ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
628
+ || die "trusted PM profile config lock helper is unavailable"
629
+ python3 -I "$helper" \
630
+ --channel slack \
631
+ --profile "$1" \
632
+ --role-yaml "$ROLE_YAML" \
633
+ --registry "$REGISTRY_FILE" \
634
+ --runtime-env "$2" \
635
+ --done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
636
+ --agent-id "$AGENT_ID" \
637
+ --role-dir "$ROLE_DIR" \
638
+ --profile-name "$PROFILE_NAME" \
639
+ --reconcile-existing \
640
+ --reference-validator "$validator"
641
+ }
642
+
643
+ channel_transaction_slack_prepare_unconfigured() {
644
+ # Caller holds the fleet registry lock. The helper disables only a channel
645
+ # whose locked role snapshot is not verified; exit 3 means verified/no-op.
646
+ local helper="$ROLE_DIR/.scripts/channel-transaction.py"
647
+ [[ -f "$helper" && ! -L "$helper" ]] \
648
+ || die "trusted channel transaction helper is missing"
649
+ [[ -f "$ROLE_DIR/.scripts/lib/profile-config-lock.py" \
650
+ && ! -L "$ROLE_DIR/.scripts/lib/profile-config-lock.py" ]] \
651
+ || die "trusted PM profile config lock helper is unavailable"
652
+ python3 -I "$helper" \
653
+ --channel slack \
654
+ --profile "$1" \
655
+ --role-yaml "$ROLE_YAML" \
656
+ --registry "$REGISTRY_FILE" \
657
+ --runtime-env "$2" \
658
+ --done-marker "$ROLE_DIR/.scripts/.done-31-slack" \
659
+ --agent-id "$AGENT_ID" \
660
+ --role-dir "$ROLE_DIR" \
661
+ --profile-name "$PROFILE_NAME" \
662
+ --prepare-unconfigured
663
+ }
664
+
109
665
  # ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
110
666
  # Single source of truth for environment-specific defaults so this template can
111
667
  # be handed to someone else without editing any script. Ship config.example.toml
@@ -265,6 +821,34 @@ fleet_lock_acquire() {
265
821
  [[ ! -L "$lock_file" ]] || die "refusing fleet lock symlink: $lock_file"
266
822
  exec {FLEET_LOCK_FD}>"$lock_file"
267
823
  chmod 600 "$lock_file"
824
+ if [[ -n "${PJANGLER_TEST_FLEET_LOCK_BARRIER:-}" ]]; then
825
+ [[ -n "${PYTEST_CURRENT_TEST:-}" ]] \
826
+ || die "PJANGLER_TEST_FLEET_LOCK_BARRIER is test-only and requires pytest"
827
+ local barrier_file="$PJANGLER_TEST_FLEET_LOCK_BARRIER"
828
+ local barrier_parent="${barrier_file%/*}"
829
+ local barrier_timeout="${PJANGLER_TEST_FLEET_LOCK_BARRIER_TIMEOUT_SECONDS:-15}"
830
+ [[ "$barrier_parent" != "$barrier_file" ]] || barrier_parent="."
831
+ [[ ! -L "$barrier_file" && ! -L "$barrier_parent" && -d "$barrier_parent" ]] \
832
+ || die "unsafe test fleet-lock barrier path: $barrier_file"
833
+ [[ "$barrier_timeout" =~ ^[1-9][0-9]*$ ]] \
834
+ || die "PJANGLER_TEST_FLEET_LOCK_BARRIER_TIMEOUT_SECONDS must be a positive integer"
835
+ printf '%s\n' 'fleet-prelock' > "${barrier_file}.ready"
836
+ local barrier_started=$SECONDS
837
+ while [[ ! -f "${barrier_file}.resume" ]]; do
838
+ (( SECONDS - barrier_started < barrier_timeout )) \
839
+ || die "timed out waiting at test fleet-lock barrier: $barrier_file"
840
+ sleep 0.02
841
+ done
842
+ fi
843
+ if [[ -n "${PJANGLER_TEST_FLEET_LOCK_ATTEMPT:-}" ]]; then
844
+ [[ -n "${PYTEST_CURRENT_TEST:-}" ]] \
845
+ || die "PJANGLER_TEST_FLEET_LOCK_ATTEMPT is test-only and requires pytest"
846
+ local attempt_file="$PJANGLER_TEST_FLEET_LOCK_ATTEMPT"
847
+ [[ ! -L "$attempt_file" && ! -L "$(dirname "$attempt_file")" \
848
+ && -d "$(dirname "$attempt_file")" ]] \
849
+ || die "unsafe test fleet-lock attempt path: $attempt_file"
850
+ printf '%s\n' "$lock_file" > "$attempt_file"
851
+ fi
268
852
  flock -w "${FLEET_LOCK_TIMEOUT_SECONDS:-30}" "$FLEET_LOCK_FD" \
269
853
  || die "timed out waiting for fleet registry lock: $lock_file"
270
854
  }
@@ -339,6 +923,195 @@ systemctl_user_unit_state() {
339
923
  esac
340
924
  }
341
925
 
926
+ # Read-only live health checks for the unit states lifecycle scripts persist in
927
+ # role.yaml. They deliberately include process result, main exit status,
928
+ # restart count, and activation/substate; `is-active` alone can briefly report
929
+ # success for a launcher that is already on its way to exit 78.
930
+ systemctl_user_show() {
931
+ # systemctl_user_show UNIT PROPERTY...
932
+ local unit="$1" output rc first_line
933
+ shift
934
+ local -a arguments=(--user show "$unit" --no-pager)
935
+ local property
936
+ for property in "$@"; do arguments+=("--property=$property"); done
937
+ set +e
938
+ output="$(LC_ALL=C systemctl "${arguments[@]}" 2>&1)"
939
+ rc=$?
940
+ set -e
941
+ if [[ $rc -ne 0 ]]; then
942
+ first_line="${output%%$'\n'*}"
943
+ first_line="${first_line//$'\t'/ }"
944
+ first_line="${first_line//|/}"
945
+ [[ -n "$first_line" ]] || first_line="exit $rc with no state"
946
+ printf 'error|%s' "$first_line"
947
+ return 0
948
+ fi
949
+ printf 'ok|%s' "$output"
950
+ }
951
+
952
+ systemd_service_health_snapshot() {
953
+ # systemd_service_health_snapshot UNIT running|oneshot
954
+ local unit="$1" kind="$2" active enabled shown payload
955
+ local load_state="" active_state="" sub_state="" result=""
956
+ local exec_status="" restarts="" key value
957
+ active="$(systemctl_user_unit_state is-active "$unit")"
958
+ enabled="$(systemctl_user_unit_state is-enabled "$unit")"
959
+ [[ "$active" == "ok|active" ]] \
960
+ || { printf 'error|activity=%s' "${active#*|}"; return 0; }
961
+ [[ "$enabled" =~ ^ok\|(enabled|enabled-runtime)$ ]] \
962
+ || { printf 'error|enablement=%s' "${enabled#*|}"; return 0; }
963
+ shown="$(systemctl_user_show "$unit" LoadState ActiveState SubState Result ExecMainStatus NRestarts)"
964
+ [[ "$shown" == ok\|* ]] || { printf '%s' "$shown"; return 0; }
965
+ payload="${shown#ok|}"
966
+ while IFS='=' read -r key value; do
967
+ case "$key" in
968
+ LoadState) load_state="$value" ;;
969
+ ActiveState) active_state="$value" ;;
970
+ SubState) sub_state="$value" ;;
971
+ Result) result="$value" ;;
972
+ ExecMainStatus) exec_status="$value" ;;
973
+ NRestarts) restarts="$value" ;;
974
+ esac
975
+ done <<< "$payload"
976
+ [[ "$load_state" == loaded && "$active_state" == active \
977
+ && "$result" == success && "$exec_status" == 0 \
978
+ && "$restarts" =~ ^[0-9]+$ ]] \
979
+ || { printf 'error|load=%s active=%s sub=%s result=%s status=%s restarts=%s' \
980
+ "$load_state" "$active_state" "$sub_state" "$result" "$exec_status" "$restarts"; return 0; }
981
+ if [[ "$kind" == running && "$sub_state" != running ]]; then
982
+ printf 'error|substate=%s' "$sub_state"
983
+ return 0
984
+ fi
985
+ printf 'ok|%s' "$restarts"
986
+ }
987
+
988
+ systemd_timer_health_snapshot() {
989
+ # systemd_timer_health_snapshot TIMER HEARTBEAT_SERVICE
990
+ local timer="$1" service="$2" active enabled shown payload
991
+ local load_state="" active_state="" sub_state="" key value
992
+ local svc_shown svc_payload svc_load="" svc_active="" svc_sub=""
993
+ local svc_result="" svc_status="" svc_restarts=""
994
+ local svc_started="" svc_exited=""
995
+ active="$(systemctl_user_unit_state is-active "$timer")"
996
+ enabled="$(systemctl_user_unit_state is-enabled "$timer")"
997
+ [[ "$active" == "ok|active" ]] \
998
+ || { printf 'error|timer-activity=%s' "${active#*|}"; return 0; }
999
+ [[ "$enabled" =~ ^ok\|(enabled|enabled-runtime)$ ]] \
1000
+ || { printf 'error|timer-enablement=%s' "${enabled#*|}"; return 0; }
1001
+ shown="$(systemctl_user_show "$timer" LoadState ActiveState SubState)"
1002
+ [[ "$shown" == ok\|* ]] || { printf '%s' "$shown"; return 0; }
1003
+ payload="${shown#ok|}"
1004
+ while IFS='=' read -r key value; do
1005
+ case "$key" in
1006
+ LoadState) load_state="$value" ;;
1007
+ ActiveState) active_state="$value" ;;
1008
+ SubState) sub_state="$value" ;;
1009
+ esac
1010
+ done <<< "$payload"
1011
+ [[ "$load_state" == loaded && "$active_state" == active \
1012
+ && "$sub_state" =~ ^(waiting|running|elapsed)$ ]] \
1013
+ || { printf 'error|timer-load=%s active=%s sub=%s' "$load_state" "$active_state" "$sub_state"; return 0; }
1014
+
1015
+ # Inspect the latest oneshot result separately from the timer. An inactive
1016
+ # (dead) service with Result=success/ExecMainStatus=0 is the healthy steady
1017
+ # state between ticks; failed/78 is never accepted.
1018
+ svc_shown="$(systemctl_user_show "$service" LoadState ActiveState SubState Result \
1019
+ ExecMainStatus NRestarts ExecMainStartTimestampMonotonic \
1020
+ ExecMainExitTimestampMonotonic)"
1021
+ [[ "$svc_shown" == ok\|* ]] || { printf '%s' "$svc_shown"; return 0; }
1022
+ svc_payload="${svc_shown#ok|}"
1023
+ while IFS='=' read -r key value; do
1024
+ case "$key" in
1025
+ LoadState) svc_load="$value" ;;
1026
+ ActiveState) svc_active="$value" ;;
1027
+ SubState) svc_sub="$value" ;;
1028
+ Result) svc_result="$value" ;;
1029
+ ExecMainStatus) svc_status="$value" ;;
1030
+ NRestarts) svc_restarts="$value" ;;
1031
+ ExecMainStartTimestampMonotonic) svc_started="$value" ;;
1032
+ ExecMainExitTimestampMonotonic) svc_exited="$value" ;;
1033
+ esac
1034
+ done <<< "$svc_payload"
1035
+ # A oneshot is healthy only after its main process has exited successfully.
1036
+ # systemd initializes Result=success/ExecMainStatus=0 before the first exit,
1037
+ # so accepting activating/start would turn a pre-exit sample into a false
1038
+ # completion claim. Monotonic start/exit timestamps prove a real invocation
1039
+ # completed and also make a new invocation visible to the stability window.
1040
+ [[ "$svc_load" == loaded && "$svc_active" == inactive \
1041
+ && "$svc_sub" == dead \
1042
+ && "$svc_result" == success && "$svc_status" == 0 \
1043
+ && "$svc_restarts" =~ ^[0-9]+$ \
1044
+ && "$svc_started" =~ ^[1-9][0-9]*$ \
1045
+ && "$svc_exited" =~ ^[1-9][0-9]*$ \
1046
+ && "$svc_exited" -ge "$svc_started" ]] \
1047
+ || { printf 'error|heartbeat-load=%s active=%s sub=%s result=%s status=%s restarts=%s started=%s exited=%s' \
1048
+ "$svc_load" "$svc_active" "$svc_sub" "$svc_result" "$svc_status" \
1049
+ "$svc_restarts" "$svc_started" "$svc_exited"; return 0; }
1050
+ printf 'ok|timer=%s:result=%s:status=%s:restarts=%s:started=%s:exited=%s' \
1051
+ "$sub_state" "$svc_result" "$svc_status" "$svc_restarts" "$svc_started" "$svc_exited"
1052
+ }
1053
+
1054
+ systemd_gateway_deferred_snapshot() {
1055
+ local unit="$1" active enabled
1056
+ active="$(systemctl_user_unit_state is-active "$unit")"
1057
+ enabled="$(systemctl_user_unit_state is-enabled "$unit")"
1058
+ if [[ "$active" == "ok|inactive" \
1059
+ && "$enabled" =~ ^ok\|(disabled|masked|masked-runtime)$ ]]; then
1060
+ printf 'ok|deferred'
1061
+ else
1062
+ printf 'error|enablement=%s activity=%s' "${enabled#*|}" "${active#*|}"
1063
+ fi
1064
+ }
1065
+
1066
+ systemd_wait_for_stable_health() {
1067
+ # systemd_wait_for_stable_health CHECK_FUNCTION ARGS...
1068
+ local checker="$1"
1069
+ shift
1070
+ local attempts="${SYSTEMD_STABILIZATION_ATTEMPTS:-6}"
1071
+ local required="${SYSTEMD_STABLE_SAMPLES:-3}"
1072
+ local interval="${SYSTEMD_STABILIZATION_INTERVAL_SECONDS:-1}"
1073
+ [[ "$attempts" =~ ^[1-9][0-9]*$ && "$required" =~ ^[1-9][0-9]*$ \
1074
+ && "$interval" =~ ^[0-9]+([.][0-9]+)?$ ]] \
1075
+ || { printf 'error|invalid stabilization settings'; return 1; }
1076
+ (( attempts >= required )) \
1077
+ || { printf 'error|stabilization attempts must cover required samples'; return 1; }
1078
+ local sample="" previous="" last_error="" stable=0 attempt
1079
+ local ever_healthy=0 unstable_after_health=0
1080
+ for ((attempt = 1; attempt <= attempts; attempt++)); do
1081
+ sample="$($checker "$@")"
1082
+ if [[ "$sample" == ok\|* ]]; then
1083
+ if [[ -z "$previous" ]]; then
1084
+ previous="$sample"
1085
+ stable=1
1086
+ elif [[ "$sample" == "$previous" ]]; then
1087
+ stable=$((stable + 1))
1088
+ else
1089
+ (( ever_healthy == 0 )) || unstable_after_health=1
1090
+ previous="$sample"
1091
+ stable=1
1092
+ fi
1093
+ ever_healthy=1
1094
+ else
1095
+ [[ -z "$sample" ]] || last_error="$sample"
1096
+ (( ever_healthy == 0 )) || unstable_after_health=1
1097
+ previous=""
1098
+ stable=0
1099
+ fi
1100
+ if (( attempt < attempts )) && [[ "$interval" != 0 ]]; then
1101
+ sleep "$interval"
1102
+ fi
1103
+ done
1104
+ # Never return early: every configured sample belongs to the declared
1105
+ # observation window. Once a unit looked healthy, a later failure, restart,
1106
+ # or invocation timestamp change makes the whole window unstable.
1107
+ if (( stable >= required && unstable_after_health == 0 )); then
1108
+ printf '%s' "$sample"
1109
+ return 0
1110
+ fi
1111
+ printf '%s' "${last_error:-${sample:-error|no health sample}}"
1112
+ return 1
1113
+ }
1114
+
342
1115
  # Resolve project repo path (the repo that holds agents/hermes/<role>/).
343
1116
  # Walk up from $ROLE_DIR until we find a git root that isn't us.
344
1117
  project_repo_path() {