@delorenj/pjangler 1.2.19 → 1.2.25
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/dist/index.js +2353 -518
- package/dist/mcp-server.js +2331 -494
- package/package.json +14 -3
- package/templates/commonproject/copier.yml +6 -4
- package/templates/commonproject/template/.agents/hooks/README.md +13 -26
- package/templates/commonproject/template/.agents/hooks/lib/local-config.sh +4 -14
- package/templates/commonproject/template/.agents/hooks/sync.py +5 -6
- package/templates/commonproject/template/.agents/local.example.json +2 -8
- package/templates/commonproject/template/.agents/skills.json +6 -0
- package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
- package/templates/commonproject/template/mise.toml.jinja +16 -16
- package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
- package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
- package/templates/hermes-agent/README.md +70 -29
- package/templates/hermes-agent/copier.yml +9 -6
- package/templates/hermes-agent/docs/architecture.md +75 -51
- package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
- package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
- package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
- package/templates/hermes-agent/docs/operations.md +179 -81
- package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
- package/templates/hermes-agent/docs/sentinel/README.md +10 -10
- package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
- package/templates/hermes-agent/docs/sentinel/development.md +12 -15
- package/templates/hermes-agent/docs/sentinel.md +1 -1
- package/templates/hermes-agent/install-local.sh +41 -4
- package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
- package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
- package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
- package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
- package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
- package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
- package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
- package/templates/hermes-agent/template/.gitignore.jinja +5 -2
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
- package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
- package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
- package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
- package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
- package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
- package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
- package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +12 -1
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +137 -0
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +21 -0
- package/templates/hermes-agent/template/SOUL.md.jinja +36 -15
- package/templates/hermes-agent/template/hermes.jinja +6 -3
- package/templates/hermes-agent/template/role.yaml.jinja +24 -13
- package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
- package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
- package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
- package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
- package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
- package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
- package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
- package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
- package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
- package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
- package/templates/commonproject/template/.mise/scripts/link-project-skills-to-clis.sh +0 -110
- package/templates/commonproject/template/.mise/scripts/unlink-project-skills-from-clis.sh +0 -45
- package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
- package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
- package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
- package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
|
@@ -5,7 +5,6 @@ source "$(dirname "$0")/_lib.sh"
|
|
|
5
5
|
load_role_env
|
|
6
6
|
|
|
7
7
|
GW_UNIT="hermes-${AGENT_ID}-gateway.service"
|
|
8
|
-
CSM_UNIT="hermes-${AGENT_ID}-consumer.service"
|
|
9
8
|
HB_TIMER="hermes-${AGENT_ID}-heartbeat.timer"
|
|
10
9
|
|
|
11
10
|
cat >&2 <<EOF
|
|
@@ -15,18 +14,16 @@ cat >&2 <<EOF
|
|
|
15
14
|
│ Talk: @$BOT_HANDLE (Telegram DM)
|
|
16
15
|
│ Shell: $ROLE_DIR/hermes chat "status"
|
|
17
16
|
│ Board: $PLANE_BASE/$PLANE_WORKSPACE/projects/$(cat "$ROLE_DIR/.scripts/.plane-project-id" 2>/dev/null || echo "(skipped)")
|
|
18
|
-
│ Runtime:
|
|
17
|
+
│ Runtime: $ROLE_DIR/runtime (pure-local, ignored)
|
|
19
18
|
│ Hermes bin: $HERMES_BIN
|
|
20
19
|
│ Fleet env: $FLEET_ENV
|
|
21
20
|
│
|
|
22
21
|
│ Start fleet daemons:
|
|
23
22
|
│ systemctl --user start $GW_UNIT
|
|
24
|
-
│ systemctl --user start $CSM_UNIT
|
|
25
23
|
│ systemctl --user start $HB_TIMER
|
|
26
24
|
│
|
|
27
25
|
│ Tail logs:
|
|
28
26
|
│ journalctl --user -fu $GW_UNIT
|
|
29
|
-
│ journalctl --user -fu $CSM_UNIT
|
|
30
27
|
│
|
|
31
28
|
│ Fleet status:
|
|
32
29
|
│ python3 -c "import yaml,pathlib; print(yaml.safe_dump(yaml.safe_load(pathlib.Path('$REGISTRY_FILE').read_text())['agents']))"
|
|
@@ -145,10 +145,17 @@ if [[ -f "$FLEET_ENV" ]]; then
|
|
|
145
145
|
# shellcheck disable=SC1090
|
|
146
146
|
source "$FLEET_ENV"
|
|
147
147
|
fi
|
|
148
|
+
# Identity-bearing chat credentials are never fleet-scoped. Platform wiring
|
|
149
|
+
# steps capture explicit invocation values before sourcing this library and
|
|
150
|
+
# restore only those values afterward; all other provisioning steps stay clean.
|
|
151
|
+
unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
148
152
|
|
|
149
153
|
# Tools we expect on the host
|
|
150
154
|
HERMES_BIN="${HERMES_BIN:-${HERMES_FLEET_BIN:-$(config_get fleet.hermes_bin "$HOME/.hermes/hermes-agent/.venv/bin/hermes")}}"
|
|
151
155
|
HERMES_AGENT_REPO="${HERMES_AGENT_REPO:-${HERMES_FLEET_REPO:-$(config_get fleet.hermes_repo "$HOME/.hermes/hermes-agent")}}"
|
|
156
|
+
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')}"
|
|
152
159
|
HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get fleet.oauth_file "$HOME/.hermes/auth.json")}}"
|
|
153
160
|
CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
|
|
154
161
|
# Prefer a scaffold vendored into this agent directory; fall back to the configured template path.
|
|
@@ -158,6 +165,29 @@ if [[ ! -d "$RUNTIME_SCAFFOLD_DIR" ]]; then
|
|
|
158
165
|
fi
|
|
159
166
|
REGISTRY_FILE="${REGISTRY_FILE:-${HERMES_FLEET_REGISTRY_FILE:-$(config_get fleet.registry_file "$HOME/.hermes/agents-registry.yaml")}}"
|
|
160
167
|
|
|
168
|
+
# Cross-process serialization for fleet identity claims and registry updates.
|
|
169
|
+
# The lock file may remain on disk; flock ownership is kernel-scoped, so a
|
|
170
|
+
# crashed provisioner cannot leave a permanently stale lock.
|
|
171
|
+
FLEET_LOCK_FD=""
|
|
172
|
+
fleet_lock_acquire() {
|
|
173
|
+
command -v flock >/dev/null 2>&1 || die "flock is required for safe fleet registry updates"
|
|
174
|
+
local lock_file="${REGISTRY_FILE}.lock"
|
|
175
|
+
mkdir -p "$(dirname "$lock_file")"
|
|
176
|
+
[[ ! -L "$lock_file" ]] || die "refusing fleet lock symlink: $lock_file"
|
|
177
|
+
exec {FLEET_LOCK_FD}>"$lock_file"
|
|
178
|
+
chmod 600 "$lock_file"
|
|
179
|
+
flock -w "${FLEET_LOCK_TIMEOUT_SECONDS:-30}" "$FLEET_LOCK_FD" \
|
|
180
|
+
|| die "timed out waiting for fleet registry lock: $lock_file"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fleet_lock_release() {
|
|
184
|
+
if [[ "${FLEET_LOCK_FD:-}" =~ ^[0-9]+$ ]]; then
|
|
185
|
+
flock -u "$FLEET_LOCK_FD" 2>/dev/null || true
|
|
186
|
+
eval "exec ${FLEET_LOCK_FD}>&-"
|
|
187
|
+
fi
|
|
188
|
+
FLEET_LOCK_FD=""
|
|
189
|
+
}
|
|
190
|
+
|
|
161
191
|
# Bloodbank / NATS
|
|
162
192
|
BLOODBANK_NATS_HOST="${BLOODBANK_NATS_HOST:-$(config_get bloodbank.nats_host '127.0.0.1')}"
|
|
163
193
|
BLOODBANK_NATS_PORT="${BLOODBANK_NATS_PORT:-$(config_get bloodbank.nats_port '4222')}"
|
|
@@ -167,7 +197,8 @@ BLOODBANK_COMPOSE_DIR="${BLOODBANK_COMPOSE_DIR:-$(config_get bloodbank.compose_d
|
|
|
167
197
|
PLANE_BASE="${PLANE_BASE:-$(config_get plane.base 'https://plane.delo.sh')}"
|
|
168
198
|
PLANE_API_KEY="${PLANE_API_KEY:-${PLANE_33GOD_API_KEY:-}}"
|
|
169
199
|
|
|
170
|
-
export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO
|
|
200
|
+
export FLEET_ENV HERMES_BIN HERMES_AGENT_REPO HERMES_RUNTIME_GIT_URL \
|
|
201
|
+
HERMES_RUNTIME_GIT_REF HERMES_RUNTIME_GIT_SHA HERMES_OAUTH_FILE CODEX_HOME \
|
|
171
202
|
RUNTIME_SCAFFOLD_DIR REGISTRY_FILE \
|
|
172
203
|
BLOODBANK_NATS_HOST BLOODBANK_NATS_PORT BLOODBANK_COMPOSE_DIR \
|
|
173
204
|
PLANE_BASE PLANE_API_KEY
|
|
@@ -180,6 +211,40 @@ systemd_user_available() {
|
|
|
180
211
|
[[ "$state" =~ ^(running|degraded|starting|maintenance)$ ]]
|
|
181
212
|
}
|
|
182
213
|
|
|
214
|
+
# Query one systemd user-unit state without conflating every non-zero exit with
|
|
215
|
+
# an inactive/disabled unit. Output is `ok|<state>` only for documented
|
|
216
|
+
# state/exit-code pairs; D-Bus, manager, and arbitrary query failures become
|
|
217
|
+
# `error|<safe summary>` so retirement callers can fail closed.
|
|
218
|
+
systemctl_user_unit_state() {
|
|
219
|
+
local query="$1" unit="$2" output rc first_line
|
|
220
|
+
command -v systemctl >/dev/null 2>&1 \
|
|
221
|
+
|| { printf 'error|systemctl unavailable'; return 0; }
|
|
222
|
+
set +e
|
|
223
|
+
output="$(LC_ALL=C systemctl --user "$query" "$unit" 2>&1)"
|
|
224
|
+
rc=$?
|
|
225
|
+
set -e
|
|
226
|
+
first_line="${output%%$'\n'*}"
|
|
227
|
+
first_line="${first_line//$'\t'/ }"
|
|
228
|
+
first_line="${first_line//|/}"
|
|
229
|
+
case "$query:$rc:$first_line" in
|
|
230
|
+
is-active:0:active|is-active:0:reloading|is-active:0:activating|is-active:0:deactivating)
|
|
231
|
+
printf 'ok|%s' "$first_line" ;;
|
|
232
|
+
is-active:3:inactive|is-active:3:failed)
|
|
233
|
+
printf 'ok|%s' "$first_line" ;;
|
|
234
|
+
is-active:4:inactive)
|
|
235
|
+
printf 'ok|not-found' ;;
|
|
236
|
+
is-enabled:0:enabled|is-enabled:0:enabled-runtime|is-enabled:0:linked|is-enabled:0:linked-runtime|is-enabled:0:alias|is-enabled:0:static|is-enabled:0:indirect|is-enabled:0:generated|is-enabled:0:transient)
|
|
237
|
+
printf 'ok|%s' "$first_line" ;;
|
|
238
|
+
is-enabled:1:disabled|is-enabled:1:masked|is-enabled:1:masked-runtime)
|
|
239
|
+
printf 'ok|%s' "$first_line" ;;
|
|
240
|
+
is-enabled:4:not-found)
|
|
241
|
+
printf 'ok|not-found' ;;
|
|
242
|
+
*)
|
|
243
|
+
[[ -n "$first_line" ]] || first_line="exit $rc with no state"
|
|
244
|
+
printf 'error|%s' "$first_line" ;;
|
|
245
|
+
esac
|
|
246
|
+
}
|
|
247
|
+
|
|
183
248
|
# Resolve project repo path (the repo that holds agents/hermes/<role>/).
|
|
184
249
|
# Walk up from $ROLE_DIR until we find a git root that isn't us.
|
|
185
250
|
project_repo_path() {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# Legacy checkpoint compatibility for installations whose runtime is still a
|
|
3
|
+
# nested Git repository. Pure-local runtimes intentionally skip this script.
|
|
3
4
|
# Idempotent — exits 0 with no commit if there are no changes.
|
|
4
5
|
#
|
|
5
6
|
# Secret-scan gate (PJAN): before committing, the staged diff is scanned for
|
|
@@ -11,7 +12,7 @@ set -euo pipefail
|
|
|
11
12
|
RUNTIME_DIR="$(cd "$(dirname "$0")/../runtime" && pwd)"
|
|
12
13
|
cd "$RUNTIME_DIR"
|
|
13
14
|
|
|
14
|
-
# Skip
|
|
15
|
+
# Skip when the pure-local runtime has no Git metadata.
|
|
15
16
|
[[ -d .git || -f .git ]] || exit 0
|
|
16
17
|
|
|
17
18
|
# Returns 0 when the staged diff looks clean, 1 when a likely secret is present.
|
|
@@ -13,8 +13,13 @@
|
|
|
13
13
|
[fleet]
|
|
14
14
|
# Absolute path to the shared Hermes executable every agent launcher execs.
|
|
15
15
|
hermes_bin = "~/.hermes/hermes-agent/.venv/bin/hermes"
|
|
16
|
-
# Checkout of the hermes-agent repo.
|
|
16
|
+
# Checkout of the shared hermes-agent repo.
|
|
17
17
|
hermes_repo = "~/.hermes/hermes-agent"
|
|
18
|
+
# Reviewed fleet runtime publication. Provisioning metadata and clean installs
|
|
19
|
+
# must agree on this fork/ref/SHA; do not substitute upstream main.
|
|
20
|
+
hermes_git_url = "https://github.com/delorenj/hermes-agent.git"
|
|
21
|
+
hermes_git_ref = "feature/PJAN-19-routing-publication"
|
|
22
|
+
hermes_git_sha = "113e1b182b6d72a7dd02a191f134a41668ceaf0e"
|
|
18
23
|
# Fallback runtime scaffold dir — used only when an agent's vendored
|
|
19
24
|
# ./.runtime-scaffold is missing.
|
|
20
25
|
runtime_scaffold_dir = "~/code/hermes-agent-template/runtime-scaffold"
|
|
@@ -45,17 +50,12 @@ symlinked_runtime_skills = [
|
|
|
45
50
|
]
|
|
46
51
|
|
|
47
52
|
[github]
|
|
48
|
-
#
|
|
49
|
-
|
|
53
|
+
# LEGACY INERT COMPATIBILITY METADATA ONLY. Older role manifests may still
|
|
54
|
+
# carry this owner field. Current provisioning does not create, attach,
|
|
55
|
+
# synchronize, restore, archive, or remove runtime storage from this value.
|
|
56
|
+
runtime_repo_owner = ""
|
|
50
57
|
|
|
51
58
|
[plane]
|
|
52
59
|
# Plane instance base URL and workspace slug (one project per agent).
|
|
53
60
|
base = "https://plane.delo.sh"
|
|
54
61
|
workspace = "33god"
|
|
55
|
-
|
|
56
|
-
[bloodbank]
|
|
57
|
-
# NATS endpoint the consumer connects to.
|
|
58
|
-
nats_host = "127.0.0.1"
|
|
59
|
-
nats_port = 4222
|
|
60
|
-
# Where the bloodbank docker compose lives (used only for a hint message). ~ ok.
|
|
61
|
-
compose_dir = "~/code/33GOD/bloodbank"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# full pass executes the role's sentinel.prompt.md, which reasons about tickets
|
|
9
9
|
# through the ticket-provider adapter (Linear | Plane | Trello) — never a
|
|
10
10
|
# hardcoded backend. After the sentinel decision (skip OR full), it
|
|
11
|
-
# opportunistically checkpoints
|
|
11
|
+
# opportunistically checkpoints only legacy nested-Git runtimes at most once
|
|
12
12
|
# per HEARTBEAT_CHECKPOINT_MIN_INTERVAL_SECONDS, so memory/session state stays
|
|
13
13
|
# durable without pushing every minute.
|
|
14
14
|
set -euo pipefail
|
|
@@ -229,6 +229,17 @@ state.update({"source":"hermes-continuous-ticket-sentinel","agent_id":agent_id,"
|
|
|
229
229
|
tmp = path.with_suffix(path.suffix + ".tmp"); tmp.write_text(json.dumps(state, indent=2, sort_keys=True)+"\n"); tmp.replace(path)
|
|
230
230
|
PYEOF
|
|
231
231
|
|
|
232
|
+
# Coexistence WIP=1 lease (momo E2/S2.3): don't full-drive if the human-drivable
|
|
233
|
+
# Momo holds it — it's driving the same board. A crashed holder's lease expires
|
|
234
|
+
# (ttl) so the board is never wedged. Release on any exit.
|
|
235
|
+
WIP_LOCK="$RUNTIME/wip-driver.lock"
|
|
236
|
+
if ! python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" acquire "$WIP_LOCK" "hermes:$AGENT_ID" --ttl 3600 >/dev/null 2>&1; then
|
|
237
|
+
printf '[heartbeat] WIP lease held by Momo — skipping full reconcile pass this tick\n'
|
|
238
|
+
maybe_checkpoint
|
|
239
|
+
exit 0
|
|
240
|
+
fi
|
|
241
|
+
trap 'python3 "$ROLE_DIR/.scripts/momo-wip-lock.py" release "$WIP_LOCK" "hermes:$AGENT_ID" >/dev/null 2>&1 || true' EXIT
|
|
242
|
+
|
|
232
243
|
prompt="$(<"$PROMPT_FILE")"
|
|
233
244
|
set +e
|
|
234
245
|
env HERMES_HOME="$RUNTIME" "$HERMES_BIN" chat -Q --source cron --max-turns 90 -q "$prompt"
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""momo-wip-lock — shared WIP=1 driver lease so interactive Momo and the Hermes
|
|
3
|
+
sentinel never double-drive one board (momo E2/S2.3, the coexistence gate).
|
|
4
|
+
|
|
5
|
+
Both drivers acquire the SAME advisory lease (a JSON file, conventionally
|
|
6
|
+
<runtime>/wip-driver.lock) before a board-driving pass. The lease PERSISTS across
|
|
7
|
+
processes — it is deliberately NOT a held flock, because a Momo pass spans many
|
|
8
|
+
separate tool-call/bash invocations over minutes. flock is used only to serialize
|
|
9
|
+
the check-and-set so two acquirers can't race. A lease is respected while its
|
|
10
|
+
heartbeat is fresh (now - heartbeat_at < ttl); a stale lease (holder died without
|
|
11
|
+
releasing) can be stolen after it expires.
|
|
12
|
+
|
|
13
|
+
Owners are free strings by convention: "momo" (interactive) or
|
|
14
|
+
"hermes:<agent_id>" (the sentinel). WIP=1 is per BOARD == per runtime, so there
|
|
15
|
+
is one lease file per runtime.
|
|
16
|
+
|
|
17
|
+
Protocol:
|
|
18
|
+
* Driver start: acquire <lock> <me> -> exit 0 → drive; exit 1 → HELD, back off.
|
|
19
|
+
* While driving: refresh <lock> <me> periodically (< ttl) so the lease stays fresh.
|
|
20
|
+
* Driver end: release <lock> <me>.
|
|
21
|
+
* A holder that crashed leaves a lease that expires after `ttl`; the next driver
|
|
22
|
+
acquires it normally (or `--steal` to take an explicitly stale one immediately).
|
|
23
|
+
|
|
24
|
+
Commands (exit 0 = you hold it; 1 = someone else holds it fresh; 2 = usage/error):
|
|
25
|
+
acquire <lockfile> <owner> [--ttl S] [--steal]
|
|
26
|
+
refresh <lockfile> <owner>
|
|
27
|
+
release <lockfile> <owner>
|
|
28
|
+
status <lockfile> # always exit 0
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
import argparse, fcntl, json, os, socket, sys, tempfile, time
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _read(path: str):
|
|
35
|
+
try:
|
|
36
|
+
with open(path) as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _fresh(lease: dict | None, now: float) -> bool:
|
|
43
|
+
return bool(lease) and (now - lease.get("heartbeat_at", 0)) < lease.get("ttl", 300)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _write_atomic(path: str, data: dict) -> None:
|
|
47
|
+
d = os.path.dirname(os.path.abspath(path)) or "."
|
|
48
|
+
fd, tmp = tempfile.mkstemp(dir=d)
|
|
49
|
+
with os.fdopen(fd, "w") as f:
|
|
50
|
+
json.dump(data, f)
|
|
51
|
+
os.replace(tmp, path)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _guard(lockfile: str):
|
|
55
|
+
"""Sibling .flock file held only for the read-modify-write critical section."""
|
|
56
|
+
g = open(lockfile + ".flock", "a")
|
|
57
|
+
fcntl.flock(g, fcntl.LOCK_EX)
|
|
58
|
+
return g
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def acquire(lockfile: str, owner: str, ttl: int, steal: bool) -> int:
|
|
62
|
+
now = time.time(); g = _guard(lockfile)
|
|
63
|
+
try:
|
|
64
|
+
cur = _read(lockfile)
|
|
65
|
+
if cur and cur.get("owner") != owner and _fresh(cur, now) and not steal:
|
|
66
|
+
print(f"HELD by {cur['owner']} (fresh, {int(now - cur['heartbeat_at'])}s ago) — back off")
|
|
67
|
+
return 1
|
|
68
|
+
started = cur["started_at"] if (cur and cur.get("owner") == owner and "started_at" in cur) else now
|
|
69
|
+
_write_atomic(lockfile, {
|
|
70
|
+
"owner": owner, "pid": os.getpid(), "host": socket.gethostname(),
|
|
71
|
+
"started_at": started, "heartbeat_at": now, "ttl": ttl,
|
|
72
|
+
})
|
|
73
|
+
print(f"ACQUIRED by {owner}" + (" (stole stale lease)" if (cur and cur.get('owner') != owner) else ""))
|
|
74
|
+
return 0
|
|
75
|
+
finally:
|
|
76
|
+
fcntl.flock(g, fcntl.LOCK_UN); g.close()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def refresh(lockfile: str, owner: str) -> int:
|
|
80
|
+
now = time.time(); g = _guard(lockfile)
|
|
81
|
+
try:
|
|
82
|
+
cur = _read(lockfile)
|
|
83
|
+
if not cur or cur.get("owner") != owner:
|
|
84
|
+
print(f"NOT OWNER (held by {cur.get('owner') if cur else 'nobody'}) — cannot refresh")
|
|
85
|
+
return 1
|
|
86
|
+
cur["heartbeat_at"] = now
|
|
87
|
+
_write_atomic(lockfile, cur); print("REFRESHED"); return 0
|
|
88
|
+
finally:
|
|
89
|
+
fcntl.flock(g, fcntl.LOCK_UN); g.close()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def release(lockfile: str, owner: str) -> int:
|
|
93
|
+
g = _guard(lockfile)
|
|
94
|
+
try:
|
|
95
|
+
cur = _read(lockfile)
|
|
96
|
+
if cur and cur.get("owner") != owner:
|
|
97
|
+
print(f"NOT OWNER (held by {cur['owner']}) — not releasing"); return 1
|
|
98
|
+
try:
|
|
99
|
+
os.remove(lockfile)
|
|
100
|
+
except FileNotFoundError:
|
|
101
|
+
pass
|
|
102
|
+
print("RELEASED"); return 0
|
|
103
|
+
finally:
|
|
104
|
+
fcntl.flock(g, fcntl.LOCK_UN); g.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def status(lockfile: str) -> int:
|
|
108
|
+
now = time.time(); cur = _read(lockfile)
|
|
109
|
+
if not cur:
|
|
110
|
+
print("FREE (no lease)"); return 0
|
|
111
|
+
state = "fresh" if _fresh(cur, now) else "STALE"
|
|
112
|
+
print(f"{cur['owner']} — {state} (heartbeat {int(now - cur.get('heartbeat_at', 0))}s ago, "
|
|
113
|
+
f"ttl {cur.get('ttl')}s, pid {cur.get('pid')}@{cur.get('host')})")
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def main() -> int:
|
|
118
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
119
|
+
ap.add_argument("cmd", choices=["acquire", "refresh", "release", "status"])
|
|
120
|
+
ap.add_argument("lockfile")
|
|
121
|
+
ap.add_argument("owner", nargs="?")
|
|
122
|
+
ap.add_argument("--ttl", type=int, default=300, help="freshness window in seconds (default 300)")
|
|
123
|
+
ap.add_argument("--steal", action="store_true", help="take an explicitly stale lease immediately")
|
|
124
|
+
a = ap.parse_args()
|
|
125
|
+
if a.cmd in ("acquire", "refresh", "release") and not a.owner:
|
|
126
|
+
print("owner required", file=sys.stderr); return 2
|
|
127
|
+
if a.cmd == "acquire":
|
|
128
|
+
return acquire(a.lockfile, a.owner, a.ttl, a.steal)
|
|
129
|
+
if a.cmd == "refresh":
|
|
130
|
+
return refresh(a.lockfile, a.owner)
|
|
131
|
+
if a.cmd == "release":
|
|
132
|
+
return release(a.lockfile, a.owner)
|
|
133
|
+
return status(a.lockfile)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
raise SystemExit(main())
|
|
@@ -31,6 +31,12 @@ on Linear, Plane, or Trello.
|
|
|
31
31
|
|
|
32
32
|
## Pass
|
|
33
33
|
|
|
34
|
+
**Trigger.** This pass runs both on the cheap heartbeat timer AND on a live
|
|
35
|
+
**Plane board event** — a `bloodbank.evt.v1.repo.<repo>.ticket_*` event delivered
|
|
36
|
+
by the `plane-webhook-bridge`. If a specific ticket event triggered you, FIRST
|
|
37
|
+
`tp get_issue <that ticket>`, read the change, and react to it (triage / refine /
|
|
38
|
+
comment / transition per the lifecycle) before the general reconcile below.
|
|
39
|
+
|
|
34
40
|
1. Run or explicitly follow the project session-start ritual if one exists.
|
|
35
41
|
2. Reconcile: active milestone (`tp active_milestone`), issues (`tp list_issues`),
|
|
36
42
|
local evidence under `_bmad-output/implementation-artifacts/issue-evidence/`,
|
|
@@ -85,6 +91,21 @@ on Linear, Plane, or Trello.
|
|
|
85
91
|
as done, move on), **held** (back to active), or a genuine **out-of-scope
|
|
86
92
|
blocker** (recorded and waited on). There is no fourth "waiting for the
|
|
87
93
|
operator's sign-off" state.
|
|
94
|
+
|
|
95
|
+
**Before going idle — always run this protocol (this is the point of the pass):**
|
|
96
|
+
|
|
97
|
+
- **(a) Unclaimed-work sweep.** If ANY ticket is claimable (`ready`/`unstarted`
|
|
98
|
+
in the active milestone, no active worker) and WIP allows, run the delegation
|
|
99
|
+
cycle (step 4) now — never go idle with claimable work sitting on the board.
|
|
100
|
+
- **(b) In-progress status sweep.** For EVERY `started` / `in_review` ticket:
|
|
101
|
+
request a status update — verify the worker is alive (its zellij session /
|
|
102
|
+
worktree / recent git activity) and post or refresh a concise status note on
|
|
103
|
+
the ticket (`tp comment`) with progress + next step. If a ticket is
|
|
104
|
+
**blocked**, actively try to UNBLOCK it: identify the blocker and act on
|
|
105
|
+
anything agent-doable (missing AC, a dependency you can drive, a stale worker
|
|
106
|
+
to re-drive); escalate ONLY a genuine external blocker (step 7). A silent or
|
|
107
|
+
stalled worker is re-driven or flagged — never left silent.
|
|
108
|
+
|
|
88
109
|
9. Update `runtime/continuous-ticket-sentinel-state.json`: `active` /
|
|
89
110
|
`blocked` / `idle` / `stalled` with the required fields (`source`, `agent_id`,
|
|
90
111
|
`repo`, `ticket_provider`, `status`, `summary`, `reason`, `updated_at`,
|
|
@@ -17,9 +17,10 @@ You are **{{ display_name }}** — a Hermes agent provisioned to work inside the
|
|
|
17
17
|
|
|
18
18
|
You operate **only** within the working directory of `{{ target_repo }}`. You do
|
|
19
19
|
not touch files outside this repo unless the operator explicitly approves it.
|
|
20
|
-
Your HERMES_HOME is the
|
|
21
|
-
`
|
|
22
|
-
|
|
20
|
+
Your HERMES_HOME is the local runtime at `./runtime/`; Hermes loads its
|
|
21
|
+
`config.yaml` directly. Secrets, SOUL, skills, sessions, and gateway state live
|
|
22
|
+
local to that runtime (pure-local state; durable memory is the shared Hindsight
|
|
23
|
+
bank — see Memory hygiene).
|
|
23
24
|
|
|
24
25
|
## Tone
|
|
25
26
|
|
|
@@ -43,8 +44,8 @@ question — not three vague ones.
|
|
|
43
44
|
You **MUST** emit a Bloodbank event for every consequential action you take.
|
|
44
45
|
Envelope shape: CloudEvents 1.0, type `bloodbank.v1.<domain>.<entity>.<action>`,
|
|
45
46
|
`actor.agent_id = {{ agent_id }}`, `producer = hermes-agent:{{ agent_id }}`,
|
|
46
|
-
`source = hermes://agent/{{ agent_id }}`.
|
|
47
|
-
|
|
47
|
+
`source = hermes://agent/{{ agent_id }}`. Inbound commands arrive through the
|
|
48
|
+
fleet-shared Hermes gateway and are routed by `data.target_agent_id`.
|
|
48
49
|
|
|
49
50
|
You **MUST NOT** invent new event `type` values. Bloodbank owns the naming
|
|
50
51
|
contract at `~/code/33GOD/bloodbank/docs/event-naming.md` —
|
|
@@ -53,12 +54,24 @@ read it before publishing a type you haven't published before.
|
|
|
53
54
|
## Role-specific behavior
|
|
54
55
|
|
|
55
56
|
{% if role == "pm" -%}
|
|
56
|
-
You are the **project
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
You are the **project-manager ORCHESTRATOR** — the autonomous Hermes carrier of
|
|
58
|
+
Momo, and the twin of the human-drivable Momo. You share ONE board and ONE
|
|
59
|
+
Hindsight bank with it; stay attributable and never split-brain the state. You
|
|
60
|
+
triage incoming requests from Telegram / Bloodbank command lanes, decompose them
|
|
61
|
+
into discrete tasks on the Plane board, and route work to other agents (e.g. the
|
|
62
|
+
dev role on `bloodbank.cmd.v1.agent.task.assign` with
|
|
63
|
+
`data.target_agent_id = {{ target_repo }}-dev`).
|
|
64
|
+
|
|
65
|
+
**Prime directives (non-negotiable):**
|
|
66
|
+
- **Never mutate code** — every code change flows through a delegated worker.
|
|
67
|
+
- **WIP = 1**, shared with the human-drivable Momo via the driver lease
|
|
68
|
+
(`.scripts/momo-wip-lock.py` → `runtime/wip-driver.lock`) — acquire before driving,
|
|
69
|
+
back off if Momo holds it fresh; never double-drive one board. (The heartbeat
|
|
70
|
+
enforces this automatically for the reconcile pass.)
|
|
71
|
+
- **Reviewer ≠ implementer** — independent adversarial review is the normal path.
|
|
72
|
+
- **Evidence over status** — a board column is a claim; repo evidence is proof.
|
|
73
|
+
- **Anti-stall** — never park a pass on operator sign-off.
|
|
74
|
+
- You do not write application code. You do not approve merges.
|
|
62
75
|
|
|
63
76
|
Default execution workflow for implementation delivery: use
|
|
64
77
|
`subagent-driven-development` in kanban-orchestrated codex mode
|
|
@@ -114,7 +127,15 @@ declared` event so the fleet knows what to route to you.
|
|
|
114
127
|
|
|
115
128
|
## Memory hygiene
|
|
116
129
|
|
|
117
|
-
Your memory is the
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
Your durable memory is the shared **Hindsight bank `{{ target_repo }}`** — one
|
|
131
|
+
bank per PROJECT, shared with the human-drivable Momo twin. Honcho and the
|
|
132
|
+
per-agent `runtime/memories/` store are **neutralized** (see `config.yaml`
|
|
133
|
+
`memory.provider: ""`): do not rely on `MEMORY.md`/`USER.md`. Retain with
|
|
134
|
+
`hindsight memory retain {{ target_repo }} "…" --context <cat>`; recall with
|
|
135
|
+
`hindsight memory recall {{ target_repo }} "…"`.
|
|
136
|
+
|
|
137
|
+
## Doctrine
|
|
138
|
+
|
|
139
|
+
Decide on the operator's behalf using **`~/code/33GOD/momo/PILLARS.md`**
|
|
140
|
+
(canonical, priority-ordered). This soul **references** that file; it does not
|
|
141
|
+
copy it. Cite the pillar(s) that drove a consequential call in its decision event.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Launcher for {{ agent_id }}. Resolves HERMES_HOME to the runtime
|
|
2
|
+
# Launcher for {{ agent_id }}. Resolves HERMES_HOME to the local runtime
|
|
3
3
|
# and execs the shared fleet Hermes binary configured in ~/.hermes/fleet.env.
|
|
4
4
|
|
|
5
5
|
set -euo pipefail
|
|
@@ -12,6 +12,9 @@ if [[ -f "$FLEET_ENV" ]]; then
|
|
|
12
12
|
# shellcheck disable=SC1090
|
|
13
13
|
source "$FLEET_ENV"
|
|
14
14
|
fi
|
|
15
|
+
# Never pass an identity-bearing chat credential from shared fleet state into
|
|
16
|
+
# a profile process. Hermes loads the profile's own runtime/.env itself.
|
|
17
|
+
unset TELEGRAM_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN
|
|
15
18
|
|
|
16
19
|
CONFIG="${HERMES_TEMPLATE_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/hermes-agent-template/config.toml}"
|
|
17
20
|
config_get() {
|
|
@@ -49,8 +52,8 @@ HERMES_OAUTH_FILE="${HERMES_OAUTH_FILE:-${HERMES_FLEET_OAUTH_FILE:-$(config_get
|
|
|
49
52
|
CODEX_HOME="${CODEX_HOME:-${HERMES_FLEET_CODEX_HOME:-$(config_get fleet.codex_home "$HOME/.codex")}}"
|
|
50
53
|
|
|
51
54
|
if [[ ! -d "$HERMES_HOME" ]]; then
|
|
52
|
-
echo "hermes: runtime
|
|
53
|
-
echo " fix:
|
|
55
|
+
echo "hermes: local runtime not provisioned at $HERMES_HOME" >&2
|
|
56
|
+
echo " fix: run $ROLE_DIR/.scripts/20-runtime-repo.sh" >&2
|
|
54
57
|
exit 1
|
|
55
58
|
fi
|
|
56
59
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Hermes agent role manifest. Tracked in the PROJECT repository.
|
|
2
2
|
# The agent's actual state (memory, sessions, evolving SOUL) lives in the
|
|
3
|
-
# runtime
|
|
3
|
+
# ignored local runtime. This file is the immutable contract for this role.
|
|
4
4
|
|
|
5
5
|
repo: {{ target_repo }}
|
|
6
6
|
role: {{ role }}
|
|
@@ -14,12 +14,24 @@ model:
|
|
|
14
14
|
name: "{{ model_name }}"
|
|
15
15
|
|
|
16
16
|
# Hermes profile name. ~/.hermes/profiles/<name> symlinks to ./runtime/, so both
|
|
17
|
-
# HERMES_HOME and `hermes --profile <name>` resolve to the runtime
|
|
17
|
+
# HERMES_HOME and `hermes --profile <name>` resolve to the local runtime.
|
|
18
18
|
profile: {{ agent_id }}
|
|
19
19
|
|
|
20
20
|
# Telegram bot identity (one bot per agent — see docs/architecture.md).
|
|
21
21
|
telegram:
|
|
22
|
+
provisioning_status: "deferred"
|
|
22
23
|
bot_username: "{{ bot_handle }}"
|
|
24
|
+
bot_id: ""
|
|
25
|
+
|
|
26
|
+
# Slack is opt-in. Credentials live only in runtime/.env; this block records
|
|
27
|
+
# safe provisioning state and the verified auth.test identity.
|
|
28
|
+
slack:
|
|
29
|
+
provisioning_status: "deferred"
|
|
30
|
+
team_id: ""
|
|
31
|
+
team_name: ""
|
|
32
|
+
bot_user_id: ""
|
|
33
|
+
bot_id: ""
|
|
34
|
+
bot_username: ""
|
|
23
35
|
|
|
24
36
|
# Ticket-provider binding (linear | plane | trello).
|
|
25
37
|
# The heartbeat reconciliation pass talks ONLY to this via .scripts/lib/ticket-provider.sh;
|
|
@@ -54,25 +66,24 @@ plane:
|
|
|
54
66
|
# identifier is set by .scripts/42-ticket-provider.sh after creation (plane only)
|
|
55
67
|
identifier: ""
|
|
56
68
|
|
|
57
|
-
# Bloodbank
|
|
69
|
+
# Bloodbank ingress is owned by one fleet-shared Hermes gateway. The gateway
|
|
70
|
+
# reads the fleet registry and routes canonical commands by target_agent_id;
|
|
71
|
+
# this profile does not install its own NATS consumer or inbox bridge.
|
|
58
72
|
bloodbank:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
- "bloodbank.cmd.v1.agent.>"
|
|
62
|
-
routing:
|
|
63
|
-
repo: "{{ target_repo }}"
|
|
64
|
-
target_agent_id: "{{ agent_id }}"
|
|
73
|
+
gateway_scope: fleet
|
|
74
|
+
target_agent_id: "{{ agent_id }}"
|
|
65
75
|
producer: "hermes-agent:{{ agent_id }}"
|
|
66
76
|
|
|
67
|
-
#
|
|
77
|
+
# Pure-local HERMES_HOME. The legacy GitHub identity remains metadata-only for
|
|
78
|
+
# locating old archives; provisioning never creates or attaches a submodule.
|
|
68
79
|
runtime:
|
|
69
80
|
# Empty owner -> resolved from config.toml [github].runtime_repo_owner.
|
|
70
81
|
github_owner: "{{ runtime_repo_owner }}"
|
|
71
82
|
github_repo: "{{ runtime_repo }}"
|
|
72
|
-
|
|
83
|
+
local_path: "./runtime"
|
|
73
84
|
checkpoint:
|
|
74
|
-
cadence: "
|
|
75
|
-
on_session_end:
|
|
85
|
+
cadence: "disabled"
|
|
86
|
+
on_session_end: false
|
|
76
87
|
|
|
77
88
|
# Provenance — managed by the template, do not hand-edit.
|
|
78
89
|
provisioned_at: "{{ '%Y-%m-%dT%H:%M:%SZ' | strftime }}"
|