@delorenj/pjangler 1.2.33 → 1.3.0

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/dist/index.js +295 -130
  2. package/dist/mcp-server.js +297 -129
  3. package/package.json +1 -1
  4. package/templates/commonproject/template/.agents/hooks/README.md +10 -10
  5. package/templates/commonproject/template/.agents/hooks/hooks.master.json +1 -1
  6. package/templates/commonproject/template/.agents/hooks/sync.py +4 -4
  7. package/templates/commonproject/template/.agents/local.example.json +1 -1
  8. package/templates/commonproject/template/.mise/scripts/hindsight-setup.sh +1 -1
  9. package/templates/commonproject/template/mise.toml.jinja +11 -11
  10. package/templates/hermes-agent/copier.yml +27 -4
  11. package/templates/hermes-agent/template/.runtime-scaffold/README.md +11 -10
  12. package/templates/hermes-agent/template/.scripts/01-config.sh +4 -4
  13. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +7 -12
  14. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +37 -32
  15. package/templates/hermes-agent/template/.scripts/30-telegram.sh +18 -4
  16. package/templates/hermes-agent/template/.scripts/70-systemd.sh +72 -12
  17. package/templates/hermes-agent/template/.scripts/80-registry.sh +13 -3
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +12 -5
  19. package/templates/hermes-agent/template/.scripts/config.example.toml +7 -4
  20. package/templates/hermes-agent/template/.scripts/credential-launch.sh +81 -0
  21. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -1
  22. package/templates/hermes-agent/template/.scripts/providers/plane.sh +30 -4
  23. package/templates/hermes-agent/template/.scripts/secret-scan.py +82 -16
  24. package/templates/hermes-agent/template/SOUL.md.jinja +4 -4
  25. package/templates/hermes-agent/template/hermes.jinja +36 -7
  26. package/templates/hermes-agent/template/role.yaml.jinja +15 -5
@@ -29,7 +29,8 @@ python3 - "$REGISTRY_FILE" "$AGENT_ID" "$REPO" "$ROLE" "$DISPLAY_NAME" \
29
29
  "$(yaml_get slack.provisioning_status)" "$(yaml_get slack.team_id)" \
30
30
  "$(yaml_get slack.team_name)" "$(yaml_get slack.bot_user_id)" \
31
31
  "$(yaml_get slack.bot_id)" "$(yaml_get slack.bot_username)" \
32
- "$(yaml_get bloodbank.gateway_scope)" "$(yaml_get bloodbank.target_agent_id)" \
32
+ "$(yaml_get bloodbank.enabled)" "$(yaml_get bloodbank.gateway_scope)" \
33
+ "$(yaml_get bloodbank.target_agent_id)" \
33
34
  "$PLANE_WORKSPACE" "$PLANE_PROJECT_ID" "$(yaml_get plane.identifier)" \
34
35
  "$RUNTIME_REPO" "$HERMES_BIN" "$HERMES_AGENT_REPO" "$HERMES_RUNTIME_GIT_URL" \
35
36
  "$HERMES_RUNTIME_GIT_REF" "$HERMES_RUNTIME_GIT_SHA" "$FLEET_ENV" \
@@ -47,13 +48,21 @@ except ImportError:
47
48
  (path, agent_id, repo, role, display, project, role_dir, profile,
48
49
  telegram_status, bot, telegram_bot_id,
49
50
  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,
51
+ slack_username, bloodbank_enabled, bloodbank_scope, bloodbank_target, plane_ws, plane_id,
51
52
  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]
53
+ hermes_git_ref, hermes_git_sha, fleet_env, gw, heartbeat) = sys.argv[1:33]
53
54
  p = pathlib.Path(path)
54
55
  if p.is_symlink():
55
56
  raise SystemExit(f"refusing to update registry symlink: {p}")
56
57
  data = yaml.safe_load(p.read_text()) or {"schema_version": 1, "agents": {}}
58
+ if bloodbank_enabled == "":
59
+ bloodbank_enabled_value = False
60
+ elif bloodbank_enabled == "true":
61
+ bloodbank_enabled_value = True
62
+ elif bloodbank_enabled == "false":
63
+ bloodbank_enabled_value = False
64
+ else:
65
+ raise SystemExit("bloodbank.enabled must be the strict YAML boolean true or false")
57
66
  data.setdefault("agents", {})[agent_id] = {
58
67
  "repo": repo, "role": role, "display_name": display,
59
68
  "project_path": project, "role_dir": role_dir,
@@ -72,6 +81,7 @@ data.setdefault("agents", {})[agent_id] = {
72
81
  "bot_username": slack_username,
73
82
  },
74
83
  "bloodbank": {
84
+ "enabled": bloodbank_enabled_value,
75
85
  "gateway_scope": bloodbank_scope,
76
86
  "target_agent_id": bloodbank_target,
77
87
  },
@@ -137,6 +137,12 @@ 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
+ }
140
146
 
141
147
  # Fleet source-of-truth (shared across all wrappers/provisioners).
142
148
  # Every default below resolves as: env var > fleet.env > config.toml > fallback.
@@ -151,11 +157,12 @@ fi
151
157
  unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
152
158
 
153
159
  # 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")}}"
160
+ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-$(config_get fleet.hermes_bin "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes")}}"
161
+ HERMES_AGENT_REPO="${HERMES_AGENT_REPO:-${HERMES_FLEET_REPO:-$(config_get fleet.hermes_repo "$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1")}}"
162
+ PJANGLER_BIN="${PJANGLER_BIN:-$(config_get fleet.pjangler_bin "pj")}"
156
163
  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')}"
164
+ HERMES_RUNTIME_GIT_REF="${HERMES_RUNTIME_GIT_REF:-$(config_get fleet.hermes_git_ref 'main')}"
165
+ HERMES_RUNTIME_GIT_SHA="${HERMES_RUNTIME_GIT_SHA:-$(config_get fleet.hermes_git_sha '0408fec7a153e6c32c064acd2b8053917f1525f1')}"
159
166
  HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
160
167
  CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
161
168
  # Prefer a scaffold vendored into this agent directory; fall back to the configured template path.
@@ -197,7 +204,7 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
197
204
  PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
198
205
  PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
199
206
 
200
- export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO HERMES_RUNTIME_GIT_URL \
207
+ export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO PJANGLER_BIN HERMES_RUNTIME_GIT_URL \
201
208
  HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
202
209
  RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
203
210
  BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
@@ -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,6 +15,7 @@ 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"
@@ -242,7 +243,7 @@ trap 'python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" release "$WIP_LOCK" "hermes:
242
243
 
243
244
  prompt="$(<"$PROMPT_FILE")"
244
245
  set +e
245
- env HERMES_HOME="$RUNTIME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
246
+ env HERMES_HOME="$RUN_HOME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
246
247
  status=$?
247
248
  set -e
248
249
 
@@ -22,10 +22,6 @@ ROLE_YAML="$ROLE_DIR/role.yaml"
22
22
  BASE="${PLANE_BASE:-https://plane.delo.sh}"
23
23
 
24
24
  FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
25
- if [ -f "$FLEET_ENV" ]; then
26
- # shellcheck disable=SC1090
27
- . "$FLEET_ENV"
28
- fi
29
25
 
30
26
  die() { echo "plane: $*" >&2; exit 1; }
31
27
  need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
@@ -36,6 +32,33 @@ workspace_key() {
36
32
  printf 'PLANE_%s_API_KEY' "$key"
37
33
  }
38
34
 
35
+ # dotenv_value FILE KEY — read one exact dotenv assignment as inert data.
36
+ # Never source the shared fleet file: it may contain unrelated command
37
+ # substitutions or credential helpers that this provider must not execute.
38
+ dotenv_value() {
39
+ python3 - "$1" "$2" <<'PY'
40
+ import pathlib, sys
41
+
42
+ path = pathlib.Path(sys.argv[1])
43
+ key = sys.argv[2]
44
+ value = ""
45
+ for raw in path.read_text(encoding="utf-8").splitlines():
46
+ line = raw.strip()
47
+ if not line or line.startswith("#"):
48
+ continue
49
+ if line.startswith("export "):
50
+ line = line[7:].lstrip()
51
+ name, sep, candidate = line.partition("=")
52
+ if sep and name.strip() == key:
53
+ candidate = candidate.strip()
54
+ if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "'\"":
55
+ candidate = candidate[1:-1]
56
+ value = candidate
57
+ break
58
+ print(value, end="")
59
+ PY
60
+ }
61
+
39
62
  tp_cfg() {
40
63
  [ -f "$ROLE_YAML" ] || return 0
41
64
  python3 - "$ROLE_YAML" "$1" <<'PY'
@@ -77,6 +100,9 @@ API="$BASE/api/v1/workspaces/$WS"
77
100
  if [ -z "${PLANE_API_KEY:-}" ]; then
78
101
  KEY="$(workspace_key "$WS")"
79
102
  eval "PLANE_API_KEY=\${$KEY:-}"
103
+ if [ -z "${PLANE_API_KEY:-}" ] && [ -f "$FLEET_ENV" ]; then
104
+ PLANE_API_KEY="$(dotenv_value "$FLEET_ENV" "$KEY")"
105
+ fi
80
106
  export PLANE_API_KEY
81
107
  fi
82
108
 
@@ -8,45 +8,111 @@ import re
8
8
  from pathlib import Path
9
9
 
10
10
  KEY_VALUE = re.compile(
11
- r"(?im)^\s*[\"']?(?:api[_-]?key|token|secret|password|authorization|cookie|client[_-]?secret|private[_-]?key)[\"']?\s*[:=]\s*[\"']?([^\s\"'#][^\r\n#]*)"
11
+ r"(?im)^\s*[\"']?(?P<key>api[_-]?key|token|secret|password|authorization|cookie|client[_-]?secret|private[_-]?key)[\"']?\s*[:=]\s*(?P<value>[^\r\n#]*)"
12
12
  )
13
13
  TOKEN = re.compile(
14
14
  r"(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|tvly-[A-Za-z0-9_-]{10,}|fc-[A-Za-z0-9_-]{10,}|[0-9]{6,}:[A-Za-z0-9_-]{20,})"
15
15
  )
16
16
  PRIVATE_KEY = re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----")
17
- SAFE_VALUES = {"openrouter", "openai", "anthropic", "auto", "none", "null", "false", "true"}
18
- SKIP_PARTS = {".git", "__pycache__"}
17
+ SAFE_VALUES = {
18
+ "openrouter",
19
+ "openai",
20
+ "anthropic",
21
+ "auto",
22
+ "none",
23
+ "null",
24
+ "false",
25
+ "true",
26
+ }
27
+ SAFE_PREFIXES = ("${", "$", "op://", "env:", "[REDACTED]", "{{", "{%")
28
+ COMPUTED_VALUE = re.compile(r"^[A-Za-z_][A-Za-z0-9_.]*\s*(?:\(|\[)")
29
+ INTERPOLATED_VALUE = re.compile(
30
+ r"(?:\$\{[A-Za-z_][A-Za-z0-9_]*[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_.]*\})"
31
+ )
32
+ ENV_ASSIGNMENT = re.compile(
33
+ r"(?m)^\s*(?:export\s+)?(?P<key>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?P<value>[^\r\n#]*)"
34
+ )
35
+ SENSITIVE_ENV_KEY = re.compile(
36
+ r"(?i)(?:^|_)(?:api_key|token|secret|password|authorization|cookie|client_secret|private_key)(?:_|$)"
37
+ )
38
+ ALLOWED_ENV_FILES = {".env.example", ".env.op"}
39
+ SKIP_PARTS = {
40
+ ".cache",
41
+ ".direnv",
42
+ ".eggs",
43
+ ".git",
44
+ ".mypy_cache",
45
+ ".nox",
46
+ ".npm",
47
+ ".pnpm-store",
48
+ ".pytest_cache",
49
+ ".ruff_cache",
50
+ ".tox",
51
+ ".turbo",
52
+ ".uv-cache",
53
+ ".venv",
54
+ ".yarn",
55
+ "__pycache__",
56
+ "dist-packages",
57
+ "node_modules",
58
+ "site-packages",
59
+ "venv",
60
+ }
61
+
62
+
63
+ def safe_assignment(raw_value: str) -> bool:
64
+ """Return true only for an explicit reference, sentinel, or computation."""
65
+
66
+ value = raw_value.strip().rstrip(",").strip().strip("\"'")
67
+ if not value or value.lower() in SAFE_VALUES:
68
+ return True
69
+ if value.startswith(SAFE_PREFIXES) or value.endswith("_ENV"):
70
+ return True
71
+ if INTERPOLATED_VALUE.search(value):
72
+ return True
73
+ # Source code commonly binds credentials from a runtime provider. A bare
74
+ # literal does not have a call/index boundary and remains fail-closed.
75
+ return COMPUTED_VALUE.match(value) is not None
76
+
77
+
78
+ def safe_env_reference(raw_value: str) -> bool:
79
+ """Keep a tracked .env.op declarative: sensitive values stay references."""
80
+
81
+ value = raw_value.strip().rstrip(",").strip().strip("\"'")
82
+ return not value or value.startswith(SAFE_PREFIXES)
19
83
 
20
84
 
21
85
  def findings(root: Path) -> list[str]:
22
86
  result: list[str] = []
23
87
  for path in root.rglob("*"):
24
- if not path.is_file() or any(part in SKIP_PARTS for part in path.parts):
88
+ relative = path.relative_to(root)
89
+ if not path.is_file() or any(part in SKIP_PARTS for part in relative.parts):
25
90
  continue
26
91
  if path.name == "secret-scan.py":
27
92
  continue
28
- if path.name.startswith(".env") and path.name != ".env.example":
29
- result.append(f"forbidden secret file: {path.relative_to(root)}")
93
+ if path.name.startswith(".env") and path.name not in ALLOWED_ENV_FILES:
94
+ result.append(f"forbidden secret file: {relative}")
30
95
  continue
31
96
  if path.name in {"auth.json", "auth.lock"} or path.suffix in {".pem", ".key"}:
32
- result.append(f"forbidden credential file: {path.relative_to(root)}")
97
+ result.append(f"forbidden credential file: {relative}")
33
98
  continue
34
99
  try:
35
100
  text = path.read_text(encoding="utf-8")
36
101
  except (UnicodeDecodeError, OSError):
37
102
  continue
38
103
  if TOKEN.search(text) or PRIVATE_KEY.search(text):
39
- result.append(f"credential token pattern: {path.relative_to(root)}")
104
+ result.append(f"credential token pattern: {relative}")
105
+ if path.name == ".env.op":
106
+ for match in ENV_ASSIGNMENT.finditer(text):
107
+ if SENSITIVE_ENV_KEY.search(
108
+ match.group("key")
109
+ ) and not safe_env_reference(match.group("value")):
110
+ result.append(f"literal credential assignment: {relative}")
111
+ break
40
112
  for match in KEY_VALUE.finditer(text):
41
- value = match.group(1).strip().strip("\"'").rstrip(",")
42
- if (
43
- not value
44
- or value.lower() in SAFE_VALUES
45
- or value.startswith(("${", "$", "op://", "env:", "[REDACTED]"))
46
- or value.endswith("_ENV")
47
- ):
113
+ if safe_assignment(match.group("value")):
48
114
  continue
49
- result.append(f"literal credential assignment: {path.relative_to(root)}")
115
+ result.append(f"literal credential assignment: {relative}")
50
116
  break
51
117
  return sorted(set(result))
52
118
 
@@ -15,10 +15,10 @@ You are **{{ display_name }}** — a Hermes agent provisioned to work inside the
15
15
 
16
16
  ## Scope
17
17
 
18
- Your HERMES_HOME is the local runtime at `./runtime/`; Hermes loads its
19
- `config.yaml` directly. Secrets, SOUL, skills, sessions, and gateway state live
20
- local to that runtime (pure-local state; durable memory is the shared Hindsight
21
- bank see Memory hygiene).
18
+ Your HERMES_HOME is the real named profile under `~/.hermes/profiles/`. Shared
19
+ config/auth/skills link to fleet truth; your SOUL, sessions, memory, and other
20
+ owned state link into the ignored local `./runtime/`. Only PJangler may repair
21
+ that wiring (`pj migrate hermes.runtime-singleton`).
22
22
 
23
23
  ## Tone
24
24
 
@@ -1,11 +1,28 @@
1
1
  #!/usr/bin/env bash
2
- # Launcher for {{ agent_id }}. Resolves HERMES_HOME to the local runtime
2
+ # Launcher for {{ agent_id }}. Resolves HERMES_HOME to the agent's NAMED PROFILE
3
3
  # and execs the shared fleet Hermes binary configured in ~/.hermes/fleet.env.
4
4
 
5
5
  set -euo pipefail
6
6
 
7
7
  ROLE_DIR="$(cd "$(dirname "$0")" && pwd)"
8
- HERMES_HOME="$ROLE_DIR/runtime"
8
+ RUNTIME_HOME="$ROLE_DIR/runtime"
9
+
10
+ FLEET_HOME="${HERMES_FLEET_HOME:-$HOME/.hermes}"
11
+ PROFILE_NAME="${HERMES_PROFILE_NAME:-{{ agent_id }}}"
12
+
13
+ # Singleton-runtime contract: HERMES_HOME MUST be the named profile dir, never
14
+ # the raw runtime path. Hermes derives the profile identity from the UNRESOLVED
15
+ # HERMES_HOME path; any HERMES_HOME that is neither under ~/.hermes nor a child
16
+ # of a "profiles" dir makes get_active_profile_name() report "default" and
17
+ # _global_auth_file_path() return None — silently disabling shared fleet auth
18
+ # and giving the agent a divergent config.yaml. The profile dir is a REAL dir
19
+ # whose shared entries (config.yaml, .env, skills) symlink to the fleet root
20
+ # and whose owned entries (memories, sessions, state.db, ...) symlink into
21
+ # $RUNTIME_HOME. Provision it with: pj migrate hermes.runtime-singleton
22
+ HERMES_HOME="$FLEET_HOME/profiles/$PROFILE_NAME"
23
+ if ! REPO_ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null)"; then
24
+ REPO_ROOT="$ROLE_DIR"
25
+ fi
9
26
 
10
27
  FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
11
28
  if [[ -f "$FLEET_ENV" ]]; then
@@ -47,16 +64,28 @@ HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
47
64
  if [[ -z "$HERMES_BIN" ]]; then
48
65
  HERMES_BIN="$(config_get fleet.hermes_bin "")"
49
66
  fi
50
- HERMES_BIN="${HERMES_BIN:-$HOME/.hermes/hermes-agent/.venv/bin/hermes}"
51
- HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
67
+ HERMES_BIN="${HERMES_BIN:-$HOME/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes}"
52
68
  CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
53
69
 
54
- if [[ ! -d "$HERMES_HOME" ]]; then
55
- echo "hermes: local runtime not provisioned at $HERMES_HOME" >&2
70
+ if [[ ! -d "$RUNTIME_HOME" ]]; then
71
+ echo "hermes: local runtime not provisioned at $RUNTIME_HOME" >&2
56
72
  echo " fix: run $ROLE_DIR/.scripts/20-runtime-repo.sh" >&2
57
73
  exit 1
58
74
  fi
59
75
 
76
+ if [[ ! -d "$HERMES_HOME" ]]; then
77
+ echo "hermes: profile not provisioned at $HERMES_HOME" >&2
78
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
79
+ exit 1
80
+ fi
81
+
82
+ if [[ -L "$HERMES_HOME" ]]; then
83
+ echo "hermes: $HERMES_HOME is a symlink; the profile dir must be a REAL dir" >&2
84
+ echo " (a symlink breaks profile-name resolution and shared fleet auth)" >&2
85
+ echo " fix: pj migrate hermes.runtime-singleton" >&2
86
+ exit 1
87
+ fi
88
+
60
89
  if [[ ! -x "$HERMES_BIN" ]]; then
61
90
  echo "hermes: binary not executable at $HERMES_BIN" >&2
62
91
  echo " set HERMES_BIN or HERMES_FLEET_BIN (in $FLEET_ENV) to the shared Hermes binary." >&2
@@ -64,5 +93,5 @@ if [[ ! -x "$HERMES_BIN" ]]; then
64
93
  fi
65
94
 
66
95
  exec env HERMES_HOME="$HERMES_HOME" HERMES_FLEET_ENV="$FLEET_ENV" \
67
- HERMES_OAUTH_FILE="$HERMES_OAUTH_FILE" CODEX_HOME="$CODEX_HOME" \
96
+ CODEX_HOME="$CODEX_HOME" TERMINAL_CWD="$REPO_ROOT" \
68
97
  "$HERMES_BIN" "$@"
@@ -8,13 +8,20 @@ agent_id: {{ agent_id }}
8
8
  display_name: "{{ display_name }}"
9
9
  purpose: "{{ agent_purpose }}"
10
10
 
11
- # Inference target (empty = inherit from global ~/.hermes/config.yaml)
11
+ # Process-local gateway inference target (empty = inherit from shared
12
+ # ~/.hermes/config.yaml). key_env is a variable NAME only; its secret value is
13
+ # injected at runtime and must never be written here.
12
14
  model:
13
15
  provider: "{{ model_provider }}"
14
16
  name: "{{ model_name }}"
17
+ base_url: "{{ model_base_url }}"
18
+ api_mode: "{{ model_api_mode }}"
19
+ key_env: "{{ model_key_env }}"
15
20
 
16
- # Hermes profile name. ~/.hermes/profiles/<name> symlinks to ./runtime/, so both
17
- # HERMES_HOME and `hermes --profile <name>` resolve to the local runtime.
21
+ # Hermes profile name. ~/.hermes/profiles/<name> is a REAL directory managed by
22
+ # `pj migrate hermes.runtime-singleton`: shared config/auth/skills link to the
23
+ # fleet root while role-owned state links into ./runtime. Never replace the
24
+ # named profile with a symlink to runtime.
18
25
  profile: {{ agent_id }}
19
26
 
20
27
  # Telegram bot identity (one bot per agent — see docs/architecture.md).
@@ -71,12 +78,15 @@ plane:
71
78
  # reads the fleet registry and routes canonical commands by target_agent_id;
72
79
  # this profile does not install its own NATS consumer or inbox bridge.
73
80
  bloodbank:
81
+ # Quarantine gate: discovery is safe; execution requires explicit activation.
82
+ enabled: false
74
83
  gateway_scope: fleet
75
84
  target_agent_id: "{{ agent_id }}"
76
85
  producer: "hermes-agent:{{ agent_id }}"
77
86
 
78
- # Pure-local HERMES_HOME. The legacy GitHub identity remains metadata-only for
79
- # locating old archives; provisioning never creates or attaches a submodule.
87
+ # Pure-local role-owned state. HERMES_HOME itself is the named profile directory
88
+ # above; the legacy GitHub identity remains metadata-only for locating old
89
+ # archives, and provisioning never creates or attaches a submodule.
80
90
  runtime:
81
91
  # Empty owner -> resolved from config.toml [github].runtime_repo_owner.
82
92
  github_owner: "{{ runtime_repo_owner }}"