@delorenj/pjangler 1.3.0 → 1.3.7

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 (26) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +4510 -2485
  3. package/dist/mcp-server.js +4166 -1959
  4. package/dist/prompt.js +404 -0
  5. package/package.json +7 -5
  6. package/templates/commonproject/copier.yml +6 -1
  7. package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
  8. package/templates/hermes-agent/copier.yml +8 -11
  9. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  10. package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
  11. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  12. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
  13. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  14. package/templates/hermes-agent/template/.scripts/70-systemd.sh +73 -43
  15. package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
  16. package/templates/hermes-agent/template/.scripts/_lib.sh +62 -6
  17. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  18. package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
  19. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  20. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
  21. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  22. package/templates/hermes-agent/template/.scripts/providers/plane.sh +1 -1
  23. package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
  24. package/templates/hermes-agent/template/hermes.jinja +20 -8
  25. package/templates/hermes-agent/template/momo.jinja +177 -0
  26. package/templates/hermes-agent/template/role.yaml.jinja +19 -19
@@ -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,59 @@ 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_environment() {
50
+ "$PYTHON_BIN" -I "$FLEET_ENV_PARSER" --systemd-environment "$1" "$2" \
51
+ || die "systemd environment validation failed"
52
+ }
53
+ systemd_exec_value() {
54
+ "$PYTHON_BIN" -I "$FLEET_ENV_PARSER" --systemd-exec-value "$1" \
55
+ || die "systemd ExecStart value validation failed"
56
+ }
57
+ GW_DESCRIPTION="$(systemd_value "Hermes Gateway — $DISPLAY_NAME")"
58
+ HB_DESCRIPTION="$(systemd_value "Hermes Heartbeat (reconcile + checkpoint) — $DISPLAY_NAME")"
59
+ TIMER_DESCRIPTION="$(systemd_value "Heartbeat (reconcile + checkpoint) for $AGENT_ID")"
60
+ ENV_HERMES_HOME="$(systemd_environment HERMES_HOME "$PROFILE_HOME")"
61
+ ENV_HERMES_BIN="$(systemd_environment HERMES_BIN "$HERMES_BIN")"
62
+ ENV_CODEX_HOME="$(systemd_environment CODEX_HOME "$CODEX_HOME")"
63
+ ENV_TERMINAL_CWD="$(systemd_environment TERMINAL_CWD "$REPO_ROOT")"
64
+ WORKING_DIRECTORY="$(systemd_value "$REPO_ROOT")"
65
+ RUNTIME_ENV_FILE="$(systemd_value "-$RUNTIME/.env")"
66
+ GW_LOG_OUTPUT="$(systemd_value "append:$RUNTIME/logs/gateway.systemd.log")"
67
+ HB_LOG_OUTPUT="$(systemd_value "append:$RUNTIME/logs/heartbeat.log")"
68
+ GW_EXEC_START="$(systemd_exec_value "$ROLE_DIR/.scripts/credential-launch.sh")"
69
+ HB_EXEC_START="$GW_EXEC_START"
70
+
71
+ # Encrypted credentials are optional and take precedence over the existing
72
+ # ignored runtime/.env fallback. Validate every path-derived unit scalar before
73
+ # any directory, unit, marker, or systemd mutation.
74
+ CREDENTIAL_DIR="${HERMES_SYSTEMD_CREDENTIAL_DIR:-$HOME/.config/hermes-agent/credentials}"
75
+ TELEGRAM_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-telegram-bot-token.cred"
76
+ MODEL_CREDENTIAL="$CREDENTIAL_DIR/${AGENT_ID}-model-api-key.cred"
77
+ GW_CREDENTIAL_LINES=""
78
+ HB_CREDENTIAL_LINES=""
79
+ if [[ -f "$TELEGRAM_CREDENTIAL" ]]; then
80
+ command -v systemd-creds >/dev/null 2>&1 \
81
+ || die "encrypted Telegram credential exists but systemd-creds is unavailable"
82
+ GW_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "telegram_bot_token:$TELEGRAM_CREDENTIAL")"
83
+ fi
84
+ if [[ -f "$MODEL_CREDENTIAL" ]]; then
85
+ [[ -n "$(yaml_get model.key_env)" ]] \
86
+ || die "encrypted model credential exists but model.key_env is blank in role.yaml"
87
+ command -v systemd-creds >/dev/null 2>&1 \
88
+ || die "encrypted model credential exists but systemd-creds is unavailable"
89
+ GW_CREDENTIAL_LINES="${GW_CREDENTIAL_LINES}${GW_CREDENTIAL_LINES:+$'\n'}LoadCredentialEncrypted=$(systemd_value "model_api_key:$MODEL_CREDENTIAL")"
90
+ HB_CREDENTIAL_LINES="LoadCredentialEncrypted=$(systemd_value "model_api_key:$MODEL_CREDENTIAL")"
91
+ fi
92
+
40
93
  # Singleton-runtime contract: units set HERMES_HOME to the agent's NAMED PROFILE
41
94
  # dir, never the raw runtime path — Hermes derives profile identity and shared
42
95
  # fleet auth from the unresolved HERMES_HOME. $RUNTIME stays correct for
@@ -101,49 +154,26 @@ chmod +x "$HEARTBEAT_BIN" "$CREDENTIAL_LAUNCHER" "$ROLE_DIR/.scripts/checkpoint.
101
154
  [[ -d "$PROFILE_HOME" && ! -L "$PROFILE_HOME" ]] \
102
155
  || die "named profile is not a real directory; run: pj migrate hermes.runtime-singleton '$REPO_ROOT'"
103
156
 
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
157
  # Gateway unit
128
158
  cat > "$SYS_DIR/$GW_UNIT" <<UNIT
129
159
  [Unit]
130
- Description=Hermes Gateway — $DISPLAY_NAME
160
+ Description=$GW_DESCRIPTION
131
161
  After=network-online.target
132
162
  Wants=network-online.target
133
163
 
134
164
  [Service]
135
165
  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
166
+ $ENV_HERMES_HOME
167
+ $ENV_HERMES_BIN
168
+ $ENV_CODEX_HOME
169
+ $ENV_TERMINAL_CWD
170
+ EnvironmentFile=$RUNTIME_ENV_FILE
141
171
  $GW_CREDENTIAL_LINES
142
- ExecStart=$CREDENTIAL_LAUNCHER gateway
172
+ ExecStart=$GW_EXEC_START gateway
143
173
  Restart=on-failure
144
174
  RestartSec=10
145
- StandardOutput=append:$RUNTIME/logs/gateway.systemd.log
146
- StandardError=append:$RUNTIME/logs/gateway.systemd.log
175
+ StandardOutput=$GW_LOG_OUTPUT
176
+ StandardError=$GW_LOG_OUTPUT
147
177
 
148
178
  [Install]
149
179
  WantedBy=default.target
@@ -155,31 +185,31 @@ UNIT
155
185
  # The per-agent EnvironmentFiles load ticket-provider creds for the sentinel pass.
156
186
  cat > "$SYS_DIR/$HB_SVC" <<UNIT
157
187
  [Unit]
158
- Description=Hermes Heartbeat (reconcile + checkpoint) — $DISPLAY_NAME
188
+ Description=$HB_DESCRIPTION
159
189
  After=network-online.target
160
190
  Wants=network-online.target
161
191
 
162
192
  [Service]
163
193
  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"
194
+ WorkingDirectory=$WORKING_DIRECTORY
195
+ $ENV_HERMES_HOME
196
+ $ENV_HERMES_BIN
197
+ $ENV_CODEX_HOME
198
+ $ENV_TERMINAL_CWD
169
199
  EnvironmentFile=-%h/.config/hermes-agent/env
170
200
  EnvironmentFile=-%h/.hermes/env
171
201
  EnvironmentFile=-%h/.hermes/hermes-agent.env
172
202
  EnvironmentFile=-%h/.hermes/${AGENT_ID}.env
173
- EnvironmentFile=-$RUNTIME/.env
203
+ EnvironmentFile=$RUNTIME_ENV_FILE
174
204
  $HB_CREDENTIAL_LINES
175
- ExecStart=$CREDENTIAL_LAUNCHER heartbeat
205
+ ExecStart=$HB_EXEC_START heartbeat
176
206
  TimeoutStartSec=45min
177
- StandardOutput=append:$RUNTIME/logs/heartbeat.log
178
- StandardError=append:$RUNTIME/logs/heartbeat.log
207
+ StandardOutput=$HB_LOG_OUTPUT
208
+ StandardError=$HB_LOG_OUTPUT
179
209
  UNIT
180
210
  cat > "$SYS_DIR/$HB_TIMER" <<UNIT
181
211
  [Unit]
182
- Description=Heartbeat (reconcile + checkpoint) for $AGENT_ID
212
+ Description=$TIMER_DESCRIPTION
183
213
 
184
214
  [Timer]
185
215
  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
@@ -144,12 +144,50 @@ clear_done() {
144
144
  rm -f -- "$ROLE_DIR/.scripts/.done-$1"
145
145
  }
146
146
 
147
+ # The parser protocol, unsafe-family scrub, complete framing validation, and
148
+ # atomic environment apply live in one reusable library shared by provisioners,
149
+ # launchers, and maintenance commands.
150
+ FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
151
+ FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
152
+ if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" ]]; then
153
+ builtin printf 'fleet environment loader rejected: trusted library is unavailable\n' >&2
154
+ return 1
155
+ fi
156
+ # shellcheck source=lib/fleet-env.sh
157
+ builtin source "$FLEET_ENV_LIBRARY"
158
+
147
159
  # Fleet source-of-truth (shared across all wrappers/provisioners).
148
160
  # Every default below resolves as: env var > fleet.env > config.toml > fallback.
161
+ # Invocation authority is not fleet configuration. Capture the caller's board
162
+ # gate before importing fleet.env so that file cannot weaken an MCP/CLI
163
+ # SKIP_PLANE decision or re-enable credentials by assigning SKIP_PLANE=0.
164
+ PJANGLER_INVOCATION_SKIP_PLANE="${SKIP_PLANE:-0}"
149
165
  FLEET_ENV="${HERMES_FLEET_ENV:-$(config_get fleet.fleet_env "$HOME/.hermes/fleet.env")}"
150
- if [[ -f "$FLEET_ENV" ]]; then
151
- # shellcheck disable=SC1090
152
- source "$FLEET_ENV"
166
+
167
+ # A direct CLI caller may not have passed through the MCP parent boundary. The
168
+ # shared loader hardens both before parser startup and again after atomic import.
169
+ if ! load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"; then
170
+ return 1
171
+ fi
172
+ SKIP_PLANE="$PJANGLER_INVOCATION_SKIP_PLANE"
173
+ unset PJANGLER_INVOCATION_SKIP_PLANE
174
+ scrub_subprocess_interpreter_injection
175
+
176
+ # fleet.env is allowed to supply provider credentials only for an explicitly
177
+ # board-authorized invocation. A no-board/deferred phase must remove every
178
+ # supported provider alias after sourcing and before any Python, Hermes,
179
+ # systemd, provider, or other child process can inherit it.
180
+ scrub_ticket_provider_authority() {
181
+ local key
182
+ unset PLANE_API_KEY TRELLO_KEY TRELLO_TOKEN LINEAR_API_KEY
183
+ while IFS= read -r key; do
184
+ case "$key" in
185
+ PLANE_*_API_KEY) unset "$key" ;;
186
+ esac
187
+ done < <(compgen -A variable PLANE_)
188
+ }
189
+ if [[ "$SKIP_PLANE" == "1" ]]; then
190
+ scrub_ticket_provider_authority
153
191
  fi
154
192
  # Identity-bearing chat credentials are never fleet-scoped. Platform wiring
155
193
  # steps capture explicit invocation values before sourcing this library and
@@ -190,7 +228,7 @@ fleet_lock_acquire() {
190
228
  fleet_lock_release() {
191
229
  if [[ "${FLEET_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then
192
230
  flock -u "$FLEET_LOCK_FD" 2>/dev/null || true
193
- eval "exec ${FLEET_LOCK_FD}>&-"
231
+ exec {FLEET_LOCK_FD}>&-
194
232
  fi
195
233
  FLEET_LOCK_FD=""
196
234
  }
@@ -202,13 +240,18 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
202
240
 
203
241
  # Plane
204
242
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
205
- PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
243
+ if [[ "$SKIP_PLANE" != "1" ]]; then
244
+ PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
245
+ fi
206
246
 
207
247
  export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO PJANGLER_BIN HERMES_RUNTIME_GIT_URL \
208
248
  HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
209
249
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
210
250
  BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
211
- PLANE_BASE PLANE_API_KEY
251
+ PLANE_BASE SKIP_PLANE
252
+ if [[ "$SKIP_PLANE" != "1" ]]; then
253
+ export PLANE_API_KEY
254
+ fi
212
255
 
213
256
  # systemd --user health check. Accept running/degraded/starting — only one
214
257
  # broken unit shouldn't disqualify the rest of the user manager.
@@ -255,6 +298,19 @@ systemctl_user_unit_state() {
255
298
  # Resolve project repo path (the repo that holds agents/hermes/<role>/).
256
299
  # Walk up from $ROLE_DIR until we find a git root that isn't us.
257
300
  project_repo_path() {
301
+ # Structured provisioners know the project root even before a fresh target
302
+ # receives its own .git directory. Accept only a root that contains this
303
+ # exact role path; otherwise fail closed instead of walking into a parent
304
+ # checkout and mutating its manifest.
305
+ if [[ -n "${PJANGLER_PROJECT_ROOT:-}" ]]; then
306
+ local explicit role_real
307
+ explicit="$(cd "$PJANGLER_PROJECT_ROOT" 2>/dev/null && pwd -P)" || return 1
308
+ role_real="$(cd "$ROLE_DIR" 2>/dev/null && pwd -P)" || return 1
309
+ case "$role_real" in
310
+ "$explicit"/agents/hermes/*) printf '%s\n' "$explicit"; return 0 ;;
311
+ *) return 1 ;;
312
+ esac
313
+ fi
258
314
  local d="$ROLE_DIR"
259
315
  [[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
260
316
  for _ in 1 2 3 4 5; do
@@ -38,6 +38,23 @@ secret_scan_ok() {
38
38
  return 0
39
39
  }
40
40
 
41
+ # A checkpoint on a DETACHED HEAD belongs to no branch: `git push origin HEAD`
42
+ # cannot resolve a destination, so every commit stays local forever. One runtime
43
+ # reached 232 orphaned commits this way before anyone noticed.
44
+ BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo HEAD)"
45
+ if [ "$BRANCH" = "HEAD" ]; then
46
+ printf '[checkpoint] ABORT: %s is on a DETACHED HEAD — refusing to create commits that can never be pushed.\n' "$RUNTIME_DIR" >&2
47
+ printf '[checkpoint] Fix: git -C %s switch -c main && git -C %s push -u origin main\n' "$RUNTIME_DIR" "$RUNTIME_DIR" >&2
48
+ exit 4
49
+ fi
50
+
51
+ # Report an existing backlog BEFORE adding to it, so a broken push is visible on
52
+ # the very next tick instead of compounding silently for months.
53
+ backlog="$(git rev-list --count "origin/$BRANCH..HEAD" 2>/dev/null || echo 0)"
54
+ if [ "$backlog" -ge 10 ]; then
55
+ printf '[checkpoint] WARNING: %s already has %s UNPUSHED commit(s) on %s.\n' "$RUNTIME_DIR" "$backlog" "$BRANCH" >&2
56
+ fi
57
+
41
58
  git add -A
42
59
  if git diff --cached --quiet; then
43
60
  exit 0
@@ -51,4 +68,15 @@ if ! secret_scan_ok; then
51
68
  fi
52
69
 
53
70
  git -c commit.gpgsign=false commit -m "checkpoint $(date -Iseconds)" >/dev/null
54
- git push origin HEAD 2>&1 | tail -1 || true
71
+
72
+ # The push MUST be observable. This line used to be
73
+ # git push origin HEAD 2>&1 | tail -1 || true
74
+ # where `|| true` swallowed every failure — diverged branch, auth, network —
75
+ # and the pipe masked git's exit status on top of it. Run hourly, that turns a
76
+ # broken push into an invisible, unbounded pile of local-only commits.
77
+ if ! git push origin "$BRANCH" >/dev/null 2>&1; then
78
+ ahead="$(git rev-list --count "origin/$BRANCH..HEAD" 2>/dev/null || echo '?')"
79
+ printf '[checkpoint] PUSH FAILED for %s (%s commit(s) exist ONLY on this disk).\n' "$RUNTIME_DIR" "$ahead" >&2
80
+ printf '[checkpoint] Diagnose: git -C %s push origin %s\n' "$RUNTIME_DIR" "$BRANCH" >&2
81
+ exit 5
82
+ fi
@@ -20,12 +20,24 @@ PROMPT_FILE="$ROLE_DIR/.scripts/sentinel.prompt.md"
20
20
  STATE_FILE="$RUNTIME/continuous-ticket-sentinel-state.json"
21
21
  LOCK_FILE="$RUNTIME/continuous-ticket-sentinel.lock"
22
22
  ROLE_YAML="$ROLE_DIR/role.yaml"
23
+ FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
24
+ FLEET_ENV_LIBRARY="$ROLE_DIR/.scripts/lib/fleet-env.sh"
25
+ FLEET_ENV_PARSER="$ROLE_DIR/.scripts/lib/parse-fleet-env.py"
23
26
  LOG_FILE="$RUNTIME/logs/heartbeat.log"
24
27
  CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
25
28
  CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
26
29
 
30
+ if [[ ! -f "$FLEET_ENV_LIBRARY" || -L "$FLEET_ENV_LIBRARY" \
31
+ || ! -f "$FLEET_ENV_PARSER" || -L "$FLEET_ENV_PARSER" ]]; then
32
+ echo "heartbeat: trusted fleet environment loader unavailable" >&2
33
+ exit 1
34
+ fi
35
+ # shellcheck source=lib/fleet-env.sh
36
+ builtin source "$FLEET_ENV_LIBRARY"
37
+ load_fleet_environment "$FLEET_ENV" "$FLEET_ENV_PARSER"
38
+
27
39
  # Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
28
- HERMES_BIN="${HERMES_BIN:-}"
40
+ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
29
41
  if [[ -z "$HERMES_BIN" ]]; then
30
42
  if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
31
43
  HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"