@delorenj/pjangler 1.2.21 → 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.
Files changed (63) hide show
  1. package/dist/index.js +1852 -368
  2. package/dist/mcp-server.js +1782 -296
  3. package/package.json +14 -3
  4. package/templates/commonproject/copier.yml +3 -0
  5. package/templates/commonproject/template/.mise/scripts/provision-bmad-skills.py +480 -0
  6. package/templates/commonproject/template/.mise/scripts/sync-skills.py +449 -0
  7. package/templates/commonproject/template/mise.toml.jinja +9 -2
  8. package/templates/commonproject/tests/test_provision_bmad_skills.py +203 -0
  9. package/templates/commonproject/tests/test_sync_skills_topology.py +120 -0
  10. package/templates/hermes-agent/README.md +70 -29
  11. package/templates/hermes-agent/copier.yml +9 -6
  12. package/templates/hermes-agent/docs/architecture.md +75 -51
  13. package/templates/hermes-agent/docs/fleet-control-plane/architecture.md +11 -6
  14. package/templates/hermes-agent/docs/fleet-control-plane/epics-and-stories.md +2 -2
  15. package/templates/hermes-agent/docs/fleet-control-plane/prd.md +2 -3
  16. package/templates/hermes-agent/docs/operations.md +179 -81
  17. package/templates/hermes-agent/docs/runbooks/runtime-checkpoint-repair.md +31 -171
  18. package/templates/hermes-agent/docs/sentinel/README.md +10 -10
  19. package/templates/hermes-agent/docs/sentinel/architecture.md +6 -8
  20. package/templates/hermes-agent/docs/sentinel/development.md +12 -15
  21. package/templates/hermes-agent/docs/sentinel.md +1 -1
  22. package/templates/hermes-agent/install-local.sh +41 -4
  23. package/templates/hermes-agent/runtime-scaffold/README.md +4 -2
  24. package/templates/hermes-agent/scripts/degit-runtime.sh +192 -0
  25. package/templates/hermes-agent/scripts/fleet-prune-debris.sh +128 -0
  26. package/templates/hermes-agent/scripts/fleet-sync.sh +164 -7
  27. package/templates/hermes-agent/scripts/hermes-runtime-templatize.py +321 -0
  28. package/templates/hermes-agent/scripts/migrate-profile-scoped-chat-secrets.py +136 -0
  29. package/templates/hermes-agent/scripts/momo-unify-agent.py +224 -0
  30. package/templates/hermes-agent/scripts/repair-runtime-checkpoint.sh +7 -119
  31. package/templates/hermes-agent/template/.gitignore.jinja +5 -2
  32. package/templates/hermes-agent/template/.runtime-scaffold/README.md +4 -2
  33. package/templates/hermes-agent/template/.scripts/00-banner.sh +1 -1
  34. package/templates/hermes-agent/template/.scripts/01-config.sh +3 -0
  35. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +12 -1
  36. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +55 -129
  37. package/templates/hermes-agent/template/.scripts/30-telegram.sh +276 -22
  38. package/templates/hermes-agent/template/.scripts/31-slack.sh +362 -0
  39. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +8 -66
  40. package/templates/hermes-agent/template/.scripts/70-systemd.sh +44 -30
  41. package/templates/hermes-agent/template/.scripts/80-registry.sh +93 -13
  42. package/templates/hermes-agent/template/.scripts/99-summary.sh +1 -4
  43. package/templates/hermes-agent/template/.scripts/_lib.sh +66 -1
  44. package/templates/hermes-agent/template/.scripts/checkpoint.sh +3 -2
  45. package/templates/hermes-agent/template/.scripts/config.example.toml +10 -10
  46. package/templates/hermes-agent/template/.scripts/heartbeat.sh +1 -1
  47. package/templates/hermes-agent/template/SOUL.md.jinja +2 -2
  48. package/templates/hermes-agent/template/hermes.jinja +6 -3
  49. package/templates/hermes-agent/template/role.yaml.jinja +24 -13
  50. package/templates/hermes-agent/tests/test_bloodbank_consumer_contract.py +429 -130
  51. package/templates/hermes-agent/tests/test_fleet_upgrade_contract.py +176 -0
  52. package/templates/hermes-agent/tests/test_launcher_chat_secret_isolation.py +85 -0
  53. package/templates/hermes-agent/tests/test_local_runtime_provisioning.py +130 -0
  54. package/templates/hermes-agent/tests/test_operator_docs_local_runtime_contract.py +132 -0
  55. package/templates/hermes-agent/tests/test_profile_env_detach_contract.py +14 -0
  56. package/templates/hermes-agent/tests/test_profile_scoped_chat_secrets.py +89 -0
  57. package/templates/hermes-agent/tests/test_repository_privacy_contract.py +32 -0
  58. package/templates/hermes-agent/tests/test_slack_provisioning.py +344 -0
  59. package/templates/hermes-agent/tests/test_telegram_provisioning.py +475 -0
  60. package/templates/hermes-agent/.codegraph/daemon.pid +0 -6
  61. package/templates/hermes-agent/.omo/run-continuation/ses_0e5b28303ffeXxL53hZjKggXDW.json +0 -10
  62. package/templates/hermes-agent/runtime-scaffold/bloodbank-consumer.py +0 -181
  63. package/templates/hermes-agent/template/.runtime-scaffold/bloodbank-consumer.py +0 -181
@@ -127,8 +127,8 @@ The script:
127
127
  5. binds the PM to the existing board (it never creates one — it scrubs the
128
128
  provider key from Copier's environment so `42-ticket-provider.sh` skips board
129
129
  creation),
130
- 6. installs the PM's heartbeat timer (board-reconciliation sentinel pass + gated
131
- runtime checkpoint) as a `launchd` agent on macOS or a `systemd` timer on
130
+ 6. installs the PM's board-reconciliation heartbeat timer as a `launchd` agent
131
+ on macOS or a `systemd` timer on
132
132
  Linux,
133
133
  7. smoke-tests the board connection through the adapter.
134
134
 
@@ -160,7 +160,8 @@ order. Several of them reach outside the repository.
160
160
  <!-- prettier-ignore -->
161
161
  > [!CAUTION]
162
162
  > Full provisioning is outward-facing and partly interactive.
163
- > `20-runtime-repo.sh` creates a private GitHub repo with `gh repo create`,
163
+ > `20-runtime-repo.sh` populates the ignored local runtime and links its profile;
164
+ > it creates no GitHub storage,
164
165
  > `30-telegram.sh` prompts for a BotFather token and blocks waiting for input,
165
166
  > and `42-ticket-provider.sh` can create a Plane project. Don't run full
166
167
  > provisioning unattended.
@@ -171,10 +172,10 @@ script checks its flag and skips cleanly.
171
172
  | Flag | Skips |
172
173
  | --- | --- |
173
174
  | `SKIP_TELEGRAM` | The interactive BotFather token step. |
174
- | `SKIP_RUNTIME_REPO` | Creating the GitHub runtime repo. |
175
+ | `SKIP_RUNTIME_REPO` | Legacy flag name: skip local runtime population/profile linking. No remote storage is created. |
175
176
  | `SKIP_PLANE` | Creating a Plane project. |
176
- | `SKIP_BLOODBANK` | Installing the BloodBank consumer. |
177
- | `SKIP_SYSTEMD` | Installing `systemd` units (gateway, consumer, heartbeat timer). |
177
+ | `SKIP_BLOODBANK` | Compatibility no-op; Bloodbank ingress is fleet-shared. |
178
+ | `SKIP_SYSTEMD` | Installing `systemd` units (profile gateway and heartbeat timer). |
178
179
 
179
180
  For example, a local install that creates no cloud resources:
180
181
 
@@ -191,9 +192,8 @@ on Linux through a `systemd` `EnvironmentFile` (for example
191
192
  `~/.hermes/<agent_id>.env`); on macOS the `launchd` agent sources that same
192
193
  per-agent env file, so write the key there.
193
194
 
194
- `SKIP_SYSTEMD` gates the gateway, consumer, and the fused `heartbeat` timer in
195
- `70-systemd.sh`. The heartbeat timer is what drives the sentinel pass and the
196
- gated checkpoint, so a local install that wants the sentinel running must leave
195
+ `SKIP_SYSTEMD` gates the profile gateway and `heartbeat` timer in
196
+ `70-systemd.sh`. A local install that wants the sentinel running must leave
197
197
  `SKIP_SYSTEMD` unset (or install the timer afterward).
198
198
 
199
199
  ## Propagating changes
@@ -219,12 +219,9 @@ The sentinel began as a bespoke loop on the `pm` role, was briefly extracted int
219
219
  a standalone `scrum-master` role with its own `continuous-ticket-sentinel` timer,
220
220
  and has now been folded back into the unified PM. Today there is exactly one
221
221
  role (`pm`), one engine (under `.scripts/sentinel/`), and one timer
222
- (`hermes-<agent>-heartbeat.timer`) that fuses the board-reconciliation sentinel
223
- pass with a gated runtime checkpoint. The old per-agent
224
- `hermes-<agent>-continuous-ticket-sentinel.timer` and the separate
225
- `hermes-<agent>-checkpoint.timer` are gone; both are replaced by the single
226
- `heartbeat` timer. The earlier standalone-role design is recoverable from Git
227
- history if you ever need to compare.
222
+ (`hermes-<agent>-heartbeat.timer`) for board reconciliation. The earlier
223
+ standalone-role design is recoverable from Git history if you ever need to
224
+ compare.
228
225
 
229
226
  ## Known gotchas
230
227
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  The PM runs a provider-agnostic ticket sentinel out-of-band on its heartbeat
4
4
  timer: a board-reconciliation pass with an autonomous adversarial review (act,
5
- do not wait), fused with a gated runtime checkpoint into a single timer tick.
5
+ do not wait). Pure-local runtime persistence is outside the heartbeat contract.
6
6
  The full documentation lives in a dedicated handoff guide.
7
7
 
8
8
  This page is kept as a stable pointer so existing links keep working.
@@ -21,6 +21,12 @@
21
21
  # Provider creds: LINEAR_API_KEY | PLANE_API_KEY+PLANE_BASE | TRELLO_KEY+TRELLO_TOKEN
22
22
  set -eu
23
23
 
24
+ # Fleet runtime publication. These values intentionally identify the reviewed
25
+ # fork commit; clean installs must never fall back to NousResearch/main.
26
+ HERMES_RUNTIME_GIT_URL="https://github.com/delorenj/hermes-agent.git"
27
+ HERMES_RUNTIME_GIT_REF="feature/PJAN-19-routing-publication"
28
+ HERMES_RUNTIME_GIT_SHA="113e1b182b6d72a7dd02a191f134a41668ceaf0e"
29
+
24
30
  say() { printf '\033[36m%s\033[0m\n' "$*"; }
25
31
  warn() { printf '\033[33m%s\033[0m\n' "$*" >&2; }
26
32
  die() { printf '\033[31m%s\033[0m\n' "$*" >&2; exit 1; }
@@ -53,7 +59,38 @@ if command -v hermes >/dev/null 2>&1; then
53
59
  say "1. hermes: found ($(command -v hermes))"
54
60
  else
55
61
  say "1. hermes: not found — installing"
56
- run "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash"
62
+ HERMES_INSTALL_DIR="${HERMES_INSTALL_DIR:-$HOME/.hermes/hermes-agent}"
63
+ if [ "${HAT_DRY_RUN:-0}" = "1" ]; then
64
+ say " [dry-run] clone $HERMES_RUNTIME_GIT_URL@$HERMES_RUNTIME_GIT_REF"
65
+ say " [dry-run] verify and install commit $HERMES_RUNTIME_GIT_SHA"
66
+ else
67
+ command -v git >/dev/null 2>&1 || die "git is required to install the pinned Hermes runtime"
68
+ if [ -d "$HERMES_INSTALL_DIR/.git" ]; then
69
+ _origin="$(git -C "$HERMES_INSTALL_DIR" remote get-url origin 2>/dev/null || true)"
70
+ [ "$_origin" = "$HERMES_RUNTIME_GIT_URL" ] \
71
+ || die "existing Hermes checkout origin is not the reviewed fleet fork: $HERMES_INSTALL_DIR"
72
+ git -C "$HERMES_INSTALL_DIR" fetch origin "$HERMES_RUNTIME_GIT_REF"
73
+ elif [ -e "$HERMES_INSTALL_DIR" ]; then
74
+ die "Hermes install path exists but is not the reviewed fork checkout: $HERMES_INSTALL_DIR"
75
+ else
76
+ mkdir -p "$(dirname "$HERMES_INSTALL_DIR")"
77
+ git clone --branch "$HERMES_RUNTIME_GIT_REF" --single-branch \
78
+ "$HERMES_RUNTIME_GIT_URL" "$HERMES_INSTALL_DIR"
79
+ fi
80
+ git -C "$HERMES_INSTALL_DIR" cat-file -e "$HERMES_RUNTIME_GIT_SHA^{commit}" \
81
+ || die "pinned Hermes commit is unavailable from the reviewed fork"
82
+ git -C "$HERMES_INSTALL_DIR" merge-base --is-ancestor \
83
+ "$HERMES_RUNTIME_GIT_SHA" "origin/$HERMES_RUNTIME_GIT_REF" \
84
+ || die "pinned Hermes commit is not on the reviewed publication ref"
85
+ bash "$HERMES_INSTALL_DIR/scripts/install.sh" \
86
+ --dir "$HERMES_INSTALL_DIR" \
87
+ --branch "$HERMES_RUNTIME_GIT_REF" \
88
+ --commit "$HERMES_RUNTIME_GIT_SHA" \
89
+ --skip-setup
90
+ _installed_sha="$(git -C "$HERMES_INSTALL_DIR" rev-parse HEAD)"
91
+ [ "$_installed_sha" = "$HERMES_RUNTIME_GIT_SHA" ] \
92
+ || die "Hermes installer did not retain pinned commit $HERMES_RUNTIME_GIT_SHA"
93
+ fi
57
94
  command -v hermes >/dev/null 2>&1 || die "hermes install did not put 'hermes' on PATH. Open a new shell and re-run."
58
95
  fi
59
96
  HERMES_BIN="$(command -v hermes)"
@@ -82,6 +119,9 @@ else
82
119
  [fleet]
83
120
  hermes_bin = "$HERMES_BIN"
84
121
  hermes_repo = "$HOME/.hermes/hermes-agent"
122
+ hermes_git_url = "$HERMES_RUNTIME_GIT_URL"
123
+ hermes_git_ref = "$HERMES_RUNTIME_GIT_REF"
124
+ hermes_git_sha = "$HERMES_RUNTIME_GIT_SHA"
85
125
  fleet_env = "~/.hermes/fleet.env"
86
126
  registry_file = "~/.hermes/agents-registry.yaml"
87
127
 
@@ -92,9 +132,6 @@ runtime_repo_owner = ""
92
132
  base = "${PLANE_BASE:-}"
93
133
  workspace = ""
94
134
 
95
- [bloodbank]
96
- nats_host = "127.0.0.1"
97
- nats_port = 4222
98
135
  TOML
99
136
  fi
100
137
  fi
@@ -13,13 +13,15 @@ and recoverable on failure.
13
13
  | `memories/MEMORY.md` | yes | The condensed mental-model summary loaded each session |
14
14
  | `memories/USER.md` | yes | The operator's persona (Jarad DeLorenzo, ...) |
15
15
  | `sessions/sessions.db` | yes (LFS) | SQLite store of every conversation |
16
- | `bloodbank-consumer.py` | yes | NATS subscriber using canonical subjects plus envelope routing data |
17
16
  | `decisions/` | yes | Agent-emitted decisions, one file per important call |
18
17
  | `.env` | **no** | API keys + Telegram bot token (per-machine secret) |
19
18
  | `auth.json` | **no** | Deprecated local OAuth store; fleet auth defaults to `HERMES_OAUTH_FILE=~/.hermes/auth.json` |
20
19
  | `audio_cache/`, `image_cache/` | **no** | Regenerable caches |
21
20
  | `sandboxes/` | **no** | Per-session ephemeral execution dirs |
22
- | `bloodbank-inbox/` | **no** | Inbox queue for incoming bloodbank events |
21
+
22
+ Bloodbank ingress is fleet-shared. The fleet gateway discovers this profile
23
+ through `~/.hermes/agents-registry.yaml` and routes commands by
24
+ `data.target_agent_id`; this runtime has no consumer process or inbox bridge.
23
25
 
24
26
  ## Checkpoint cadence
25
27
 
@@ -0,0 +1,192 @@
1
+ #!/usr/bin/env bash
2
+ # degit-runtime — make a repo's Hermes agent runtime(s) PURE LOCAL state.
3
+ #
4
+ # Run from (or point --root at) the ROOT of a project repo. Discovers every
5
+ # agents/hermes/*/runtime under it and, in one shot, removes it from git and
6
+ # gitignores it so it never re-enters version control.
7
+ #
8
+ # Policy = pure-local (fleet decision D2, 2026-07-21): the runtime is no longer
9
+ # a git-tracked submodule NOR its own checkpoint repo. Per-agent memory
10
+ # durability moves to the per-repo Hindsight bank. The
11
+ # github.com/delorenj/agent-hm-*-pm remotes are left intact (recoverable) but
12
+ # are no longer pushed to. This is the INVERSE of repair-runtime-checkpoint.sh,
13
+ # which keeps the checkpoint repo alive.
14
+ #
15
+ # Per runtime:
16
+ # Layer A (project repo, if one exists):
17
+ # - gitlink submodule -> git rm --cached <rel>
18
+ # + drop [submodule "<rel>"] from .gitmodules
19
+ # + rm -rf .git/modules/<rel>
20
+ # - plain tracked files -> git rm -r --cached <rel>
21
+ # - ensure .gitignore ignores agents/hermes/*/runtime/
22
+ # Layer B (the runtime itself):
23
+ # - if it has unpushed commits, push once (best-effort) unless --force
24
+ # - rm -rf <runtime>/.git (retire the checkpoint repo)
25
+ # - write <runtime>/.gitignore = "*\n!.gitignore" (pure-local marker)
26
+ #
27
+ # REPORTED but not touched here (fleet-level; handled by fleet-prune-debris.sh):
28
+ # ~/.hermes/profiles/<profile> symlink, agents-registry.yaml runtime_repo,
29
+ # and the now-dead -checkpoint.service/.timer units.
30
+ #
31
+ # Dry-run by default (prints the plan). --apply mutates. Idempotent: every step
32
+ # checks state first, so a second run is a clean no-op.
33
+ #
34
+ # Usage: degit-runtime.sh [--apply] [--force] [--root DIR]
35
+ set -euo pipefail
36
+
37
+ APPLY=0; FORCE=0; ROOT=""
38
+ while [ $# -gt 0 ]; do
39
+ case "$1" in
40
+ --apply) APPLY=1 ;;
41
+ --force) FORCE=1 ;;
42
+ --root) shift; ROOT="${1:-}" ;;
43
+ -h|--help) sed -n '2,37p' "$0"; exit 0 ;;
44
+ *) printf 'degit-runtime: unknown arg: %s\n' "$1" >&2; exit 2 ;;
45
+ esac
46
+ shift
47
+ done
48
+
49
+ die(){ printf 'ERROR: %s\n' "$*" >&2; exit 1; }
50
+ note(){ printf ' %s\n' "$*"; }
51
+ # run CMD... — execute on --apply, otherwise print the plan line.
52
+ run(){ if [ "$APPLY" -eq 1 ]; then "$@"; else printf ' [would] %s\n' "$*"; fi; }
53
+
54
+ ROOT="${ROOT:-$PWD}"
55
+ [ -d "$ROOT" ] || die "no such directory: $ROOT"
56
+ ROOT="$(cd "$ROOT" && pwd)"
57
+ [ -d "$ROOT/agents/hermes" ] || die "no agents/hermes under $ROOT — run from a project repo root (or pass --root)"
58
+
59
+ MODE="dry-run"; [ "$APPLY" -eq 1 ] && MODE="APPLY"
60
+ printf '== degit-runtime (%s) :: %s ==\n' "$MODE" "$ROOT"
61
+
62
+ # Project repo root (the superproject). Empty when the project has no git at all
63
+ # (e.g. DeLoDocs, pjangler) — Layer A is then skipped.
64
+ PROJ="$(git -C "$ROOT" rev-parse --show-toplevel 2>/dev/null || true)"
65
+ if [ -n "$PROJ" ]; then note "project repo: $PROJ"; else note "project repo: (none — Layer A skipped)"; fi
66
+
67
+ # Discover runtimes: agents/hermes/<role>/runtime (depth 2 under agents/hermes).
68
+ mapfile -t RUNTIMES < <(find "$ROOT/agents/hermes" -mindepth 2 -maxdepth 2 -type d -name runtime 2>/dev/null | sort)
69
+ [ "${#RUNTIMES[@]}" -gt 0 ] || die "no agents/hermes/*/runtime found under $ROOT"
70
+ note "runtimes: ${#RUNTIMES[@]}"
71
+
72
+ CHANGED=0
73
+
74
+ ensure_project_ignore(){ # $1 = relpath of runtime from project root
75
+ local rel="$1" gi="$PROJ/.gitignore"
76
+ # Prefer a broad, role-agnostic rule; fall back to the exact path.
77
+ if git -C "$PROJ" check-ignore -q "$rel" 2>/dev/null; then
78
+ note ".gitignore already ignores $rel"; return 0
79
+ fi
80
+ local rule="agents/hermes/*/runtime/"
81
+ if [ -f "$gi" ] && grep -qxF "$rule" "$gi" 2>/dev/null; then
82
+ note ".gitignore already has rule: $rule"; return 0
83
+ fi
84
+ if [ "$APPLY" -eq 1 ]; then
85
+ printf '\n# Hermes agent runtime — pure-local state, never tracked (degit-runtime).\n%s\n' "$rule" >> "$gi"
86
+ note "appended ignore rule to .gitignore: $rule"
87
+ else
88
+ printf ' [would] append %s to %s\n' "$rule" "$gi"
89
+ fi
90
+ CHANGED=1
91
+ }
92
+
93
+ layer_a(){ # $1 = absolute runtime dir
94
+ local rt="$1" rel
95
+ [ -n "$PROJ" ] || return 0
96
+ rel="${rt#"$PROJ"/}"
97
+ # Is it a gitlink submodule (mode 160000) in the index?
98
+ if git -C "$PROJ" ls-files --stage -- "$rel" 2>/dev/null | grep -q '^160000 '; then
99
+ note "Layer A: '$rel' is a gitlink submodule -> de-submodule"
100
+ # -f: the gitlink may be staged with a pointer differing from HEAD (e.g. a
101
+ # freshly-added, not-yet-committed submodule). --cached is index-only and
102
+ # never touches the working tree, so forcing is safe.
103
+ run git -C "$PROJ" rm --cached -f -q -- "$rel"
104
+ if [ -f "$PROJ/.gitmodules" ] && git -C "$PROJ" config -f "$PROJ/.gitmodules" --get "submodule.$rel.url" >/dev/null 2>&1; then
105
+ run git -C "$PROJ" config -f "$PROJ/.gitmodules" --remove-section "submodule.$rel"
106
+ # Drop .gitmodules entirely once no [submodule] sections remain.
107
+ # (In dry-run the section is still present, so this stays conservative.)
108
+ if [ ! -f "$PROJ/.gitmodules" ] || ! grep -q '^\[submodule' "$PROJ/.gitmodules" 2>/dev/null; then
109
+ run rm -f "$PROJ/.gitmodules"
110
+ run git -C "$PROJ" rm --cached -q --ignore-unmatch -- .gitmodules
111
+ fi
112
+ fi
113
+ # The real gitdir (runtime layer already removed the runtime/.git pointer).
114
+ [ -e "$PROJ/.git/modules/$rel" ] && run rm -rf "$PROJ/.git/modules/$rel"
115
+ CHANGED=1
116
+ elif git -C "$PROJ" ls-files --error-unmatch -- "$rel" >/dev/null 2>&1; then
117
+ note "Layer A: '$rel' is tracked as plain content -> unstage"
118
+ run git -C "$PROJ" rm -r --cached -q -- "$rel"
119
+ CHANGED=1
120
+ else
121
+ note "Layer A: '$rel' is not tracked in the project repo (good)"
122
+ fi
123
+ ensure_project_ignore "$rel"
124
+ }
125
+
126
+ layer_b(){ # $1 = absolute runtime dir
127
+ local rt="$1"
128
+ # hard guard before any rm -rf
129
+ case "$rt" in */runtime) : ;; *) die "refusing: '$rt' does not end in /runtime" ;; esac
130
+ [ -d "$rt" ] || die "refusing: runtime dir vanished: $rt"
131
+
132
+ if [ -e "$rt/.git" ]; then
133
+ # Guard unpushed local commits: push once (best-effort) unless --force.
134
+ local ahead=0
135
+ if git -C "$rt" rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
136
+ ahead="$(git -C "$rt" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)"
137
+ else
138
+ ahead="unknown(no-upstream)"
139
+ fi
140
+ note "Layer B: runtime has .git (unpushed ahead: $ahead)"
141
+ if [ "$ahead" != "0" ]; then
142
+ if [ "$FORCE" -eq 1 ]; then
143
+ note " --force: discarding local .git without pushing ($ahead ahead)"
144
+ else
145
+ note " attempting one final push before retiring .git (--force to skip)"
146
+ if [ "$APPLY" -eq 1 ]; then
147
+ if GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=20' \
148
+ timeout 45 git -C "$rt" push 2>/dev/null; then
149
+ note " final push OK"
150
+ else
151
+ note " MANUAL: final push FAILED and commits are unpushed — skipping .git removal for this runtime (re-run with --force to discard, or push by hand)"
152
+ return 0
153
+ fi
154
+ else
155
+ printf ' [would] git -C %s push (final backup)\n' "$rt"
156
+ fi
157
+ fi
158
+ fi
159
+ run rm -rf "$rt/.git"
160
+ CHANGED=1
161
+ else
162
+ note "Layer B: runtime already has no .git (good)"
163
+ fi
164
+
165
+ # Pure-local marker: ignore everything except the marker itself.
166
+ local gi="$rt/.gitignore" want=$'*\n!.gitignore'
167
+ if [ -f "$gi" ] && [ "$(cat "$gi" 2>/dev/null)" = "$want" ]; then
168
+ note "Layer B: runtime/.gitignore already pure-local"
169
+ else
170
+ if [ "$APPLY" -eq 1 ]; then printf '%s\n' "$want" > "$gi"; note "wrote pure-local runtime/.gitignore"
171
+ else printf ' [would] write pure-local %s\n' "$gi"; fi
172
+ CHANGED=1
173
+ fi
174
+ }
175
+
176
+ for rt in "${RUNTIMES[@]}"; do
177
+ printf -- '-- %s\n' "$rt"
178
+ # Runtime layer FIRST: any final push needs the gitdir, which the project
179
+ # layer's `rm -rf .git/modules/<rel>` would otherwise remove.
180
+ layer_b "$rt"
181
+ layer_a "$rt"
182
+ done
183
+
184
+ echo
185
+ if [ "$CHANGED" -eq 0 ]; then
186
+ printf 'RESULT: already pure-local — nothing to do.\n'
187
+ elif [ "$APPLY" -eq 1 ]; then
188
+ printf 'RESULT: applied. Commit the project-repo changes (.gitignore / .gitmodules / unstaged runtime) when ready.\n'
189
+ printf ' Fleet-level cleanup (dead -checkpoint units, profile symlink) -> fleet-prune-debris.sh\n'
190
+ else
191
+ printf 'RESULT: dry-run only. Re-run with --apply to mutate.\n'
192
+ fi
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env bash
2
+ # fleet-prune-debris — remove evolutionary debris from the Hermes fleet's
3
+ # systemd USER units, and retire now-dead runtime-checkpoint units for agents
4
+ # whose runtime is already pure-local (see degit-runtime.sh, fleet decision D2).
5
+ #
6
+ # Conservative by design. It only AUTO-removes things that are unambiguously
7
+ # dead:
8
+ # - a hardcoded allowlist of DISABLED stale units (old singleton gateways,
9
+ # the delodocs per-agent dashboard) — and only while they are NOT active;
10
+ # - *.bak-* backup copies of hermes unit files;
11
+ # - hermes-<id>-pm-checkpoint.{service,timer} whose runtime has NO .git
12
+ # (already pure-local -> the checkpoint unit can never do anything again).
13
+ # Everything ambiguous (active/enabled redundant units, the carrie persona, the
14
+ # delonet-company-reporter archetype, FAILED units still git-backed) is REPORTED
15
+ # as MANUAL and never touched.
16
+ #
17
+ # Dry-run by default. --apply performs the removals + one daemon-reload.
18
+ #
19
+ # Usage: fleet-prune-debris.sh [--apply]
20
+ set -euo pipefail
21
+
22
+ APPLY=0
23
+ for a in "$@"; do
24
+ case "$a" in
25
+ --apply) APPLY=1 ;;
26
+ -h|--help) sed -n '2,26p' "$0"; exit 0 ;;
27
+ *) printf 'fleet-prune-debris: unknown arg: %s\n' "$a" >&2; exit 2 ;;
28
+ esac
29
+ done
30
+
31
+ SD="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
32
+ [ -d "$SD" ] || { printf 'ERROR: no systemd user dir at %s\n' "$SD" >&2; exit 1; }
33
+
34
+ MODE="dry-run"; [ "$APPLY" -eq 1 ] && MODE="APPLY"
35
+ printf '== fleet-prune-debris (%s) :: %s ==\n' "$MODE" "$SD"
36
+ note(){ printf ' %s\n' "$*"; }
37
+ uc(){ systemctl --user "$@" 2>/dev/null; } # user systemctl, quiet
38
+ is_active(){ uc is-active "$1" >/dev/null; }
39
+ exists(){ [ -e "$SD/$1" ]; }
40
+ DID=0
41
+
42
+ # remove_unit BASENAME... — disable+stop (if loaded) then rm the file(s).
43
+ remove_unit(){
44
+ local u
45
+ for u in "$@"; do
46
+ exists "$u" || { note "absent (ok): $u"; continue; }
47
+ if [ "$APPLY" -eq 1 ]; then
48
+ uc disable --now "$u" || true
49
+ rm -f "$SD/$u"
50
+ note "removed: $u"
51
+ else
52
+ note "[would] disable --now + rm: $u"
53
+ fi
54
+ DID=1
55
+ done
56
+ }
57
+
58
+ printf '\n[1] disabled stale units (old singleton gateways / stale dashboard)\n'
59
+ # From recon: these are DISABLED + superseded. Auto-remove only if not active.
60
+ for u in hermes-gateway.service hermes-gateway-de01182b.service hermes-dashboard-delodocs-pm.service; do
61
+ if exists "$u"; then
62
+ if is_active "$u"; then note "MANUAL: $u is ACTIVE — expected disabled; confirm before removing";
63
+ else remove_unit "$u"; fi
64
+ else note "absent (ok): $u"; fi
65
+ done
66
+
67
+ printf '\n[2] *.bak-* backup copies of hermes unit files\n'
68
+ shopt -s nullglob
69
+ BAKS=("$SD"/hermes-*.bak-*) # matches *.service.bak-* / *.timer.bak-* too (glob * spans dots)
70
+ shopt -u nullglob
71
+ if [ "${#BAKS[@]}" -eq 0 ]; then note "none"; else
72
+ for f in "${BAKS[@]}"; do
73
+ if [ "$APPLY" -eq 1 ]; then rm -f "$f"; note "removed: $(basename "$f")"; else note "[would] rm: $(basename "$f")"; fi
74
+ DID=1
75
+ done
76
+ fi
77
+
78
+ printf '\n[3] dead checkpoint units for pure-local runtimes\n'
79
+ shopt -s nullglob
80
+ CKPTS=("$SD"/hermes-*-pm-checkpoint.service)
81
+ shopt -u nullglob
82
+ if [ "${#CKPTS[@]}" -eq 0 ]; then note "none"; else
83
+ for svc in "${CKPTS[@]}"; do
84
+ base="$(basename "$svc")"
85
+ # Derive the runtime from ExecStart=<role_dir>/.scripts/checkpoint.sh
86
+ exec_line="$(grep -m1 '^ExecStart=' "$svc" 2>/dev/null | sed 's/^ExecStart=//')"
87
+ script="${exec_line%% *}" # strip any args
88
+ role_dir="$(dirname "$(dirname "$script")")" # .../pm/.scripts/x.sh -> .../pm
89
+ runtime="$role_dir/runtime"
90
+ if [ ! -d "$runtime" ]; then
91
+ note "MANUAL: $base -> runtime not found ($runtime); leaving"
92
+ continue
93
+ fi
94
+ if [ -e "$runtime/.git" ]; then
95
+ note "SKIP: $base -> runtime still git-backed (degit-runtime.sh first): $runtime"
96
+ else
97
+ note "pure-local -> retiring: $base (+ .timer)"
98
+ remove_unit "$base" "${base%.service}.timer"
99
+ fi
100
+ done
101
+ fi
102
+
103
+ printf '\n[4] MANUAL review (not auto-touched)\n'
104
+ # Running-redundant / ambiguous units surfaced for a human decision.
105
+ for u in hermes-gateway-intelliforia-voice-agent.service hermes-carrie-backend.service hermes-carrie-telegram-gateway.service; do
106
+ exists "$u" && note "MANUAL: $u ($(uc is-active "$u" || true)/$(uc is-enabled "$u" || true)) — redundant/legacy persona; decide keep vs retire"
107
+ done
108
+ # delonet-company-reporter is a different archetype (not a PM).
109
+ shopt -s nullglob
110
+ for u in "$SD"/hermes-delonet-company-reporter-*.service; do
111
+ b="$(basename "$u")"; note "MANUAL: $b ($(uc is-active "$b" || true)) — non-PM reporter archetype; repair or remove as a unit"
112
+ done
113
+ shopt -u nullglob
114
+ # Any remaining failed hermes units.
115
+ FAILED="$(uc --plain --no-legend list-units --state=failed 'hermes-*' | awk '{print $1}')"
116
+ if [ -n "$FAILED" ]; then
117
+ printf '%s\n' "$FAILED" | while read -r f; do [ -n "$f" ] && note "FAILED: $f — investigate (degit fixes git-push failures; missing-script units are orphans to remove)"; done
118
+ else note "no failed hermes units"; fi
119
+
120
+ echo
121
+ if [ "$APPLY" -eq 1 ]; then
122
+ uc daemon-reload || true
123
+ printf 'RESULT: applied + daemon-reload. Re-run to confirm idempotent (should be all "absent/none/SKIP").\n'
124
+ elif [ "$DID" -eq 1 ]; then
125
+ printf 'RESULT: dry-run. Re-run with --apply to remove the [would] items above.\n'
126
+ else
127
+ printf 'RESULT: nothing to prune (already clean) — only MANUAL items remain.\n'
128
+ fi