@delorenj/pjangler 1.3.0 → 1.4.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 (43) hide show
  1. package/.mise/scripts/link-agentfiles.sh +38 -5
  2. package/README.md +92 -0
  3. package/dist/assets/project-notebook-skill/SHA256SUMS +10 -0
  4. package/dist/assets/project-notebook-skill/SKILL.md +64 -0
  5. package/dist/assets/project-notebook-skill/agents/openai.yaml +6 -0
  6. package/dist/assets/project-notebook-skill/export-manifest.json +56 -0
  7. package/dist/assets/project-notebook-skill/hooks/claude.settings.json +26 -0
  8. package/dist/assets/project-notebook-skill/hooks/hooks.master.json +26 -0
  9. package/dist/assets/project-notebook-skill/hooks/session-end.sh +228 -0
  10. package/dist/assets/project-notebook-skill/hooks/session-start.sh +228 -0
  11. package/dist/assets/project-notebook-skill/references/configuration.md +93 -0
  12. package/dist/assets/project-notebook-skill/references/recovery.md +54 -0
  13. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +865 -0
  14. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +848 -0
  15. package/dist/index.js +11307 -2809
  16. package/dist/mcp-server.js +9637 -2094
  17. package/dist/prompt.js +404 -0
  18. package/package.json +8 -5
  19. package/templates/commonproject/copier.yml +19 -5
  20. package/templates/commonproject/template/.mise/scripts/link-agentfiles.sh +38 -5
  21. package/templates/commonproject/template/.mise/scripts/provision-packs.py +74 -52
  22. package/templates/commonproject/template/.mise/scripts/sync-skills.py +479 -24
  23. package/templates/commonproject/template/mise.toml.jinja +12 -6
  24. package/templates/hermes-agent/copier.yml +8 -11
  25. package/templates/hermes-agent/template/.gitignore.jinja +1 -0
  26. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  27. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  28. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  29. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  30. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  31. package/templates/hermes-agent/template/.scripts/70-systemd.sh +77 -43
  32. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  33. package/templates/hermes-agent/template/.scripts/_lib.sh +116 -16
  34. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  35. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  36. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  37. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +761 -0
  38. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  39. package/templates/hermes-agent/template/.scripts/providers/plane.sh +19 -3
  40. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  41. package/templates/hermes-agent/template/hermes.jinja +20 -8
  42. package/templates/hermes-agent/template/momo.jinja +177 -0
  43. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
@@ -12,20 +12,26 @@ _.file = [".env"]
12
12
  # i.e. All linked agent files MUST be siblings at
13
13
  # any given level of nesting.
14
14
  #
15
+ # NOTE: every managed script is handed config_root EXPLICITLY. A mise enter
16
+ # hook runs with cwd set to the directory you cd'd into -- including a PARENT
17
+ # config's hook -- so a script that reads its subject from cwd reshapes
18
+ # whichever nested repo you entered. config_root locates the file; the
19
+ # argument locates the subject. The scripts refuse to run without it.
20
+ #
15
21
  # NOTE: hooks are an array of [[hooks.enter]] tables (NOT enter = [ ... ]) and
16
22
  # every {{config_root}} path is single-quoted so it survives a space in the
17
23
  # project path — mise runs each `script` via `sh -c`.
18
24
  [[hooks.enter]]
19
- script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh'"
25
+ script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh' '{% raw %}{{config_root}}{% endraw %}'"
20
26
  [[hooks.enter]]
21
27
  # Atomic (PJAN-24/PJAN-57): shell complexity lives in the managed script so
22
28
  # mise never interpolates temporary variables. The script uses mktemp, traps
23
29
  # cleanup, and replaces .env only after op inject succeeds.
24
30
  script = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/materialize-env.sh'"
25
31
  [[hooks.enter]]
26
- script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-packs.py'"
32
+ script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-packs.py' --root '{% raw %}{{config_root}}{% endraw %}'"
27
33
  [[hooks.enter]]
28
- script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project"
34
+ script = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project --root '{% raw %}{{config_root}}{% endraw %}'"
29
35
  [[hooks.enter]]
30
36
  script = "[ -f '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' ] && '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/codegraph.sh' || true"
31
37
  {%- if agent_hooks_layer %}
@@ -51,16 +57,16 @@ task = "hooks:sync"
51
57
  {% endif %}
52
58
  [tasks."link:agentfiles"]
53
59
  description = "Symlink all agent files to AGENTS.md"
54
- run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh'"
60
+ run = "'{% raw %}{{config_root}}{% endraw %}/.mise/scripts/link-agentfiles.sh' '{% raw %}{{config_root}}{% endraw %}'"
55
61
 
56
62
  [tasks."skills:sync"]
57
63
  description = "Sync skills from manifest to local CLI dirs"
58
64
  depends = ["skills:provision:packs"]
59
- run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project"
65
+ run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/sync-skills.py' --scope project --root '{% raw %}{{config_root}}{% endraw %}'"
60
66
 
61
67
  [tasks."skills:provision:packs"]
62
68
  description = "Provision every Skillex pack declared in .agents/skills.json"
63
- run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-packs.py'"
69
+ run = "python3 '{% raw %}{{config_root}}{% endraw %}/.mise/scripts/provision-packs.py' --root '{% raw %}{{config_root}}{% endraw %}'"
64
70
  {% if agent_hooks_layer %}
65
71
  # --- Project-scoped agent hooks + skill fan-out (see .agents/hooks/README.md) ---
66
72
 
@@ -46,17 +46,14 @@ target_repo:
46
46
 
47
47
  role:
48
48
  type: str
49
- help: "Role for this agent"
49
+ help: "Role for this agent (safe single path segment; arbitrary roles are supported)"
50
50
  default: pm
51
- choices:
52
- "Project Manager (pm)": pm
53
- "Company Director (director)": director
54
- "Developer (dev)": dev
55
- "Reviewer (review)": review
56
- "Company Reporter — daily cross-project rollup (reporter)": reporter
57
- "Ops (ops)": ops
58
- "QA (qa)": qa
59
- validator: "{% if not role %}Required{% endif %}"
51
+ validator: >-
52
+ {% set alnum = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' -%}
53
+ {% set safe_chars = alnum ~ '._-' -%}
54
+ {% if not role or role != role|trim or role in ['.', '..'] or role[0] not in alnum or role[-1] not in alnum or role|reject('in', safe_chars)|list|length -%}
55
+ Role must be a non-empty safe single path segment using letters, numbers, dots, underscores, or hyphens
56
+ {%- endif %}
60
57
 
61
58
  ticket_provider:
62
59
  type: str
@@ -157,7 +154,7 @@ plane_workspace:
157
154
  # All scripts are idempotent — safe to re-run if a step fails.
158
155
 
159
156
  _tasks:
160
- - "chmod +x .scripts/*.sh .scripts/sentinel/bin/*.sh hermes 2>/dev/null || true"
157
+ - "chmod +x .scripts/*.sh .scripts/sentinel/bin/*.sh hermes momo 2>/dev/null || true"
161
158
  - "./.scripts/00-banner.sh"
162
159
  - "./.scripts/01-config.sh"
163
160
  - "./.scripts/05-fleet-env.sh"
@@ -8,3 +8,4 @@ runtime/
8
8
  .scripts/.provision.log
9
9
  .scripts/.last-run
10
10
  .scripts/.done-*
11
+ .scripts/.plane-project-id
@@ -0,0 +1,44 @@
1
+ # Per-agent runtime — what NOT to commit to the checkpoint repo.
2
+ # Sensitive credentials never go in git.
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ auth.json
7
+ auth.lock
8
+ secrets.*
9
+ *.pem
10
+ *.key
11
+ gateway.pid
12
+ gateway_state.json
13
+ processes.json
14
+ interrupt_debug.log
15
+
16
+ # Caches (large, regenerable)
17
+ audio_cache/
18
+ image_cache/
19
+ models_dev_cache.json
20
+ context_length_cache.yaml
21
+ honcho.json
22
+
23
+ # Sandboxes (ephemeral execution environments)
24
+ sandboxes/
25
+
26
+ # Heartbeat / sentinel ephemeral state — local to this runtime, never checkpointed.
27
+ # (Keeps the fused checkpoint a true no-op on a clean tree.)
28
+ continuous-ticket-sentinel-state.json
29
+ continuous-ticket-sentinel.lock
30
+ continuous-ticket-sentinel.lock.d/
31
+ .last-checkpoint
32
+
33
+ # Runtime logs (we want the LFS-tracked summary.log if we add one, not the noise)
34
+ logs/*.log
35
+ logs/*.systemd.log
36
+
37
+ # Python cruft
38
+ __pycache__/
39
+ *.pyc
40
+ .scripts/.done-*
41
+ .scripts/.provision.log
42
+
43
+ # Allow these subdirs to exist
44
+ !logs/.gitkeep
@@ -1,6 +1,15 @@
1
1
  #!/usr/bin/env bash
2
2
  # Ensure the distributable config exists before any other step reads it.
3
3
  # Seeds ~/.config/hermes-agent-template/config.toml from the shipped example.
4
+
5
+ # MCP render transactions establish repo-local lifecycle eligibility before
6
+ # touching any host-global state. This guard must precede _lib.sh because that
7
+ # library creates the role log and may read fleet configuration.
8
+ if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
9
+ printf '%s\n' '[01] host config — DEFERRED (SKIP_HOST_STATE=1)' >&2
10
+ exit 0
11
+ fi
12
+
4
13
  # shellcheck source=_lib.sh
5
14
  source "$(dirname "$0")/_lib.sh"
6
15
 
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  # Ensure a single shared fleet source-of-truth file exists.
3
+
4
+ if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
5
+ printf '%s\n' '[05] fleet env — DEFERRED (SKIP_HOST_STATE=1)' >&2
6
+ exit 0
7
+ fi
8
+
3
9
  # shellcheck source=_lib.sh
4
10
  source "$(dirname "$0")/_lib.sh"
5
11
  load_role_env
@@ -8,45 +14,29 @@ already_done 05-fleet-env && log "[05] fleet env already checked — refreshing
8
14
 
9
15
  mkdir -p "$(dirname "$FLEET_ENV")"
10
16
 
17
+ new_fleet_env=0
11
18
  if [[ ! -f "$FLEET_ENV" ]]; then
12
19
  log "[05] creating fleet source-of-truth: $FLEET_ENV"
13
- cat > "$FLEET_ENV" <<EOF
14
- # Hermes fleet source of truth.
15
- # All generated wrappers and provisioning scripts read this file.
16
- HERMES_FLEET_BIN=${HERMES_BIN}
17
- HERMES_FLEET_REPO=${HERMES_AGENT_REPO}
18
- HERMES_FLEET_REGISTRY_FILE=${REGISTRY_FILE}
19
- HERMES_FLEET_OAUTH_FILE=${HERMES_OAUTH_FILE}
20
- HERMES_FLEET_CODEX_HOME=${CODEX_HOME}
21
- EOF
22
- chmod 600 "$FLEET_ENV"
20
+ new_fleet_env=1
23
21
  else
24
22
  log "[05] fleet env exists: $FLEET_ENV"
25
23
  fi
26
24
 
25
+ fleet_env_parser="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
26
+ fleet_env_python="$(builtin type -P python3)" \
27
+ || die "python3 is required to update the fleet source-of-truth"
28
+
27
29
  upsert_fleet_env() {
28
30
  local key="$1" value="$2"
29
- python3 - "$FLEET_ENV" "$key" "$value" <<'PYEOF'
30
- import pathlib
31
- import sys
32
-
33
- path = pathlib.Path(sys.argv[1])
34
- key = sys.argv[2]
35
- value = sys.argv[3]
36
- line = f"{key}={value}"
37
- lines = path.read_text().splitlines() if path.exists() else []
38
- for idx, existing in enumerate(lines):
39
- if existing.startswith(f"{key}="):
40
- lines[idx] = line
41
- break
42
- else:
43
- lines.append(line)
44
- path.write_text("\n".join(lines) + "\n")
45
- PYEOF
31
+ "$fleet_env_python" -I "$fleet_env_parser" --upsert "$FLEET_ENV" "$key" "$value"
46
32
  }
47
33
 
34
+ if [[ "$new_fleet_env" == "1" ]]; then
35
+ upsert_fleet_env HERMES_FLEET_BIN "$HERMES_BIN"
36
+ upsert_fleet_env HERMES_FLEET_REPO "$HERMES_AGENT_REPO"
37
+ upsert_fleet_env HERMES_FLEET_REGISTRY_FILE "$REGISTRY_FILE"
38
+ fi
48
39
  upsert_fleet_env HERMES_FLEET_OAUTH_FILE "$HERMES_OAUTH_FILE"
49
40
  upsert_fleet_env HERMES_FLEET_CODEX_HOME "$CODEX_HOME"
50
- chmod 600 "$FLEET_ENV"
51
41
 
52
42
  mark_done 05-fleet-env
@@ -3,6 +3,12 @@
3
3
  # The directory remains REAL. Step 20 delegates final shared-vs-owned link
4
4
  # topology to `pj migrate hermes.runtime-singleton`; no template step may
5
5
  # replace the profile itself with a symlink.
6
+
7
+ if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
8
+ printf '%s\n' '[10] Hermes profile — DEFERRED (SKIP_HOST_STATE=1)' >&2
9
+ exit 0
10
+ fi
11
+
6
12
  # shellcheck source=_lib.sh
7
13
  source "$(dirname "$0")/_lib.sh"
8
14
  load_role_env
@@ -108,8 +114,56 @@ PYEOF
108
114
  fi
109
115
 
110
116
  # Never persist a project-specific terminal.cwd through the named profile.
111
- # PJangler intentionally links profile config.yaml to the fleet-shared config;
112
- # the generated launchers therefore pass TERMINAL_CWD process-locally instead.
117
+ # The generated launchers pass TERMINAL_CWD process-locally instead.
118
+ #
119
+ # config.yaml is GENERATED, never hand-written and never a symlink to the fleet
120
+ # base. It is deep_merge(~/.hermes/config.yaml, <profile>/config.delta.yaml).
121
+ # The old symlink-to-base topology was actively harmful: Hermes' atomic writes
122
+ # use os.replace, which REPLACES a symlink with a regular file, so the first
123
+ # in-agent write (/model, onboarding, a config migration) silently detached the
124
+ # profile onto a frozen copy of an old base — and a symlink gave the profile no
125
+ # way to override anything in the first place.
126
+ #
127
+ # Seed an EMPTY delta: a new agent should be identical to the fleet base, and
128
+ # every line here is an override someone must justify later.
129
+ PROFILE_DELTA="$PROFILE_HOME/config.delta.yaml"
130
+ if [[ ! -f "$PROFILE_DELTA" ]]; then
131
+ log " seeding empty config.delta.yaml (override-only SSOT)"
132
+ cat > "$PROFILE_DELTA" <<'DELTA_EOF'
133
+ # Override-only delta for this Hermes profile.
134
+ # Merged over ~/.hermes/config.yaml to produce config.yaml (which is GENERATED).
135
+ # Empty == identical to the fleet base. Add ONLY what must differ.
136
+ {}
137
+ DELTA_EOF
138
+ chmod 600 "$PROFILE_DELTA"
139
+ fi
140
+
141
+ # Pin the identity-memory bank explicitly rather than relying on the fleet
142
+ # bank_id_template (agent-{profile}). {profile} resolves through Hermes'
143
+ # get_active_profile_name(), which calls Path.resolve() on HERMES_HOME and
144
+ # requires a lowercase id sitting directly under profiles/. A symlinked profile
145
+ # dir or an uppercase name silently yields the literal "custom" — which would
146
+ # merge this agent's PRIVATE memory into a bank shared with every other agent
147
+ # that also failed to resolve.
148
+ PROFILE_MEM_CFG="$PROFILE_HOME/hindsight/config.json"
149
+ mkdir -p "$(dirname "$PROFILE_MEM_CFG")"
150
+ if [[ ! -f "$PROFILE_MEM_CFG" ]]; then
151
+ log " pinning identity-memory bank: agent-$PROFILE_NAME"
152
+ printf '{\n "bank_id": "agent-%s"\n}\n' "$PROFILE_NAME" > "$PROFILE_MEM_CFG"
153
+ chmod 600 "$PROFILE_MEM_CFG"
154
+ fi
155
+
156
+ # Render config.yaml from base + delta when the renderer is available. Without
157
+ # it the profile still boots (Hermes reads whatever config.yaml exists), but it
158
+ # is not yet under inheritance and `pj audit` will say so.
159
+ PROFILE_RENDERER="${PROFILE_RENDERER:-$HOME/code/33GOD/hermes-agent-template/scripts/hermes-profile-config.py}"
160
+ if [[ -x "$PROFILE_RENDERER" || -f "$PROFILE_RENDERER" ]]; then
161
+ log " rendering config.yaml from fleet base + delta"
162
+ python3 "$PROFILE_RENDERER" render --profile "$PROFILE_NAME" >/dev/null 2>&1 \
163
+ || warn " render failed; run hermes-profile-config.py render --profile $PROFILE_NAME"
164
+ else
165
+ warn " profile renderer not found at $PROFILE_RENDERER — config.yaml not rendered"
166
+ fi
113
167
 
114
168
  # Canonical shared-skill source of truth + local PM fallback sync.
115
169
  CANONICAL_SKILLS_DIR="${CANONICAL_SKILLS_DIR:-$(config_get fleet.canonical_skills_dir '/home/delorenj/.agents/skills')}"
@@ -117,8 +171,18 @@ CANONICAL_PM_SKILL_SRC="$CANONICAL_SKILLS_DIR/subagent-driven-development"
117
171
  LOCAL_PM_SKILL_DST="$PROFILE_HOME/skills/software-development/subagent-driven-development"
118
172
 
119
173
  if [[ -d "$CANONICAL_SKILLS_DIR" ]]; then
120
- log " setting skills.external_dirs[0] = $CANONICAL_SKILLS_DIR"
121
- env HERMES_HOME="$PROFILE_HOME" "$HERMES_BIN" config set skills.external_dirs.0 "$CANONICAL_SKILLS_DIR"
174
+ # skills.external_dirs is a FLEET setting and already lives in
175
+ # ~/.hermes/config.yaml, so every rendered profile inherits it. Writing it
176
+ # per-profile here would (a) be redundant, and (b) write into the GENERATED
177
+ # config.yaml, where the next render discards it — the classic "I set it and
178
+ # it reverted" trap. Verify inheritance instead of re-asserting it.
179
+ if ! env HERMES_HOME="$PROFILE_HOME" "$HERMES_BIN" config get skills.external_dirs 2>/dev/null \
180
+ | grep -qF "$CANONICAL_SKILLS_DIR"; then
181
+ warn " skills.external_dirs does not include $CANONICAL_SKILLS_DIR"
182
+ warn " add it to the FLEET base (~/.hermes/config.yaml), then: hermes-profile-config.py render --all"
183
+ else
184
+ log " skills.external_dirs inherited from fleet base: $CANONICAL_SKILLS_DIR"
185
+ fi
122
186
 
123
187
  # Ensure key PM/local-ops skills are symlinked into runtime/profile skills root.
124
188
  # This preserves canonical ownership and keeps updates instant across agents.
@@ -12,6 +12,15 @@
12
12
  # it becomes the SOT for every agent in this repo.
13
13
  # Either way we register this agent under .project.json `agents` and mirror the
14
14
  # binding into role.yaml for back-compat (80-registry.sh / 99-summary.sh).
15
+
16
+ # The caller's negative board grant is authoritative. Check it before sourcing
17
+ # _lib.sh so a skipped step cannot load fleet credentials, create logs/markers,
18
+ # inspect bindings, or reach a provider adapter.
19
+ if [[ "${SKIP_PLANE:-0}" == "1" ]]; then
20
+ printf '%s\n' '[42] ticket provider — SKIPPED (SKIP_PLANE=1)' >&2
21
+ exit 0
22
+ fi
23
+
15
24
  # shellcheck source=_lib.sh
16
25
  source "$(dirname "$0")/_lib.sh"
17
26
  load_role_env
@@ -74,7 +83,15 @@ if set_provider == "1":
74
83
  if board_url: tp["board_url"] = board_url
75
84
  if team: tp["team"] = team
76
85
  ag = d.setdefault("agents", {})
77
- ag[os.environ["AGENT_ID"]] = {"role": os.environ["ROLE"], "role_dir": os.environ["ROLE_DIR_REL"]}
86
+ entry = ag.get(os.environ["AGENT_ID"], {})
87
+ if not isinstance(entry, dict):
88
+ entry = {}
89
+ entry.update({
90
+ "role": os.environ["ROLE"],
91
+ "role_dir": os.environ["ROLE_DIR_REL"],
92
+ "provisioning_state": "provisioned",
93
+ })
94
+ ag[os.environ["AGENT_ID"]] = entry
78
95
  p.write_text(json.dumps(d, indent=2) + "\n")
79
96
  PY
80
97
  log " .project.json updated (agent=$AGENT_ID)"
@@ -37,6 +37,63 @@ if [[ "${SKIP_SYSTEMD:-0}" == "1" ]]; then
37
37
  exit 0
38
38
  fi
39
39
 
40
+ # Render every caller-controlled systemd scalar through the same data-only
41
+ # serializer used by fleet backfill. This validation happens before mkdir,
42
+ # systemctl, or unit writes, so CR/LF/NUL cannot create a second directive and
43
+ # spaces/quotes/backslashes/specifier bytes remain exact.
44
+ PYTHON_BIN="$(builtin type -P python3)" || die "python3 is unavailable for systemd unit serialization"
45
+ systemd_value() {
46
+ "$PYTHON_BIN" -I "$FLEET_ENV_PARSER" --systemd-value "$1" \
47
+ || die "systemd value validation failed"
48
+ }
49
+ systemd_scalar() {
50
+ "$PYTHON_BIN" -I "$FLEET_ENV_PARSER" --systemd-scalar "$1" \
51
+ || die "systemd scalar validation failed"
52
+ }
53
+ systemd_environment() {
54
+ "$PYTHON_BIN" -I "$FLEET_ENV_PARSER" --systemd-environment "$1" "$2" \
55
+ || die "systemd environment validation failed"
56
+ }
57
+ systemd_exec_value() {
58
+ "$PYTHON_BIN" -I "$FLEET_ENV_PARSER" --systemd-exec-value "$1" \
59
+ || die "systemd ExecStart value validation failed"
60
+ }
61
+ GW_DESCRIPTION="$(systemd_scalar "Hermes Gateway — $DISPLAY_NAME")"
62
+ HB_DESCRIPTION="$(systemd_scalar "Hermes Heartbeat (reconcile + checkpoint) — $DISPLAY_NAME")"
63
+ TIMER_DESCRIPTION="$(systemd_scalar "Heartbeat (reconcile + checkpoint) for $AGENT_ID")"
64
+ ENV_HERMES_HOME="$(systemd_environment HERMES_HOME "$PROFILE_HOME")"
65
+ ENV_HERMES_BIN="$(systemd_environment HERMES_BIN "$HERMES_BIN")"
66
+ ENV_CODEX_HOME="$(systemd_environment CODEX_HOME "$CODEX_HOME")"
67
+ ENV_TERMINAL_CWD="$(systemd_environment TERMINAL_CWD "$REPO_ROOT")"
68
+ WORKING_DIRECTORY="$(systemd_scalar "$REPO_ROOT")"
69
+ RUNTIME_ENV_FILE="$(systemd_scalar "-$RUNTIME/.env")"
70
+ GW_LOG_OUTPUT="$(systemd_scalar "append:$RUNTIME/logs/gateway.systemd.log")"
71
+ HB_LOG_OUTPUT="$(systemd_scalar "append:$RUNTIME/logs/heartbeat.log")"
72
+ GW_EXEC_START="$(systemd_exec_value "$ROLE_DIR/.scripts/credential-launch.sh")"
73
+ HB_EXEC_START="$GW_EXEC_START"
74
+
75
+ # Encrypted credentials are optional and take precedence over the existing
76
+ # ignored runtime/.env fallback. Validate every path-derived unit scalar before
77
+ # any directory, unit, marker, or systemd mutation.
78
+ CREDENTIAL_DIR="${HERMES_SYSTEMD_CREDENTIAL_DIR:-$HOME/.config/hermes-agent/credentials}"
79
+ TELEGRAM_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-telegram-bot-token.cred"
80
+ MODEL_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-model-api-key.cred"
81
+ GW_CREDENTIAL_LINES=""
82
+ HB_CREDENTIAL_LINES=""
83
+ if [[ -f "$TELEGRAM_CREDENTIAL" ]]; then
84
+ command -v systemd-creds >/dev/null 2>&1 \
85
+ || die "encrypted Telegram credential exists but systemd-creds is unavailable"
86
+ GW_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "telegram_bot_token:$TELEGRAM_CREDENTIAL")"
87
+ fi
88
+ if [[ -f "$MODEL_CREDENTIAL" ]]; then
89
+ [[ -n "$(yaml_get model.key_env)" ]] \
90
+ || die "encrypted model credential exists but model.key_env is blank in role.yaml"
91
+ command -v systemd-creds >/dev/null 2>&1 \
92
+ || die "encrypted model credential exists but systemd-creds is unavailable"
93
+ GW_CREDENTIAL_LINES="${GW_CREDENTIAL_LINES}${GW_CREDENTIAL_LINES:+$'\n'}LoadCredentialEncrypted=$(systemd_value "model_api_key:$MODEL_CREDENTIAL")"
94
+ HB_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "model_api_key:$MODEL_CREDENTIAL")"
95
+ fi
96
+
40
97
  # Singleton-runtime contract: units set HERMES_HOME to the agent's NAMED PROFILE
41
98
  # dir, never the raw runtime path — Hermes derives profile identity and shared
42
99
  # fleet auth from the unresolved HERMES_HOME. $RUNTIME stays correct for
@@ -101,49 +158,26 @@ chmod +x "$HEARTBEAT_BIN" "$CREDENTIAL_LAUNCHER" "$ROLE_DIR/.scripts/checkpoint.
101
158
  [[ -d "$PROFILE_HOME" && ! -L "$PROFILE_HOME" ]] \
102
159
  || die "named profile is not a real directory; run: pj migrate hermes.runtime-singleton '$REPO_ROOT'"
103
160
 
104
- # Encrypted credentials are optional and take precedence over the existing
105
- # ignored runtime/.env fallback. Their plaintext appears only below /run while
106
- # systemd executes the unit. Files are provisioned separately with
107
- # `systemd-creds encrypt --user`; this step never reads or writes secret values.
108
- CREDENTIAL_DIR="${HERMES_SYSTEMD_CREDENTIAL_DIR:-$HOME/.config/hermes-agent/credentials}"
109
- TELEGRAM_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-telegram-bot-token.cred"
110
- MODEL_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-model-api-key.cred"
111
- GW_CREDENTIAL_LINES=""
112
- HB_CREDENTIAL_LINES=""
113
- if [[ -f "$TELEGRAM_CREDENTIAL" ]]; then
114
- command -v systemd-creds >/dev/null 2>&1 \
115
- || die "encrypted Telegram credential exists but systemd-creds is unavailable"
116
- GW_CREDENTIAL_LINES="LoadCredentialEncrypted=telegram_bot_token:$TELEGRAM_CREDENTIAL"
117
- fi
118
- if [[ -f "$MODEL_CREDENTIAL" ]]; then
119
- [[ -n "$(yaml_get model.key_env)" ]] \
120
- || die "encrypted model credential exists but model.key_env is blank in role.yaml"
121
- command -v systemd-creds >/dev/null 2>&1 \
122
- || die "encrypted model credential exists but systemd-creds is unavailable"
123
- GW_CREDENTIAL_LINES="${GW_CREDENTIAL_LINES}${GW_CREDENTIAL_LINES:+$'\n'}LoadCredentialEncrypted=model_api_key:$MODEL_CREDENTIAL"
124
- HB_CREDENTIAL_LINES="LoadCredentialEncrypted=model_api_key:$MODEL_CREDENTIAL"
125
- fi
126
-
127
161
  # Gateway unit
128
162
  cat > "$SYS_DIR/$GW_UNIT" <<UNIT
129
163
  [Unit]
130
- Description=Hermes Gateway — $DISPLAY_NAME
164
+ Description=$GW_DESCRIPTION
131
165
  After=network-online.target
132
166
  Wants=network-online.target
133
167
 
134
168
  [Service]
135
169
  Type=simple
136
- Environment=HERMES_HOME=$PROFILE_HOME
137
- Environment=HERMES_BIN=$HERMES_BIN
138
- Environment=CODEX_HOME=$CODEX_HOME
139
- Environment="TERMINAL_CWD=$REPO_ROOT"
140
- EnvironmentFile=-$RUNTIME/.env
170
+ $ENV_HERMES_HOME
171
+ $ENV_HERMES_BIN
172
+ $ENV_CODEX_HOME
173
+ $ENV_TERMINAL_CWD
174
+ EnvironmentFile=$RUNTIME_ENV_FILE
141
175
  $GW_CREDENTIAL_LINES
142
- ExecStart=$CREDENTIAL_LAUNCHER gateway
176
+ ExecStart=$GW_EXEC_START gateway
143
177
  Restart=on-failure
144
178
  RestartSec=10
145
- StandardOutput=append:$RUNTIME/logs/gateway.systemd.log
146
- StandardError=append:$RUNTIME/logs/gateway.systemd.log
179
+ StandardOutput=$GW_LOG_OUTPUT
180
+ StandardError=$GW_LOG_OUTPUT
147
181
 
148
182
  [Install]
149
183
  WantedBy=default.target
@@ -155,31 +189,31 @@ UNIT
155
189
  # The per-agent EnvironmentFiles load ticket-provider creds for the sentinel pass.
156
190
  cat > "$SYS_DIR/$HB_SVC" <<UNIT
157
191
  [Unit]
158
- Description=Hermes Heartbeat (reconcile + checkpoint) — $DISPLAY_NAME
192
+ Description=$HB_DESCRIPTION
159
193
  After=network-online.target
160
194
  Wants=network-online.target
161
195
 
162
196
  [Service]
163
197
  Type=oneshot
164
- WorkingDirectory=$REPO_ROOT
165
- Environment=HERMES_HOME=$PROFILE_HOME
166
- Environment=HERMES_BIN=$HERMES_BIN
167
- Environment=CODEX_HOME=$CODEX_HOME
168
- Environment="TERMINAL_CWD=$REPO_ROOT"
198
+ WorkingDirectory=$WORKING_DIRECTORY
199
+ $ENV_HERMES_HOME
200
+ $ENV_HERMES_BIN
201
+ $ENV_CODEX_HOME
202
+ $ENV_TERMINAL_CWD
169
203
  EnvironmentFile=-%h/.config/hermes-agent/env
170
204
  EnvironmentFile=-%h/.hermes/env
171
205
  EnvironmentFile=-%h/.hermes/hermes-agent.env
172
206
  EnvironmentFile=-%h/.hermes/${AGENT_ID}.env
173
- EnvironmentFile=-$RUNTIME/.env
207
+ EnvironmentFile=$RUNTIME_ENV_FILE
174
208
  $HB_CREDENTIAL_LINES
175
- ExecStart=$CREDENTIAL_LAUNCHER heartbeat
209
+ ExecStart=$HB_EXEC_START heartbeat
176
210
  TimeoutStartSec=45min
177
- StandardOutput=append:$RUNTIME/logs/heartbeat.log
178
- StandardError=append:$RUNTIME/logs/heartbeat.log
211
+ StandardOutput=$HB_LOG_OUTPUT
212
+ StandardError=$HB_LOG_OUTPUT
179
213
  UNIT
180
214
  cat > "$SYS_DIR/$HB_TIMER" <<UNIT
181
215
  [Unit]
182
- Description=Heartbeat (reconcile + checkpoint) for $AGENT_ID
216
+ Description=$TIMER_DESCRIPTION
183
217
 
184
218
  [Timer]
185
219
  OnBootSec=1min
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env bash
2
2
  # Append this agent's entry to the global fleet registry.
3
+
4
+ if [[ "${SKIP_HOST_STATE:-0}" == "1" ]]; then
5
+ printf '%s\n' '[80] fleet registry — DEFERRED (SKIP_HOST_STATE=1)' >&2
6
+ exit 0
7
+ fi
8
+
3
9
  # shellcheck source=_lib.sh
4
10
  source "$(dirname "$0")/_lib.sh"
5
11
  load_role_env