@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.
- package/README.md +72 -0
- package/dist/index.js +4510 -2485
- package/dist/mcp-server.js +4166 -1959
- package/dist/prompt.js +404 -0
- package/package.json +7 -5
- package/templates/commonproject/copier.yml +6 -1
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +14 -50
- package/templates/hermes-agent/copier.yml +8 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +44 -0
- package/templates/hermes-agent/template/.scripts/01-config.sh +9 -0
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +18 -28
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +68 -4
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +18 -1
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +73 -43
- package/templates/hermes-agent/template/.scripts/80-registry.sh +6 -0
- package/templates/hermes-agent/template/.scripts/_lib.sh +62 -6
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +29 -1
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +13 -1
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +202 -0
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +734 -0
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +126 -0
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +1 -1
- package/templates/hermes-agent/template/SOUL.md.jinja +44 -8
- package/templates/hermes-agent/template/hermes.jinja +20 -8
- package/templates/hermes-agent/template/momo.jinja +177 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
112
|
-
#
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
EnvironmentFile
|
|
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=$
|
|
172
|
+
ExecStart=$GW_EXEC_START gateway
|
|
143
173
|
Restart=on-failure
|
|
144
174
|
RestartSec=10
|
|
145
|
-
StandardOutput
|
|
146
|
-
StandardError
|
|
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
|
|
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=$
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
203
|
+
EnvironmentFile=$RUNTIME_ENV_FILE
|
|
174
204
|
$HB_CREDENTIAL_LINES
|
|
175
|
-
ExecStart=$
|
|
205
|
+
ExecStart=$HB_EXEC_START heartbeat
|
|
176
206
|
TimeoutStartSec=45min
|
|
177
|
-
StandardOutput
|
|
178
|
-
StandardError
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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")"
|