@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.
Files changed (83) hide show
  1. package/README.md +41 -520
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +5747 -13483
  10. package/dist/mcp-server.js +6853 -14059
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -513
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. 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"