@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.
- package/.agents/agents/huginn-researcher.md +1 -1
- package/.agents/agents/kvasir-scout.md +1 -1
- package/.agents/agents/sindri-developer.md +1 -1
- package/.agents/backend/README.md +74 -0
- package/.agents/backend/model-bridge.py +74 -4
- package/.agents/config/cron.yaml +2 -0
- package/.agents/config/eindri-dispatch.json +6 -6
- package/.agents/migrations/0003-private-data-separation.sh +11 -1
- package/.agents/migrations/0005-roots-out-of-tree.sh +158 -0
- package/.agents/skills/README.md +1 -1
- package/.agents/skills/galdr-ymirsystem/SKILL.md +2 -1
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +73 -10
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +44 -1
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +290 -24
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +3 -1
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +39 -1
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +4 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +7 -2
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +1 -1
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +2 -2
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +16 -11
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +147 -4
- package/.agents/skills/groa-update/SKILL.md +20 -0
- package/.agents/skills/hamr-adapters/SKILL.md +2 -2
- package/.agents/skills/lifecycle/smoke_test.sh +7 -2
- package/.agents/skills/ymir-host/SKILL.md +2 -1
- package/.agents/skills/ymir-host/assets/install.md +69 -0
- package/AGENTS.md +78 -34
- package/CHANGELOG.md +1362 -1
- package/README.md +1 -1
- package/RULES/02-agents.md +2 -2
- package/RULES/04-hoard.md +32 -0
- package/RULES/06-append-only.md +50 -0
- package/RULES/07-config.md +56 -0
- package/RULES/09-electron.md +58 -0
- package/RULES/README.md +2 -0
- package/bin/a2a-mcp.sh +42 -7
- package/bin/agents-config.sh +30 -7
- package/bin/apodex-smoke-test.sh +14 -1
- package/bin/bifrost-bridge.sh +25 -6
- package/bin/bootstrap-macos.sh +6 -1
- package/bin/brokk-send.sh +14 -1
- package/bin/changelog-assemble.sh +154 -0
- package/bin/changelog-guard.sh +39 -7
- package/bin/crash-sense.sh +14 -1
- package/bin/design-check.sh +80 -0
- package/bin/design-icon.sh +156 -0
- package/bin/docs-guard.sh +2 -2
- package/bin/editor-place.sh +33 -15
- package/bin/eindri-acclaim.sh +14 -1
- package/bin/eindri-seat.sh +83 -0
- package/bin/eindri-seen.sh +14 -1
- package/bin/eindri-start.sh +14 -1
- package/bin/einherjar-spawn.sh +9 -3
- package/bin/eir-doctor.sh +89 -1
- package/bin/electron-lib.sh +34 -0
- package/bin/feature-inventory.sh +73 -0
- package/bin/fleet-apply.sh +15 -2
- package/bin/github-deploy.sh +14 -1
- package/bin/gjallarhorn-expose.sh +43 -9
- package/bin/gjallarhorn-notify.sh +14 -1
- package/bin/gjallarhorn-purge.sh +14 -1
- package/bin/gjallarhorn-tunnel.sh +16 -3
- package/bin/groa-update.sh +9 -1
- package/bin/hall-snapshot.sh +21 -2
- package/bin/herdr-run.sh +23 -2
- package/bin/hlidskjalf-agents.sh +172 -0
- package/bin/hlidskjalf-usage.sh +196 -0
- package/bin/hoard-guard.sh +135 -0
- package/bin/hoard-lib.sh +91 -11
- package/bin/hodd.sh +28 -4
- package/bin/huginn-research-worker.sh +14 -1
- package/bin/local-model-lock.sh +22 -3
- package/bin/mimir-bridge.py +33 -0
- package/bin/mimir-bridge.sh +14 -1
- package/bin/mimir-reflect.sh +27 -0
- package/bin/mjollnir-webhook.sh +14 -1
- package/bin/mjollnir.sh +22 -3
- package/bin/model-hardware.sh +17 -3
- package/bin/model-resolve.sh +14 -1
- package/bin/nornir-job-hall-snapshot.sh +28 -0
- package/bin/nornir-job-nsr-compliance.sh +54 -0
- package/bin/npm-publish.sh +136 -0
- package/bin/omarchy-install.sh +18 -5
- package/bin/omarchy-plugins.sh +15 -2
- package/bin/omarchy-sense.sh +14 -1
- package/bin/pi-model-agnostic.sh +71 -0
- package/bin/private-guard.sh +20 -0
- package/bin/project-git.sh +18 -4
- package/bin/public-guard.sh +3 -2
- package/bin/sessrumnir-ensure.sh +1 -1
- package/bin/sessrumnir.sh +16 -3
- package/bin/smidja-board.sh +136 -0
- package/bin/smidja-bootstrap.sh +5 -1
- package/bin/smidja-lib.sh +49 -0
- package/bin/syn-asset-pretool-check.sh +14 -1
- package/bin/syn-spawn-pretool-check.sh +58 -0
- package/bin/tailscale-sync.sh +15 -2
- package/bin/telegram-bot.sh +14 -1
- package/bin/ui-truth-check.sh +75 -0
- package/bin/valknut-load.sh +42 -4
- package/bin/wedge-notify.sh +14 -1
- package/bin/workspace-rag.sh +14 -1
- package/bin/ymir-install.sh +257 -57
- package/bin/ymir-plan.sh +390 -0
- package/bin/ymir-say.sh +14 -1
- package/bin/ymir-setup-auth.sh +12 -1
- package/bin/ymir-style.sh +108 -0
- package/bin/ymir-validate.sh +64 -11
- package/bin/ymir.js +82 -34
- package/docs/design.md +2 -2
- package/docs/lore.md +5 -1
- package/docs/runbooks/agents.md +1 -1
- package/package.json +13 -3
- package/scripts/electron.sh +74 -15
- package/scripts/lower.sh +34 -0
- package/scripts/raise.sh +42 -0
- package/scripts/start.sh +65 -3
- package/.agents/config/agents.yaml +0 -50
- package/.agents/config/tailscale-sync.yaml +0 -27
- package/.agents/memory/kaia.engram +0 -0
- package/.agents/memory/kaia.engram-shm +0 -0
- package/.agents/memory/kaia.engram-wal +0 -0
- package/.agents/memory/well/episodes.jsonl +0 -367
- 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
|
|
2
|
+
# hoard-lib.sh — where the operator's HOME lives, and the two roots beneath it.
|
|
3
3
|
#
|
|
4
|
-
# The
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
-
#
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
+
}
|