@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
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  mode: subagent
3
- model: apodex/apodex-1.0-mini
3
+ model: opencode-go/deepseek-v4.1-flash
4
4
  permission:
5
5
  read: allow
6
6
  edit: deny
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  mode: subagent
3
- model: qwen-9b/Qwen3.5-9B-Q8_0.gguf
3
+ model: llamacpp/qwen3.5-9b
4
4
  permission:
5
5
  read: allow
6
6
  edit: deny
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  mode: subagent
3
- model: qwen3-coder-30b/Qwen3-Coder-30B-A3B-Instruct-Q8_0.gguf
3
+ model: llamacpp-coder/qwen3-coder-30b
4
4
  permission:
5
5
  read: allow
6
6
  edit: allow
@@ -0,0 +1,74 @@
1
+ # `.agents/backend/` — the backend layer
2
+
3
+ **What this folder is.** Two things that share one job — *make work happen and
4
+ report it truthfully*:
5
+
6
+ 1. **The vendored firstmate runtime** (`fm-*`) — the validated upstream we ported
7
+ Ymir's dispatch from (`~/firstmate`, the source of the Einherjar/fm-spawn
8
+ lifecycle). Vendored, not rewritten: it is the reference implementation of
9
+ spawn → send → peek → watch → teardown, and it stays byte-comparable to
10
+ upstream so a port can be checked against it.
11
+ 2. **The model bridges** (`model-bridge.py`, `opencode-go-bridge.py`) — Ymir's
12
+ own, the only files here we wrote. They are the reason this README exists: the
13
+ bridge was the cause of the Cloudflare 1010 night (see the plan's Amendment I).
14
+
15
+ **Every file's own header is the owner of its contract.** They all say so
16
+ explicitly ("this header is the one owner of…"), and that is deliberate: this
17
+ README must not become a second, drifting description. Read a file's first twenty
18
+ lines for its exact rule; read here for the map.
19
+
20
+ ## The owners, by family
21
+
22
+ ```
23
+ families[12]{family,owns,subsystem,ui_part}:
24
+ "spawn / lifecycle","fm-spawn.sh fm-send.sh fm-peek.sh fm-control.sh fm-teardown.sh fm-promote.sh fm-merge-local.sh","Einherjar (the door)","Hlidskjalf · Fleet — create, steer, land a task"
25
+ "state","state/<id>.meta, crew/current state: fm-crew-state.sh fm-inactive-reconcile.sh fm-project-mode.sh","Hlidskjalf · Fleet","the task cards: kind, posture, branch, worktree"
26
+ "watch / wake","the watcher and its wake queue: fm-watch.sh fm-watch-arm.sh fm-watch-checkpoint.sh fm-wake-*.sh fm-classify-lib.sh fm-transition-lib.sh","Sýn (supervision)","Hlidskjalf · Status — needs-decision, blocked, the lock state"
27
+ "lease / lock","who may act on a task: fm-lease.sh fm-lease-lib.sh fm-lock.sh fm-session-lock-lib.sh","Gleipnir (the lock)","Hlidskjalf · Status — the session-lock indicator"
28
+ "busy / composer","is the agent working, is the composer ready: fm-busy-lib.sh fm-busy-event.sh fm-composer-lib.sh fm-tmux-lib.sh","Hlidskjalf · Fleet","the per-task state chip (working / paused / waiting)"
29
+ "process → events","long-polling children turned into wakes: fm-procevent.sh fm-procevent-lib.sh fm-procevent-{quota,when,lavish,remote-reply}.sh","Nornir (fate)","Hlidskjalf · Status — scheduled events and quota warnings"
30
+ "remote / secondmate","other homes over SSH: fm-on.sh fm-remote-*.sh fm-home-seed.sh fm-remote-home-*.sh","Yggdrasil (the trunk)","Hlidskjalf · Homes — the secondmate list and its liveness"
31
+ "pr / merge","the delivery gate's other half: fm-pr-check.sh fm-pr-lib.sh fm-pr-merge.sh fm-pr-poll.sh fm-merge-outcome-lib.sh","Mjollnir (the gate)","Hlidskjalf · Reviews (Glitnir) — open, merged, refused"
32
+ "briefs / contracts","what a worker is told, and what done means: fm-brief.sh fm-dod-lib.sh fm-branch-prompt.sh","Smiðja · Erindi","Smiðja · run detail — the brief and the definition of done"
33
+ "voice","the spoken interface: fm-voice-client.py fm-voice-relay.py fm_voice_*.py","Óðrerir (the hall)","Óðrerir · the hall — spoken status and queued work"
34
+ "CI installs","pinned, checksum-verified tool builds: fm-install-{herdr,shellcheck,actionlint,treehouse}.sh","Ymir's CI","(no UI — the pipeline only)"
35
+ "model bridge","the provider door: model-bridge.py + the opencode-go-bridge.py shim","Bifrost (the crossing)","Hlidskjalf · Models — which provider answers, and whether it is local"
36
+ ```
37
+
38
+ ## What the UI must be connected to
39
+
40
+ The rule for every row above: **the UI reads the record, never the prose.** A
41
+ panel that parses a log for state is a panel that will lie to the operator.
42
+
43
+ ```
44
+ ui_links[6]{surface,reads,shows}:
45
+ "Hlidskjalf · Fleet","state/<id>.meta via fm-crew-state.sh","each task: kind, harness, model, branch, worktree, posture"
46
+ "Hlidskjalf · Status","fm-busy-lib.sh's semantic busy contract (never a pane scrape)","working / paused / needs-decision / done / failed, and the lock"
47
+ "Hlidskjalf · Reviews (Glitnir)","fm-pr-*.sh records (provider, url, number, head)","what is open, merged or refused — the delivery gate"
48
+ "Hlidskjalf · Homes","fm-fleet-snapshot.sh --json and the secondmate registry","the other homes, their liveness and their scope"
49
+ "Óðrerir · the hall","fm-fleet-snapshot.sh --json (the ONE structured contract)","the live board: sessions, phases, spend, ledger"
50
+ "Smiðja · trace","the run's own SQLite trace","phases, envelopes, retries — what the work actually did"
51
+ ```
52
+
53
+ **The rule this folder exists to enforce, and it is the lesson of this session:**
54
+ a seat is not a spawn; a spawn is not a delivery; a delivery is not a record. The
55
+ lifecycle above is the only path that turns an intention into a *record* the UI can
56
+ honestly show — which is why `bin/einherjar-spawn.sh` is the door and typing into a
57
+ pane is forbidden (rule 09 / the spawn seatbelt).
58
+
59
+ ## The bridges, and the law they broke
60
+
61
+ `model-bridge.py` exposes a provider to pi as a local OpenAI-compatible endpoint,
62
+ reading the credential from the env. It sat at `127.0.0.1:4603` **while forwarding
63
+ to a cloud gateway** — a localhost address that left the machine. Its `urllib`
64
+ client was refused by Cloudflare (1010, browser_signature_banned) while `curl`
65
+ passed on the same request, and the fix is a session header plus a transport with
66
+ a fingerprint the edge admits. The law it violated is now Rule 09's sibling:
67
+ *local is not a port number; it is a promise.*
68
+
69
+ ## Maintaining this
70
+
71
+ - **Owner:** Brokk. **Upstream:** `~/firstmate` — a change here that upstream does
72
+ not have is drift, and should be a documented port, not a quiet edit.
73
+ - **Never rename an `fm-*` file** to a Norse name while the upstream reference is
74
+ needed: names are the join between the two trees.
@@ -79,6 +79,12 @@ KEY = ""
79
79
  BASE_URL = ""
80
80
  PROVIDER = "opencode-go"
81
81
 
82
+ # The gateway sits behind Cloudflare, which BANS the signature of a scripted
83
+ # client (Python-urllib/3.x) with error 1010: "banned your access based on your
84
+ # browser's signature". That was the whole 403 - our own bridge was the client.
85
+ USER_AGENT = os.environ.get("YMIR_MODEL_UA", "curl/8.5.0")
86
+ SESSION_ID = os.environ.get("YMIR_MODEL_SESSION") or str(__import__("uuid").uuid4())
87
+
82
88
 
83
89
  def load_env(path: str) -> dict:
84
90
  env = {}
@@ -149,8 +155,10 @@ class Handler(BaseHTTPRequestHandler):
149
155
  # Ask the provider what it serves; fall back to the static catalogue.
150
156
  try:
151
157
  req = urllib.request.Request(_upstream("models"),
152
- headers=_auth_headers())
153
- with urllib.request.urlopen(req, timeout=10) as r:
158
+ headers={**_auth_headers(), "User-Agent": USER_AGENT,
159
+ # The Zen gateway requires a session id; without it: 400 MissingSessionID.
160
+ "x-opencode-session": SESSION_ID})
161
+ with _curl_open(req, timeout=10) as r:
154
162
  data = json.loads(r.read())
155
163
  return data.get("data", [])
156
164
  except Exception:
@@ -192,7 +200,7 @@ class Handler(BaseHTTPRequestHandler):
192
200
  method="POST",
193
201
  )
194
202
  try:
195
- resp = urllib.request.urlopen(req, timeout=300)
203
+ resp = _curl_open(req, timeout=300)
196
204
  except urllib.error.HTTPError as e:
197
205
  # Relay the provider's error verbatim so the operator sees the cause.
198
206
  payload = e.read()
@@ -240,12 +248,74 @@ def _upstream(suffix: str) -> str:
240
248
 
241
249
  def _auth_headers(stream: bool = False) -> dict:
242
250
  h = {"Content-Type": "application/json",
243
- "Accept": "text/event-stream" if stream else "application/json"}
251
+ "Accept": "text/event-stream" if stream else "application/json",
252
+ # Cloudflare at the gateway bans the signature of a scripted client
253
+ # (Python-urllib/3.x) with error 1010. Our own bridge WAS that client,
254
+ # which is why every worker's first turn came back 403.
255
+ "User-Agent": USER_AGENT,
256
+ # The Zen gateway requires a session id; without it: 400 MissingSessionID.
257
+ "x-opencode-session": SESSION_ID}
244
258
  if KEY:
245
259
  h["Authorization"] = f"Bearer {KEY}"
246
260
  return h
247
261
 
248
262
 
263
+
264
+ class _CurlResponse:
265
+ """What urlopen's callers actually use: .status, .read(), iteration.
266
+
267
+ My first curl transport returned a bare BytesIO, so the proxy died on
268
+ '.status' - a bridge that answers 500 looks like a broken model when it is a
269
+ broken wrapper. This presents the shape the callers expect.
270
+ """
271
+
272
+ def __init__(self, body: bytes, status: int):
273
+ self._body = body
274
+ self.status = status
275
+ self.headers = {"content-type": "application/json"}
276
+
277
+ def read(self, *_a) -> bytes:
278
+ return self._body
279
+
280
+ def __iter__(self):
281
+ return iter(self._body.splitlines(True))
282
+
283
+ def __enter__(self):
284
+ return self
285
+
286
+ def __exit__(self, *_a):
287
+ return False
288
+
289
+
290
+ def _curl_open(req, timeout: int = 60):
291
+ """Perform an upstream request with curl's fingerprint.
292
+
293
+ Cloudflare at the gateway bans a scripted client's TLS shape (error 1010,
294
+ browser_signature_banned) whatever User-Agent it claims - measured: urllib 403,
295
+ curl 200 on the same request. So the bridge speaks through curl and hands the
296
+ caller a response-shaped object.
297
+ """
298
+ import json as _json, subprocess
299
+ argv = ["curl", "-sS", "--max-time", str(timeout), "-w", "\n%{http_code}",
300
+ "-X", (req.get_method() or "GET")]
301
+ for k, v in (req.header_items() or []):
302
+ argv += ["-H", f"{k}: {v}"]
303
+ data = req.data
304
+ if data:
305
+ argv += ["--data-binary", data.decode() if isinstance(data, bytes) else data]
306
+ argv.append(req.full_url)
307
+ out = subprocess.run(argv, capture_output=True)
308
+ raw = out.stdout
309
+ body, _, tail = raw.rpartition(b"\n")
310
+ try:
311
+ status = int(tail.strip() or b"0")
312
+ except ValueError:
313
+ body, status = raw, 0
314
+ if status == 0 and out.returncode != 0:
315
+ raise OSError((out.stderr or b"curl failed").decode()[:300])
316
+ return _CurlResponse(body, status or 200)
317
+
318
+
249
319
  def main() -> int:
250
320
  ap = argparse.ArgumentParser(description=__doc__)
251
321
  ap.add_argument("--port", type=int,
@@ -4,6 +4,8 @@
4
4
 
5
5
  07:00 bin/nornir-job-daily-briefing.sh
6
6
  06:00 bin/nornir-job-observer.sh
7
+ 08:00 bin/nornir-job-hall-snapshot.sh
8
+ 02:30 bin/nornir-job-nsr-compliance.sh
7
9
  00:30 bin/nornir-job-skillopt-sleep.sh
8
10
  00:15 bin/nornir-job-memory-housekeeping.sh
9
11
  00:00 bin/nornir-job-git-sync.sh
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "version": 1,
3
3
  "platform": "ymir",
4
- "notes": "Brokk reads these rules before dispatching an Eindri and passes only concrete --harness/--model/--effort flags to bin/einherjar-spawn.sh. opencode is the primary Ymir harness; pi is the verified secondary harness. Model tokens must match a model opencode/pi can run on this host, or be omitted to use the harness default. effort values: low|medium|high|xhigh|max.",
4
+ "notes": "TEMPLATE \u2014 copy to $YMIR_HOME/hodd/config/eindri-dispatch.json and fill in YOUR models. Brokk reads these rules before dispatching an Eindri and passes only concrete --harness/--model/--effort flags to bin/einherjar-spawn.sh. Model tokens must match a model your harness can run on your host, or be omitted to use the harness default. effort values: low|medium|high|xhigh|max.",
5
5
  "rules": [
6
6
  {
7
7
  "when": "General implementation, refactoring, or bug fixes in a codebase",
8
8
  "use": [
9
9
  {
10
10
  "harness": "opencode",
11
- "model": "opencode-go/deepseek-v4.1-flash",
11
+ "model": "<your-model-id>",
12
12
  "effort": "medium"
13
13
  }
14
14
  ],
@@ -19,7 +19,7 @@
19
19
  "use": [
20
20
  {
21
21
  "harness": "opencode",
22
- "model": "opencode-go/deepseek-v4.1-flash",
22
+ "model": "<your-model-id>",
23
23
  "effort": "xhigh"
24
24
  }
25
25
  ],
@@ -30,7 +30,7 @@
30
30
  "use": [
31
31
  {
32
32
  "harness": "opencode",
33
- "model": "opencode-go/deepseek-v4.1-flash",
33
+ "model": "<your-model-id>",
34
34
  "effort": "low"
35
35
  }
36
36
  ],
@@ -60,8 +60,8 @@
60
60
  "default": [
61
61
  {
62
62
  "harness": "opencode",
63
- "model": "opencode-go/deepseek-v4.1-flash",
63
+ "model": "<your-model-id>",
64
64
  "effort": "medium"
65
65
  }
66
66
  ]
67
- }
67
+ }
@@ -15,10 +15,20 @@ echo " target: $YMIR_HOME"
15
15
  mkdir -p "$YMIR_HOME"/{config,secrets,identity,workspaces/{work,personal},memory/{daily,well},smidja,state,data}
16
16
 
17
17
  # 2. Migrate hodd/ (Hoard) → YMIR_HOME/
18
+ # Non-destructive: never overwrites an existing file, and MERGES a directory
19
+ # into its target. The target dirs are pre-created above, so a naive
20
+ # `[ -e "$2" ] && return` skipped every directory source — `data/` among them,
21
+ # which silently dropped the realm declaration and made 0004 fall back to a
22
+ # neutral realm.
18
23
  copy() { # <src> <dest>
19
24
  [ -e "$1" ] || return 0
20
- mkdir -p "$(dirname "$2")" || return 0
25
+ if [ -d "$1" ]; then
26
+ mkdir -p "$2" || return 0
27
+ cp -rn "$1"/. "$2"/ 2>/dev/null && printf ' copied %s\n' "${1#$ROOT/}"
28
+ return 0
29
+ fi
21
30
  if [ -e "$2" ]; then return 0; fi
31
+ mkdir -p "$(dirname "$2")" || return 0
22
32
  cp -rn "$1" "$2" 2>/dev/null && printf ' copied %s\n' "${1#$ROOT/}"
23
33
  }
24
34
 
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env bash
2
+ # 0005-roots-out-of-tree — the operator's things leave the code tree.
3
+ #
4
+ # The law this migration serves: the package is the CODE THAT RUNS THE PROGRAMS;
5
+ # everything the operator owns lives in the home they chose. A packaged install
6
+ # (npm) treats its tree as read-only, and the next upgrade replaces it — so
7
+ # anything of theirs kept in the tree is kept at its peril. Every *writer* was
8
+ # converted to resolve the home through bin/hoard-lib.sh; this carries what is
9
+ # already sitting in the tree to where those writers now look.
10
+ #
11
+ # what moves from the tree to the home
12
+ # ------------------ --------------------- ----------------------------
13
+ # this machine's data/ <hoard>/data
14
+ # records
15
+ # runtime state state/ <home>/state
16
+ # (pids · logs · locks · the applied-marker)
17
+ # the operator's the local env file <home>/.env.local
18
+ # credentials (if one is in the tree)
19
+ # the operator's .agents/config/* <home>/config
20
+ # settings that git does not track
21
+ #
22
+ # Idempotent: every entry is checked before it moves, a second run finds nothing
23
+ # to do, and a move that would land on an existing file is REFUSED and reported
24
+ # rather than merged blindly — the home always wins, nothing is ever overwritten.
25
+ #
26
+ # What is deliberately NOT moved: `*.example` templates and `.gitkeep` (they are
27
+ # code), and any settings file git tracks (that is the distro's shipped default,
28
+ # not the operator's own). Moving a tracked file out of the tree would be a code
29
+ # change wearing a migration's clothes.
30
+ #
31
+ # NOTE: run this while the runtime is STOPPED if you want no stale pids; running
32
+ # services keep their open file handles across the move (an `mv` preserves the
33
+ # inode), so nothing is lost either way, but the pid files they wrote will be
34
+ # found in their new place after the next restart.
35
+ set -u
36
+
37
+ R="${YMIR_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
38
+ # The resolver lives in the tree that ships this migration. When YMIR_ROOT points
39
+ # somewhere else (a sandbox, a test fixture), fall back to the lib beside this
40
+ # script rather than failing to resolve the home at all.
41
+ LIB="$R/bin/hoard-lib.sh"
42
+ [ -r "$LIB" ] || LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/bin/hoard-lib.sh"
43
+ # shellcheck source=bin/hoard-lib.sh
44
+ . "$LIB"
45
+ ymir_home_root H
46
+ hoard_root HOARD
47
+ hoard_data_dir RECORDS
48
+ hoard_state_dir RUNTIME
49
+ hoard_settings_dir SETTINGS
50
+ hoard_local_env ENVFILE
51
+
52
+ moved=0; kept=0; refused=0; merged=0; rows=0
53
+ # The row buffers are written as they happen and printed at the end, so the TOON
54
+ # header can carry the true count. `rows` is kept beside them: under `set -u`,
55
+ # bash older than 4.4 reads an empty `declare -a` array as unbound.
56
+ declare -a ROW_ACT=() ROW_FROM=() ROW_TO=()
57
+ row() { ROW_ACT+=("$1"); ROW_FROM+=("$2"); ROW_TO+=("$3"); rows=$((rows+1)); }
58
+
59
+ # tracked_by_git <path> — 1 when git knows the file (the distro ships it)
60
+ tracked_by_git() {
61
+ command -v git >/dev/null 2>&1 || return 1
62
+ git -C "$R" ls-files --error-unmatch "${1#"$R"/}" >/dev/null 2>&1
63
+ }
64
+
65
+ # carry <src> <dst-dir> — move one entry home, unless the home already holds it
66
+ carry() {
67
+ local src="$1" dst="$2" base stale
68
+ base="$(basename "$src")"
69
+ mkdir -p "$dst" 2>/dev/null || { refused=$((refused+1)); row refuse "$src" "cannot write $dst"; return 0; }
70
+ if [ ! -e "$dst/$base" ]; then
71
+ if mv "$src" "$dst/$base" 2>/dev/null; then
72
+ moved=$((moved+1)); row moved "$src" "$dst/$base"
73
+ else
74
+ refused=$((refused+1)); row refuse "$src" "could not move to $dst/$base"
75
+ fi
76
+ return 0
77
+ fi
78
+ # The home already holds this name. Two files, one name — decide by evidence,
79
+ # never by assumption: identical content means the tree's copy can go (nothing
80
+ # is lost, and the content is at home); different content means BOTH are real,
81
+ # so the home's wins the name and the tree's is carried beside it as `.stale-*`
82
+ # rather than merged or discarded.
83
+ if [ -f "$src" ] && [ -f "$dst/$base" ] && cmp -s "$src" "$dst/$base"; then
84
+ rm -f "$src" && { merged=$((merged+1)); row merged "$src" "identical to $dst/$base — the tree's copy removed, nothing lost"; }
85
+ elif [ -f "$src" ]; then
86
+ stale="$dst/$base.stale-$(date -u +%Y%m%dT%H%M%SZ)"
87
+ if mv "$src" "$stale" 2>/dev/null; then
88
+ moved=$((moved+1)); row kept-as "$src" "$stale — differs from the home's copy, so both are kept"
89
+ else
90
+ refused=$((refused+1)); row refuse "$src" "differs from the home's copy and could not be carried"
91
+ fi
92
+ else
93
+ kept=$((kept+1)); row kept "$src" "home already holds $base — left in place, nothing overwritten"
94
+ fi
95
+ }
96
+
97
+ # ── the machine's records ────────────────────────────────────────────────────
98
+ if [ -d "$R/data" ]; then
99
+ for f in "$R"/data/* "$R"/data/.[!.]*; do
100
+ [ -e "$f" ] || continue
101
+ case "$(basename "$f")" in .gitkeep|*.example|*.example.*) kept=$((kept+1)); continue ;; esac
102
+ carry "$f" "$RECORDS"
103
+ done
104
+ fi
105
+
106
+ # ── the runtime state ────────────────────────────────────────────────────────
107
+ if [ -d "$R/state" ]; then
108
+ for f in "$R"/state/* "$R"/state/.[!.]*; do
109
+ [ -e "$f" ] || continue
110
+ case "$(basename "$f")" in .gitkeep) kept=$((kept+1)); continue ;; esac
111
+ carry "$f" "$RUNTIME"
112
+ done
113
+ fi
114
+
115
+ # ── the operator's credentials ───────────────────────────────────────────────
116
+ # Never printed, never parsed — only carried.
117
+ for cand in "$R"/.env.loc[a]l; do
118
+ [ -f "$cand" ] || continue
119
+ if [ -f "$ENVFILE" ]; then
120
+ kept=$((kept+1)); row kept "${cand#"$R"/}" "the home already holds its own — left in place, never merged"
121
+ else
122
+ mkdir -p "$(dirname "$ENVFILE")" 2>/dev/null
123
+ if mv "$cand" "$ENVFILE" 2>/dev/null; then
124
+ chmod 600 "$ENVFILE" 2>/dev/null || true
125
+ moved=$((moved+1)); row moved "${cand#"$R"/}" "${ENVFILE#"$H"/} (0600)"
126
+ else
127
+ refused=$((refused+1)); row refuse "${cand#"$R"/}" "could not move to the home"
128
+ fi
129
+ fi
130
+ done
131
+
132
+ # ── the operator's own settings ──────────────────────────────────────────────
133
+ # Only what git does not track: a tracked settings file is the distro's shipped
134
+ # default and stays where the code ships it. `config` may be a symlink into
135
+ # .agents/config, so resolve the real directory before walking it.
136
+ cfgdir="$R/.agents/config"
137
+ [ -d "$R/config" ] && [ ! -L "$R/config" ] && cfgdir="$R/config"
138
+ if [ -d "$cfgdir" ]; then
139
+ for f in "$cfgdir"/* "$cfgdir"/.[!.]*; do
140
+ [ -e "$f" ] || continue
141
+ base="$(basename "$f")"
142
+ case "$base" in .gitkeep|*.example|*.example.*) kept=$((kept+1)); continue ;; esac
143
+ if tracked_by_git "$f"; then
144
+ kept=$((kept+1)); row kept "config/$base" "the distro ships this one — it is a default, not the operator's own"
145
+ continue
146
+ fi
147
+ carry "$f" "$SETTINGS"
148
+ done
149
+ fi
150
+
151
+ if [ "$rows" -gt 0 ]; then
152
+ printf 'ymir-migrate-0005[%d]{action,from,to}:\n' "$rows"
153
+ for i in "${!ROW_ACT[@]}"; do
154
+ printf ' "%s","%s","%s"\n' "${ROW_ACT[$i]}" "${ROW_FROM[$i]}" "${ROW_TO[$i]}"
155
+ done
156
+ fi
157
+ printf 'ymir-migrate-0005[1]{moved,merged,kept,refused}:\n "%s",%s,%s,%s\n' "$moved" "$merged" "$kept" "$refused"
158
+ exit 0
@@ -16,7 +16,7 @@ skills[26]{skill,does,owner}:
16
16
  "tyr-check","the judge — galdr principles + runtime gates","tyr"
17
17
  "rules-check-drift","rules-file drift — keeps AGENTS.md true after code changes","tyr"
18
18
  "no-mistakes","the clean-PR gate — vendored engine skill: validate, push, PR, CI","brokk"
19
- "smidja-factory","the smithy — agent factory: roster, phases, envelopes, visualizer","volundr"
19
+ "smidja-factory","the smithy — agent factory: roster, phases, envelopes, visualizer (app-provided: cloned by step_apps from its own repo, absent until then)","volundr"
20
20
  "hvild-afk","away-mode — supervision of routine wakes and batched escalations","brokk"
21
21
  "saga-bearings","bearings — fleet status digest (/bearings) + recap (/ahoy)","saga"
22
22
  "muninn-stow","memory — session-knowledge curation, routing, persistence","muninn"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: galdr
3
3
  description: Galdr — agent-CLI ergonomics and the master builder/maintainer of the Ymir (Brokk distro) runtime. Use when building, modifying, or reviewing any agent-facing CLI, or when building/maintaining any Ymir subsystem across harnesses (OpenCode, Pi, Claude Code, Cursor, Codex). Load its assets[] row for the task before editing a governed path.
4
+ domain: brokkforge
4
5
  allowed-tools: read,write,bash,glob,grep
5
6
  ---
6
7
 
@@ -46,7 +47,7 @@ assets[24]{path,load_when}:
46
47
  "assets/local-models.md","Vog — local models: engines (llama.cpp, LM Studio, Ollama), detection, harness wiring, honest measurement"
47
48
  "assets/runtime-compliance.md","runtime acceptance gates + runnable checklist"
48
49
  "assets/memory-well.md","Mimirsbrunn/engram: the well, bridge, MCP, harness wiring, laws"
49
- "assets/installation.md","first setup / install: ymir-install, engines, hermes, workspaces"
50
+ "assets/installation.md","first setup / install: the computed plan (bin/ymir-plan.sh), the home the operator chooses, the roots law (code in the package, the operator's things in the home), engines, hermes"
50
51
  "assets/harness-integration/README.md","choosing a harness; adding one"
51
52
  "assets/harness-integration/opencode.md","OpenCode adapter"
52
53
  "assets/harness-integration/pi.md","Pi adapter"
@@ -20,13 +20,19 @@ The governing plan is [`docs/plans/29-brokk-distro-runtime.md`](../../../../../d
20
20
 
21
21
  ## 1. What a harness adapter is
22
22
 
23
- > **MCP scope (Ymir's well + A2A mesh).** `bin/a2a-mcp.sh install` wires the
23
+ > **MCP scope (well · mesh · Teams · Anchor).** `bin/a2a-mcp.sh install` wires the
24
24
  > `engram` (memory well) and `a2abridge` (A2A mesh) MCP servers into OpenCode's
25
- > repo `opencode.json` **and** Pi. By default the Pi side writes the **global**
25
+ > repo `opencode.json` **and** Pi. The **Teams** plane (`wayofteams`) and
26
+ > **Anchor** memory are **remote** MCP servers named by URL: set
27
+ > `WAYOFTEAMS_MCP_URL` / `ANCHOR_MCP_URL` in the private platform env, then run
28
+ > `bin/a2a-mcp.sh install`. OpenCode speaks `type: remote` natively; Pi has no
29
+ > remote transport, so it reaches them through the `mcp-remote` stdio bridge. The
30
+ > URLs never enter the tracked tree. By default the Pi side writes the **global**
26
31
  > `~/.pi/agent/mcp.json`; `bin/a2a-mcp.sh install --project` writes the repo's
27
32
  > `.pi/mcp.json` instead, leaving a Pi used elsewhere untouched (launch with
28
33
  > `pi --mcp-config .pi/mcp.json`). Prefer `--project` when integrating Ymir into
29
- > an existing workflow.
34
+ > an existing workflow. `bin/a2a-mcp.sh show` reports what is **actually** wired —
35
+ > every key present, not a fixed list.
30
36
 
31
37
 
32
38
  The Ymir runtime is a **distro**: a directory of instructions, skills, tooling and conventions that turns a general-purpose agent into a specialized one. Launching a supported harness inside `BROKK_HOME` is supposed to instantiate **Brokk** and address the operator as the **Allfather** *before the model's first turn*.
@@ -220,6 +226,7 @@ Legend: ✅ implemented · ⚠️ partial/inert-by-design · ❌ not implemented
220
226
  | `.pi/shared/extensions/open-editor.ts` | `/edit [path]` and `ctrl+shift+e` — opens files from cwd in the Allfather's editor; strictly user-facing, no LLM tool. **Resolution:** `$VISUAL` → `$EDITOR` → the first editor that exists (`code cursor zed subl nvim vim hx helix nano micro emacs vi`), so a host that is not Omarchy — where Omarchy's own launcher or a bare `vi` may be absent — still gets a working editor instead of an ENOENT | user |
221
227
  | `.pi/shared/extensions/herdr-agent-state.ts` | reports pane agent lifecycle state to herdr | 2 |
222
228
  | `.pi/shared/extensions/todo.ts` | the todo surface | user |
229
+ | `.pi/shared/extensions/ymir-subagents.ts` | **the Eindri roster as a Pi tool** — reads the canonical `.agents/agents/*.md` tree and exposes every figure through a `subagent` tool (and a `/subagents` command). See "Pi has no agent loader" below | user |
223
230
  | `.pi/shared/extensions/lib/rodd-operational-input.ts` | `encodeRoddOperationalInput`, `classifyRoddOperationalText`, `classifyRoddCurrentOperationalText` | shared wire |
224
231
  | `.pi/extensions/lib/vordr-sessionstart-supervisor.mjs` | detached child supervisor (Vörðr) | 1 (transport) |
225
232
  | `.claude/settings.json` | `hooks.SessionStart[]`, `hooks.Stop[]` | 1, 3, 5 |
@@ -495,7 +502,7 @@ harness config like `mode`/`model`/`permission` in their frontmatter). The
495
502
  harness directories **bind** them by symlink — they are never hand-written
496
503
  duplicates:
497
504
 
498
- - OpenCode: `.opencode/agent/<name>.md` → `../../.agents/agents/<profile>.md`
505
+ - OpenCode: `.opencode/agents/<name>.md` → `../../.agents/agents/<profile>.md`
499
506
  - Pi: `.pi/agents/<profile>.md` → the same canonical files
500
507
 
501
508
  `bin/valknut-load.sh` creates the symlinks (`--opencode`, `--pi`, `--global`,
@@ -505,7 +512,7 @@ hand-made subset. Naming differs by harness and must be respected:
505
512
 
506
513
  ```
507
514
  agent_binding[5]{harness,dir,name_rule}:
508
- "opencode",".opencode/agent/","the frontmatter `name:` — bragi.md -> bragi-marketer.md"
515
+ "opencode",".opencode/agents/","the frontmatter `name:` — bragi.md -> bragi-marketer.md"
509
516
  "pi",".pi/agents/","the profile file name — bragi-marketer.md"
510
517
  "claude",".claude/agents/","the profile file name"
511
518
  "codex",".codex/agents/","the profile file name"
@@ -544,7 +551,10 @@ writers[2]{writer,owns}:
544
551
 
545
552
  **Both merge; neither overwrites.** The loader's `config_out` adds missing keys (
546
553
  deep, `setdefault`-style), ensures `skills.paths`, and re-asserts nothing else;
547
- `agents-config` does the same for providers and models. This is not tidiness
554
+ `agents-config` does the same for providers and models and it writes **only
555
+ OpenCode agents** into `opencode.json`. A `pi` (or `hermes`) agent's model is that
556
+ harness's own id (e.g. `llamacpp/qwen3.5-9b`), which OpenCode cannot resolve, so
557
+ those agents are deliberately left out rather than handed to OpenCode broken. This is not tidiness —
548
558
  the loader used to re-render from the example with `sed` + `mv`, and because the
549
559
  example carried only `llama.cpp`, **every loader run deleted the Apodex
550
560
  provider**, which lived only in the live file. A blind render of a file two
@@ -581,10 +591,63 @@ einherjar[20]{figure,craft,domain,engine}:
581
591
  "Völundr","master smith — Smíðja's orchestrator","brokkforge","—"
582
592
  ```
583
593
 
584
- Bind with `bin/valknut-load.sh --all` (OpenCode: `.opencode/agent/<name>.md`; Pi:
594
+ Bind with `bin/valknut-load.sh --all` (OpenCode: `.opencode/agents/<name>.md`; Pi:
585
595
  `.pi/agents/<profile>.md`) — never edit the harness dirs.
586
596
 
587
- ## Pi extension single-home (2026-09-12)
597
+ ## Pi has no agent loader (2026-09-17)
598
+
599
+ **Pi core does not load `.pi/agents/`.** Agent loading in Pi is a *package*
600
+ (`pi-agents`, `pi-agent-mode`, `pi-simple-agents`), not a core feature, and Ymir
601
+ installs none of them. So `.pi/agents/` held twenty correct profile links that
602
+ **nothing in Pi ever read** — the same failure as OpenCode's singular
603
+ `.opencode/agent/`, arrived at from the other side.
604
+
605
+ The fix lives in the repo, not in a root-pi package: **`.pi/shared/extensions/ymir-subagents.ts`**
606
+ discovers the canonical `.agents/agents/*.md` tree itself and registers a
607
+ `subagent` tool. A call runs the chosen figure as a nested model call in the
608
+ current session — the figure's markdown body is its system prompt, its
609
+ frontmatter `model:` picks the model where the machine serves it.
610
+
611
+ ```
612
+ subagent({ agent: "kvasir", task: "find every AGENTS.md" }) # dispatch
613
+ subagent({}) # list the roster
614
+ /subagents # list the roster
615
+ ```
616
+
617
+ Rules this extension follows, learned the hard way:
618
+
619
+ - **No imports.** `@earendil-works/pi-coding-agent` is **not installed as a
620
+ package**, so an extension that imports its types cannot load at all. Every
621
+ working extension in this tree takes `pi` as `any` and declares tool
622
+ `parameters` as a plain JSON schema object. `typebox` *is* installed, but the
623
+ plain object needs nothing.
624
+ - **One home.** It lives in `.pi/shared/extensions/` (deployed), never in
625
+ `.pi/extensions/` — a copy in both makes pi refuse the duplicate tool.
626
+ - **The canonical tree is the source.** It reads `.agents/agents/`; it never
627
+ copies from it (Rule 02).
628
+
629
+ ### A rename that left a reader behind (same day)
630
+
631
+ `skuld-branch-supervision.ts` imported `calmTranscriptClassIsVisible` and
632
+ `CalmPresentationState` from `./lib/ro-visibility.ts`, but that module exports
633
+ `roTranscriptClassIsVisible` and `RoPresentationState` — the names were renamed
634
+ and the importer was not. Pi refuses the **whole extension** at load with
635
+ `does not provide an export named …`, so Skuld's supervision branch was dead in
636
+ every session while nothing reported it. Fixed in the same change.
637
+
638
+ **Check every extension actually loads**, not just that its file exists:
639
+
640
+ ```bash
641
+ cd ~/.pi/agent/extensions
642
+ for f in *.ts; do
643
+ node --input-type=module --eval "import('file://$PWD/$f').then(()=>console.log('$f LOADS')).catch(e=>console.log('$f FAIL: '+e.message.split('\n')[0]))"
644
+ done
645
+ ```
646
+
647
+ Run it after every deploy. A module that cannot resolve is invisible from the
648
+ file listing, and pi reports it only as a startup line that scrolls away.
649
+
650
+
588
651
 
589
652
  Pi loads **both** the project `.pi/extensions/` and the global
590
653
  `~/.pi/agent/extensions/` directories; because it does not de-duplicate by
@@ -632,14 +695,14 @@ under **`ymir_tools:`**; permission is expressed by the `permission:` block.
632
695
 
633
696
  ```
634
697
  canonical[3]{kind,canonical,load_path}:
635
- "agent profiles",".agents/agents/<profile>.md",".opencode/agent/<name>.md · .pi/agents/<name>.md (symlinks)"
698
+ "agent profiles",".agents/agents/<profile>.md",".opencode/agents/<name>.md · .pi/agents/<name>.md (symlinks)"
636
699
  "OpenCode plugins",".agents/harness/opencode/plugins/",".opencode/plugins (symlink)"
637
700
  "skills",".agents/skills/","loaded via opencode.json skills.paths"
638
701
  ```
639
702
 
640
703
  Rules:
641
704
  - **Edit only `.agents/`.** Never edit a file under `.opencode/` or `.pi/` — those are
642
- symlinks to `.agents/` (e.g. `.opencode/agent/bragi.md` links to
705
+ symlinks to `.agents/` (e.g. `.opencode/agents/bragi.md` links to
643
706
  `.agents/agents/bragi-marketer.md`).
644
707
  - After any change: `bin/valknut-load.sh --all` to rebind.
645
708
  - **OpenCode requires REAL `.opencode/{node_modules,package.json,.gitignore}`** — the