@delorenj/pjangler 1.4.6 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +41 -596
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +9385 -18379
  10. package/dist/mcp-server.js +9432 -17852
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -584
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
@@ -1,1138 +0,0 @@
1
- # shellcheck shell=bash
2
- # Common helpers sourced by every numbered provisioning step.
3
-
4
- set -euo pipefail
5
-
6
- # These three are set by Copier into the rendered role.yaml; we re-derive them
7
- # here so each script is callable in isolation (e.g. for repair runs).
8
- ROLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
9
- ROLE_YAML="$ROLE_DIR/role.yaml"
10
- PROV_LOG="$ROLE_DIR/.scripts/.provision.log"
11
-
12
- mkdir -p "$ROLE_DIR/.scripts"
13
-
14
- # Logging
15
- log() { local msg="[$(date +%H:%M:%S)] $*"; printf '\033[36m%s\033[0m\n' "$msg" >&2; printf '%s\n' "$msg" >> "$PROV_LOG"; }
16
- warn() { local msg="[$(date +%H:%M:%S)] $*"; printf '\033[33m%s\033[0m\n' "$msg" >&2; printf '%s\n' "$msg" >> "$PROV_LOG"; }
17
- err() { local msg="[$(date +%H:%M:%S)] $*"; printf '\033[31m%s\033[0m\n' "$msg" >&2; printf '%s\n' "$msg" >> "$PROV_LOG"; }
18
- die() { err "$*"; exit 1; }
19
-
20
- # Read a single field from role.yaml. Requires python3 (no yaml dep).
21
- yaml_get() {
22
- # yaml_get KEY[.SUBKEY] e.g. yaml_get role, yaml_get telegram.bot_username
23
- local key="$1"
24
- python3 - "$ROLE_YAML" "$key" <<'PYEOF'
25
- import sys, re, pathlib
26
- path, key = sys.argv[1:3]
27
- text = pathlib.Path(path).read_text()
28
- parts = key.split(".")
29
- # Trivial YAML walker — handles flat and one-level nested keys.
30
- indent = -1
31
- prefix = ""
32
- for part in parts[:-1]:
33
- indent += 2
34
- prefix += part + ":"
35
- m = re.search(rf"(?m)^{re.escape(part)}:\s*$", text)
36
- if not m:
37
- sys.exit(0)
38
- text = text[m.end():]
39
- key = parts[-1]
40
- m = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', text)
41
- if m:
42
- print(m.group(1).strip())
43
- PYEOF
44
- }
45
-
46
- # Apply a sed substitution to role.yaml in-place. Used to record IDs after
47
- # external provisioning steps return them.
48
- yaml_set() {
49
- # yaml_set KEY VALUE (supports flat and one-level nested keys)
50
- local key="$1" val="$2"
51
- python3 - "$ROLE_YAML" "$key" "$val" <<'PYEOF'
52
- import json, pathlib, re, sys
53
-
54
- path, key, val = sys.argv[1:4]
55
- p = pathlib.Path(path)
56
- text = p.read_text(encoding="utf-8")
57
- parts = key.split(".")
58
- if len(parts) not in (1, 2) or any(
59
- not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_-]*", part) for part in parts
60
- ):
61
- sys.exit(f"yaml_set: unsupported key '{key}'")
62
-
63
- lines = text.splitlines(keepends=True)
64
- start, end, parent_indent = 0, len(lines), -1
65
- if len(parts) == 2:
66
- parent, leaf = parts
67
- parents = []
68
- for index, raw in enumerate(lines):
69
- line = raw.rstrip("\r\n")
70
- match = re.fullmatch(rf"( *){re.escape(parent)}:\s*(?:#.*)?", line)
71
- if match and len(match.group(1)) == 0:
72
- parents.append(index)
73
- if len(parents) != 1:
74
- sys.exit(f"yaml_set: parent '{parent}' not found uniquely in {path}")
75
- parent_index = parents[0]
76
- parent_indent = 0
77
- start = parent_index + 1
78
- for index in range(start, len(lines)):
79
- candidate = lines[index].rstrip("\r\n")
80
- if not candidate.strip() or candidate.lstrip().startswith("#"):
81
- continue
82
- indent = len(candidate) - len(candidate.lstrip(" "))
83
- if indent <= parent_indent:
84
- end = index
85
- break
86
- else:
87
- leaf = parts[0]
88
-
89
- targets = []
90
- for index in range(start, end):
91
- raw = lines[index]
92
- line = raw.rstrip("\r\n")
93
- match = re.match(rf"^( *){re.escape(leaf)}:\s*", line)
94
- if not match:
95
- continue
96
- indent = len(match.group(1))
97
- if (parent_indent < 0 and indent == 0) or (parent_indent >= 0 and indent > parent_indent):
98
- targets.append((index, match.group(1)))
99
- if len(targets) != 1:
100
- sys.exit(f"yaml_set: key '{key}' not found uniquely in {path}")
101
-
102
- index, indent = targets[0]
103
- ending = "\r\n" if lines[index].endswith("\r\n") else "\n" if lines[index].endswith("\n") else ""
104
- lines[index] = f"{indent}{leaf}: {json.dumps(val, ensure_ascii=False)}{ending}"
105
- p.write_text("".join(lines), encoding="utf-8")
106
- PYEOF
107
- }
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
-
665
- # ─── Distributable config (~/.config/hermes-agent-template/config.toml) ──────
666
- # Single source of truth for environment-specific defaults so this template can
667
- # be handed to someone else without editing any script. Ship config.example.toml
668
- # is copied here on first provision (see .scripts/01-config.sh).
669
- HERMES_TEMPLATE_CONFIG="${HERMES_TEMPLATE_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/hermes-agent-template/config.toml}"
670
- export HERMES_TEMPLATE_CONFIG
671
-
672
- # config_get <dotted.key> [default] — print a value from config.toml (paths are
673
- # tilde-expanded; arrays are space-joined). Falls back to [default] when the file,
674
- # python3, or the key is missing. Always exits 0 so it's safe under `set -e`.
675
- config_get() {
676
- local key="$1" def="${2:-}"
677
- if [[ ! -f "$HERMES_TEMPLATE_CONFIG" ]] || ! command -v python3 >/dev/null 2>&1; then
678
- printf '%s' "$def"; return 0
679
- fi
680
- python3 - "$HERMES_TEMPLATE_CONFIG" "$key" "$def" <<'PYEOF' || printf '%s' "$def"
681
- import sys, os
682
- try:
683
- import tomllib
684
- except ModuleNotFoundError: # python < 3.11
685
- print(sys.argv[3], end=""); sys.exit(0)
686
- path, key, default = sys.argv[1], sys.argv[2], sys.argv[3]
687
- try:
688
- with open(path, "rb") as f:
689
- cur = tomllib.load(f)
690
- except Exception:
691
- print(default, end=""); sys.exit(0)
692
- for part in key.split("."):
693
- if isinstance(cur, dict) and part in cur:
694
- cur = cur[part]
695
- else:
696
- print(default, end=""); sys.exit(0)
697
- if isinstance(cur, list):
698
- cur = " ".join(str(x) for x in cur)
699
- else:
700
- cur = str(cur)
701
- print(os.path.expanduser(cur), end="")
702
- PYEOF
703
- return 0
704
- }
705
-
706
- # Re-export role fields into the environment for the rest of the script.
707
- load_role_env() {
708
- ROLE=$(yaml_get role)
709
- REPO=$(yaml_get repo)
710
- AGENT_ID=$(yaml_get agent_id)
711
- DISPLAY_NAME=$(yaml_get display_name)
712
- BOT_HANDLE=$(yaml_get telegram.bot_username)
713
- PROFILE_NAME=$(yaml_get profile)
714
-
715
- # Plane workspace: empty in role.yaml -> resolve from config.toml.
716
- PLANE_WORKSPACE=$(yaml_get plane.workspace)
717
- [[ -n "$PLANE_WORKSPACE" ]] || PLANE_WORKSPACE=$(config_get plane.workspace "33god")
718
-
719
- # Runtime repo: role.yaml stores the bare repo name plus an optional owner.
720
- # Older manifests stored "owner/name" directly in github_repo; honor both.
721
- RUNTIME_REPO=$(yaml_get runtime.github_repo)
722
- if [[ "$RUNTIME_REPO" != */* ]]; then
723
- local owner; owner=$(yaml_get runtime.github_owner)
724
- [[ -n "$owner" ]] || owner=$(config_get github.runtime_repo_owner "delorenj")
725
- RUNTIME_REPO="$owner/$RUNTIME_REPO"
726
- fi
727
-
728
- export ROLE REPO AGENT_ID DISPLAY_NAME BOT_HANDLE \
729
- PLANE_WORKSPACE RUNTIME_REPO PROFILE_NAME
730
- }
731
-
732
- # Skip a step if previously completed (idempotent reruns).
733
- already_done() {
734
- local marker="$ROLE_DIR/.scripts/.done-$1"
735
- [[ -f "$marker" ]]
736
- }
737
- mark_done() {
738
- touch "$ROLE_DIR/.scripts/.done-$1"
739
- }
740
- clear_done() {
741
- # Remove only the marker for the explicitly deferred step. This lets a
742
- # later activation reconcile that phase without disturbing completed,
743
- # unrelated provisioning state.
744
- rm -f -- "$ROLE_DIR/.scripts/.done-$1"
745
- }
746
-
747
- # The parser protocol, unsafe-family scrub, complete framing validation, and
748
- # atomic environment apply live in one reusable library shared by provisioners,
749
- # launchers, and maintenance commands.
750
- FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
751
- FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
752
- if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" ]]; then
753
- builtin printf 'fleet environment loader rejected: trusted library is unavailable\n' >&2
754
- return 1
755
- fi
756
- # shellcheck source=lib/fleet-env.sh
757
- builtin source "$FLEET_ENV_LIBRARY"
758
-
759
- # Fleet source-of-truth (shared across all wrappers/provisioners).
760
- # Every default below resolves as: env var > fleet.env > config.toml > fallback.
761
- # Invocation authority is not fleet configuration. Capture the caller's board
762
- # gate before importing fleet.env so that file cannot weaken an MCP/CLI
763
- # SKIP_PLANE decision or re-enable credentials by assigning SKIP_PLANE=0.
764
- PJANGLER_INVOCATION_SKIP_PLANE="${SKIP_PLANE:-0}"
765
- FLEET_ENV="${HERMES_FLEET_ENV:-$(config_get fleet.fleet_env "$HOME/.hermes/fleet.env")}"
766
-
767
- # A direct CLI caller may not have passed through the MCP parent boundary. The
768
- # shared loader hardens both before parser startup and again after atomic import.
769
- if ! load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"; then
770
- return 1
771
- fi
772
- SKIP_PLANE="$PJANGLER_INVOCATION_SKIP_PLANE"
773
- unset PJANGLER_INVOCATION_SKIP_PLANE
774
- scrub_subprocess_interpreter_injection
775
-
776
- # fleet.env is allowed to supply provider credentials only for an explicitly
777
- # board-authorized invocation. A no-board/deferred phase must remove every
778
- # supported provider alias after sourcing and before any Python, Hermes,
779
- # systemd, provider, or other child process can inherit it.
780
- scrub_ticket_provider_authority() {
781
- local key
782
- unset PLANE_API_KEY TRELLO_KEY TRELLO_TOKEN LINEAR_API_KEY
783
- while IFS= read -r key; do
784
- case "$key" in
785
- PLANE_*_API_KEY) unset "$key" ;;
786
- esac
787
- done < <(compgen -A variable PLANE_)
788
- }
789
- if [[ "$SKIP_PLANE" == "1" ]]; then
790
- scrub_ticket_provider_authority
791
- fi
792
- # Identity-bearing chat credentials are never fleet-scoped. Platform wiring
793
- # steps capture explicit invocation values before sourcing this library and
794
- # restore only those values afterward; all other provisioning steps stay clean.
795
- unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
796
-
797
- # Tools we expect on the host
798
- HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-$(config_get fleet.hermes_bin "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes")}}"
799
- HERMES_AGENT_REPO="${HERMES_AGENT_REPO:-${HERMES_FLEET_REPO:-$(config_get fleet.hermes_repo "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1")}}"
800
- PJANGLER_BIN="${PJANGLER_BIN:-$(config_get fleet.pjangler_bin "pj")}"
801
- HERMES_RUNTIME_GIT_URL="${HERMES_RUNTIME_GIT_URL:-$(config_get fleet.hermes_git_url 'https://github.com/delorenj/hermes-agent.git')}"
802
- HERMES_RUNTIME_GIT_REF="${HERMES_RUNTIME_GIT_REF:-$(config_get fleet.hermes_git_ref 'main')}"
803
- HERMES_RUNTIME_GIT_SHA="${HERMES_RUNTIME_GIT_SHA:-$(config_get fleet.hermes_git_sha '0408fec7a153e6c32c064acd2b8053917f1525f1')}"
804
- HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
805
- CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
806
- # Prefer a scaffold vendored into this agent directory; fall back to the configured template path.
807
- RUNTIME_SCAFFOLD_DIR="${RUNTIME_SCAFFOLD_DIR:-$ROLE_DIR/.runtime-scaffold}"
808
- if [[ ! -d "$RUNTIME_SCAFFOLD_DIR" ]]; then
809
- RUNTIME_SCAFFOLD_DIR="${HERMES_TEMPLATE_RUNTIME_SCAFFOLD:-$(config_get fleet.runtime_scaffold_dir "$HOME/code/hermes-agent-template/runtime-scaffold")}"
810
- fi
811
- REGISTRY_FILE="${REGISTRY_FILE:-${HERMES_FLEET_REGISTRY_FILE:-$(config_get fleet.registry_file "$HOME/.hermes/agents-registry.yaml")}}"
812
-
813
- # Cross-process serialization for fleet identity claims and registry updates.
814
- # The lock file may remain on disk; flock ownership is kernel-scoped, so a
815
- # crashed provisioner cannot leave a permanently stale lock.
816
- FLEET_LOCK_FD=""
817
- fleet_lock_acquire() {
818
- command -v flock >/dev/null 2>&1 || die "flock is required for safe fleet registry updates"
819
- local lock_file="${REGISTRY_FILE}.lock"
820
- mkdir -p "$(dirname "$lock_file")"
821
- [[ ! -L "$lock_file" ]] || die "refusing fleet lock symlink: $lock_file"
822
- exec {FLEET_LOCK_FD}>"$lock_file"
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
852
- flock -w "${FLEET_LOCK_TIMEOUT_SECONDS:-30}" "$FLEET_LOCK_FD" \
853
- || die "timed out waiting for fleet registry lock: $lock_file"
854
- }
855
-
856
- fleet_lock_release() {
857
- if [[ "${FLEET_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then
858
- flock -u "$FLEET_LOCK_FD" 2>/dev/null || true
859
- exec {FLEET_LOCK_FD}>&-
860
- fi
861
- FLEET_LOCK_FD=""
862
- }
863
-
864
- # Bloodbank / NATS
865
- BLOODBANK_NATS_HOST="${BLOODBANK_NATS_HOST:-$(config_get bloodbank.nats_host '127.0.0.1')}"
866
- BLOODBANK_NATS_PORT="${BLOODBANK_NATS_PORT:-$(config_get bloodbank.nats_port '4222')}"
867
- BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_dir "$HOME/code/33GOD/bloodbank")}"
868
-
869
- # Plane
870
- PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
871
- if [[ "$SKIP_PLANE" != "1" ]]; then
872
- PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
873
- fi
874
-
875
- export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO PJANGLER_BIN HERMES_RUNTIME_GIT_URL \
876
- HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
877
- RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
878
- BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
879
- PLANE_BASE SKIP_PLANE
880
- if [[ "$SKIP_PLANE" != "1" ]]; then
881
- export PLANE_API_KEY
882
- fi
883
-
884
- # systemd --user health check. Accept running/degraded/starting — only one
885
- # broken unit shouldn't disqualify the rest of the user manager.
886
- systemd_user_available() {
887
- command -v systemctl >/dev/null || return 1
888
- local state; state=$(systemctl --user is-system-running 2>&1)
889
- [[ "$state" =~ ^(running|degraded|starting|maintenance)$ ]]
890
- }
891
-
892
- # Query one systemd user-unit state without conflating every non-zero exit with
893
- # an inactive/disabled unit. Output is `ok|<state>` only for documented
894
- # state/exit-code pairs; D-Bus, manager, and arbitrary query failures become
895
- # `error|<safe summary>` so retirement callers can fail closed.
896
- systemctl_user_unit_state() {
897
- local query="$1" unit="$2" output rc first_line
898
- command -v systemctl >/dev/null 2>&1 \
899
- || { printf 'error|systemctl unavailable'; return 0; }
900
- set +e
901
- output="$(LC_ALL=C systemctl --user "$query" "$unit" 2>&1)"
902
- rc=$?
903
- set -e
904
- first_line="${output%%$'\n'*}"
905
- first_line="${first_line//$'\t'/ }"
906
- first_line="${first_line//|/}"
907
- case "$query:$rc:$first_line" in
908
- is-active:0:active|is-active:0:reloading|is-active:0:activating|is-active:0:deactivating)
909
- printf 'ok|%s' "$first_line" ;;
910
- is-active:3:inactive|is-active:3:failed)
911
- printf 'ok|%s' "$first_line" ;;
912
- is-active:4:inactive)
913
- printf 'ok|not-found' ;;
914
- 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)
915
- printf 'ok|%s' "$first_line" ;;
916
- is-enabled:1:disabled|is-enabled:1:masked|is-enabled:1:masked-runtime)
917
- printf 'ok|%s' "$first_line" ;;
918
- is-enabled:4:not-found)
919
- printf 'ok|not-found' ;;
920
- *)
921
- [[ -n "$first_line" ]] || first_line="exit $rc with no state"
922
- printf 'error|%s' "$first_line" ;;
923
- esac
924
- }
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
-
1115
- # Resolve project repo path (the repo that holds agents/hermes/<role>/).
1116
- # Walk up from $ROLE_DIR until we find a git root that isn't us.
1117
- project_repo_path() {
1118
- # Structured provisioners know the project root even before a fresh target
1119
- # receives its own .git directory. Accept only a root that contains this
1120
- # exact role path; otherwise fail closed instead of walking into a parent
1121
- # checkout and mutating its manifest.
1122
- if [[ -n "${PJANGLER_PROJECT_ROOT:-}" ]]; then
1123
- local explicit role_real
1124
- explicit="$(cd "$PJANGLER_PROJECT_ROOT" 2>/dev/null && pwd -P)" || return 1
1125
- role_real="$(cd "$ROLE_DIR" 2>/dev/null && pwd -P)" || return 1
1126
- case "$role_real" in
1127
- "$explicit"/agents/hermes/*) printf '%s\n' "$explicit"; return 0 ;;
1128
- *) return 1 ;;
1129
- esac
1130
- fi
1131
- local d="$ROLE_DIR"
1132
- [[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
1133
- for _ in 1 2 3 4 5; do
1134
- d="$(dirname "$d")"
1135
- [[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
1136
- done
1137
- return 1
1138
- }