@zerwiz/ymir 0.1.5 → 0.1.7

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 (118) hide show
  1. package/.agents/agents/huginn-researcher.md +1 -1
  2. package/.agents/agents/kvasir-scout.md +1 -1
  3. package/.agents/agents/sindri-developer.md +1 -1
  4. package/.agents/backend/README.md +74 -0
  5. package/.agents/backend/model-bridge.py +74 -4
  6. package/.agents/config/cron.yaml +2 -0
  7. package/.agents/config/eindri-dispatch.json +6 -6
  8. package/.agents/migrations/0003-private-data-separation.sh +11 -1
  9. package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
  10. package/.agents/skills/README.md +1 -1
  11. package/.agents/skills/galdr-ymirsystem/SKILL.md +2 -1
  12. package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +73 -10
  13. package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +44 -1
  14. package/.agents/skills/galdr-ymirsystem/assets/installation.md +215 -23
  15. package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +3 -1
  16. package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +39 -1
  17. package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +4 -0
  18. package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +7 -2
  19. package/.agents/skills/galdr-ymirsystem/assets/registry.md +1 -1
  20. package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +2 -2
  21. package/.agents/skills/galdr-ymirsystem/assets/smidja.md +16 -11
  22. package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +147 -4
  23. package/.agents/skills/groa-update/SKILL.md +20 -0
  24. package/.agents/skills/hamr-adapters/SKILL.md +2 -2
  25. package/.agents/skills/lifecycle/smoke_test.sh +7 -2
  26. package/.agents/skills/ymir-host/SKILL.md +2 -1
  27. package/.agents/skills/ymir-host/assets/install.md +69 -0
  28. package/AGENTS.md +78 -34
  29. package/CHANGELOG.md +1033 -1
  30. package/README.md +1 -1
  31. package/RULES/02-agents.md +2 -2
  32. package/RULES/04-hoard.md +32 -0
  33. package/RULES/06-append-only.md +50 -0
  34. package/RULES/07-config.md +56 -0
  35. package/RULES/09-electron.md +58 -0
  36. package/RULES/README.md +2 -0
  37. package/bin/a2a-mcp.sh +42 -7
  38. package/bin/agents-config.sh +30 -7
  39. package/bin/apodex-smoke-test.sh +14 -1
  40. package/bin/bifrost-bridge.sh +25 -6
  41. package/bin/bootstrap-macos.sh +6 -1
  42. package/bin/brokk-send.sh +14 -1
  43. package/bin/changelog-assemble.sh +154 -0
  44. package/bin/changelog-guard.sh +39 -7
  45. package/bin/crash-sense.sh +14 -1
  46. package/bin/design-check.sh +80 -0
  47. package/bin/design-icon.sh +153 -0
  48. package/bin/docs-guard.sh +2 -2
  49. package/bin/editor-place.sh +33 -15
  50. package/bin/eindri-acclaim.sh +14 -1
  51. package/bin/eindri-seat.sh +83 -0
  52. package/bin/eindri-seen.sh +14 -1
  53. package/bin/eindri-start.sh +14 -1
  54. package/bin/eir-doctor.sh +47 -1
  55. package/bin/feature-inventory.sh +73 -0
  56. package/bin/fleet-apply.sh +15 -2
  57. package/bin/github-deploy.sh +14 -1
  58. package/bin/gjallarhorn-expose.sh +43 -9
  59. package/bin/gjallarhorn-notify.sh +14 -1
  60. package/bin/gjallarhorn-purge.sh +14 -1
  61. package/bin/gjallarhorn-tunnel.sh +16 -3
  62. package/bin/groa-update.sh +9 -1
  63. package/bin/hall-snapshot.sh +21 -2
  64. package/bin/herdr-run.sh +23 -2
  65. package/bin/hlidskjalf-agents.sh +172 -0
  66. package/bin/hlidskjalf-usage.sh +196 -0
  67. package/bin/hoard-guard.sh +135 -0
  68. package/bin/hoard-lib.sh +91 -11
  69. package/bin/hodd.sh +28 -4
  70. package/bin/huginn-research-worker.sh +14 -1
  71. package/bin/local-model-lock.sh +22 -3
  72. package/bin/mimir-bridge.py +33 -0
  73. package/bin/mimir-bridge.sh +14 -1
  74. package/bin/mimir-reflect.sh +27 -0
  75. package/bin/mjollnir-webhook.sh +14 -1
  76. package/bin/mjollnir.sh +22 -3
  77. package/bin/model-hardware.sh +17 -3
  78. package/bin/model-resolve.sh +14 -1
  79. package/bin/nornir-job-hall-snapshot.sh +28 -0
  80. package/bin/nornir-job-nsr-compliance.sh +54 -0
  81. package/bin/npm-publish.sh +136 -0
  82. package/bin/omarchy-install.sh +18 -5
  83. package/bin/omarchy-plugins.sh +15 -2
  84. package/bin/omarchy-sense.sh +14 -1
  85. package/bin/pi-model-agnostic.sh +71 -0
  86. package/bin/private-guard.sh +20 -0
  87. package/bin/project-git.sh +18 -4
  88. package/bin/public-guard.sh +3 -2
  89. package/bin/sessrumnir-ensure.sh +1 -1
  90. package/bin/sessrumnir.sh +16 -3
  91. package/bin/smidja-bootstrap.sh +5 -1
  92. package/bin/syn-asset-pretool-check.sh +14 -1
  93. package/bin/syn-spawn-pretool-check.sh +58 -0
  94. package/bin/tailscale-sync.sh +15 -2
  95. package/bin/telegram-bot.sh +14 -1
  96. package/bin/ui-truth-check.sh +75 -0
  97. package/bin/valknut-load.sh +42 -4
  98. package/bin/wedge-notify.sh +14 -1
  99. package/bin/workspace-rag.sh +14 -1
  100. package/bin/ymir-install.sh +219 -54
  101. package/bin/ymir-plan.sh +356 -0
  102. package/bin/ymir-say.sh +14 -1
  103. package/bin/ymir-setup-auth.sh +12 -1
  104. package/bin/ymir-validate.sh +28 -8
  105. package/docs/lore.md +5 -1
  106. package/docs/runbooks/agents.md +1 -1
  107. package/package.json +13 -3
  108. package/scripts/electron.sh +74 -15
  109. package/scripts/lower.sh +34 -0
  110. package/scripts/raise.sh +42 -0
  111. package/scripts/start.sh +52 -2
  112. package/.agents/config/agents.yaml +0 -50
  113. package/.agents/config/tailscale-sync.yaml +0 -27
  114. package/.agents/memory/kaia.engram +0 -0
  115. package/.agents/memory/kaia.engram-shm +0 -0
  116. package/.agents/memory/kaia.engram-wal +0 -0
  117. package/.agents/memory/well/episodes.jsonl +0 -367
  118. package/.agents/memory/well/workspace.jsonl +0 -27
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env bash
2
+ # hlidskjalf-usage.sh — what the HARNESSES spent, not just the smithy.
3
+ #
4
+ # The Statistics gate counted only smithy runs (smidja.db), so a machine burning
5
+ # tokens through opencode and pi from morning to night reported "No runs yet". The
6
+ # sessions are the usage; this reads them.
7
+ #
8
+ # bin/hlidskjalf-usage.sh [--days N] # JSON, aggregated
9
+ #
10
+ # SOURCES (verified on this machine):
11
+ # opencode ~/.local/share/opencode/opencode.db (SQLite, Drizzle)
12
+ # session / message / part; usage lives in message.data as JSON:
13
+ # {role, modelID, providerID, cost, tokens:{total,input,output,
14
+ # reasoning,cache:{read,write}}}. cost is stored 0 - tokens are truth.
15
+ # pi ~/.pi/agent/sessions/**/*.jsonl
16
+ #
17
+ # BOUNDED BY CONSTRUCTION: the db here is 37 GB and holds 132k messages. Nothing
18
+ # loads rows into the UI - the aggregation is one SQL statement with json_extract,
19
+ # a time window, and a hard row cap, and the result is small by definition.
20
+ set -u
21
+
22
+ VERSION="1.0.0"
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+
25
+ case "${1-}" in
26
+ -v|-V|--version) printf '%s\n' "$VERSION"; exit 0 ;;
27
+ -h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
28
+ esac
29
+
30
+ DAYS="${YMIR_USAGE_DAYS:-30}"
31
+ while [ $# -gt 0 ]; do
32
+ case "$1" in --days) DAYS="${2:-30}"; shift 2 ;; *) shift ;; esac
33
+ done
34
+
35
+ DAYS="$DAYS" python3 - <<'PY'
36
+ import json, os, sqlite3, sys, glob
37
+ from datetime import datetime, timedelta, timezone
38
+
39
+ days = int(os.environ.get("DAYS", "30"))
40
+ since_ms = int((datetime.now(timezone.utc) - timedelta(days=days)).timestamp() * 1000)
41
+ out = {"window_days": days, "sources": {}, "totals": {}, "by_model": []}
42
+
43
+ oc = os.path.expanduser("~/.local/share/opencode/opencode.db")
44
+ if os.path.exists(oc):
45
+ try:
46
+ c = sqlite3.connect("file:%s?mode=ro" % oc, uri=True)
47
+ c.execute("PRAGMA query_only=ON")
48
+ # One statement, aggregated in the engine: never the rows, only the sums.
49
+ rows = c.execute("""
50
+ select coalesce(json_extract(data,'$.modelID'),'unknown') model,
51
+ count(*) msgs,
52
+ sum(coalesce(json_extract(data,'$.tokens.input'),0)) input,
53
+ sum(coalesce(json_extract(data,'$.tokens.output'),0)) output,
54
+ sum(coalesce(json_extract(data,'$.tokens.cache.read'),0)) cache_read,
55
+ sum(coalesce(json_extract(data,'$.tokens.cache.write'),0)) cache_write,
56
+ sum(coalesce(json_extract(data,'$.tokens.reasoning'),0)) reasoning
57
+ from message
58
+ where json_extract(data,'$.tokens') is not null
59
+ and coalesce(json_extract(data,'$.time.created'), 0) >= ?
60
+ group by model order by (sum(coalesce(json_extract(data,'$.tokens.input'),0))
61
+ + sum(coalesce(json_extract(data,'$.tokens.output'),0))) desc
62
+ limit 40
63
+ """, (since_ms,)).fetchall()
64
+ rows = [r for r in rows if r[1]]
65
+ out["sources"]["opencode"] = {
66
+ "model": "harness",
67
+ "messages": sum(r[1] for r in rows),
68
+ "input": sum(r[2] for r in rows), "output": sum(r[3] for r in rows),
69
+ "cache_read": sum(r[4] for r in rows), "cache_write": sum(r[5] for r in rows),
70
+ "reasoning": sum(r[6] for r in rows),
71
+ }
72
+ out["by_model"] += [
73
+ {"source": "opencode", "model": r[0], "messages": r[1], "input": r[2],
74
+ "output": r[3], "cache_read": r[4], "cache_write": r[5], "reasoning": r[6]}
75
+ for r in rows
76
+ ]
77
+ except Exception as e:
78
+ out["sources"]["opencode"] = {"error": str(e)[:120]}
79
+
80
+ # pi keeps TWO stores: the agent home and the older tree beside it. Scanning only
81
+ # one under-reported the harness by an order of magnitude (75 messages against 657
82
+ # session files), which is how a "small number" masks a missing source.
83
+ pi_files = []
84
+ for root in ("~/.pi/agent/sessions", "~/.pi/sessions"):
85
+ pi_files += glob.glob(os.path.expanduser(root + "/**/*.jsonl"), recursive=True)
86
+ if pi_files:
87
+ agg = {"input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "messages": 0}
88
+ models = {}
89
+ for f in pi_files:
90
+ try:
91
+ with open(f, "r", errors="ignore") as fh:
92
+ for line in fh:
93
+ if '"tokens"' not in line and '"usage"' not in line:
94
+ continue
95
+ try:
96
+ d = json.loads(line)
97
+ except Exception:
98
+ continue
99
+ t = d.get("tokens") or d.get("usage") or {}
100
+ if not t:
101
+ continue
102
+ inp = t.get("input") or t.get("input_tokens") or 0
103
+ outp = t.get("output") or t.get("output_tokens") or 0
104
+ cr = (t.get("cache") or {}).get("read") or t.get("cache_read_input_tokens") or 0
105
+ cw = (t.get("cache") or {}).get("write") or t.get("cache_creation_input_tokens") or 0
106
+ agg["input"] += inp; agg["output"] += outp
107
+ agg["cache_read"] += cr; agg["cache_write"] += cw; agg["messages"] += 1
108
+ m = d.get("modelID") or d.get("model") or "unknown"
109
+ mm = models.setdefault(m, {"input": 0, "output": 0, "messages": 0})
110
+ mm["input"] += inp; mm["output"] += outp; mm["messages"] += 1
111
+ except Exception:
112
+ continue
113
+ out["sources"]["pi"] = {"model": "harness", **agg}
114
+ out["by_model"] += [{"source": "pi", "model": k, **v} for k, v in
115
+ sorted(models.items(), key=lambda kv: -(kv[1]["input"] + kv[1]["output"]))[:20]]
116
+
117
+ tot = {"messages": 0, "input": 0, "output": 0, "cache_read": 0, "cache_write": 0, "reasoning": 0}
118
+ for s in out["sources"].values():
119
+ for k in tot:
120
+ tot[k] += int(s.get(k) or 0)
121
+ tot["total"] = tot["input"] + tot["output"]
122
+ prompt = tot["input"] + tot["cache_read"]
123
+ # local = our own hardware; online = a cloud API. By model id, honestly.
124
+ LOCAL_MARKERS = ("llama", "lmstudio", "ollama", "local", "qwen3.6-35b-a3b")
125
+ for m in out["by_model"]:
126
+ mid = str(m.get("model", "")).lower()
127
+ m["kind"] = "local" if any(k in mid for k in LOCAL_MARKERS) else "online"
128
+
129
+ def _empty():
130
+ return {"tokens": 0, "calls": 0, "input": 0, "output": 0, "cache_read": 0}
131
+ lo = {"local": _empty(), "online": _empty()}
132
+ for m in out["by_model"]:
133
+ k = m.get("kind", "online")
134
+ lo[k]["tokens"] += int(m.get("input") or 0) + int(m.get("output") or 0)
135
+ lo[k]["calls"] += int(m.get("messages") or 0)
136
+ lo[k]["input"] += int(m.get("input") or 0)
137
+ lo[k]["output"] += int(m.get("output") or 0)
138
+ lo[k]["cache_read"] += int(m.get("cache_read") or 0)
139
+ out["local_online"] = lo
140
+
141
+ # the gate's section names, filled from the harnesses
142
+ oc = out["sources"].get("opencode", {})
143
+ pi = out["sources"].get("pi", {})
144
+ out["runs"] = {
145
+ "total": int(tot["messages"]), "success": int(tot["messages"]), "fail": 0, "running": 0,
146
+ "success_rate": 100.0 if tot["messages"] else 0.0,
147
+ "tokens": int(tot["input"]) + int(tot["output"]),
148
+ "cost": 0,
149
+ "by_source": {"opencode": int(oc.get("messages") or 0), "pi": int(pi.get("messages") or 0)},
150
+ }
151
+ out["by_chain"] = [
152
+ {"chain": m.get("source", "harness"), "workflow": m.get("source", "harness"),
153
+ "runs": int(m.get("messages") or 0), "success": int(m.get("messages") or 0),
154
+ "rate": 100.0, "tokens": int(m.get("input") or 0) + int(m.get("output") or 0), "cost": 0}
155
+ for m in out["by_model"][:12]
156
+ ]
157
+
158
+ tot["cache_hit_ratio"] = round(tot["cache_read"] / prompt, 4) if prompt else 0.0
159
+ out["totals"] = tot
160
+
161
+ # THE GATE'S OWN NAMES, filled from the harnesses - so Statistics reports pi and
162
+ # opencode, and the smithy's runs stay the smithy's.
163
+ out["gate"] = {
164
+ "totals": {
165
+ "runs": int(tot["messages"]), "success": int(tot["messages"]), "fail": 0,
166
+ "running": 0, "tokens": int(tot["input"]) + int(tot["output"]), "cost": 0,
167
+ },
168
+ "usage": {
169
+ "input": int(tot["input"]), "output": int(tot["output"]),
170
+ "cache_read": int(tot["cache_read"]), "cache_write": int(tot["cache_write"]),
171
+ "total": int(tot["input"]) + int(tot["output"]),
172
+ },
173
+ "providers": {
174
+ "local": {"events": lo["local"]["calls"], "sessions": lo["local"]["calls"],
175
+ "tokens": lo["local"]["tokens"], "cost": 0,
176
+ "input": lo["local"]["tokens"], "output": 0, "cache_read": 0},
177
+ "online": {"events": lo["online"]["calls"], "sessions": lo["online"]["calls"],
178
+ "tokens": lo["online"]["tokens"], "cost": 0,
179
+ "input": lo["online"]["input"], "output": lo["online"]["output"], "cache_read": lo["online"]["cache_read"]},
180
+ "per_model": [
181
+ {"model": m["model"], "agent": m.get("source", "harness"), "kind": m.get("kind", "online"),
182
+ "cells": int(m.get("messages") or 0), "tokens": int(m.get("input") or 0) + int(m.get("output") or 0), "cost": 0}
183
+ for m in out["by_model"]
184
+ ],
185
+ },
186
+ "by_chain": out["by_chain"],
187
+ "by_model": [
188
+ {"model": m["model"], "agent": m.get("source", "harness"), "kind": m.get("kind", "online"),
189
+ "cells": int(m.get("messages") or 0),
190
+ "tokens": int(m.get("input") or 0) + int(m.get("output") or 0), "cost": 0}
191
+ for m in out["by_model"]
192
+ ],
193
+ }
194
+
195
+ print(json.dumps(out))
196
+ PY
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env bash
2
+ # hoard-guard.sh — the ward for the PRIVATE HOME repo ($YMIR_HOME).
3
+ #
4
+ # Why this exists: the public repo has six guards, the home had none — yet the
5
+ # home is where every private byte lives. The one real leak of 2026-09-17
6
+ # (platform.env.prev-fill, 44 credentials) happened HERE, in a scratch backup
7
+ # swept up by `git add -A`. Nothing was watching.
8
+ #
9
+ # This is not a script an agent must remember to run. It is seated as
10
+ # .git/hooks/pre-commit in the home repo, so GIT runs it on every commit —
11
+ # including the ones made in a hurry, by a loop, or by an agent that has never
12
+ # heard of it.
13
+ #
14
+ # bin/hoard-guard.sh # scan the staged change (used as pre-commit)
15
+ # bin/hoard-guard.sh --install # seat it in $YMIR_HOME/.git/hooks/pre-commit
16
+ # bin/hoard-guard.sh --all # scan every tracked file in the home
17
+ # bin/hoard-guard.sh --log-bypass <reason> # record a --no-verify commit
18
+ #
19
+ # Exit 1 on any hit, so the commit is blocked before the secret is committed.
20
+ set -u
21
+
22
+ VERSION="1.0.0"
23
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
24
+ # shellcheck source=bin/hoard-lib.sh
25
+ . "$SCRIPT_DIR/hoard-lib.sh"
26
+ hoard_root HOARD
27
+ HOME_REPO="$(cd "$HOARD/.." && pwd)"
28
+ LEDGER="$HOARD/memory/runes_audit.md"
29
+
30
+ case "${1:-}" in
31
+ -v|-V|--version) printf '%s\n' "$VERSION"; exit 0 ;;
32
+ -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
33
+ --install)
34
+ [ -d "$HOME_REPO/.git" ] || { printf 'hoard-guard: not a git repo: %s\n' "$HOME_REPO" >&2; exit 1; }
35
+ hook="$HOME_REPO/.git/hooks/pre-commit"
36
+ mkdir -p "$(dirname "$hook")" || exit 1
37
+ printf '#!/usr/bin/env bash\n# seated by bin/hoard-guard.sh --install — the ward for the private home\nexec "%s/hoard-guard.sh" "$@"\n' "$SCRIPT_DIR" >"$hook"
38
+ chmod +x "$hook"
39
+ printf 'hoard-guard[1]{action,path}:\n "install","%s"\n' "$hook"
40
+ exit 0 ;;
41
+ --log-bypass)
42
+ # Called when a --no-verify commit is detected, so a bypass is visible after
43
+ # the fact rather than silent.
44
+ mkdir -p "$(dirname "$LEDGER")" 2>/dev/null || true
45
+ printf '%s | hoard-guard BYPASS | %s | %s\n' \
46
+ "$(date -Is)" "${1:-no reason given}" "$(git -C "$HOME_REPO" log -1 --format=%h 2>/dev/null || echo unknown)" >>"$LEDGER" 2>/dev/null || true
47
+ printf 'hoard-guard[1]{action,logged}:\n "bypass","%s"\n' "$LEDGER"
48
+ exit 0 ;;
49
+ esac
50
+
51
+ # ── The checks ────────────────────────────────────────────────────────────────
52
+ # Every shape that caused, or could cause, a real leak.
53
+ SECRET_SHAPES='(AKIA[0-9A-Z]{16}|-----BEGIN [A-Z ]*PRIVATE KEY-----|-----BEGIN OPENSSH PRIVATE KEY-----|(ghp|gho|ghs|ghr)_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]+|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{30,})'
54
+
55
+ # Filenames that must never be tracked: plaintext vault files, scratch backups,
56
+ # anything that is a copy of a secret under another name.
57
+ BAD_NAME_RE='(^|/)(platform\.env|id_rsa|id_ed25519|\.env\.local|\.env\.realm)$'
58
+ SCRATCH_RE='(\.prev-|\.prev\.|\.bak$|\.orig$|\.scratch$|\.save$|~$|\.env\.[0-9])'
59
+ # Deliberately allowed: age.key IS the vault key and is committed by decision
60
+ # (see hodd/docs/secrets-vault.md). It is exempt BY NAME, in one place, so the
61
+ # exception is auditable rather than accidental.
62
+ ALLOWED_RE='^hodd/secrets/age\.key$'
63
+
64
+ why() { printf 'hoard-guard: %s: %s\n' "$2" "$1" >&2; }
65
+
66
+ # A base64 blob whose DECODE contains a PEM header — the evasion that would have
67
+ # hidden a key. The data arrives on fd 3, NOT stdin: a heredoc on stdin would
68
+ # otherwise consume the pipe and the checker would read nothing.
69
+ check_base64_pem() { # <path-in-repo>; data on fd 3
70
+ local f=$1
71
+ python3 - "$f" /dev/fd/3 3<&3 <<'PY'
72
+ import base64, re, sys
73
+ try:
74
+ data = open(sys.argv[2], 'rb').read()
75
+ except OSError:
76
+ sys.exit(0)
77
+ for m in re.finditer(rb'[A-Za-z0-9+/=]{120,}', data):
78
+ chunk = m.group(0)
79
+ for cand in (chunk, chunk + b'=' * (-len(chunk) % 4)):
80
+ try:
81
+ dec = base64.b64decode(cand, validate=True)
82
+ except Exception:
83
+ continue
84
+ if b'PRIVATE KEY' in dec or b'BEGIN OPENSSH' in dec:
85
+ print(f"hoard-guard: possible secret in {sys.argv[1]} (base64-encoded key)", file=sys.stderr)
86
+ sys.exit(1)
87
+ sys.exit(0)
88
+ PY
89
+ }
90
+
91
+ scan_list() {
92
+ local hit=0 f blob
93
+ while IFS= read -r f; do
94
+ [ -n "$f" ] || continue
95
+
96
+ case "$f" in
97
+ */.gitignore|.gitignore) continue ;;
98
+ esac
99
+ if printf '%s' "$f" | grep -Eq "$ALLOWED_RE"; then continue; fi
100
+
101
+ if printf '%s' "$f" | grep -Eq "$BAD_NAME_RE"; then
102
+ why "$f" "plaintext secret file"; hit=1; continue
103
+ fi
104
+ if printf '%s' "$f" | grep -Eq "$SCRATCH_RE"; then
105
+ why "$f" "scratch/backup file — this is the shape that leaked on 2026-09-17"
106
+ hit=1; continue
107
+ fi
108
+
109
+ # Text only — a binary (sqlite, engram, image) has no secret shape to read
110
+ # and would only emit null-byte noise.
111
+ case "$f" in
112
+ *.engram|*.engram-wal|*.engram-shm|*.db|*.db-wal|*.db-shm|*.png|*.jpg|*.jpeg|*.gif|*.pdf|*.zip|*.gz|*.tar|*.woff|*.woff2|*.ttf|*.ico|*.icns) continue ;;
113
+ esac
114
+ blob="$(git -C "$HOME_REPO" show ":$f" 2>/dev/null | head -c 400000 | tr -d '\000')" || continue
115
+ [ -n "$blob" ] || continue
116
+
117
+ if printf '%s' "$blob" | grep -Eq "$SECRET_SHAPES"; then
118
+ why "$f" "possible secret"; hit=1; continue
119
+ fi
120
+ if ! check_base64_pem "$f" 3< <(printf '%s' "$blob"); then hit=1; fi
121
+ done
122
+ return "$hit"
123
+ }
124
+
125
+ if [ "${1:-}" = "--all" ]; then
126
+ scan_list < <(git -C "$HOME_REPO" ls-files)
127
+ else
128
+ scan_list < <(git -C "$HOME_REPO" diff --cached --name-only --diff-filter=ACM)
129
+ fi || {
130
+ printf '\nhoard-guard: BLOCKED — the home repo is the vault. Do not commit this.\n' >&2
131
+ printf ' move the secret to hodd/secrets/platform.env.age (encrypted)\n' >&2
132
+ printf ' or delete the scratch file; see hodd/docs/secrets-vault.md\n' >&2
133
+ exit 1
134
+ }
135
+ exit 0
package/bin/hoard-lib.sh CHANGED
@@ -1,20 +1,64 @@
1
1
  #!/usr/bin/env bash
2
- # hoard-lib.sh — where Hodd (the private hoard) lives. ONE documented default.
2
+ # hoard-lib.sh — where the operator's HOME lives, and the two roots beneath it.
3
3
  #
4
- # The hoard is NEVER inside the checkout (RULES/04-hoard.md): the repo's `hodd/`
5
- # holds only the guard, the README and `*.example` scaffolds. Private data
6
- # docs, secrets, identity, tenants lives at $YMIR_HOARD, else $YMIR_HOME, else
7
- # $HOME/Documents/Ymir. Scripts resolve it HERE so the default can never drift
8
- # between them (Rule 07: configuration is never hardcoded; one documented
9
- # default).
4
+ # The law (RULES/04-hoard.md): no private thing — not a name, a key, a plan, a
5
+ # schedule, a client, a note ever sits inside this repo. It lives in ONE place
6
+ # the operator OWNS and CHOSE at installation, and every script resolves that
7
+ # place HERE, so the answer can never drift between them (Rule 07: configuration
8
+ # is never hardcoded; ONE documented default).
10
9
  #
11
- # Source-safe; call hoard_root <result-var>.
10
+ # ymir_home_root the chosen home ($YMIR_HOME the recorded choice → the default)
11
+ # hoard_root the hoard within it ($YMIR_HOARD → <home>/hodd)
12
+ # hoard_data_dir this machine's records ($YMIR_DATA_DIR → <hoard>/data)
13
+ # hoard_state_dir runtime state ($YMIR_STATE_DIR → <home>/state)
14
+ #
15
+ # The recorded choice is machine state, not user data: a path under
16
+ # `~/.config/ymir/` (the same place `engram-python` and `accounts.json` live), so
17
+ # a packaged install can be told where the home is without the home having to sit
18
+ # in the package. The tree is code; the home is the operator's.
19
+ #
20
+ # Source-safe, defines functions only; call `<fn> <result-var>`.
12
21
  set -u
13
22
 
14
- hoard_root() { # <result-var> the hoard root (may not exist yet)
15
- local result_var=${1-}
23
+ # Where a machine records its choices (never in the repo, never in the home).
24
+ YMIR_CONFIG_DIR="${YMIR_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/ymir}"
25
+ YMIR_HOME_DEFAULT="${YMIR_HOME_DEFAULT:-$HOME/Documents/Ymir}"
26
+
27
+ ymir_home_record() { # <result-var> — the home recorded at installation, or empty
28
+ local result_var=${1-} f="$YMIR_CONFIG_DIR/home"
16
29
  [ -n "$result_var" ] || return 2
17
- printf -v "$result_var" '%s' "${YMIR_HOARD:-${YMIR_HOME:-$HOME/Documents/Ymir}/hodd}"
30
+ if [ -r "$f" ]; then
31
+ printf -v "$result_var" '%s' "$(head -n1 "$f" 2>/dev/null | tr -d '[:space:]')"
32
+ else
33
+ printf -v "$result_var" '%s' ""
34
+ fi
35
+ }
36
+
37
+ ymir_home_root() { # <result-var> — the operator's home: env → recorded → default
38
+ local result_var=${1-} rec=""
39
+ [ -n "$result_var" ] || return 2
40
+ if [ -n "${YMIR_HOME:-}" ]; then
41
+ printf -v "$result_var" '%s' "$YMIR_HOME"; return 0
42
+ fi
43
+ ymir_home_record rec
44
+ printf -v "$result_var" '%s' "${rec:-$YMIR_HOME_DEFAULT}"
45
+ }
46
+
47
+ ymir_home_record_set() { # <path> — record the operator's choice (machine state)
48
+ local home=${1-}
49
+ [ -n "$home" ] || return 2
50
+ mkdir -p "$YMIR_CONFIG_DIR" 2>/dev/null || return 1
51
+ printf '%s\n' "$home" >"$YMIR_CONFIG_DIR/home" || return 1
52
+ }
53
+
54
+ hoard_root() { # <result-var> — the hoard inside the home (may not exist yet)
55
+ local result_var=${1-} home
56
+ [ -n "$result_var" ] || return 2
57
+ if [ -n "${YMIR_HOARD:-}" ]; then
58
+ printf -v "$result_var" '%s' "$YMIR_HOARD"; return 0
59
+ fi
60
+ ymir_home_root home
61
+ printf -v "$result_var" '%s' "$home/hodd"
18
62
  }
19
63
 
20
64
  hoard_env() { # <result-var> — the platform env file inside the hoard
@@ -23,3 +67,39 @@ hoard_env() { # <result-var> — the platform env file inside the hoard
23
67
  hoard_root root
24
68
  printf -v "$result_var" '%s' "$root/secrets/platform.env"
25
69
  }
70
+
71
+ # This machine's own records (operator, fleet, the host profile) and the runtime
72
+ # state (pids, logs, locks). Both live in the HOME — never in the code tree: a
73
+ # packaged install treats its tree as read-only and the next upgrade replaces it,
74
+ # so state written there is state lost. One documented default each; an env
75
+ # override wins, so a single machine can point a root elsewhere without a fork.
76
+ hoard_data_dir() { # <result-var> — where this machine's records live
77
+ local result_var=${1-} root
78
+ [ -n "$result_var" ] || return 2
79
+ hoard_root root
80
+ printf -v "$result_var" '%s' "${YMIR_DATA_DIR:-$root/data}"
81
+ }
82
+
83
+ hoard_state_dir() { # <result-var> — runtime state: ephemeral, outside the tree
84
+ local result_var=${1-} home
85
+ [ -n "$result_var" ] || return 2
86
+ ymir_home_root home
87
+ printf -v "$result_var" '%s' "${YMIR_STATE_DIR:-$home/state}"
88
+ }
89
+
90
+ # The operator's SETTINGS — the agent set, cron, the local env file with its
91
+ # credentials. Settings are the operator's, not the package's: a packaged tree is
92
+ # replaced on upgrade, and a credential must never sit in a tree that ships.
93
+ hoard_settings_dir() { # <result-var> — the settings dir inside the home
94
+ local result_var=${1-} home
95
+ [ -n "$result_var" ] || return 2
96
+ ymir_home_root home
97
+ printf -v "$result_var" '%s' "${YMIR_SETTINGS_DIR:-$home/config}"
98
+ }
99
+
100
+ hoard_local_env() { # <result-var> — the operator's env file (secrets, ports)
101
+ local result_var=${1-} home
102
+ [ -n "$result_var" ] || return 2
103
+ ymir_home_root home
104
+ printf -v "$result_var" '%s' "${YMIR_ENV_FILE:-$home/.env.local}"
105
+ }
package/bin/hodd.sh CHANGED
@@ -42,6 +42,24 @@ PY
42
42
  printf 'hodd[1]{action,file}:\n "load","%s"\n' "$f"
43
43
  }
44
44
 
45
+ # Resolve a secrets path: if the plaintext is absent but an .age sibling and the
46
+ # hoard key exist, decrypt to stdout. Prints a path to a readable file (a temp
47
+ # when decrypted) via the result var. Secrets stay encrypted at rest; the agent
48
+ # never reads ciphertext directly — it asks for the resolved path.
49
+ resolve_secret() { # <file> <result-var>
50
+ local f=$1 var=$2 tmp key
51
+ if [ -r "$f" ]; then printf -v "$var" '%s' "$f"; return 0; fi
52
+ if [ -r "$f.age" ]; then
53
+ key="$HOARD/secrets/age.key"
54
+ command -v age >/dev/null 2>&1 || { printf 'error: %s is encrypted but age is not installed\nhelp: sudo pacman -S age\n' "$f.age" >&2; return 1; }
55
+ [ -r "$key" ] || { printf 'error: encrypted %s but no key at %s\n' "$f.age" "$key" >&2; return 1; }
56
+ tmp="$(mktemp)"; chmod 600 "$tmp"
57
+ age -d -i "$key" "$f.age" >"$tmp" 2>/dev/null || { rm -f "$tmp"; printf 'error: cannot decrypt %s\n' "$f.age" >&2; return 1; }
58
+ printf -v "$var" '%s' "$tmp"; return 0
59
+ fi
60
+ printf 'error: not readable: %s (and no %s.age)\n' "$f" "$f" >&2; return 1
61
+ }
62
+
45
63
  case "$ACTION" in
46
64
  path) printf '%s\n' "$HOARD" ;;
47
65
  init)
@@ -56,23 +74,29 @@ case "$ACTION" in
56
74
  load)
57
75
  f="${1:-}"; [ -n "$f" ] || { printf 'error: load needs a path under the Hoard\n' >&2; exit 2; }
58
76
  case "$f" in /*) ;; *) f="$HOARD/$f" ;; esac
59
- load_env "$f" ;;
77
+ resolve_secret "$f" rf || exit 1
78
+ load_env "$rf"
79
+ case "$rf" in "$f") ;; *) rm -f "$rf" ;; esac ;;
60
80
  emit)
61
81
  # Print `export KEY=value` lines (quoting-safe) for the CALLER to eval, so
62
82
  # `eval "$(bin/hodd.sh emit <file>)"` sets the vars in the invoking shell.
83
+ # Transparently decrypts an .age sibling when the plaintext is absent.
63
84
  f="${1:-}"; [ -n "$f" ] || { printf 'error: emit needs a path under the Hoard\n' >&2; exit 2; }
64
85
  case "$f" in /*) ;; *) f="$HOARD/$f" ;; esac
65
- [ -r "$f" ] || { printf 'error: not readable: %s\n' "$f" >&2; exit 1; }
66
- python3 - "$f" <<'PY'
86
+ resolve_secret "$f" rf || exit 1
87
+ python3 - "$rf" <<'PY'
67
88
  import re, shlex, sys
68
89
  for line in open(sys.argv[1]):
69
90
  m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)$', line.rstrip("\n"))
70
91
  if m:
71
92
  print(f"export {m.group(1)}={shlex.quote(m.group(2))}")
72
93
  PY
94
+ case "$rf" in "$f") ;; *) rm -f "$rf" ;; esac
73
95
  ;;
74
96
  tenant)
75
97
  t="${1:-}"; [ -n "$t" ] || { printf 'error: tenant needs a name\n' >&2; exit 2; }
76
- load_env "$HOARD/tenants/$t/.env" ;;
98
+ resolve_secret "$HOARD/tenants/$t/.env" rf || exit 1
99
+ load_env "$rf"
100
+ case "$rf" in "$HOARD/tenants/$t/.env") ;; *) rm -f "$rf" ;; esac ;;
77
101
  *) printf 'error: unknown action %s\nhelp: bin/hodd.sh [path|init|ls|load|emit|tenant]\n' "$ACTION" >&2; exit 2 ;;
78
102
  esac
@@ -12,6 +12,19 @@ set -u
12
12
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
13
13
  ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
14
14
 
15
+ # The operator's settings and secrets live in the home they chose, never in the
16
+ # code tree — a packaged install replaces its tree on upgrade, and a credential
17
+ # must never sit in a tree that ships (Rule 04).
18
+ if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
19
+ _yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
21
+ [ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
22
+ done
23
+ unset _yr _yc
24
+ fi
25
+ hoard_settings_dir YMIR_SETTINGS_DIR
26
+ hoard_local_env YMIR_ENV_FILE
27
+
15
28
  BRIEF="" OUTDIR="" MODEL=""
16
29
  while [ $# -gt 0 ]; do
17
30
  case "$1" in
@@ -28,7 +41,7 @@ done
28
41
  mkdir -p "$OUTDIR"
29
42
 
30
43
  # Resolve env — source .env.local if present.
31
- for f in "$ROOT/.env.local" "${YMIR_HOARD:-$HOME/Documents/Ymir}/.env.local"; do
44
+ for f in "$YMIR_ENV_FILE" "${YMIR_HOARD:-$HOME/Documents/Ymir}/.env.local"; do
32
45
  [ -f "$f" ] && . "$f" 2>/dev/null || true
33
46
  done
34
47
 
@@ -12,8 +12,27 @@ set -u
12
12
 
13
13
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
14
14
  ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
15
- CFG="${YMIR_AGENTS_YAML:-$ROOT/config/agents.yaml}"
16
- LOCK="$ROOT/state/local-model.lock"
15
+
16
+ # The operator's settings and secrets live in the home they chose, never in the
17
+ # code tree — a packaged install replaces its tree on upgrade, and a credential
18
+ # must never sit in a tree that ships (Rule 04).
19
+ if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
20
+ _yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
21
+ for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
22
+ [ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
23
+ done
24
+ unset _yr _yc
25
+ fi
26
+ hoard_settings_dir YMIR_SETTINGS_DIR
27
+ hoard_local_env YMIR_ENV_FILE
28
+ hoard_state_dir YMIR_STATE_DIR
29
+ hoard_data_dir YMIR_DATA_DIR
30
+
31
+ # The roots that live OUTSIDE the code tree: this machine's records and the
32
+ # runtime state belong to the home the operator chose at installation, never in
33
+ # the tree — a packaged install replaces its tree on upgrade (Rule 04).
34
+ CFG="${YMIR_AGENTS_YAML:-$YMIR_SETTINGS_DIR/agents.yaml}"
35
+ LOCK="$YMIR_STATE_DIR/local-model.lock"
17
36
  WAIT="${LOCAL_MODEL_WAIT:-1800}"
18
37
 
19
38
  [ $# -gt 0 ] || { printf 'error: usage: bin/local-model-lock.sh <command...>\n' >&2; exit 2; }
@@ -31,7 +50,7 @@ PY
31
50
  )"
32
51
  [ -n "$N" ] || N=1
33
52
 
34
- mkdir -p "$ROOT/state"
53
+ mkdir -p "$YMIR_STATE_DIR"
35
54
 
36
55
  if [ "$N" -le 1 ]; then
37
56
  # one at a time — exclusive lock, wait for the peer to finish
@@ -49,6 +49,35 @@ def engine(agent: str | None) -> Engram:
49
49
  return _pool[key]
50
50
 
51
51
 
52
+ def layers():
53
+ """What the well actually holds, layer by layer - the record a UI reads."""
54
+ out = {"facts": 0, "facts_active": 0, "facts_superseded": 0, "entities": 0,
55
+ "edges": 0, "reflections": 0, "last_reflection": None, "vec_index": 0}
56
+ try:
57
+ con = sqlite3.connect(f"file:{STORE}?mode=ro", uri=True)
58
+ for key, sql in (
59
+ ("facts", "select count(*) from facts"),
60
+ ("facts_active", "select count(*) from facts where superseded_at is null"),
61
+ ("facts_superseded", "select count(*) from facts where superseded_at is not null"),
62
+ ("entities", "select count(*) from entities"),
63
+ ("edges", "select count(*) from edges"),
64
+ ("reflections", "select count(*) from reflections"),
65
+ ("vec_index", "select count(*) from vec_episodes_rowids"),
66
+ ):
67
+ try:
68
+ out[key] = int(con.execute(sql).fetchone()[0])
69
+ except Exception:
70
+ pass
71
+ try:
72
+ out["last_reflection"] = con.execute(
73
+ "select max(finished_at) from reflections").fetchone()[0]
74
+ except Exception:
75
+ pass
76
+ except Exception as e:
77
+ out["error"] = str(e)
78
+ return out
79
+
80
+
52
81
  def count_episodes() -> int:
53
82
  try:
54
83
  con = sqlite3.connect(f"file:{STORE}?mode=ro", uri=True)
@@ -98,10 +127,14 @@ class Handler(BaseHTTPRequestHandler):
98
127
  "agents": engine(None).list_agents(),
99
128
  })
100
129
  if u.path == "/inspect":
130
+ # THE LAYERS, not just the count. The smithy shows what it is told,
131
+ # and it was told three fields while the well holds six: a panel that
132
+ # reads only episodes cannot show that facts and entities exist.
101
133
  return self._json(200, {
102
134
  "store": str(STORE),
103
135
  "episodes": count_episodes(),
104
136
  "agents": engine(None).list_agents(),
137
+ **layers(),
105
138
  })
106
139
  if u.path == "/recent":
107
140
  limit = int((q.get("limit") or ["60"])[0])
@@ -18,6 +18,19 @@ fi
18
18
  VERSION="1.0.0"
19
19
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
20
  ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
21
+
22
+ # The roots that live OUTSIDE the code tree: this machine's records and the
23
+ # runtime state belong to the home the operator chose at installation, never in
24
+ # the tree — a packaged install replaces its tree on upgrade (Rule 04).
25
+ if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
26
+ _yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
27
+ for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
28
+ [ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
29
+ done
30
+ unset _yr _yc
31
+ fi
32
+ hoard_state_dir YMIR_STATE_DIR
33
+ hoard_data_dir YMIR_DATA_DIR
21
34
  YMIR_HOME="${YMIR_HOME:-$HOME/Documents/Ymir}"
22
35
  BRIDGE="$ROOT/bin/mimir-bridge.py"
23
36
  DB="${ENGRAM_DB:-$YMIR_HOME/memory/kaia.engram}"
@@ -75,7 +88,7 @@ if ! "$PY" -c "import engram" 2>/dev/null; then
75
88
  printf 'error: engram not installed for %s\nhelp: bin/prereq-ensure.sh engram (installs it into a compatible Python)\n' "$PY" >&2; exit 1
76
89
  fi
77
90
 
78
- mkdir -p "$ROOT/state" "$(dirname "$DB")"
91
+ mkdir -p "$YMIR_STATE_DIR" "$(dirname "$DB")"
79
92
  ENGRAM_DB="$DB" MIMIRSBRUNN_PORT="$PORT" nohup "$PY" "$BRIDGE" >"$LOG_FILE" 2>&1 &
80
93
  echo $! >"$PID_FILE"
81
94
  sleep 3