@delorenj/pjangler 1.2.33 → 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 (38) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +4754 -2564
  3. package/dist/mcp-server.js +4516 -2141
  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/.agents/hooks/README.md +10 -10
  8. package/templates/commonproject/template/.agents/hooks/hooks.master.json +1 -1
  9. package/templates/commonproject/template/.agents/hooks/sync.py +4 -4
  10. package/templates/commonproject/template/.agents/local.example.json +1 -1
  11. package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +1 -1
  12. package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
  13. package/templates/commonproject/template/mise.toml.jinja +11 -11
  14. package/templates/hermes-agent/copier.yml +34 -14
  15. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
  16. package/templates/hermes-agent/template/.runtime-scaffold/README.md +11 -10
  17. package/templates/hermes-agent/template/.scripts/01-config.sh +13 -4
  18. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
  19. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +73 -14
  20. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +37 -32
  21. package/templates/hermes-agent/template/.scripts/30-telegram.sh +18 -4
  22. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
  23. package/templates/hermes-agent/template/.scripts/70-systemd.sh +114 -24
  24. package/templates/hermes-agent/template/.scripts/80-registry.sh +19 -3
  25. package/templates/hermes-agent/template/.scripts/_lib.sh +74 -11
  26. package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
  27. package/templates/hermes-agent/template/.scripts/config.example.toml +7 -4
  28. package/templates/hermes-agent/template/.scripts/credential-launch.sh +81 -0
  29. package/templates/hermes-agent/template/.scripts/heartbeat.sh +15 -2
  30. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
  31. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
  32. package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
  33. package/templates/hermes-agent/template/.scripts/providers/plane.sh +31 -5
  34. package/templates/hermes-agent/template/.scripts/secret-scan.py +82 -16
  35. package/templates/hermes-agent/template/SOUL.md.jinja +48 -12
  36. package/templates/hermes-agent/template/hermes.jinja +51 -10
  37. package/templates/hermes-agent/template/momo.jinja +177 -0
  38. package/templates/hermes-agent/template/role.yaml.jinja +31 -21
@@ -46,15 +46,28 @@ path.write_text(text[: match.start("body")] + body + text[match.end("body") :],
46
46
  PYEOF
47
47
  }
48
48
 
49
- already_done 30-telegram && { log "[30] telegram already wired — skipping"; exit 0; }
49
+ telegram_status="$(yaml_get telegram.provisioning_status)"
50
50
 
51
51
  if [[ "${SKIP_TELEGRAM:-0}" == "1" ]]; then
52
- telegram_yaml_update provisioning_status disabled
53
- log "[30] telegram — SKIPPED (SKIP_TELEGRAM=1)"
54
- mark_done 30-telegram
52
+ if [[ "$telegram_status" == "verified" ]] && already_done 30-telegram; then
53
+ log "[30] telegram — SKIPPED; existing verified wiring preserved"
54
+ else
55
+ telegram_yaml_update provisioning_status disabled
56
+ clear_done 30-telegram
57
+ log "[30] telegram — DEFERRED (SKIP_TELEGRAM=1; completion marker cleared)"
58
+ fi
55
59
  exit 0
56
60
  fi
57
61
 
62
+ if already_done 30-telegram; then
63
+ if [[ "$telegram_status" == "verified" ]]; then
64
+ log "[30] telegram already wired — skipping"
65
+ exit 0
66
+ fi
67
+ clear_done 30-telegram
68
+ log "[30] stale deferred completion marker cleared — reconciling Telegram"
69
+ fi
70
+
58
71
  RUNTIME="$ROLE_DIR/runtime"
59
72
  ENVF="$RUNTIME/.env"
60
73
  mkdir -p "$RUNTIME"
@@ -81,6 +94,7 @@ fi
81
94
 
82
95
  if [[ -z "${TELEGRAM_BOT_TOKEN:-}" ]]; then
83
96
  telegram_yaml_update provisioning_status deferred
97
+ clear_done 30-telegram
84
98
  warn " no token provided; Telegram step deferred"
85
99
  warn " re-run later with a profile-dedicated TELEGRAM_BOT_TOKEN invocation"
86
100
  exit 0
@@ -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)"
@@ -12,9 +12,88 @@ if [[ "$ROLE" == "reporter" ]]; then
12
12
  exit 0
13
13
  fi
14
14
 
15
+ # Resolve expected paths without creating directories or querying the user
16
+ # manager. A legacy skip-created marker is complete only when all units exist.
15
17
  RUNTIME="$ROLE_DIR/runtime"
18
+ FLEET_HOME="${HERMES_FLEET_HOME:-$HOME/.hermes}"
19
+ PROFILE_HOME="$FLEET_HOME/profiles/${PROFILE_NAME:-$AGENT_ID}"
16
20
  REPO_ROOT="$(project_repo_path)" || REPO_ROOT="$ROLE_DIR"
17
21
  SYS_DIR="$HOME/.config/systemd/user"
22
+ GW_UNIT="hermes-${AGENT_ID}-gateway.service"
23
+ HB_SVC="hermes-${AGENT_ID}-heartbeat.service"
24
+ HB_TIMER="hermes-${AGENT_ID}-heartbeat.timer"
25
+
26
+ # Local-only provisioning must not inspect or mutate the host user manager.
27
+ # Honor the explicit skip before creating unit directories or querying legacy
28
+ # unit state; cleanup remains fail-closed whenever systemd management is active.
29
+ if [[ "${SKIP_SYSTEMD:-0}" == "1" ]]; then
30
+ if already_done 70-systemd \
31
+ && [[ -f "$SYS_DIR/$GW_UNIT" && -f "$SYS_DIR/$HB_SVC" && -f "$SYS_DIR/$HB_TIMER" ]]; then
32
+ log "[70] systemd — SKIPPED; existing complete unit set preserved"
33
+ else
34
+ clear_done 70-systemd
35
+ log "[70] systemd — DEFERRED (completion marker cleared)"
36
+ fi
37
+ exit 0
38
+ fi
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
+
93
+ # Singleton-runtime contract: units set HERMES_HOME to the agent's NAMED PROFILE
94
+ # dir, never the raw runtime path — Hermes derives profile identity and shared
95
+ # fleet auth from the unresolved HERMES_HOME. $RUNTIME stays correct for
96
+ # EnvironmentFile and logs: those are the repo-owned side the profile links to.
18
97
  mkdir -p "$SYS_DIR" "$RUNTIME/logs"
19
98
 
20
99
  # Upgrade remediation must run before honoring a legacy done marker. Older
@@ -56,34 +135,45 @@ if [[ $legacy_consumer_present -eq 1 ]]; then
56
135
  log " retired legacy per-profile Bloodbank consumer: $LEGACY_CONSUMER_UNIT"
57
136
  fi
58
137
 
59
- already_done 70-systemd && { log "[70] systemd already installed — legacy cleanup checked"; exit 0; }
60
- [[ "${SKIP_SYSTEMD:-0}" == "1" ]] && { log "[70] systemd — SKIPPED"; mark_done 70-systemd; exit 0; }
138
+ if already_done 70-systemd; then
139
+ if [[ -f "$SYS_DIR/$GW_UNIT" && -f "$SYS_DIR/$HB_SVC" && -f "$SYS_DIR/$HB_TIMER" ]]; then
140
+ log "[70] systemd already installed — reconciling unit definitions"
141
+ else
142
+ clear_done 70-systemd
143
+ log " stale/incomplete systemd marker cleared — reconciling required units"
144
+ fi
145
+ fi
61
146
 
62
147
  # The heartbeat runner (board-reconciliation sentinel pass + gated checkpoint)
63
148
  # and the checkpoint helper both render into the role dir; just ensure they are
64
149
  # executable. heartbeat.sh calls checkpoint.sh internally.
65
150
  HEARTBEAT_BIN="$ROLE_DIR/.scripts/heartbeat.sh"
66
- chmod +x "$HEARTBEAT_BIN" "$ROLE_DIR/.scripts/checkpoint.sh" 2>/dev/null || true
151
+ CREDENTIAL_LAUNCHER="$ROLE_DIR/.scripts/credential-launch.sh"
152
+ chmod +x "$HEARTBEAT_BIN" "$CREDENTIAL_LAUNCHER" "$ROLE_DIR/.scripts/checkpoint.sh" 2>/dev/null || true
153
+
154
+ [[ -d "$PROFILE_HOME" && ! -L "$PROFILE_HOME" ]] \
155
+ || die "named profile is not a real directory; run: pj migrate hermes.runtime-singleton '$REPO_ROOT'"
67
156
 
68
157
  # Gateway unit
69
- GW_UNIT="hermes-${AGENT_ID}-gateway.service"
70
158
  cat > "$SYS_DIR/$GW_UNIT" <<UNIT
71
159
  [Unit]
72
- Description=Hermes Gateway — $DISPLAY_NAME
160
+ Description=$GW_DESCRIPTION
73
161
  After=network-online.target
74
162
  Wants=network-online.target
75
163
 
76
164
  [Service]
77
165
  Type=simple
78
- Environment=HERMES_HOME=$RUNTIME
79
- Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
80
- Environment=CODEX_HOME=$CODEX_HOME
81
- EnvironmentFile=-$RUNTIME/.env
82
- ExecStart=$HERMES_BIN gateway run --replace
166
+ $ENV_HERMES_HOME
167
+ $ENV_HERMES_BIN
168
+ $ENV_CODEX_HOME
169
+ $ENV_TERMINAL_CWD
170
+ EnvironmentFile=$RUNTIME_ENV_FILE
171
+ $GW_CREDENTIAL_LINES
172
+ ExecStart=$GW_EXEC_START gateway
83
173
  Restart=on-failure
84
174
  RestartSec=10
85
- StandardOutput=append:$RUNTIME/logs/gateway.systemd.log
86
- StandardError=append:$RUNTIME/logs/gateway.systemd.log
175
+ StandardOutput=$GW_LOG_OUTPUT
176
+ StandardError=$GW_LOG_OUTPUT
87
177
 
88
178
  [Install]
89
179
  WantedBy=default.target
@@ -93,33 +183,33 @@ UNIT
93
183
  # Frequent ticks (1 min); heartbeat.sh's own cooldown/lock logic rate-limits the
94
184
  # full Hermes pass, and the checkpoint is gated to ~hourly inside the runner.
95
185
  # The per-agent EnvironmentFiles load ticket-provider creds for the sentinel pass.
96
- HB_SVC="hermes-${AGENT_ID}-heartbeat.service"
97
- HB_TIMER="hermes-${AGENT_ID}-heartbeat.timer"
98
186
  cat > "$SYS_DIR/$HB_SVC" <<UNIT
99
187
  [Unit]
100
- Description=Hermes Heartbeat (reconcile + checkpoint) — $DISPLAY_NAME
188
+ Description=$HB_DESCRIPTION
101
189
  After=network-online.target
102
190
  Wants=network-online.target
103
191
 
104
192
  [Service]
105
193
  Type=oneshot
106
- WorkingDirectory=$REPO_ROOT
107
- Environment=HERMES_HOME=$RUNTIME
108
- Environment=HERMES_OAUTH_FILE=$HERMES_OAUTH_FILE
109
- Environment=CODEX_HOME=$CODEX_HOME
194
+ WorkingDirectory=$WORKING_DIRECTORY
195
+ $ENV_HERMES_HOME
196
+ $ENV_HERMES_BIN
197
+ $ENV_CODEX_HOME
198
+ $ENV_TERMINAL_CWD
110
199
  EnvironmentFile=-%h/.config/hermes-agent/env
111
200
  EnvironmentFile=-%h/.hermes/env
112
201
  EnvironmentFile=-%h/.hermes/hermes-agent.env
113
202
  EnvironmentFile=-%h/.hermes/${AGENT_ID}.env
114
- EnvironmentFile=-$RUNTIME/.env
115
- ExecStart=$HEARTBEAT_BIN
203
+ EnvironmentFile=$RUNTIME_ENV_FILE
204
+ $HB_CREDENTIAL_LINES
205
+ ExecStart=$HB_EXEC_START heartbeat
116
206
  TimeoutStartSec=45min
117
- StandardOutput=append:$RUNTIME/logs/heartbeat.log
118
- StandardError=append:$RUNTIME/logs/heartbeat.log
207
+ StandardOutput=$HB_LOG_OUTPUT
208
+ StandardError=$HB_LOG_OUTPUT
119
209
  UNIT
120
210
  cat > "$SYS_DIR/$HB_TIMER" <<UNIT
121
211
  [Unit]
122
- Description=Heartbeat (reconcile + checkpoint) for $AGENT_ID
212
+ Description=$TIMER_DESCRIPTION
123
213
 
124
214
  [Timer]
125
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
@@ -29,7 +35,8 @@ python3 - "$REGISTRY_FILE" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" \
29
35
  "$(yaml_get slack.provisioning_status)" "$(yaml_get slack.team_id)" \
30
36
  "$(yaml_get slack.team_name)" "$(yaml_get slack.bot_user_id)" \
31
37
  "$(yaml_get slack.bot_id)" "$(yaml_get slack.bot_username)" \
32
- "$(yaml_get bloodbank.gateway_scope)" "$(yaml_get bloodbank.target_agent_id)" \
38
+ "$(yaml_get bloodbank.enabled)" "$(yaml_get bloodbank.gateway_scope)" \
39
+ "$(yaml_get bloodbank.target_agent_id)" \
33
40
  "$PLANE_WORKSPACE" "$PLANE_PROJECT_ID" "$(yaml_get plane.identifier)" \
34
41
  "$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$HERMES_RUNTIME_GIT_URL" \
35
42
  "$HERMES_RUNTIME_GIT_REF" "$HERMES_RUNTIME_GIT_SHA" "$FLEET_ENV" \
@@ -47,13 +54,21 @@ except ImportError:
47
54
  (path, agent_id, repo, role, display, project, role_dir, profile,
48
55
  telegram_status, bot, telegram_bot_id,
49
56
  slack_status, slack_team_id, slack_team_name, slack_user_id, slack_bot_id,
50
- slack_username, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
57
+ slack_username, bloodbank_enabled, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
51
58
  plane_ident, runtime_repo, hermes_bin, hermes_repo, hermes_git_url,
52
- hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat) = sys.argv[1:32]
59
+ hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat) = sys.argv[1:33]
53
60
  p = pathlib.Path(path)
54
61
  if p.is_symlink():
55
62
  raise SystemExit(f"refusing to update registry symlink: {p}")
56
63
  data = yaml.safe_load(p.read_text()) or {"schema_version": 1, "agents": {}}
64
+ if bloodbank_enabled == "":
65
+ bloodbank_enabled_value = False
66
+ elif bloodbank_enabled == "true":
67
+ bloodbank_enabled_value = True
68
+ elif bloodbank_enabled == "false":
69
+ bloodbank_enabled_value = False
70
+ else:
71
+ raise SystemExit("bloodbank.enabled must be the strict YAML boolean true or false")
57
72
  data.setdefault("agents", {})[agent_id] = {
58
73
  "repo": repo, "role": role, "display_name": display,
59
74
  "project_path": project, "role_dir": role_dir,
@@ -72,6 +87,7 @@ data.setdefault("agents", {})[agent_id] = {
72
87
  "bot_username": slack_username,
73
88
  },
74
89
  "bloodbank": {
90
+ "enabled": bloodbank_enabled_value,
75
91
  "gateway_scope": bloodbank_scope,
76
92
  "target_agent_id": bloodbank_target,
77
93
  },
@@ -137,13 +137,57 @@ already_done() {
137
137
  mark_done() {
138
138
  touch "$ROLE_DIR/.scripts/.done-$1"
139
139
  }
140
+ clear_done() {
141
+ # Remove only the marker for the explicitly deferred step. This lets a
142
+ # later activation reconcile that phase without disturbing completed,
143
+ # unrelated provisioning state.
144
+ rm -f -- "$ROLE_DIR/.scripts/.done-$1"
145
+ }
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"
140
158
 
141
159
  # Fleet source-of-truth (shared across all wrappers/provisioners).
142
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}"
143
165
  FLEET_ENV="${HERMES_FLEET_ENV:-$(config_get fleet.fleet_env "$HOME/.hermes/fleet.env")}"
144
- if [[ -f "$FLEET_ENV" ]]; then
145
- # shellcheck disable=SC1090
146
- 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
147
191
  fi
148
192
  # Identity-bearing chat credentials are never fleet-scoped. Platform wiring
149
193
  # steps capture explicit invocation values before sourcing this library and
@@ -151,11 +195,12 @@ fi
151
195
  unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
152
196
 
153
197
  # Tools we expect on the host
154
- HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-$(config_get fleet.hermes_bin "$HOME/.hermes/hermes-agent/.venv/bin/hermes")}}"
155
- HERMES_AGENT_REPO="${HERMES_AGENT_REPO:-${HERMES_FLEET_REPO:-$(config_get fleet.hermes_repo "$HOME/.hermes/hermes-agent")}}"
198
+ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-$(config_get fleet.hermes_bin "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes")}}"
199
+ HERMES_AGENT_REPO="${HERMES_AGENT_REPO:-${HERMES_FLEET_REPO:-$(config_get fleet.hermes_repo "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1")}}"
200
+ PJANGLER_BIN="${PJANGLER_BIN:-$(config_get fleet.pjangler_bin "pj")}"
156
201
  HERMES_RUNTIME_GIT_URL="${HERMES_RUNTIME_GIT_URL:-$(config_get fleet.hermes_git_url 'https://github.com/delorenj/hermes-agent.git')}"
157
- HERMES_RUNTIME_GIT_REF="${HERMES_RUNTIME_GIT_REF:-$(config_get fleet.hermes_git_ref 'feature/PJAN-19-routing-publication')}"
158
- HERMES_RUNTIME_GIT_SHA="${HERMES_RUNTIME_GIT_SHA:-$(config_get fleet.hermes_git_sha '113e1b182b6d72a7dd02a191f134a41668ceaf0e')}"
202
+ HERMES_RUNTIME_GIT_REF="${HERMES_RUNTIME_GIT_REF:-$(config_get fleet.hermes_git_ref 'main')}"
203
+ HERMES_RUNTIME_GIT_SHA="${HERMES_RUNTIME_GIT_SHA:-$(config_get fleet.hermes_git_sha '0408fec7a153e6c32c064acd2b8053917f1525f1')}"
159
204
  HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
160
205
  CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
161
206
  # Prefer a scaffold vendored into this agent directory; fall back to the configured template path.
@@ -183,7 +228,7 @@ fleet_lock_acquire() {
183
228
  fleet_lock_release() {
184
229
  if [[ "${FLEET_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then
185
230
  flock -u "$FLEET_LOCK_FD" 2>/dev/null || true
186
- eval "exec ${FLEET_LOCK_FD}>&-"
231
+ exec {FLEET_LOCK_FD}>&-
187
232
  fi
188
233
  FLEET_LOCK_FD=""
189
234
  }
@@ -195,13 +240,18 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
195
240
 
196
241
  # Plane
197
242
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
198
- 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
199
246
 
200
- export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO HERMES_RUNTIME_GIT_URL \
247
+ export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO PJANGLER_BIN HERMES_RUNTIME_GIT_URL \
201
248
  HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
202
249
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
203
250
  BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
204
- PLANE_BASE PLANE_API_KEY
251
+ PLANE_BASE SKIP_PLANE
252
+ if [[ "$SKIP_PLANE" != "1" ]]; then
253
+ export PLANE_API_KEY
254
+ fi
205
255
 
206
256
  # systemd --user health check. Accept running/degraded/starting — only one
207
257
  # broken unit shouldn't disqualify the rest of the user manager.
@@ -248,6 +298,19 @@ systemctl_user_unit_state() {
248
298
  # Resolve project repo path (the repo that holds agents/hermes/<role>/).
249
299
  # Walk up from $ROLE_DIR until we find a git root that isn't us.
250
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
251
314
  local d="$ROLE_DIR"
252
315
  [[ -d "$d/.git" || -f "$d/.git" ]] && { echo "$d"; return 0; }
253
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
@@ -12,14 +12,17 @@
12
12
 
13
13
  [fleet]
14
14
  # Absolute path to the shared Hermes executable every agent launcher execs.
15
- hermes_bin = "~/.hermes/hermes-agent/.venv/bin/hermes"
15
+ hermes_bin = "~/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes"
16
16
  # Checkout of the shared hermes-agent repo.
17
- hermes_repo = "~/.hermes/hermes-agent"
17
+ hermes_repo = "~/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1"
18
+ # Canonical CommonProject migration CLI. Runtime provisioning delegates named
19
+ # profile topology to this command; it never rewrites profile links itself.
20
+ pjangler_bin = "pj"
18
21
  # Reviewed fleet runtime publication. Provisioning metadata and clean installs
19
22
  # must agree on this fork/ref/SHA; do not substitute upstream main.
20
23
  hermes_git_url = "https://github.com/delorenj/hermes-agent.git"
21
- hermes_git_ref = "feature/PJAN-19-routing-publication"
22
- hermes_git_sha = "113e1b182b6d72a7dd02a191f134a41668ceaf0e"
24
+ hermes_git_ref = "main"
25
+ hermes_git_sha = "0408fec7a153e6c32c064acd2b8053917f1525f1"
23
26
  # Fallback runtime scaffold dir — used only when an agent's vendored
24
27
  # ./.runtime-scaffold is missing.
25
28
  runtime_scaffold_dir = "~/code/hermes-agent-template/runtime-scaffold"
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env bash
2
+ # Secret-free systemd entrypoint. Decrypted values are read only from the
3
+ # service manager's volatile credential directory and never printed or written.
4
+ set -euo pipefail
5
+
6
+ ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)"
7
+ ROLE_YAML="$ROLE_DIR/role.yaml"
8
+
9
+ yaml_get() {
10
+ python3 - "$ROLE_YAML" "$1" <<'PYEOF'
11
+ import re, sys
12
+ from pathlib import Path
13
+ text = Path(sys.argv[1]).read_text()
14
+ parts = sys.argv[2].split(".")
15
+ if len(parts) == 2:
16
+ match = re.search(
17
+ rf"(?m)^{re.escape(parts[0])}:[ \t]*\n((?:[ \t]+\S.*\n?)*)", text
18
+ )
19
+ text = match.group(1) if match else ""
20
+ key = parts[-1]
21
+ match = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', text)
22
+ print(match.group(1).strip() if match else "")
23
+ PYEOF
24
+ }
25
+
26
+ AGENT_ID="$(yaml_get agent_id)"
27
+ PROFILE_NAME="$(yaml_get profile)"
28
+ [[ -n "$AGENT_ID" && -n "$PROFILE_NAME" ]] \
29
+ || { printf 'credential-launch: incomplete role identity\n' >&2; exit 2; }
30
+ [[ -n "${HERMES_BIN:-}" && -x "$HERMES_BIN" ]] \
31
+ || { printf 'credential-launch: HERMES_BIN is not executable\n' >&2; exit 1; }
32
+
33
+ mode="${1:-}"
34
+ [[ "$mode" == "gateway" || "$mode" == "heartbeat" ]] \
35
+ || { printf 'credential-launch: expected gateway or heartbeat\n' >&2; exit 2; }
36
+
37
+ FLEET_HOME="${HERMES_FLEET_HOME:-$HOME/.hermes}"
38
+ PROFILE_HOME="$FLEET_HOME/profiles/${PROFILE_NAME:-$AGENT_ID}"
39
+ [[ -d "$PROFILE_HOME" && ! -L "$PROFILE_HOME" ]] \
40
+ || { printf 'credential-launch: named profile is not a real directory\n' >&2; exit 1; }
41
+ export HERMES_HOME="$PROFILE_HOME"
42
+
43
+ if ! REPO_ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null)"; then
44
+ REPO_ROOT="$ROLE_DIR"
45
+ fi
46
+ export TERMINAL_CWD="$REPO_ROOT"
47
+
48
+ load_credential() {
49
+ local credential_id="$1" env_name="$2" credential_file value
50
+ [[ "$env_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
51
+ || { printf 'credential-launch: invalid credential environment name\n' >&2; exit 2; }
52
+ credential_file="${CREDENTIALS_DIRECTORY:-}/$credential_id"
53
+ [[ -n "${CREDENTIALS_DIRECTORY:-}" && -r "$credential_file" ]] || return 0
54
+ value="$(<"$credential_file")"
55
+ printf -v "$env_name" '%s' "$value"
56
+ export "$env_name"
57
+ unset value
58
+ }
59
+
60
+ load_credential telegram_bot_token TELEGRAM_BOT_TOKEN
61
+ MODEL_KEY_ENV="$(yaml_get model.key_env)"
62
+ if [[ -n "$MODEL_KEY_ENV" ]]; then
63
+ load_credential model_api_key "$MODEL_KEY_ENV"
64
+ fi
65
+
66
+ if [[ "$mode" == "heartbeat" ]]; then
67
+ exec "$ROLE_DIR/.scripts/heartbeat.sh"
68
+ fi
69
+
70
+ gateway_args=(gateway run --replace)
71
+ MODEL_NAME="$(yaml_get model.name)"
72
+ MODEL_PROVIDER="$(yaml_get model.provider)"
73
+ MODEL_BASE_URL="$(yaml_get model.base_url)"
74
+ MODEL_API_MODE="$(yaml_get model.api_mode)"
75
+ [[ -z "$MODEL_NAME" ]] || gateway_args+=(--model "$MODEL_NAME")
76
+ [[ -z "$MODEL_PROVIDER" ]] || gateway_args+=(--provider "$MODEL_PROVIDER")
77
+ [[ -z "$MODEL_BASE_URL" ]] || gateway_args+=(--base-url "$MODEL_BASE_URL")
78
+ [[ -z "$MODEL_API_MODE" ]] || gateway_args+=(--api-mode "$MODEL_API_MODE")
79
+ [[ -z "$MODEL_KEY_ENV" ]] || gateway_args+=(--key-env "$MODEL_KEY_ENV")
80
+
81
+ exec "$HERMES_BIN" "${gateway_args[@]}"
@@ -15,16 +15,29 @@ set -euo pipefail
15
15
 
16
16
  ROLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # agents/hermes/<role>
17
17
  RUNTIME="$ROLE_DIR/runtime"
18
+ RUN_HOME="${HERMES_HOME:-$RUNTIME}"
18
19
  PROMPT_FILE="$ROLE_DIR/.scripts/sentinel.prompt.md"
19
20
  STATE_FILE="$RUNTIME/continuous-ticket-sentinel-state.json"
20
21
  LOCK_FILE="$RUNTIME/continuous-ticket-sentinel.lock"
21
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"
22
26
  LOG_FILE="$RUNTIME/logs/heartbeat.log"
23
27
  CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
24
28
  CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
25
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
+
26
39
  # Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
27
- HERMES_BIN="${HERMES_BIN:-}"
40
+ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
28
41
  if [[ -z "$HERMES_BIN" ]]; then
29
42
  if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
30
43
  HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"
@@ -242,7 +255,7 @@ trap 'python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" release "$WIP_LOCK" "hermes:
242
255
 
243
256
  prompt="$(<"$PROMPT_FILE")"
244
257
  set +e
245
- env HERMES_HOME="$RUNTIME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
258
+ env HERMES_HOME="$RUN_HOME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
246
259
  status=$?
247
260
  set -e
248
261