@delorenj/pjangler 1.4.5 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -520
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +5747 -13483
- package/dist/mcp-server.js +6853 -14059
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -513
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Legacy checkpoint compatibility for installations whose runtime is still a
|
|
3
|
-
# nested Git repository. Pure-local runtimes intentionally skip this script.
|
|
4
|
-
# Idempotent — exits 0 with no commit if there are no changes.
|
|
5
|
-
#
|
|
6
|
-
# Secret-scan gate (PJAN): before committing, the staged diff is scanned for
|
|
7
|
-
# high-signal credentials. On a hit the checkpoint ABORTS — it unstages, does
|
|
8
|
-
# NOT commit or push, logs loudly, and exits non-zero — so the known
|
|
9
|
-
# auto-commit secret-leak recurrence cannot re-leak through the heartbeat.
|
|
10
|
-
set -euo pipefail
|
|
11
|
-
|
|
12
|
-
RUNTIME_DIR="$(cd "$(dirname "$0")/../runtime" && pwd)"
|
|
13
|
-
cd "$RUNTIME_DIR"
|
|
14
|
-
|
|
15
|
-
# Skip when the pure-local runtime has no Git metadata.
|
|
16
|
-
[[ -d .git || -f .git ]] || exit 0
|
|
17
|
-
|
|
18
|
-
# Returns 0 when the staged diff looks clean, 1 when a likely secret is present.
|
|
19
|
-
secret_scan_ok() {
|
|
20
|
-
local added
|
|
21
|
-
added="$(git diff --cached -U0 --no-color 2>/dev/null | grep -E '^\+' | grep -vE '^\+\+\+ ' || true)"
|
|
22
|
-
[[ -n "$added" ]] || return 0
|
|
23
|
-
|
|
24
|
-
# Dependency-free, high-signal patterns (distinctive prefixes → ~zero false
|
|
25
|
-
# positives) over ADDED lines only.
|
|
26
|
-
if printf '%s\n' "$added" | grep -Eq \
|
|
27
|
-
'AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|gh[posru]_[A-Za-z0-9]{20,}|xox[baprs]-[0-9A-Za-z-]{10,}|sk-[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}'; then
|
|
28
|
-
return 1
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
# Deeper scan when gitleaks is installed (exit 1 == leaks found; other
|
|
32
|
-
# non-zero == tool/usage error, which we ignore so checkpoints aren't blocked).
|
|
33
|
-
if command -v gitleaks >/dev/null 2>&1; then
|
|
34
|
-
local rc=0
|
|
35
|
-
git diff --cached --no-color 2>/dev/null | gitleaks stdin --no-banner --redact >/dev/null 2>&1 || rc=$?
|
|
36
|
-
[[ "$rc" -eq 1 ]] && return 1
|
|
37
|
-
fi
|
|
38
|
-
return 0
|
|
39
|
-
}
|
|
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
|
-
|
|
58
|
-
git add -A
|
|
59
|
-
if git diff --cached --quiet; then
|
|
60
|
-
exit 0
|
|
61
|
-
fi
|
|
62
|
-
|
|
63
|
-
if ! secret_scan_ok; then
|
|
64
|
-
printf '[checkpoint] ABORT: potential secret in staged runtime changes — not committing/pushing.\n' >&2
|
|
65
|
-
printf '[checkpoint] Inspect: git -C %s diff --cached (remove the secret or add a .gitignore rule)\n' "$RUNTIME_DIR" >&2
|
|
66
|
-
git reset -q || true
|
|
67
|
-
exit 3
|
|
68
|
-
fi
|
|
69
|
-
|
|
70
|
-
git -c commit.gpgsign=false commit -m "checkpoint $(date -Iseconds)" >/dev/null
|
|
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
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# hermes-agent-template — distributable configuration
|
|
2
|
-
#
|
|
3
|
-
# This file holds every environment-specific default the provisioning scripts
|
|
4
|
-
# need. Copy it to ~/.config/hermes-agent-template/config.toml (the first
|
|
5
|
-
# provisioning run does this for you) and edit the values for YOUR machine.
|
|
6
|
-
#
|
|
7
|
-
# Resolution precedence for each value (highest wins):
|
|
8
|
-
# explicit env var > ~/.hermes/fleet.env > this file > built-in fallback
|
|
9
|
-
#
|
|
10
|
-
# Override the config location with $HERMES_TEMPLATE_CONFIG if you keep it
|
|
11
|
-
# somewhere other than $XDG_CONFIG_HOME/hermes-agent-template/config.toml.
|
|
12
|
-
|
|
13
|
-
[fleet]
|
|
14
|
-
# Absolute path to the shared Hermes executable every agent launcher execs.
|
|
15
|
-
hermes_bin = "~/.local/share/hermes-agent/releases/0408fec7a153e6c32c064acd2b8053917f1525f1/.venv/bin/hermes"
|
|
16
|
-
# Checkout of the shared hermes-agent repo.
|
|
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"
|
|
21
|
-
# Reviewed fleet runtime publication. Provisioning metadata and clean installs
|
|
22
|
-
# must agree on this fork/ref/SHA; do not substitute upstream main.
|
|
23
|
-
hermes_git_url = "https://github.com/delorenj/hermes-agent.git"
|
|
24
|
-
hermes_git_ref = "main"
|
|
25
|
-
hermes_git_sha = "0408fec7a153e6c32c064acd2b8053917f1525f1"
|
|
26
|
-
# Fallback runtime scaffold dir — used only when an agent's vendored
|
|
27
|
-
# ./.runtime-scaffold is missing.
|
|
28
|
-
runtime_scaffold_dir = "~/code/hermes-agent-template/runtime-scaffold"
|
|
29
|
-
# Shared fleet source-of-truth env file + fleet registry. ~ is expanded.
|
|
30
|
-
fleet_env = "~/.hermes/fleet.env"
|
|
31
|
-
registry_file = "~/.hermes/agents-registry.yaml"
|
|
32
|
-
# Shared auth roots. HERMES_OAUTH_FILE is the Hermes provider OAuth store
|
|
33
|
-
# (including openai-codex); CODEX_HOME is for Codex CLI/app-server auth.
|
|
34
|
-
oauth_file = "~/.hermes/auth.json"
|
|
35
|
-
codex_home = "~/.codex"
|
|
36
|
-
# Canonical external skills dir mirrored into each agent profile.
|
|
37
|
-
canonical_skills_dir = "/home/delorenj/.agents/skills"
|
|
38
|
-
# Optional Vox plugin + service defaults for Hermes PM agents.
|
|
39
|
-
vox_plugin_name = "vox"
|
|
40
|
-
vox_plugin_dir = "~/code/voxxy/plugins/tts/vox"
|
|
41
|
-
vox_voice = "carlin"
|
|
42
|
-
vox_url = "https://vox.delo.sh"
|
|
43
|
-
# Channel credentials are written directly to Password items in this vault;
|
|
44
|
-
# only op:// references are recorded in named-profile config.delta.yaml.
|
|
45
|
-
onepassword_vault = "DeLoSecrets"
|
|
46
|
-
onepassword_item_prefix = "hermes-agent"
|
|
47
|
-
# Canonical PM config.yaml the provisioner seeds each runtime's config.yaml from.
|
|
48
|
-
# This is the fleet-wide Hermes inference/skill config (model, toolsets, etc.)
|
|
49
|
-
# every agent runtime starts from. ~ is expanded.
|
|
50
|
-
# canonical_pm_config = "~/.hermes/config.yaml"
|
|
51
|
-
# Skills symlinked into every agent's runtime skills root.
|
|
52
|
-
symlinked_runtime_skills = [
|
|
53
|
-
"delonet-conventions",
|
|
54
|
-
"delonet-dotenv",
|
|
55
|
-
"hermes-pm-template-maintenance",
|
|
56
|
-
"hindsight",
|
|
57
|
-
"33god-projects",
|
|
58
|
-
"subagent-driven-development",
|
|
59
|
-
]
|
|
60
|
-
|
|
61
|
-
[github]
|
|
62
|
-
# LEGACY INERT COMPATIBILITY METADATA ONLY. Older role manifests may still
|
|
63
|
-
# carry this owner field. Current provisioning does not create, attach,
|
|
64
|
-
# synchronize, restore, archive, or remove runtime storage from this value.
|
|
65
|
-
runtime_repo_owner = ""
|
|
66
|
-
|
|
67
|
-
[plane]
|
|
68
|
-
# Plane instance base URL and workspace slug (one project per agent).
|
|
69
|
-
base = "https://plane.delo.sh"
|
|
70
|
-
workspace = "33god"
|
|
@@ -1,85 +0,0 @@
|
|
|
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
|
-
# Do not forward a stale/raw channel value inherited from the service manager,
|
|
49
|
-
# login shell, or a legacy runtime EnvironmentFile. Hermes hydrates the named
|
|
50
|
-
# profile's validated 1Password references after this launcher execs it.
|
|
51
|
-
unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
52
|
-
|
|
53
|
-
load_credential() {
|
|
54
|
-
local credential_id="$1" env_name="$2" credential_file value
|
|
55
|
-
[[ "$env_name" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] \
|
|
56
|
-
|| { printf 'credential-launch: invalid credential environment name\n' >&2; exit 2; }
|
|
57
|
-
credential_file="${CREDENTIALS_DIRECTORY:-}/$credential_id"
|
|
58
|
-
[[ -n "${CREDENTIALS_DIRECTORY:-}" && -r "$credential_file" ]] || return 0
|
|
59
|
-
value="$(<"$credential_file")"
|
|
60
|
-
printf -v "$env_name" '%s' "$value"
|
|
61
|
-
export "$env_name"
|
|
62
|
-
unset value
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
MODEL_KEY_ENV="$(yaml_get model.key_env)"
|
|
66
|
-
if [[ -n "$MODEL_KEY_ENV" ]]; then
|
|
67
|
-
load_credential model_api_key "$MODEL_KEY_ENV"
|
|
68
|
-
fi
|
|
69
|
-
|
|
70
|
-
if [[ "$mode" == "heartbeat" ]]; then
|
|
71
|
-
exec "$ROLE_DIR/.scripts/heartbeat.sh"
|
|
72
|
-
fi
|
|
73
|
-
|
|
74
|
-
gateway_args=(gateway run --replace)
|
|
75
|
-
MODEL_NAME="$(yaml_get model.name)"
|
|
76
|
-
MODEL_PROVIDER="$(yaml_get model.provider)"
|
|
77
|
-
MODEL_BASE_URL="$(yaml_get model.base_url)"
|
|
78
|
-
MODEL_API_MODE="$(yaml_get model.api_mode)"
|
|
79
|
-
[[ -z "$MODEL_NAME" ]] || gateway_args+=(--model "$MODEL_NAME")
|
|
80
|
-
[[ -z "$MODEL_PROVIDER" ]] || gateway_args+=(--provider "$MODEL_PROVIDER")
|
|
81
|
-
[[ -z "$MODEL_BASE_URL" ]] || gateway_args+=(--base-url "$MODEL_BASE_URL")
|
|
82
|
-
[[ -z "$MODEL_API_MODE" ]] || gateway_args+=(--api-mode "$MODEL_API_MODE")
|
|
83
|
-
[[ -z "$MODEL_KEY_ENV" ]] || gateway_args+=(--key-env "$MODEL_KEY_ENV")
|
|
84
|
-
|
|
85
|
-
exec "$HERMES_BIN" "${gateway_args[@]}"
|
|
@@ -1,283 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Unified agent heartbeat: continuous board-reconciliation sentinel pass +
|
|
3
|
-
# gated runtime checkpoint, fused into one systemd-timer tick.
|
|
4
|
-
#
|
|
5
|
-
# Runs often (systemd timer, ~1 min). Only invokes Hermes for a full
|
|
6
|
-
# reconciliation pass when local state says no worker is active, the worker
|
|
7
|
-
# heartbeat is stale, or the last full run is outside the cooldown window. The
|
|
8
|
-
# full pass executes the role's sentinel.prompt.md, which reasons about tickets
|
|
9
|
-
# through the ticket-provider adapter (Linear | Plane | Trello) — never a
|
|
10
|
-
# hardcoded backend. After the sentinel decision (skip OR full), it
|
|
11
|
-
# opportunistically checkpoints only legacy nested-Git runtimes at most once
|
|
12
|
-
# per HEARTBEAT_CHECKPOINT_MIN_INTERVAL_SECONDS, so memory/session state stays
|
|
13
|
-
# durable without pushing every minute.
|
|
14
|
-
set -euo pipefail
|
|
15
|
-
|
|
16
|
-
ROLE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # agents/hermes/<role>
|
|
17
|
-
RUNTIME="$ROLE_DIR/runtime"
|
|
18
|
-
RUN_HOME="${HERMES_HOME:-$RUNTIME}"
|
|
19
|
-
PROMPT_FILE="$ROLE_DIR/.scripts/sentinel.prompt.md"
|
|
20
|
-
STATE_FILE="$RUNTIME/continuous-ticket-sentinel-state.json"
|
|
21
|
-
LOCK_FILE="$RUNTIME/continuous-ticket-sentinel.lock"
|
|
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"
|
|
26
|
-
LOG_FILE="$RUNTIME/logs/heartbeat.log"
|
|
27
|
-
CHECKPOINT_BIN="$ROLE_DIR/.scripts/checkpoint.sh"
|
|
28
|
-
CHECKPOINT_STAMP="$RUNTIME/.last-checkpoint"
|
|
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
|
-
|
|
39
|
-
# Hermes binary: explicit env > ~/.config/hermes-agent/hermes-bin > PATH.
|
|
40
|
-
HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-}}"
|
|
41
|
-
if [[ -z "$HERMES_BIN" ]]; then
|
|
42
|
-
if [[ -r "$HOME/.config/hermes-agent/hermes-bin" ]]; then
|
|
43
|
-
HERMES_BIN="$(cat "$HOME/.config/hermes-agent/hermes-bin")"
|
|
44
|
-
else
|
|
45
|
-
HERMES_BIN="$(command -v hermes || echo hermes)"
|
|
46
|
-
fi
|
|
47
|
-
fi
|
|
48
|
-
|
|
49
|
-
ACTIVE_MAX_IDLE_SECONDS="${SENTINEL_ACTIVE_MAX_IDLE_SECONDS:-600}"
|
|
50
|
-
FULL_RUN_COOLDOWN_SECONDS="${SENTINEL_FULL_RUN_COOLDOWN_SECONDS:-300}"
|
|
51
|
-
BLOCKED_FULL_RUN_COOLDOWN_SECONDS="${SENTINEL_BLOCKED_FULL_RUN_COOLDOWN_SECONDS:-900}"
|
|
52
|
-
CHECKPOINT_MIN_INTERVAL_SECONDS="${HEARTBEAT_CHECKPOINT_MIN_INTERVAL_SECONDS:-3600}"
|
|
53
|
-
|
|
54
|
-
# Opportunistic runtime checkpoint, fused into the heartbeat. Runs at most once
|
|
55
|
-
# per interval; checkpoint.sh is itself a no-op on a clean tree, so this only
|
|
56
|
-
# commits+pushes when there is genuinely new state AND enough time has passed
|
|
57
|
-
# since the last push. Never fails the heartbeat — checkpoint is best-effort.
|
|
58
|
-
maybe_checkpoint() {
|
|
59
|
-
[[ -x "$CHECKPOINT_BIN" ]] || return 0
|
|
60
|
-
local now last
|
|
61
|
-
now="$(date +%s)"
|
|
62
|
-
last="$(cat "$CHECKPOINT_STAMP" 2>/dev/null || echo 0)"
|
|
63
|
-
[[ "$last" =~ ^[0-9]+$ ]] || last=0
|
|
64
|
-
if (( now - last >= CHECKPOINT_MIN_INTERVAL_SECONDS )); then
|
|
65
|
-
printf '%s' "$now" > "$CHECKPOINT_STAMP" 2>/dev/null || true
|
|
66
|
-
printf '[heartbeat] checkpoint tick (>= %ss since last push)\n' "$CHECKPOINT_MIN_INTERVAL_SECONDS"
|
|
67
|
-
"$CHECKPOINT_BIN" || true
|
|
68
|
-
fi
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
yaml_value() {
|
|
72
|
-
python3 - "$ROLE_YAML" "$1" <<'PYEOF'
|
|
73
|
-
import re, sys
|
|
74
|
-
from pathlib import Path
|
|
75
|
-
path, key = sys.argv[1:3]
|
|
76
|
-
text = Path(path).read_text()
|
|
77
|
-
m = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', text)
|
|
78
|
-
print(m.group(1).strip() if m else "")
|
|
79
|
-
PYEOF
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
yaml_block_value() {
|
|
83
|
-
python3 - "$ROLE_YAML" "$1" "$2" <<'PYEOF'
|
|
84
|
-
import re, sys
|
|
85
|
-
from pathlib import Path
|
|
86
|
-
path, block_name, key = sys.argv[1:4]
|
|
87
|
-
text = Path(path).read_text()
|
|
88
|
-
m = re.search(rf'(?m)^{re.escape(block_name)}:[ \t]*\n((?:[ \t]+\S.*\n?)*)', text)
|
|
89
|
-
block = m.group(1) if m else ""
|
|
90
|
-
me = re.search(rf'(?m)^[ \t]+{re.escape(key)}:[ \t]*([^\n#]*)', block)
|
|
91
|
-
value = me.group(1).strip() if me else ""
|
|
92
|
-
print(value.strip().strip('"').strip("'"))
|
|
93
|
-
PYEOF
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
# True only when role.yaml has a reconcile: block with enabled: true. Block-aware
|
|
97
|
-
# so an unrelated `enabled:` leaf elsewhere in the file can't flip it on.
|
|
98
|
-
reconcile_enabled() {
|
|
99
|
-
python3 - "$ROLE_YAML" <<'PYEOF'
|
|
100
|
-
import re, sys
|
|
101
|
-
from pathlib import Path
|
|
102
|
-
text = Path(sys.argv[1]).read_text()
|
|
103
|
-
m = re.search(r'(?m)^reconcile:[ \t]*\n((?:[ \t]+\S.*\n?)*)', text)
|
|
104
|
-
block = m.group(1) if m else ""
|
|
105
|
-
me = re.search(r'(?m)^[ \t]+enabled:[ \t]*"?([A-Za-z]+)"?', block)
|
|
106
|
-
print("true" if (me and me.group(1).lower() == "true") else "false")
|
|
107
|
-
PYEOF
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
AGENT_ID="$(yaml_value agent_id)"
|
|
111
|
-
REPO_NAME="$(yaml_value repo)"
|
|
112
|
-
PROVIDER="$(yaml_block_value ticket_provider name)"
|
|
113
|
-
|
|
114
|
-
repo_root() {
|
|
115
|
-
local dir="$ROLE_DIR"
|
|
116
|
-
for _ in 1 2 3 4 5; do
|
|
117
|
-
dir="$(dirname "$dir")"
|
|
118
|
-
if [[ -d "$dir/.git" || -f "$dir/.git" ]]; then printf '%s\n' "$dir"; return 0; fi
|
|
119
|
-
done
|
|
120
|
-
return 1
|
|
121
|
-
}
|
|
122
|
-
REPO_ROOT="$(repo_root)"
|
|
123
|
-
cd "$REPO_ROOT"
|
|
124
|
-
mkdir -p "$RUNTIME/logs"
|
|
125
|
-
|
|
126
|
-
# Single-run lock. Prefer flock (Linux); fall back to an atomic mkdir lock so
|
|
127
|
-
# this also works on macOS, which doesn't ship flock.
|
|
128
|
-
if command -v flock >/dev/null 2>&1; then
|
|
129
|
-
exec 9>"$LOCK_FILE"
|
|
130
|
-
if ! flock -n 9; then
|
|
131
|
-
printf '[heartbeat] another heartbeat/full run is active; skipping\n'; exit 0
|
|
132
|
-
fi
|
|
133
|
-
else
|
|
134
|
-
LOCK_DIR="$LOCK_FILE.d"
|
|
135
|
-
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
|
136
|
-
# Steal a stale lock older than 60 minutes (a crashed run left it behind).
|
|
137
|
-
if [ -n "$(find "$LOCK_DIR" -maxdepth 0 -mmin +60 2>/dev/null)" ]; then
|
|
138
|
-
rmdir "$LOCK_DIR" 2>/dev/null && mkdir "$LOCK_DIR" 2>/dev/null \
|
|
139
|
-
|| { printf '[heartbeat] another run active; skipping\n'; exit 0; }
|
|
140
|
-
else
|
|
141
|
-
printf '[heartbeat] another heartbeat/full run is active; skipping\n'; exit 0
|
|
142
|
-
fi
|
|
143
|
-
fi
|
|
144
|
-
trap 'rmdir "$LOCK_DIR" 2>/dev/null' EXIT
|
|
145
|
-
fi
|
|
146
|
-
|
|
147
|
-
# Reconcile gate: the autonomous board-reconciliation pass runs only when
|
|
148
|
-
# role.yaml's reconcile.enabled is true. Deployed PMs default on; setting it
|
|
149
|
-
# false explicitly opts into checkpoint-only operation.
|
|
150
|
-
if [[ "$(reconcile_enabled)" != "true" ]]; then
|
|
151
|
-
printf '[heartbeat] reconcile disabled (reconcile.enabled != true) — checkpoint-only tick\n'
|
|
152
|
-
maybe_checkpoint
|
|
153
|
-
exit 0
|
|
154
|
-
fi
|
|
155
|
-
|
|
156
|
-
decision="$(
|
|
157
|
-
python3 - "$STATE_FILE" "$ACTIVE_MAX_IDLE_SECONDS" "$FULL_RUN_COOLDOWN_SECONDS" "$BLOCKED_FULL_RUN_COOLDOWN_SECONDS" <<'PYEOF'
|
|
158
|
-
import json, subprocess, sys, time
|
|
159
|
-
from pathlib import Path
|
|
160
|
-
state_path = Path(sys.argv[1]); active_max_idle = int(sys.argv[2])
|
|
161
|
-
full_run_cooldown = int(sys.argv[3]); blocked_full_run_cooldown = int(sys.argv[4])
|
|
162
|
-
now = time.time()
|
|
163
|
-
state = {}
|
|
164
|
-
if state_path.exists():
|
|
165
|
-
try: state = json.loads(state_path.read_text())
|
|
166
|
-
except Exception: state = {}
|
|
167
|
-
session = str(state.get("session") or ""); status = str(state.get("status") or "")
|
|
168
|
-
worktree = str(state.get("worktree") or "")
|
|
169
|
-
def iso_epoch(value):
|
|
170
|
-
if not value: return 0
|
|
171
|
-
try: return time.mktime(time.strptime(str(value).replace("Z","+0000"), "%Y-%m-%dT%H:%M:%S%z"))
|
|
172
|
-
except Exception:
|
|
173
|
-
try:
|
|
174
|
-
from datetime import datetime
|
|
175
|
-
return datetime.fromisoformat(str(value).replace("Z","+00:00")).timestamp()
|
|
176
|
-
except Exception: return 0
|
|
177
|
-
if status in {"blocked","stalled","error"}:
|
|
178
|
-
last_blocked_run = (float(state.get("last_full_run_epoch") or 0)
|
|
179
|
-
or iso_epoch(state.get("last_runner_completed_at")) or iso_epoch(state.get("updated_at")))
|
|
180
|
-
if last_blocked_run and (now - last_blocked_run) < blocked_full_run_cooldown:
|
|
181
|
-
print("skip:blocker-cooldown"); raise SystemExit(0)
|
|
182
|
-
if status in {"active","delegated","working"} and session:
|
|
183
|
-
process_active = False
|
|
184
|
-
try:
|
|
185
|
-
result = subprocess.run(["ps","-u",str(__import__("os").getuid()),"-o","pid=,command="],
|
|
186
|
-
check=False, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=5)
|
|
187
|
-
markers = ("codex exec","opencode run","auggie","gemini","kimi","copilot")
|
|
188
|
-
process_active = any(((session and session in line) or (worktree and worktree in line))
|
|
189
|
-
and any(m in line for m in markers) for line in result.stdout.splitlines())
|
|
190
|
-
except Exception: process_active = False
|
|
191
|
-
log_path = Path.home()/".local"/"state"/"zellij"/"sessions"/session/"zellij.log"
|
|
192
|
-
log_recent = log_path.exists() and (now - log_path.stat().st_mtime) <= active_max_idle
|
|
193
|
-
marker_recent = False
|
|
194
|
-
last_activity = iso_epoch(state.get("last_activity_at")) or iso_epoch(state.get("updated_at"))
|
|
195
|
-
if last_activity: marker_recent = (now - last_activity) <= active_max_idle
|
|
196
|
-
if process_active and (log_recent or marker_recent):
|
|
197
|
-
print("skip:active-worker"); raise SystemExit(0)
|
|
198
|
-
last_full_run = float(state.get("last_full_run_epoch") or 0)
|
|
199
|
-
if last_full_run and (now - last_full_run) < full_run_cooldown: print("skip:cooldown")
|
|
200
|
-
else: print("run:full")
|
|
201
|
-
PYEOF
|
|
202
|
-
)"
|
|
203
|
-
|
|
204
|
-
write_state() { # write_state <json-merge-via-python args...>
|
|
205
|
-
python3 - "$STATE_FILE" "$AGENT_ID" "$REPO_NAME" "$LOG_FILE" "$@" "$PROVIDER"
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
case "$decision" in
|
|
209
|
-
skip:*)
|
|
210
|
-
write_state "$decision" <<'PYEOF'
|
|
211
|
-
import json, sys, time
|
|
212
|
-
from datetime import datetime, timezone
|
|
213
|
-
from pathlib import Path
|
|
214
|
-
path = Path(sys.argv[1]); agent_id, repo, log_path, decision, provider = sys.argv[2:7]
|
|
215
|
-
state = json.loads(path.read_text()) if path.exists() else {}
|
|
216
|
-
now_iso = datetime.fromtimestamp(time.time(), timezone.utc).isoformat()
|
|
217
|
-
state.update({"source":"hermes-continuous-ticket-sentinel","agent_id":agent_id,"repo":repo,
|
|
218
|
-
"ticket_provider":provider,"log_path":log_path,"last_heartbeat_at":now_iso,"last_decision":decision})
|
|
219
|
-
state.setdefault("updated_at", now_iso); state.setdefault("summary", state.get("reason") or decision)
|
|
220
|
-
tmp = path.with_suffix(path.suffix + ".tmp"); tmp.write_text(json.dumps(state, indent=2, sort_keys=True)+"\n"); tmp.replace(path)
|
|
221
|
-
PYEOF
|
|
222
|
-
printf '[heartbeat] %s\n' "$decision"
|
|
223
|
-
maybe_checkpoint
|
|
224
|
-
exit 0
|
|
225
|
-
;;
|
|
226
|
-
esac
|
|
227
|
-
|
|
228
|
-
write_state "$decision" <<'PYEOF'
|
|
229
|
-
import json, sys, time
|
|
230
|
-
from datetime import datetime, timezone
|
|
231
|
-
from pathlib import Path
|
|
232
|
-
path = Path(sys.argv[1]); agent_id, repo, log_path, decision, provider = sys.argv[2:7]
|
|
233
|
-
state = json.loads(path.read_text()) if path.exists() else {}
|
|
234
|
-
now = time.time()
|
|
235
|
-
state.update({"source":"hermes-continuous-ticket-sentinel","agent_id":agent_id,"repo":repo,
|
|
236
|
-
"ticket_provider":provider,"status":"checking",
|
|
237
|
-
"summary":"PM is reconciling the ticket board, evidence, and worker state.",
|
|
238
|
-
"log_path":log_path,"last_heartbeat_at":datetime.fromtimestamp(now,timezone.utc).isoformat(),
|
|
239
|
-
"last_decision":decision,"last_full_run_epoch":now,
|
|
240
|
-
"last_full_run_started_at":datetime.fromtimestamp(now,timezone.utc).isoformat()})
|
|
241
|
-
tmp = path.with_suffix(path.suffix + ".tmp"); tmp.write_text(json.dumps(state, indent=2, sort_keys=True)+"\n"); tmp.replace(path)
|
|
242
|
-
PYEOF
|
|
243
|
-
|
|
244
|
-
# Coexistence WIP=1 lease (momo E2/S2.3): don't full-drive if the human-drivable
|
|
245
|
-
# Momo holds it — it's driving the same board. A crashed holder's lease expires
|
|
246
|
-
# (ttl) so the board is never wedged. Release on any exit.
|
|
247
|
-
WIP_LOCK="$RUNTIME/wip-driver.lock"
|
|
248
|
-
if ! python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" acquire "$WIP_LOCK" "hermes:$AGENT_ID" --ttl 3600 >/dev/null 2>&1; then
|
|
249
|
-
printf '[heartbeat] WIP lease held by Momo — skipping full reconcile pass this tick\n'
|
|
250
|
-
maybe_checkpoint
|
|
251
|
-
exit 0
|
|
252
|
-
fi
|
|
253
|
-
trap 'python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" release "$WIP_LOCK" "hermes:$AGENT_ID" >/dev/null 2>&1 || true' EXIT
|
|
254
|
-
|
|
255
|
-
prompt="$(<"$PROMPT_FILE")"
|
|
256
|
-
set +e
|
|
257
|
-
env HERMES_HOME="$RUN_HOME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
|
|
258
|
-
status=$?
|
|
259
|
-
set -e
|
|
260
|
-
|
|
261
|
-
runner_exit="$(
|
|
262
|
-
python3 - "$STATE_FILE" "$status" "$AGENT_ID" "$REPO_NAME" "$LOG_FILE" <<'PYEOF'
|
|
263
|
-
import json, sys, time
|
|
264
|
-
from datetime import datetime, timezone
|
|
265
|
-
from pathlib import Path
|
|
266
|
-
path = Path(sys.argv[1]); exit_code = int(sys.argv[2]); agent_id, repo, log_path = sys.argv[3:6]
|
|
267
|
-
state = json.loads(path.read_text()) if path.exists() else {}
|
|
268
|
-
now_iso = datetime.fromtimestamp(time.time(), timezone.utc).isoformat()
|
|
269
|
-
state.update({"source":"hermes-continuous-ticket-sentinel","agent_id":agent_id,"repo":repo,
|
|
270
|
-
"log_path":log_path,"last_heartbeat_at":now_iso})
|
|
271
|
-
state["last_runner_exit_code"] = exit_code; state["last_runner_completed_at"] = now_iso
|
|
272
|
-
state.setdefault("last_full_run_epoch", time.time())
|
|
273
|
-
if state.get("status") == "checking":
|
|
274
|
-
state["status"] = "idle" if exit_code == 0 else "error"
|
|
275
|
-
state["summary"] = ("PM completed the reconciliation pass without claiming work."
|
|
276
|
-
if exit_code == 0 else "PM reconciliation failed; inspect the heartbeat log.")
|
|
277
|
-
state.setdefault("updated_at", now_iso)
|
|
278
|
-
tmp = path.with_suffix(path.suffix + ".tmp"); tmp.write_text(json.dumps(state, indent=2, sort_keys=True)+"\n"); tmp.replace(path)
|
|
279
|
-
print(0 if state.get("status") in {"active","blocked","stalled","idle"} else exit_code)
|
|
280
|
-
PYEOF
|
|
281
|
-
)"
|
|
282
|
-
maybe_checkpoint
|
|
283
|
-
exit "$runner_exit"
|