| name | deploy |
| description | Build, restart, and health-check the Ymir daemon after code changes. Use after editing any ymir/ plugin, transport, or core file to verify the live service is healthy — runs compile, discovery, the isolated test suite, an integrity re-baseline, restart via launchctl, then curls the health endpoint and scans logs. Catches cascading deployment failures before declaring a fix complete. |
Deploy Ymir
Run this after any change to the Ymir daemon (~/ymir) to build, restart the
live launchd service, and verify end-to-end health. Do the steps in order and
STOP if a step fails — report the failure instead of continuing.
1. Build (compile everything)
cd ~/ymir && .venv/bin/python -m py_compile ymir/*.py ymir/kernel/*.py ymir/transports/*.py ymir/plugins/*.py && echo "COMPILE OK"
If this fails, fix the syntax/import error before going further — do not restart a broken build.
ymir/kernel/*.py is included deliberately: it is a large subtree and omitting
it means a kernel syntax error is only discovered by the restart.
2. Discovery + no intent collisions
cd ~/ymir && YMIR_DATA_DIR=$(mktemp -d) .venv/bin/python -c "
from ymir.plugins.base import discover
ps = discover()
n_int = sum(len(p.intents) for p in ps)
uniq = len(set(i for p in ps for i in p.intents))
cat = [e for p in ps for e in p.catalog_entries()]
print(len(ps), 'plugins |', n_int, 'intents | no collisions:', n_int == uniq)
print('advertised catalog entries:', len(cat))
print('chat fallback present:', 'chat' in {i for p in ps for i in p.intents})
"
Every plugin must import cleanly; intents must be collision-free; the chat
catch-all must exist. Advertised entries are fewer than intents on purpose:
synonyms route but are not advertised, so the router is never asked to choose
between two identical descriptions.
3. Unit tests — NEVER a bare unittest discover
cd ~/ymir && bash scripts/test.sh -q 2>&1 | tail -3
Expect OK. Investigate any failure before restarting.
Do not substitute .venv/bin/python -m unittest discover -s tests. That
writes the LIVE checkout: data/provider_health.json,
data/route_shadow.jsonl, data/dealership/cache.json, work/, and rows
appended to the hash-chained data/audit.sqlite3. scripts/test.sh points
YMIR_DATA_DIR at a throwaway state root; tests/test_isolation.py fails
loudly if the suite is ever started without it. This skill prescribed the bare
form until 2026-08-10, which is how the live state kept getting written by
test runs.
4. Check the iMessage watermark BEFORE restarting
cd ~/ymir && .venv/bin/python -c "
import json, sqlite3, os
mx = sqlite3.connect('file:'+os.path.expanduser('~/Library/Messages/chat.db')+'?mode=ro',uri=True).execute('select max(rowid) from message').fetchone()[0]
st = json.load(open('data/imessage.state.json'))
gap = mx - st['last_seen_rowid']
print(f'watermark {st[\"last_seen_rowid\"]} chat.db max {mx} gap {gap}')
print('SAFE' if gap < 300 else 'STOP: advance the watermark first')
"
A large gap means the daemon has been down while messages accumulated. On
restart it will work through every one of them, routing each at 60-90s of Max
quota and TEXTING A REPLY to each. On 2026-08-10 a restart after five days down
sent 13 replies to five-day-old questions before it was caught, with 6,221 more
queued behind them.
If the gap is large and those messages are stale, advance the cursor to the
current tip first:
cd ~/ymir && cp data/imessage.state.json data/imessage.state.json.bak && .venv/bin/python -c "
import json, sqlite3, os
mx = sqlite3.connect('file:'+os.path.expanduser('~/Library/Messages/chat.db')+'?mode=ro',uri=True).execute('select max(rowid) from message').fetchone()[0]
p='data/imessage.state.json'; st=json.load(open(p)); old=st['last_seen_rowid']
st['last_seen_rowid']=int(mx); json.dump(st, open(p,'w'))
print(f'watermark {old} -> {mx} (skipped {mx-old} stale rows)')
"
Keep seen_guids intact — it is the separate replay guard for iCloud
re-inserts, not this.
5. Re-baseline integrity (deliberate act, tied to this verified deploy)
cd ~/ymir && .venv/bin/python -c "
import subprocess
from ymir import integrity
sha = subprocess.run(['git','rev-parse','--short','HEAD'], capture_output=True, text=True).stdout.strip() or 'unknown'
b = integrity.baseline(reason=f'deploy {sha}')
print('baseline:', len(b.get('files', {})), 'files @', sha)
"
This runs ONLY here — after compile/discovery/tests passed on code you just
reviewed — so the integrity check stops alarming on your own deploy while
still catching changes that didn't come through this ritual. Never run
baseline() outside a deploy you verified.
6. Restart the live service
cd ~/ymir && OLD=$(pgrep -f 'ymir.main' | head -1); echo "old pid: ${OLD:-none}"
launchctl kickstart -k gui/$(id -u)/com.adept.ymir 2>&1; sleep 10
NEW=$(pgrep -f 'ymir.main' | head -1); echo "new pid: ${NEW:-NONE}"
[ -n "$NEW" ] && [ "$NEW" != "$OLD" ] && echo "RESTARTED" || echo "STOP: pid did not change or process is gone"
Comparing the pid matters: kickstart can report success while the service
fails its restart and launchd leaves the old process, or none at all.
If bootstrap/kickstart fails with a bare Input/output error, the label is
probably in launchd's disabled list. Check and clear it:
launchctl print-disabled gui/$(id -u) | grep ymir
launchctl enable gui/$(id -u)/com.adept.ymir
7. Curl the health endpoint — at the CONFIGURED host
cd ~/ymir && YMIR_URL=$(.venv/bin/python -c "
import json; w=json.load(open('config.json')).get('web',{})
print('http://%s:%s/' % (w.get('host') or '127.0.0.1', w.get('port', 8787)))")
curl -s -o /dev/null -m 8 -w "web %{http_code} at $YMIR_URL\n" "$YMIR_URL"
Expect 200. Do not hardcode 127.0.0.1: web.host may be bound to the
machine's Tailscale address so the UI is reachable from the phone but not from
café Wi-Fi, and a loopback curl then fails on a perfectly healthy daemon.
Python emits the whole URL on purpose. An earlier version printed host and port
separated by a space and split them with set --, which works in bash and
silently does not in zsh, where unquoted parameters are not word-split. It also
used HOST, which zsh already defines as the machine hostname. The result was
a malformed URL and a 000 on a healthy daemon.
8. Tail logs for errors (catch cascading failures)
cd ~/ymir && L=data/launchd.log
BOOT=$(grep -n "ymir starting" $L | tail -1 | cut -d: -f1)
echo "=== this boot starts at log line $BOOT of $(wc -l < $L) ==="
tail -n +$BOOT $L | grep -oE "discovered [0-9]+ plugins" | tail -1
tail -n +$BOOT $L | grep -oE "registered job name=[a-z_0-9]+" | wc -l | xargs echo "scheduler jobs:"
tail -n +$BOOT $L | grep -ciE "traceback|ERROR" | xargs echo "errors THIS boot:"
tail -n +$BOOT $L | grep -c "ymir.router | routed" | xargs echo "messages routed (0 unless you sent one):"
grep -c "poll iteration failed" $L | xargs echo "poll failures (cumulative):"
python3 -c "import os,time; print('heartbeat age:', round(time.time()-os.path.getmtime(os.path.expanduser('~/ymir/.heartbeat')),1),'s')"
Anchor on ymir starting, not on web ui on: the scheduler registers its jobs
BEFORE the web transport announces itself, so anchoring on the later line
reports zero jobs on a perfectly healthy boot.
data/launchd.log is cumulative across every run. A raw grep -c ERROR over
the whole file counts history, not this deploy.
Pass criteria (all must hold)
- Compile OK, discovery clean (no collisions,
chat present), tests OK
- Watermark gap small, or deliberately advanced
- pid changed and the process is alive
- Service serving
200 at the CONFIGURED host
- Fresh heartbeat (< ~30s),
0 poll failures
- No NEW tracebacks since the last
ymir starting (older ones in the
cumulative log are fine)
- Plugin count matches what you expect after your change
Only declare the deploy successful when every criterion holds. If a scheduled
job or plugin is newly registered, confirm it appears in the registered job
lines. Note: live-running a plugin spends the user's Max quota — verify with
compile/discovery/health checks, not by firing claude -p plugins repeatedly.