236 lines
11 KiB
Markdown
236 lines
11 KiB
Markdown
---
|
|||
|
|
name: hermes-source-updates
|
||
|
|
description: "Use when hermes update stashes source or cleanup is needed."
|
||
|
|
version: 1.1.0
|
||
|
|
author: Hermes Agent
|
||
|
|
license: MIT
|
||
|
|
platforms: [linux]
|
||
|
|
metadata:
|
||
|
|
hermes:
|
||
|
|
tags: [hermes, update, git, stash, install, source]
|
||
|
|
related_skills: [hermes-agent, hermes-profile-management, ask-claude]
|
||
|
|
---
|
||
|
|
|
||
|
|
# Hermes Source Updates & Autostash Cleanup
|
||
|
|
|
||
|
|
Git-installed Hermes lives at `~/.hermes/hermes-agent`. `hermes update` pulls that repo. Uncommitted source edits get auto-stashed first.
|
||
|
|
|
||
|
|
## What "local changes are stashed" means
|
||
|
|
|
||
|
|
- **Is:** temporary git stash of edits inside the Hermes **source tree** so `git pull` can run.
|
||
|
|
- **Is not:** config, `.env`, skills, sessions, memory, profiles, cron. Those live under `~/.hermes/` outside the git tree and are covered by the update's pre-update snapshot, not by stash messages.
|
||
|
|
|
||
|
|
Docs: https://hermes-agent.nousresearch.com/docs/getting-started/updating/
|
||
|
|
|
||
|
|
Interactive update: stash → pull → prompt to restore.
|
||
|
|
Non-interactive (`/update`, desktop, `--yes`): `updates.non_interactive_local_changes` = `stash` (default, auto-restore) or `discard`.
|
||
|
|
|
||
|
|
## Inspect stashes (always start here)
|
||
|
|
|
||
|
|
```bash
|
||
|
|
cd ~/.hermes/hermes-agent
|
||
|
|
git status -sb
|
||
|
|
git stash list
|
||
|
|
git stash show --stat 'stash@{N}'
|
||
|
|
git stash show -p 'stash@{N}' | head -200
|
||
|
|
```
|
||
|
|
|
||
|
|
Autostash names look like: `hermes-update-autostash-YYYYMMDD-HHMMSS`.
|
||
|
|
|
||
|
|
## Noise vs keep
|
||
|
|
|
||
|
|
| Signal | Treat as |
|
||
|
|
|--------|----------|
|
||
|
|
| Only `package-lock.json` / `"peer": true` churn | **Noise → drop** |
|
||
|
|
| WIP name + lockfile-only | **Noise → drop** |
|
||
|
|
| Real `.py` / `SOUL.md` / gateway / voice patches | **Keep** until proven obsolete |
|
||
|
|
| Mixed lockfile + real code | **Keep whole stash** (do not surgical-extract unless user asks) |
|
||
|
|
|
||
|
|
Confirm identity before drop:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git stash list
|
||
|
|
git stash show --stat 'stash@{N}' # must match expected noise
|
||
|
|
git stash drop 'stash@{N}'
|
||
|
|
```
|
||
|
|
|
||
|
|
**After any drop, indexes renumber.** Re-run `git stash list` before the next drop.
|
||
|
|
|
||
|
|
## Is an old stash already on main?
|
||
|
|
|
||
|
|
Do **not** use `git diff main stash@{N}` for this. Old stash commits diverge from current main across the whole tree (thousands of files) and look "non-empty" even when the *patch intent* is gone or unrelated.
|
||
|
|
|
||
|
|
Use the **patch** of the stash:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git stash show -p 'stash@{N}' > /tmp/stashN.patch
|
||
|
|
|
||
|
|
# Optional: strip lockfiles before checks
|
||
|
|
python3 - <<'PY'
|
||
|
|
from pathlib import Path
|
||
|
|
p = Path('/tmp/stashN.patch').read_text(errors='replace')
|
||
|
|
parts = p.split('diff --git ')
|
||
|
|
keep = []
|
||
|
|
for part in parts:
|
||
|
|
if not part.strip():
|
||
|
|
continue
|
||
|
|
head = part.split('\n', 1)[0]
|
||
|
|
if 'package-lock.json' in head:
|
||
|
|
continue
|
||
|
|
keep.append('diff --git ' + part)
|
||
|
|
Path('/tmp/stashN-code.patch').write_text(''.join(keep))
|
||
|
|
print('wrote code-only patch')
|
||
|
|
PY
|
||
|
|
|
||
|
|
cd ~/.hermes/hermes-agent
|
||
|
|
git apply --reverse --check /tmp/stashN-code.patch # exit 0 → content already in tree
|
||
|
|
git apply --check /tmp/stashN-code.patch # exit 0 → clean apply on current tree
|
||
|
|
```
|
||
|
|
|
||
|
|
Interpretation:
|
||
|
|
|
||
|
|
- reverse-check **succeeds** → patch content already present → safe to drop as obsolete
|
||
|
|
- reverse **and** forward both **fail** → tree drifted; patch is unique or outdated form → **keep** unless user chooses discard
|
||
|
|
- forward succeeds → still local-only; keep or re-apply intentionally
|
||
|
|
|
||
|
|
Also grep current tree for distinctive symbols from the stash.
|
||
|
|
|
||
|
|
## Safe cleanup workflow
|
||
|
|
|
||
|
|
1. List + `--stat` every stash.
|
||
|
|
2. Drop pure lockfile/WIP noise only (confirm `--stat` first).
|
||
|
|
3. Hold intentional patches (SOUL, hang fixes, TTS timeouts, delegate/SearXNG local work).
|
||
|
|
4. For mixed/old stashes: code-only patch check above; only drop if reverse-check proves already upstream.
|
||
|
|
5. Verify: `git stash list` + `git status -s` (expect clean aside from known untracked like `build/`).
|
||
|
|
|
||
|
|
## When user says "ask Claude to clean up" / analyze stashes
|
||
|
|
|
||
|
|
Claude on 10.0.0.28 has **no automatic** access to Hermes-MAIN. Two modes:
|
||
|
|
|
||
|
|
1. **Paste mode (default):** inventory (`stash list`, `--stat`, short `-p`) into ask-claude. Ask for keep/drop + exact commands. **You execute locally.**
|
||
|
|
2. **SSH mode (only when user says Claude may SSH in):** export patches on Hermes-MAIN, authorize Claude host key if needed, then let Claude inspect live tree.
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# On Hermes-MAIN (10.0.0.42) — export for Claude
|
||
|
|
mkdir -p /tmp/hermes-stashes
|
||
|
|
cd ~/.hermes/hermes-agent
|
||
|
|
for i in 0 1 2; do
|
||
|
|
git stash show -p "stash@{$i}" > "/tmp/hermes-stashes/stash${i}.patch" 2>/dev/null || true
|
||
|
|
git stash show --stat "stash@{$i}" > "/tmp/hermes-stashes/stash${i}.stat" 2>/dev/null || true
|
||
|
|
done
|
||
|
|
|
||
|
|
# From 10.0.0.28 (Claude host key is vera-ai):
|
||
|
|
ssh -i ~/.ssh/vera-ai -o StrictHostKeyChecking=accept-new [email protected] \
|
||
|
|
'git -C /home/n8n/.hermes/hermes-agent stash list; ls /tmp/hermes-stashes/'
|
||
|
|
```
|
||
|
|
|
||
|
|
If Permission denied, append vera-ai.pub to Hermes-MAIN ~/.ssh/authorized_keys (user must allow). Tell Claude: analysis only unless operator authorized apply/drop.
|
||
|
|
|
||
|
|
Detail: `references/update-stash-cleanup.md`.
|
||
|
|
|
||
|
|
## Re-apply intentional stashes (after Claude KEEP+RE-APPLY)
|
||
|
|
|
||
|
|
Do **not** `git stash pop` blindly. Prefer `git stash apply 'stash@{N}'`, resolve conflicts, leave as **unstaged working-tree mods** (so next `hermes update` will stash them again intentionally).
|
||
|
|
|
||
|
|
### Pre-flight: is the intent already upstream?
|
||
|
|
|
||
|
|
Before fighting conflicts, grep current main for the *behavior*, not the old symbol names:
|
||
|
|
|
||
|
|
| Old stash intent | Upstream equivalent (check first) |
|
||
|
|
|------------------|-----------------------------------|
|
||
|
|
| inline `_DaemonThreadPoolExecutor` in `tool_executor.py` | `tools/daemon_pool.py` → `DaemonThreadPoolExecutor` |
|
||
|
|
| `gateway/run.py` `_voice_mode_getter` wire | often already near `_sync_voice_mode_state_to_adapter` |
|
||
|
|
| old SearXNG in `web_tools.py` + `refresh_delegate_schema` | newer SearXNG provider + MCP inherit helpers — usually **DISCARD** |
|
||
|
|
| `load_soul_identity=True` on child agents | kwarg may be **gone** — keep stash for intent, do **not** blind-apply |
|
||
|
|
|
||
|
|
```bash
|
||
|
|
rg -n "DaemonThreadPoolExecutor|daemon_pool" agent/tool_executor.py tools/daemon_pool.py
|
||
|
|
rg -n "_voice_mode_getter" gateway/run.py gateway/platforms/base.py
|
||
|
|
rg -n "load_soul_identity" -g '*.py'
|
||
|
|
```
|
||
|
|
|
||
|
|
### When `git stash apply` fails (heavy upstream drift)
|
||
|
|
|
||
|
|
If `git stash apply` produces conflicts on most files, do NOT fight through
|
||
|
|
merge markers. Extract the patch, inspect each file's changes, check what's
|
||
|
|
already upstream, and apply only the missing pieces fresh with the `patch` tool:
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# 1. Extract the full patch
|
||
|
|
git stash show -p 'stash@{N}' > /tmp/stash.patch
|
||
|
|
|
||
|
|
# 2. Split per-file and inspect each change
|
||
|
|
python3 -c "
|
||
|
|
patch = open('/tmp/stash.patch').read()
|
||
|
|
parts = patch.split('diff --git ')
|
||
|
|
for part in parts:
|
||
|
|
if not part.strip():
|
||
|
|
continue
|
||
|
|
head = part.split('\n', 1)[0]
|
||
|
|
f = head.split()[-1].replace('b/', '')
|
||
|
|
print(f'=== {f} ===')
|
||
|
|
for line in part.split('\n'):
|
||
|
|
if line.startswith('+') and not line.startswith('+++'):
|
||
|
|
print(line)
|
||
|
|
elif line.startswith('-') and not line.startswith('---'):
|
||
|
|
print(line)
|
||
|
|
print()
|
||
|
|
"
|
||
|
|
|
||
|
|
# 3. For each file, grep current tree to see what's already upstream
|
||
|
|
# (e.g. grep for the behavior, not the exact old line numbers)
|
||
|
|
|
||
|
|
# 4. Apply only the missing changes with the patch tool
|
||
|
|
# (patch mode='replace' on each file, not git stash apply)
|
||
|
|
|
||
|
|
# 5. Drop the stash once all changes are applied
|
||
|
|
git stash drop 'stash@{N}'
|
||
|
|
```
|
||
|
|
|
||
|
|
This avoids merge conflicts entirely and produces a clean working tree
|
||
|
|
with only the intentional mods that aren't already upstream.
|
||
|
|
|
||
|
|
### Conflict resolution pattern (Jul 2026) — for light drift
|
||
|
|
|
||
|
|
When `git stash apply` succeeds with only a few conflicts:
|
||
|
|
|
||
|
|
1. `git stash apply 'stash@{N}'` (not pop).
|
||
|
|
2. For each UU file:
|
||
|
|
- Upstream already has equivalent → **take upstream**.
|
||
|
|
- Stash has unique behavior on moved upstream code → **merge** (e.g. keep `_final_delivery_adapter` + add `and not _voice_only`).
|
||
|
|
- Stash hunk landed in wrong method after drift → **discard stash side**; confirm real wire elsewhere.
|
||
|
|
3. Clear all conflict markers.
|
||
|
|
4. `python3 -m py_compile` on every touched `.py`.
|
||
|
|
5. `git restore --staged .` so changes are plain local mods.
|
||
|
|
6. Only then `git stash drop 'stash@{N}'`.
|
||
|
|
7. Gateway/CLI restart for gateway/voice/SOUL runtime paths.
|
||
|
|
|
||
|
|
### Intentional local mods on this install (post 2026-07-25)
|
||
|
|
|
||
|
|
- `docker/SOUL.md` — custom Primary Directive SOUL
|
||
|
|
- `cli.py`, `hermes_cli/voice.py`, `tools/tts_tool.py` — TTS 600s + Kokoro `response.content`
|
||
|
|
- `tools/transcription_tools.py` — `.oga`
|
||
|
|
- `hermes_cli/cli_agent_setup_mixin.py` — model alias routing
|
||
|
|
- `gateway/platforms/base.py` — voice_only text suppression merged with current delivery path
|
||
|
|
|
||
|
|
Not re-applied: hang-fix class (upstream `daemon_pool`), obsolete April SearXNG/delegate rewrite, broken `load_soul_identity` one-liner.
|
||
|
|
|
||
|
|
Cross-skill: voice patches also under `voice-systems`.
|
||
|
|
|
||
|
|
## Pitfalls
|
||
|
|
|
||
|
|
- Confusing stashed **source** edits with missing **config/skills** after update.
|
||
|
|
- Dropping stashes by old index numbers after a prior drop (renumbering).
|
||
|
|
- Using whole-tree `git diff main stash@{N}` to decide obsolescence — false positives.
|
||
|
|
- Re-applying ancient stashes blind — inspect + selective restore only with user direction.
|
||
|
|
- Re-applying hang-fix / SearXNG stashes that **regress** newer upstream implementations.
|
||
|
|
- Leaving merge conflict markers or staged half-applies; always py_compile + unstage.
|
||
|
|
- Forgetting gateway restart after applying gateway/voice/SOUL source patches.
|
||
|
|
- **Any running gateway process holds stale Python modules in `sys.modules` after `hermes update` touches source on disk — not just for stash re-applies.** Symptom: cron jobs (or any code path) that were working fine suddenly throw `ImportError: cannot import name 'X' from 'module'` even though the symbol clearly exists when you read the file. This isn't specific to manual patch re-application — a plain `hermes update` while a gateway is running for that profile can trigger it too. Fix: `hermes -p <profile> gateway restart` for every profile with a live gateway after any source update, not just the one you're actively debugging. Verify via `stat -c '%y' <file>` vs `systemctl --user show <service> -p ExecMainStartTimestamp` (or `ps -o lstart -p <pid>`) — if the file mtime is after the process start time, the running process is stale. See `hermes-cron-management` skill's stale-gateway-module-cache pitfall for the full diagnostic recipe and a worked cron-job example.
|
||
|
|
- Bundled `hermes-agent` skill is protected; put install/update ops learnings here, not there.
|
||
|
|
- **Empty stashes (0 lines).** When `hermes update` runs and there are no local changes to stash, it may still create an empty autostash. `git stash show --stat` shows nothing. These are pure noise — drop immediately.
|
||
|
|
|
||
|
|
## Quick user-facing explanation
|
||
|
|
|
||
|
|
> Hermes is a git checkout under `~/.hermes/hermes-agent`. Update stashes uncommitted source edits so pull can succeed. Your profiles/config are separate. Stashes named `hermes-update-autostash-*` are those parked edits.
|