@zerwiz/ymir 0.1.5 → 0.1.8

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 (125) 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 +290 -24
  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 +1362 -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 +156 -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/einherjar-spawn.sh +9 -3
  55. package/bin/eir-doctor.sh +89 -1
  56. package/bin/electron-lib.sh +34 -0
  57. package/bin/feature-inventory.sh +73 -0
  58. package/bin/fleet-apply.sh +15 -2
  59. package/bin/github-deploy.sh +14 -1
  60. package/bin/gjallarhorn-expose.sh +43 -9
  61. package/bin/gjallarhorn-notify.sh +14 -1
  62. package/bin/gjallarhorn-purge.sh +14 -1
  63. package/bin/gjallarhorn-tunnel.sh +16 -3
  64. package/bin/groa-update.sh +9 -1
  65. package/bin/hall-snapshot.sh +21 -2
  66. package/bin/herdr-run.sh +23 -2
  67. package/bin/hlidskjalf-agents.sh +172 -0
  68. package/bin/hlidskjalf-usage.sh +196 -0
  69. package/bin/hoard-guard.sh +135 -0
  70. package/bin/hoard-lib.sh +91 -11
  71. package/bin/hodd.sh +28 -4
  72. package/bin/huginn-research-worker.sh +14 -1
  73. package/bin/local-model-lock.sh +22 -3
  74. package/bin/mimir-bridge.py +33 -0
  75. package/bin/mimir-bridge.sh +14 -1
  76. package/bin/mimir-reflect.sh +27 -0
  77. package/bin/mjollnir-webhook.sh +14 -1
  78. package/bin/mjollnir.sh +22 -3
  79. package/bin/model-hardware.sh +17 -3
  80. package/bin/model-resolve.sh +14 -1
  81. package/bin/nornir-job-hall-snapshot.sh +28 -0
  82. package/bin/nornir-job-nsr-compliance.sh +54 -0
  83. package/bin/npm-publish.sh +136 -0
  84. package/bin/omarchy-install.sh +18 -5
  85. package/bin/omarchy-plugins.sh +15 -2
  86. package/bin/omarchy-sense.sh +14 -1
  87. package/bin/pi-model-agnostic.sh +71 -0
  88. package/bin/private-guard.sh +20 -0
  89. package/bin/project-git.sh +18 -4
  90. package/bin/public-guard.sh +3 -2
  91. package/bin/sessrumnir-ensure.sh +1 -1
  92. package/bin/sessrumnir.sh +16 -3
  93. package/bin/smidja-board.sh +136 -0
  94. package/bin/smidja-bootstrap.sh +5 -1
  95. package/bin/smidja-lib.sh +49 -0
  96. package/bin/syn-asset-pretool-check.sh +14 -1
  97. package/bin/syn-spawn-pretool-check.sh +58 -0
  98. package/bin/tailscale-sync.sh +15 -2
  99. package/bin/telegram-bot.sh +14 -1
  100. package/bin/ui-truth-check.sh +75 -0
  101. package/bin/valknut-load.sh +42 -4
  102. package/bin/wedge-notify.sh +14 -1
  103. package/bin/workspace-rag.sh +14 -1
  104. package/bin/ymir-install.sh +257 -57
  105. package/bin/ymir-plan.sh +390 -0
  106. package/bin/ymir-say.sh +14 -1
  107. package/bin/ymir-setup-auth.sh +12 -1
  108. package/bin/ymir-style.sh +108 -0
  109. package/bin/ymir-validate.sh +64 -11
  110. package/bin/ymir.js +82 -34
  111. package/docs/design.md +2 -2
  112. package/docs/lore.md +5 -1
  113. package/docs/runbooks/agents.md +1 -1
  114. package/package.json +13 -3
  115. package/scripts/electron.sh +74 -15
  116. package/scripts/lower.sh +34 -0
  117. package/scripts/raise.sh +42 -0
  118. package/scripts/start.sh +65 -3
  119. package/.agents/config/agents.yaml +0 -50
  120. package/.agents/config/tailscale-sync.yaml +0 -27
  121. package/.agents/memory/kaia.engram +0 -0
  122. package/.agents/memory/kaia.engram-shm +0 -0
  123. package/.agents/memory/kaia.engram-wal +0 -0
  124. package/.agents/memory/well/episodes.jsonl +0 -367
  125. package/.agents/memory/well/workspace.jsonl +0 -27
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env bash
2
+ # hlidskjalf-agents.sh — WHO IS ACTUALLY STANDING, and what they are doing.
3
+ #
4
+ # The control plane showed the ROSTER: the twenty profiles that *could* run.
5
+ # A roster is a leaflet; the panes are the truth. This reads the live pane list
6
+ # (herdr) and emits the sessions that exist right now, so the Fleet gate shows a
7
+ # fleet rather than a painting of one — every opencode and pi session, with its
8
+ # kind, its state, its pane and where it works.
9
+ #
10
+ # bin/hlidskjalf-agents.sh # JSON (what /api/agents serves)
11
+ # bin/hlidskjalf-agents.sh --toon # TOON (for a human at a terminal)
12
+ #
13
+ # State comes from the pane's own reported status. Nothing here infers liveness
14
+ # from a process list or from prose: if herdr does not say an agent is there, it
15
+ # is not on the board.
16
+ set -u
17
+
18
+ VERSION="1.0.0"
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
21
+ HERDR="${HERDR_BIN_PATH:-herdr}"
22
+ MODE="json"
23
+ [ "${1:-}" = "--toon" ] && MODE="toon"
24
+ case "${1-}" in -v|-V|--version) printf '%s\n' "$VERSION"; exit 0 ;;
25
+ -h|--help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
26
+
27
+ command -v "$HERDR" >/dev/null 2>&1 || { [ "$MODE" = json ] && printf '[]\n' || printf 'agents[0]{name}:\n'; exit 0; }
28
+
29
+ raw="$("$HERDR" pane list 2>/dev/null)"
30
+
31
+ MODE="$MODE" RAW="$raw" python3 - <<'PY'
32
+ import json, os, sys
33
+
34
+ mode = os.environ.get("MODE", "json")
35
+ raw = os.environ.get("RAW", "")
36
+
37
+ ROSTER_DIR = os.environ.get("ROSTER_DIR", ".agents/agents")
38
+
39
+ def panes():
40
+ """The STANDING seats: a herdr pane an agent runs in. A pane is the WHERE."""
41
+ try:
42
+ d = json.loads(raw)
43
+ except Exception:
44
+ return []
45
+ pl = (d.get("result") or {}).get("panes") or d.get("panes") or []
46
+ out = []
47
+ for p in pl:
48
+ agent = p.get("agent") or ""
49
+ if not agent and p.get("agent_status") in (None, "", "unknown"):
50
+ continue
51
+ title = (p.get("terminal_title_stripped") or p.get("terminal_title") or "").strip()
52
+ out.append({
53
+ "id": p.get("pane_id", ""),
54
+ "name": title.split("|")[0].strip() or agent or p.get("pane_id", ""),
55
+ "harness": agent or "agent",
56
+ "state": p.get("agent_status") or "unknown",
57
+ "title": title,
58
+ "cwd": p.get("foreground_cwd") or p.get("cwd") or "",
59
+ "workspace": p.get("workspace_id", ""),
60
+ "task": title.split("|", 1)[1].strip() if "|" in title else "",
61
+ "matched": False,
62
+ })
63
+ return out
64
+
65
+
66
+ def roster():
67
+ """The AGENTS, from the canonical roster (.agents/agents/*.md).
68
+
69
+ A pane is the where; this is the WHO. Emitting panes alone is why the Fleet
70
+ showed thirteen OpenCode terminals and not one of the smiths who might be
71
+ standing in them. The roster is canonical (RULES/02); a harness seat is not an
72
+ agent, and an agent is not its seat.
73
+ """
74
+ try:
75
+ files = sorted(f for f in os.listdir(ROSTER_DIR) if f.endswith(".md"))
76
+ except Exception:
77
+ return []
78
+ out = []
79
+ for f in files:
80
+ stem = f[:-3]
81
+ parts = stem.split("-")
82
+ model = mode = domain = ""
83
+ try:
84
+ with open(os.path.join(ROSTER_DIR, f)) as fh:
85
+ txt = fh.read(6000)
86
+ if txt.startswith("---"):
87
+ for line in txt.split("---", 2)[1].splitlines():
88
+ if line.startswith("model:") and not model:
89
+ model = line.split(":", 1)[1].strip()
90
+ if line.startswith("mode:") and not mode:
91
+ mode = line.split(":", 1)[1].strip()
92
+ if line.startswith("domain:") and not domain:
93
+ domain = line.split(":", 1)[1].strip()
94
+ except Exception:
95
+ pass
96
+ out.append({
97
+ "stem": stem,
98
+ "figure": parts[0],
99
+ "craft": parts[1] if len(parts) > 1 else "agent",
100
+ "model": model,
101
+ "mode": mode,
102
+ "domain": domain,
103
+ })
104
+ return out
105
+
106
+
107
+ ps = panes()
108
+ rs = roster()
109
+ rows = []
110
+
111
+ def card(name, role, model, state, domain="ymirlabs", pane=None, task="", where=""):
112
+ status = {"working": "nominal", "idle": "nominal", "unseated": "seated"}.get(state, "degraded")
113
+ return {
114
+ "id": pane["id"] if pane else "roster:" + name,
115
+ "name": name,
116
+ "role": role,
117
+ "realm": "work",
118
+ "domain": domain,
119
+ "status": status,
120
+ "capabilities": [],
121
+ "skills": [],
122
+ "interface": {
123
+ "protocol": "a2a/1.0",
124
+ "endpoint": ("local://%s" % pane["id"]) if pane else "",
125
+ "signed": False,
126
+ },
127
+ "model": model or (pane["harness"] if pane else ""),
128
+ "uptime": 0,
129
+ "tasksDone": 0,
130
+ "kind": role,
131
+ "state": state,
132
+ "pane": pane["id"] if pane else "",
133
+ "cwd": (pane["cwd"] if pane else where),
134
+ "task": task,
135
+ "live": ({
136
+ "kind": pane["harness"], "state": state, "pane": pane["id"],
137
+ "workspace": pane["workspace"], "cwd": pane["cwd"], "task": pane["task"],
138
+ } if pane else None),
139
+ }
140
+
141
+ # THE AGENTS FIRST — every smith on the roster, seated or not. An unseated agent is
142
+ # still an agent; hiding it made the board look like a rack of terminals.
143
+ for a in rs:
144
+ hit = None
145
+ for p in ps:
146
+ hay = ("%s %s %s" % (p["name"], p["harness"], p["title"])).lower()
147
+ if a["figure"] and a["figure"] in hay:
148
+ p["matched"] = True
149
+ hit = p
150
+ break
151
+ rows.append(card(a["figure"], a["craft"], a["model"],
152
+ hit["state"] if hit else "unseated",
153
+ domain=a["domain"] or "ymirlabs",
154
+ pane=hit, task=hit["task"] if hit else ""))
155
+
156
+ # Harness seats that answer to no one on the roster: kept visible, named honestly.
157
+ for p in ps:
158
+ if not p["matched"]:
159
+ rows.append(card(p["name"], p["harness"], p["harness"], p["state"], pane=p,
160
+ task=p["task"]))
161
+
162
+ if mode == "json":
163
+ print(json.dumps(rows))
164
+ raise SystemExit
165
+
166
+ print("agents[%d]{name,kind,state,pane,cwd,task}:" % len(rows))
167
+ for r in rows:
168
+ q = lambda s: '"%s"' % str(s).replace('"', '\\"')
169
+ print(" %s,%s,%s,%s,%s,%s" % (q(r["name"]), q(r["kind"]), q(r["state"]), q(r["pane"]), q(r["cwd"]), q(r["task"])))
170
+ if not rows:
171
+ print("agents: 0 live sessions — nothing is standing")
172
+ PY
@@ -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
+ }