@zerwiz/ymir 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +215 -23
- 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 +1033 -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 +153 -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/eir-doctor.sh +47 -1
- 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-bootstrap.sh +5 -1
- 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 +219 -54
- package/bin/ymir-plan.sh +356 -0
- package/bin/ymir-say.sh +14 -1
- package/bin/ymir-setup-auth.sh +12 -1
- package/bin/ymir-validate.sh +28 -8
- 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 +52 -2
- 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
package/README.md
CHANGED
|
@@ -243,7 +243,7 @@ Utgard sandbox on a Yggdrasil worktree.
|
|
|
243
243
|
| **Kvasir** | the knowing | scout | reconnaissance — changes nothing |
|
|
244
244
|
|
|
245
245
|
Profiles live in [`.agents/agents/`](.agents/agents/) and are bound to each tool by
|
|
246
|
-
`bin/valknut-load.sh` (OpenCode reads `.opencode/
|
|
246
|
+
`bin/valknut-load.sh` (OpenCode reads `.opencode/agents/`; Pi links resolve under
|
|
247
247
|
`.pi/agents/`).
|
|
248
248
|
|
|
249
249
|
---
|
package/RULES/02-agents.md
CHANGED
|
@@ -8,9 +8,9 @@ Agent profiles live **only** in **`.agents/agents/*.md`**. That directory is the
|
|
|
8
8
|
source of truth; everything else binds to it.
|
|
9
9
|
|
|
10
10
|
- Harness directories are **symlinks**, never copies:
|
|
11
|
-
- OpenCode: `.opencode/
|
|
11
|
+
- OpenCode: `.opencode/agents/<name>.md` → `../../.agents/agents/<profile>.md`
|
|
12
12
|
- Pi: `.pi/agents/<profile>.md` → the same canonical files
|
|
13
|
-
- **Never edit** `.opencode/
|
|
13
|
+
- **Never edit** `.opencode/agents` or `.pi/agents` — they are links. Edit
|
|
14
14
|
`.agents/agents/<profile>.md` and re-run `bin/valknut-load.sh --all`.
|
|
15
15
|
|
|
16
16
|
## 2. The two kinds of agent
|
package/RULES/04-hoard.md
CHANGED
|
@@ -63,3 +63,35 @@ read. Operator-private documents — plans, strategy, roadmaps, the masterplan,
|
|
|
63
63
|
`bin/docs-guard.sh` blocks a commit that stages such a document under `docs/`
|
|
64
64
|
(wired into the pre-commit hook beside `secret-guard.sh`). When it fires, move
|
|
65
65
|
the file to `$YMIR_HOME/docs/` in the private repo.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Correction — 2026-09-17: hodd/ *is* the YMIR_HOME data path
|
|
70
|
+
|
|
71
|
+
*Appended, citing the sections above; nothing above is rewritten.*
|
|
72
|
+
|
|
73
|
+
The mapping table above ("What lives at YMIR_HOME") implies a **flat** layout —
|
|
74
|
+
`hodd/identity/` → `$YMIR_HOME/identity/`, `data/` → `$YMIR_HOME/data/` — i.e.
|
|
75
|
+
that the repo-side `hodd/` and the data path are two different locations. On a
|
|
76
|
+
real home that reading produced **two parallel stores**: a flat
|
|
77
|
+
`$YMIR_HOME/identity/` and `$YMIR_HOME/data/` alongside `$YMIR_HOME/hodd/identity/`
|
|
78
|
+
and `$YMIR_HOME/hodd/data/`. They drifted, and the flat pair went stale.
|
|
79
|
+
|
|
80
|
+
The truth on a live home, and the intent of this rule:
|
|
81
|
+
|
|
82
|
+
- **`$YMIR_HOME/hodd/` *is* the private data path.** There is no second, flat
|
|
83
|
+
copy. `identity/`, `data/`, `docs/`, `secrets/`, `tenants/`, and the memory
|
|
84
|
+
well live **under `hodd/`**.
|
|
85
|
+
- **`bin/hoard-lib.sh` is the single source of truth** for that path: it
|
|
86
|
+
resolves `${YMIR_HOARD:-${YMIR_HOME:-$HOME/Documents/Ymir}/hodd}`. A script
|
|
87
|
+
that needs the hoard calls `hoard_root`, never a hardcoded path.
|
|
88
|
+
- **`.ymir-layout.yaml` records the real layout** and must name only paths that
|
|
89
|
+
exist. A layout entry pointing at a nonexistent directory is stale, and a
|
|
90
|
+
stale map is what let private work land outside the hoard.
|
|
91
|
+
- The three-way reading in the section above (`hodd/x/` → `$YMIR_HOME/x/`) is
|
|
92
|
+
**superseded**: read every row as `$YMIR_HOME/hodd/x/`.
|
|
93
|
+
- The "docs/ is public" section's closing line ("move the file to
|
|
94
|
+
`$YMIR_HOME/docs/`") is likewise read as **`$YMIR_HOME/hodd/docs/`**.
|
|
95
|
+
|
|
96
|
+
Confirmed by the Allfather 2026-09-17. Drift of this kind is caught by
|
|
97
|
+
`bin/eir-doctor.sh`'s hoard placement check.
|
package/RULES/06-append-only.md
CHANGED
|
@@ -56,3 +56,53 @@ are always named.
|
|
|
56
56
|
|
|
57
57
|
**Related:** `RULES/04-hoard.md` (where private records live), `AGENTS.md`,
|
|
58
58
|
`.agents/skills/galdr-ymirsystem/assets/installation.md` (the move procedure).
|
|
59
|
+
|
|
60
|
+
## Amendment — the changelog writes through fragments (2026-09-17)
|
|
61
|
+
|
|
62
|
+
Appended, not rewritten; the clause above stands.
|
|
63
|
+
|
|
64
|
+
**The problem this amendment answers.** `CHANGELOG.md` is one file, and every
|
|
65
|
+
branch appends at the same position — the top. Git sees two branches inserting
|
|
66
|
+
different lines at the same spot and calls it a conflict, so **every merge
|
|
67
|
+
re-conflicts every open branch**. With N branches that is O(N²) conflicts, all of
|
|
68
|
+
them meaningless: both entries belong, and neither is wrong. Sixteen open PRs
|
|
69
|
+
were each resolved by hand for this reason alone.
|
|
70
|
+
|
|
71
|
+
**The amendment.** A change tells its story through a **fragment**:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
CHANGELOG.d/<YYYY-MM-DD>-<slug>.md the entry, beginning with its `## YYYY-MM-DD — title` heading
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
- **A fragment is a new file with a unique name.** Two branches never touch the
|
|
78
|
+
same one, so the collision cannot occur. This is the whole point.
|
|
79
|
+
- **`bin/changelog-assemble.sh` folds fragments into `CHANGELOG.md`** — newest
|
|
80
|
+
first, above everything already recorded. Folding is an **append**: the ledger's
|
|
81
|
+
existing entries are copied verbatim, never reordered, never rewritten.
|
|
82
|
+
- **The pre-push hook runs the assembler first**, so the ledger is never behind
|
|
83
|
+
what the fragments already tell. A push that would leave unfolded fragments
|
|
84
|
+
behind is refused with the instruction to commit the fold and push again.
|
|
85
|
+
- **The guard accepts either form.** A push satisfies the duty by appending to
|
|
86
|
+
`CHANGELOG.md` **or** by adding a fragment. Appending directly still works and
|
|
87
|
+
is still valid; the fragment is simply the form that cannot conflict.
|
|
88
|
+
|
|
89
|
+
**Why this obeys the clause above, not contradicts it.** Append, never rewrite —
|
|
90
|
+
a fragment is a *new file*, and the ledger only ever grows. Never truncate —
|
|
91
|
+
fragments are folded in, never dropped. Never lose one in a move — **`CHANGELOG.d/`
|
|
92
|
+
joins the append-only set**, so a move must carry it exactly as it carries
|
|
93
|
+
`CHANGELOG.md`. A correction is a new entry citing the old — a fragment may cite;
|
|
94
|
+
nothing is edited.
|
|
95
|
+
|
|
96
|
+
**The append-only set is now:**
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
append_only[7]{artifact,why}:
|
|
100
|
+
"$YMIR_HOME/memory/runes_audit.md","the Runes ledger — checksum-chained"
|
|
101
|
+
"$YMIR_HOME/docs/append-only-log.md","the decision log"
|
|
102
|
+
"CHANGELOG.md","the assembled chronological ledger; folded from fragments, never hand-rewritten"
|
|
103
|
+
"CHANGELOG.d/","pending entries — one file per change; folded in, never dropped"
|
|
104
|
+
"RULES/*.md","house law"
|
|
105
|
+
"$YMIR_HOME/**","the private record"
|
|
106
|
+
"*.jsonl / branch outcomes / session logs","event streams"
|
|
107
|
+
```
|
|
108
|
+
|
package/RULES/07-config.md
CHANGED
|
@@ -43,3 +43,59 @@ Hardcoding is how a substrate written on one machine breaks on another, or
|
|
|
43
43
|
collides with a neighbour's port on a shared server — the exact failure a
|
|
44
44
|
host-agnostic core exists to prevent. This rule is the companion of Rule 05: Rule
|
|
45
45
|
05 keeps *behaviour* portable; Rule 07 keeps *configuration* portable.
|
|
46
|
+
|
|
47
|
+
## Amendment — models, providers, and paths are the user's, in the hoard (2026-09-17)
|
|
48
|
+
|
|
49
|
+
Appended, not rewritten; the clauses above stand.
|
|
50
|
+
|
|
51
|
+
### A. Models and providers live in the hoard, per user, read from YAML
|
|
52
|
+
|
|
53
|
+
**Every user's models and providers differ.** One operator runs local llama.cpp
|
|
54
|
+
on a 16 GB card; another runs hosted models; a third runs both. There is therefore
|
|
55
|
+
**no such thing as a global model or provider** — a shipped default that names a
|
|
56
|
+
model is a claim about someone else's machine.
|
|
57
|
+
|
|
58
|
+
- **The one source.** A user's models and providers live in **`$YMIR_HOME/hodd/`**
|
|
59
|
+
— the user's own workspace, their private hoard — and are read from a **YAML
|
|
60
|
+
file there** (`hodd/config/agents.yaml`). Nothing else owns them.
|
|
61
|
+
- **No global.** A model id or provider name must not appear in shipped runtime
|
|
62
|
+
code, in a tracked config, or in any repo file as a *value*. The repo ships a
|
|
63
|
+
**template** (`config/agents.yaml.example`) with placeholders; the live file is
|
|
64
|
+
the user's, and it is private (Rule 04).
|
|
65
|
+
- **Resolve, never restate.** Code reads a model or provider through the resolver
|
|
66
|
+
(`bin/model-resolve.sh`, `bin/agents-config.sh`) or from env. It never embeds
|
|
67
|
+
the id.
|
|
68
|
+
- **A comment may name an example; code may not.** A usage line in a header
|
|
69
|
+
(`# bin/pi-seat.sh -m <model>`) is documentation. A literal assigned to a
|
|
70
|
+
variable, written into a config, or passed as a default is a violation.
|
|
71
|
+
|
|
72
|
+
### B. No hardcoded absolute file paths
|
|
73
|
+
|
|
74
|
+
**A path that names a user, or a machine's layout, is not portable.**
|
|
75
|
+
|
|
76
|
+
- **User-agnostic.** No `/home/<user>/…`, no `/Users/<user>/…`, no `C:\Users\…`,
|
|
77
|
+
no baked-in username anywhere in shipped code, config, or docs.
|
|
78
|
+
- **Relative or resolved.** A path is relative to a resolved root
|
|
79
|
+
(`$YMIR_HOME`, `$YMIR_HOARD`, `$ROOT`, `$SCRIPT_DIR`), or comes from env/config
|
|
80
|
+
with one documented default. Never a literal absolute path.
|
|
81
|
+
- **The home is resolved, not assumed.** `$YMIR_HOME` resolves through
|
|
82
|
+
`bin/hoard-lib.sh` (`hoard_root`) — never a hardcoded `~/Documents/Ymir`, never
|
|
83
|
+
another user's home.
|
|
84
|
+
- **A test may use a temp dir; it may not use a real home.** `mktemp -d` and
|
|
85
|
+
`$TMPDIR` are correct. A fixture that names a real user's home is a violation
|
|
86
|
+
that will fail on every other machine.
|
|
87
|
+
|
|
88
|
+
### Enforcement
|
|
89
|
+
|
|
90
|
+
`compliance-check.sh` gains the **`config`** gate, which fails on:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
config_gate[3]{class,what_it_catches}:
|
|
94
|
+
"model","a model id or provider name used as a VALUE in shipped code or a tracked config"
|
|
95
|
+
"path","an absolute path naming a user (/home/<user>, /Users/<user>, C:\\Users\\<user>)"
|
|
96
|
+
"hoard","a model/provider config outside $YMIR_HOME/hodd/"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Comments, templates (`*.example`), `CHANGELOG*`, and `assets/reference/`
|
|
100
|
+
(provenance) are exempt by intent — they document, they do not configure.
|
|
101
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Rule 09 — Electron is a Local Seat
|
|
2
|
+
|
|
3
|
+
A desktop window is the machine talking to itself. Every rule below follows from
|
|
4
|
+
that one sentence.
|
|
5
|
+
|
|
6
|
+
## 09.1 Local connections only
|
|
7
|
+
|
|
8
|
+
**An Electron shell loads loopback and nothing else.** A URL that is not
|
|
9
|
+
`127.0.0.1`, `localhost` or `::1` is refused, with a warning, and replaced by the
|
|
10
|
+
local default. If a shell is handed a public hostname — a tunnel, a `*.zerwiz.org`
|
|
11
|
+
name — it must **not** honour it.
|
|
12
|
+
|
|
13
|
+
Why the law, not a preference: a desktop app that dials out is a desktop app whose
|
|
14
|
+
failures belong to somebody else's edge. A bridge that dressed a cloud endpoint as
|
|
15
|
+
`127.0.0.1` cost a night of "the agents never start"; a shell that reaches for a
|
|
16
|
+
public hall when its own machine is quiet is the same mistake wearing different
|
|
17
|
+
clothes.
|
|
18
|
+
|
|
19
|
+
**Cloudflare has nothing to do with Electron.** Tunnels exist for the *web* door,
|
|
20
|
+
so a person elsewhere can reach the apps. The desktop is already where the apps
|
|
21
|
+
live.
|
|
22
|
+
|
|
23
|
+
## 09.2 No login in the desktop seat
|
|
24
|
+
|
|
25
|
+
The web door has exactly one login; the desktop seat does not have one at all. The
|
|
26
|
+
shell is a local, trusted seat — its preload marks its requests, and the gate
|
|
27
|
+
honours that marker **only from loopback**. A login prompt inside an Electron
|
|
28
|
+
window means the surface has been mistaken for the web.
|
|
29
|
+
|
|
30
|
+
## 09.3 A service that is down says so
|
|
31
|
+
|
|
32
|
+
When a local service is not answering, the shell reports it — it does not fall
|
|
33
|
+
back to a remote copy, a cached one, or a public hostname. A wrong answer from
|
|
34
|
+
the wrong server is worse than no answer.
|
|
35
|
+
|
|
36
|
+
## 09.4 The shell carries the house mark
|
|
37
|
+
|
|
38
|
+
Every app ships its own **rune** icon (see `midgard/design-system/runes.md`), and
|
|
39
|
+
installation writes both the icon into the user's icon theme and the `.desktop`
|
|
40
|
+
entry into their applications directory — with this machine's root rendered in, so
|
|
41
|
+
the entry works and the operator can pin it. An app nobody can pin is an app half
|
|
42
|
+
installed.
|
|
43
|
+
|
|
44
|
+
## 09.5 One lifecycle for the web and the windows
|
|
45
|
+
|
|
46
|
+
`scripts/raise.sh` and `scripts/lower.sh` own both. Lowering takes the **windows
|
|
47
|
+
down first**, so no shell is left watching a port that has just vanished.
|
|
48
|
+
|
|
49
|
+
## 09.6 References
|
|
50
|
+
|
|
51
|
+
- `apps/hlidskjalf/electron/`, `apps/odrerir/electron/`, `apps/sessrumnir/src/main/`
|
|
52
|
+
- `scripts/electron.sh` — the shell lifecycle; `scripts/raise.sh` / `scripts/lower.sh`
|
|
53
|
+
- `bin/design-icon.sh` — the rune and the entry, installed
|
|
54
|
+
- Rule 05 (platforms), Rule 07 (config resolves from env, never a literal)
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
*Append-only. A correction is a new entry citing the old one.*
|
package/RULES/README.md
CHANGED
|
@@ -18,3 +18,5 @@ rules_index[9]{file,subject}:
|
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
Keep each rule short and unambiguous; reference it from code where it bites.
|
|
21
|
+
|
|
22
|
+
| 09 | `09-electron.md` | Electron is a local seat: local connections only, no login, the rune icon and entry installed, one lifecycle with the web. |
|
package/bin/a2a-mcp.sh
CHANGED
|
@@ -39,9 +39,26 @@ ENGRAM_BIN="${ENGRAM_BIN:-$HOME/.local/bin/engram-mcp}"
|
|
|
39
39
|
YMIR_HOME="${YMIR_HOME:-$HOME/Documents/Ymir}"
|
|
40
40
|
ENGRAM_DB="${ENGRAM_DB:-$YMIR_HOME/memory/kaia.engram}"
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
# The Teams plane (WayOfTeams) and Anchor memory are REMOTE MCP servers, named by
|
|
43
|
+
# URL. The URL is credential-ish, so it comes from the environment — never the
|
|
44
|
+
# tracked tree. Pi has no remote transport, so it reaches them through the
|
|
45
|
+
# `mcp-remote` stdio bridge; OpenCode speaks `type: remote` natively.
|
|
46
|
+
# The environment wins; else the private platform env, so no caller must export.
|
|
47
|
+
platform_env_val() { # <KEY>
|
|
48
|
+
local k=$1 f v
|
|
49
|
+
for f in "${YMIR_HOARD:-$YMIR_HOME/hodd}/secrets/platform.env" "$YMIR_HOME/hodd/secrets/platform.env" "$YMIR_HOME/secrets/platform.env"; do
|
|
50
|
+
[ -r "$f" ] || continue
|
|
51
|
+
v="$(grep -E "^$k=" "$f" 2>/dev/null | tail -1 | cut -d= -f2-)"
|
|
52
|
+
[ -n "$v" ] && { printf '%s' "$v"; return 0; }
|
|
53
|
+
done
|
|
54
|
+
}
|
|
55
|
+
TEAMS_URL="${WAYOFTEAMS_MCP_URL:-}"; [ -n "$TEAMS_URL" ] || TEAMS_URL="$(platform_env_val WAYOFTEAMS_MCP_URL)"
|
|
56
|
+
ANCHOR_URL="${ANCHOR_MCP_URL:-}"; [ -n "$ANCHOR_URL" ] || ANCHOR_URL="$(platform_env_val ANCHOR_MCP_URL)"
|
|
57
|
+
|
|
58
|
+
python3 - "$ROOT" "$PI_MCP" "$OC" "$A2AB" "$DIR" "$ADVERT" "$WOTES" "$ACTION" "$ENGRAM_BIN" "$ENGRAM_DB" "$TEAMS_URL" "$ANCHOR_URL" <<'PY'
|
|
43
59
|
import sys, os, json
|
|
44
|
-
root, pi_path, oc_path, a2ab, durl, advert, wotes, action, engram_bin, engram_db
|
|
60
|
+
(root, pi_path, oc_path, a2ab, durl, advert, wotes, action, engram_bin, engram_db,
|
|
61
|
+
teams_url, anchor_url) = sys.argv[1:13]
|
|
45
62
|
|
|
46
63
|
def load(p, default):
|
|
47
64
|
try: return json.load(open(p))
|
|
@@ -59,20 +76,38 @@ ones = {
|
|
|
59
76
|
"oc": {"type": "local", "command": [engram_bin, "--db", engram_db], "environment": {}},
|
|
60
77
|
},
|
|
61
78
|
}
|
|
62
|
-
|
|
79
|
+
# The Teams plane: a URL (remote) wins over a local `wayofteams-mcp` binary.
|
|
80
|
+
if teams_url:
|
|
81
|
+
ones["wayofteams"] = {
|
|
82
|
+
"pi": {"command": "npx", "args": ["-y", "mcp-remote", teams_url]},
|
|
83
|
+
"oc": {"type": "remote", "url": teams_url},
|
|
84
|
+
}
|
|
85
|
+
elif wotes:
|
|
63
86
|
ones["wayofteams"] = {
|
|
64
87
|
"pi": {"command": wotes, "args": ["--stdio"], "env": {"WOTEAMS_URL": os.environ.get("WOTEAMS_URL","")}},
|
|
65
88
|
"oc": {"type": "local", "command": [wotes, "--stdio"], "environment": {"WOTEAMS_URL": os.environ.get("WOTEAMS_URL","")}},
|
|
66
89
|
}
|
|
67
90
|
|
|
91
|
+
# Anchor — the memory plane, remote by URL.
|
|
92
|
+
if anchor_url:
|
|
93
|
+
ones["way-of-anchor-sse"] = {
|
|
94
|
+
"pi": {"command": "npx", "args": ["-y", "mcp-remote", anchor_url]},
|
|
95
|
+
"oc": {"type": "remote", "url": anchor_url},
|
|
96
|
+
}
|
|
97
|
+
|
|
68
98
|
if action == "show":
|
|
69
99
|
pi = load(pi_path, {}).get("mcpServers", {})
|
|
70
100
|
oc = load(oc_path, {}).get("mcp", {})
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
# Report what is ACTUALLY wired, not a fixed list: a key may come from an
|
|
102
|
+
# earlier install, a hand edit, or a server this script does not itself add.
|
|
103
|
+
names = sorted(set(ones) | set(pi) | set(oc))
|
|
104
|
+
print(f"a2a-mcp[{len(names)}]{{server,in_pi,in_opencode}}:")
|
|
105
|
+
for name in names:
|
|
73
106
|
print(f' "{name}","{"yes" if name in pi else "no"}","{"yes" if name in oc else "no"}"')
|
|
74
|
-
if not wotes:
|
|
75
|
-
print('help:
|
|
107
|
+
if not wotes and not teams_url:
|
|
108
|
+
print('help: set WAYOFTEAMS_MCP_URL (or install wayofteams-mcp) to enable the Teams plane')
|
|
109
|
+
if not anchor_url:
|
|
110
|
+
print('help: set ANCHOR_MCP_URL to enable the Anchor memory plane')
|
|
76
111
|
sys.exit(0)
|
|
77
112
|
|
|
78
113
|
if action != "install":
|
package/bin/agents-config.sh
CHANGED
|
@@ -18,9 +18,21 @@ set -u
|
|
|
18
18
|
VERSION="1.0.0"
|
|
19
19
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
20
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
|
|
22
|
+
# The operator's settings and secrets live in the home they chose, never in the
|
|
23
|
+
# code tree — a packaged install replaces its tree on upgrade, and a credential
|
|
24
|
+
# must never sit in a tree that ships (Rule 04).
|
|
25
|
+
if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
|
|
26
|
+
_yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
|
+
for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
|
|
28
|
+
[ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
|
|
29
|
+
done
|
|
30
|
+
unset _yr _yc
|
|
31
|
+
fi
|
|
32
|
+
hoard_settings_dir YMIR_SETTINGS_DIR
|
|
33
|
+
hoard_local_env YMIR_ENV_FILE
|
|
34
|
+
CFG="${YMIR_AGENTS_YAML:-$YMIR_SETTINGS_DIR/agents.yaml}"
|
|
35
|
+
RESOLVED="${YMIR_STATE_DIR}/agents-resolved.json"
|
|
24
36
|
|
|
25
37
|
case "${1-}" in
|
|
26
38
|
-v|-V|--version) printf '%s\n' "$VERSION"; exit 0 ;;
|
|
@@ -35,6 +47,7 @@ if [ "$ACTION" = init ]; then
|
|
|
35
47
|
exit 0
|
|
36
48
|
fi
|
|
37
49
|
TPL="$ROOT/config/agents.yaml.example"
|
|
50
|
+
[ -r "$TPL" ] || TPL="$ROOT/.agents/config/agents.yaml.example"
|
|
38
51
|
[ -r "$TPL" ] || { printf 'error: template missing: %s\n' "$TPL" >&2; exit 1; }
|
|
39
52
|
mkdir -p "$(dirname "$CFG")" 2>/dev/null || true
|
|
40
53
|
cp "$TPL" "$CFG" || { printf 'error: could not write %s\n' "$CFG" >&2; exit 1; }
|
|
@@ -211,12 +224,22 @@ if os.path.exists(oc):
|
|
|
211
224
|
changed.append(f"provider:{name}")
|
|
212
225
|
ablock = d.setdefault("agent", {})
|
|
213
226
|
for a in agents:
|
|
227
|
+
# opencode.json is OpenCode's file: only OpenCode agents belong in it. A
|
|
228
|
+
# pi (or hermes) agent's model is that harness's id — e.g.
|
|
229
|
+
# `llamacpp/qwen3.5-9b` — which OpenCode cannot resolve, so writing it
|
|
230
|
+
# here would hand OpenCode an agent it cannot run. Skip them.
|
|
231
|
+
if harness_of(a) != "opencode":
|
|
232
|
+
continue
|
|
214
233
|
m = resolve_model(model_of(a))
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
234
|
+
# The roster DECLARES every figure it names. Before this, `apply` only
|
|
235
|
+
# touched an agent already present in opencode.json (`if a in ablock`),
|
|
236
|
+
# so a figure the roster knew but the config had never seen stayed
|
|
237
|
+
# undeclared — and OpenCode never loaded it. The roster is the source of
|
|
238
|
+
# truth; a name in it must reach the harness config.
|
|
239
|
+
entry = ablock.setdefault(a, {})
|
|
240
|
+
if entry.get("model") != m:
|
|
241
|
+
entry["model"] = m; changed.append(f"oc:{a}")
|
|
218
242
|
if spec_of(a).get("primary"):
|
|
219
|
-
entry = ablock.setdefault(a, {})
|
|
220
243
|
entry["mode"] = "primary"
|
|
221
244
|
entry["model"] = m
|
|
222
245
|
entry.setdefault("description", f"{a} — primary (from config/agents.yaml)")
|
package/bin/apodex-smoke-test.sh
CHANGED
|
@@ -15,8 +15,21 @@ set -u
|
|
|
15
15
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
16
16
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
17
17
|
|
|
18
|
+
# The operator's settings and secrets live in the home they chose, never in the
|
|
19
|
+
# code tree — a packaged install replaces its tree on upgrade, and a credential
|
|
20
|
+
# must never sit in a tree that ships (Rule 04).
|
|
21
|
+
if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
|
|
22
|
+
_yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
23
|
+
for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
|
|
24
|
+
[ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
|
|
25
|
+
done
|
|
26
|
+
unset _yr _yc
|
|
27
|
+
fi
|
|
28
|
+
hoard_settings_dir YMIR_SETTINGS_DIR
|
|
29
|
+
hoard_local_env YMIR_ENV_FILE
|
|
30
|
+
|
|
18
31
|
# Source env if available (project .env.local first, then the private hoard).
|
|
19
|
-
for f in "$
|
|
32
|
+
for f in "$YMIR_ENV_FILE" "${YMIR_HOARD:-$HOME/Documents/Ymir}/.env.local"; do
|
|
20
33
|
[ -f "$f" ] && . "$f" 2>/dev/null || true
|
|
21
34
|
done
|
|
22
35
|
|
package/bin/bifrost-bridge.sh
CHANGED
|
@@ -25,11 +25,30 @@ fi
|
|
|
25
25
|
VERSION="2.0.0"
|
|
26
26
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
27
27
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
28
|
+
|
|
29
|
+
# The operator's settings and secrets live in the home they chose, never in the
|
|
30
|
+
# code tree — a packaged install replaces its tree on upgrade, and a credential
|
|
31
|
+
# must never sit in a tree that ships (Rule 04).
|
|
32
|
+
if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
|
|
33
|
+
_yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
34
|
+
for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
|
|
35
|
+
[ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
|
|
36
|
+
done
|
|
37
|
+
unset _yr _yc
|
|
38
|
+
fi
|
|
39
|
+
hoard_settings_dir YMIR_SETTINGS_DIR
|
|
40
|
+
hoard_local_env YMIR_ENV_FILE
|
|
41
|
+
hoard_state_dir YMIR_STATE_DIR
|
|
42
|
+
hoard_data_dir YMIR_DATA_DIR
|
|
43
|
+
|
|
44
|
+
# The roots that live OUTSIDE the code tree: this machine's records and the
|
|
45
|
+
# runtime state belong to the home the operator chose at installation, never in
|
|
46
|
+
# the tree — a packaged install replaces its tree on upgrade (Rule 04).
|
|
28
47
|
BRIDGE="$ROOT/.agents/backend/model-bridge.py"
|
|
29
|
-
ENV_FILE="${BROKK_ENV_FILE:-$
|
|
48
|
+
ENV_FILE="${BROKK_ENV_FILE:-$YMIR_ENV_FILE}"
|
|
30
49
|
PORT="${OPENCODE_GO_BRIDGE_PORT:-4603}"
|
|
31
|
-
PID_FILE="$
|
|
32
|
-
LOG_FILE="$
|
|
50
|
+
PID_FILE="$YMIR_STATE_DIR/model-bridge.pid"
|
|
51
|
+
LOG_FILE="$YMIR_STATE_DIR/model-bridge.log"
|
|
33
52
|
PROVIDER="${YMIR_MODEL_PROVIDER:-}"
|
|
34
53
|
|
|
35
54
|
case "${1-}" in
|
|
@@ -40,8 +59,8 @@ ACTION="start"
|
|
|
40
59
|
while [ $# -gt 0 ]; do
|
|
41
60
|
case "$1" in
|
|
42
61
|
--start|"") ACTION=start; shift ;;
|
|
43
|
-
--stop) ACTION=stop; shift ;;
|
|
44
|
-
--status) ACTION=status; shift ;;
|
|
62
|
+
--stop|stop) ACTION=stop; shift ;;
|
|
63
|
+
--status|status) ACTION=status; shift ;;
|
|
45
64
|
--port) PORT=${2-}; shift 2 ;;
|
|
46
65
|
--provider) PROVIDER=${2-}; shift 2 ;;
|
|
47
66
|
*) printf 'error: unknown flag %s\nhelp: bin/bifrost-bridge.sh [--start|--stop|--status|--port|--provider]\n' "$1" >&2; exit 2 ;;
|
|
@@ -95,7 +114,7 @@ if [ -z "$PROVIDER" ]; then
|
|
|
95
114
|
fi
|
|
96
115
|
|
|
97
116
|
# A missing env file is fine for keyless providers; the bridge reads what it can.
|
|
98
|
-
mkdir -p "$
|
|
117
|
+
mkdir -p "$YMIR_STATE_DIR"
|
|
99
118
|
nohup python3 "$BRIDGE" --port "$PORT" --env "$ENV_FILE" --provider "$PROVIDER" >"$LOG_FILE" 2>&1 &
|
|
100
119
|
echo $! >"$PID_FILE"
|
|
101
120
|
sleep 2
|
package/bin/bootstrap-macos.sh
CHANGED
|
@@ -25,7 +25,12 @@ VERSION="1.0.0"
|
|
|
25
25
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
26
26
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
27
27
|
VM="ymir"; CHECK=0; REPO=""
|
|
28
|
-
|
|
28
|
+
# The VM's home is resolved, never assumed (Rule 07B). Inside the Lima VM the
|
|
29
|
+
# operator is `ubuntu`, but that is the VM's fact, not this script's — it comes
|
|
30
|
+
# from env with one documented default, and the guest user is overridable so a
|
|
31
|
+
# host that provisions a different user still works.
|
|
32
|
+
VM_USER="${YMIR_VM_USER:-ubuntu}"
|
|
33
|
+
VM_HOME="${YMIR_HOME:-/home/${VM_USER}/Documents/Ymir}"
|
|
29
34
|
|
|
30
35
|
while [ $# -gt 0 ]; do
|
|
31
36
|
case "$1" in
|
package/bin/brokk-send.sh
CHANGED
|
@@ -10,7 +10,20 @@ set -u
|
|
|
10
10
|
VERSION="1.0.0"
|
|
11
11
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
12
12
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
13
|
-
|
|
13
|
+
|
|
14
|
+
# The roots that live OUTSIDE the code tree: this machine's records and the
|
|
15
|
+
# runtime state belong to the home the operator chose at installation, never in
|
|
16
|
+
# the tree — a packaged install replaces its tree on upgrade (Rule 04).
|
|
17
|
+
if [ -z "${YMIR_HOARD_LIB_LOADED:-}" ]; then
|
|
18
|
+
_yr="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
for _yc in "$_yr/hoard-lib.sh" "$(dirname "$_yr")/bin/hoard-lib.sh"; do
|
|
20
|
+
[ -r "$_yc" ] && { . "$_yc"; YMIR_HOARD_LIB_LOADED=1; break; }
|
|
21
|
+
done
|
|
22
|
+
unset _yr _yc
|
|
23
|
+
fi
|
|
24
|
+
hoard_state_dir YMIR_STATE_DIR
|
|
25
|
+
hoard_data_dir YMIR_DATA_DIR
|
|
26
|
+
REG="${BROKK_EINDRI_HOMES:-$YMIR_DATA_DIR/eindri-homes.md}"
|
|
14
27
|
|
|
15
28
|
case "${1-}" in -v|-V|--version) printf '%s\n' "$VERSION"; exit 0 ;; -h|--help|"") sed -n '2,9p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; esac
|
|
16
29
|
ID="${1-}"; shift || true
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# changelog-assemble.sh — fold CHANGELOG.d/ fragments into CHANGELOG.md.
|
|
3
|
+
#
|
|
4
|
+
# WHY THIS EXISTS
|
|
5
|
+
# CHANGELOG.md is one file, and every branch appends at the same position — the
|
|
6
|
+
# top. Git sees two branches inserting different lines at the same spot and
|
|
7
|
+
# calls it a conflict, so every merge re-conflicts every open branch. With N
|
|
8
|
+
# branches that is O(N^2) conflicts, all of them meaningless.
|
|
9
|
+
#
|
|
10
|
+
# A fragment directory removes the collision at the root: each change adds ONE
|
|
11
|
+
# file with a UNIQUE name, so two branches never touch the same file and can
|
|
12
|
+
# never conflict. This script folds the fragments into the ledger.
|
|
13
|
+
#
|
|
14
|
+
# THE LAW (RULES/06-append-only.md)
|
|
15
|
+
# Append, never rewrite. Never truncate. Never lose one in a move.
|
|
16
|
+
# A fragment is a new file; the ledger only ever grows. Folding is an append.
|
|
17
|
+
# CHANGELOG.d/ joins the append-only set, so a move must carry it.
|
|
18
|
+
#
|
|
19
|
+
# FRAGMENT FORMAT
|
|
20
|
+
# CHANGELOG.d/<YYYY-MM-DD>-<slug>.md — the entry exactly as it should appear in
|
|
21
|
+
# CHANGELOG.md, starting with its `## YYYY-MM-DD — title` heading.
|
|
22
|
+
#
|
|
23
|
+
# USAGE
|
|
24
|
+
# bin/changelog-assemble.sh # fold every fragment, newest first
|
|
25
|
+
# bin/changelog-assemble.sh --dry-run # report what would fold
|
|
26
|
+
# bin/changelog-assemble.sh --check # exit 1 if fragments are unfolded
|
|
27
|
+
#
|
|
28
|
+
# Exit: 0 ok, 1 error (or --check with fragments present), 2 usage.
|
|
29
|
+
set -euo pipefail
|
|
30
|
+
|
|
31
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
32
|
+
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
33
|
+
FRAG_DIR="$ROOT/CHANGELOG.d"
|
|
34
|
+
LEDGER="$ROOT/CHANGELOG.md"
|
|
35
|
+
|
|
36
|
+
DRY=0; CHECK=0
|
|
37
|
+
for a in "$@"; do
|
|
38
|
+
case "$a" in
|
|
39
|
+
--dry-run) DRY=1 ;;
|
|
40
|
+
--check) CHECK=1 ;;
|
|
41
|
+
-h|--help) sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
|
42
|
+
*) printf 'error: unknown flag %s\nhelp: bin/changelog-assemble.sh [--dry-run|--check]\n' "$a" >&2; exit 2 ;;
|
|
43
|
+
esac
|
|
44
|
+
done
|
|
45
|
+
|
|
46
|
+
[ -f "$LEDGER" ] || { printf 'error: no ledger at %s\n' "$LEDGER" >&2; exit 1; }
|
|
47
|
+
|
|
48
|
+
# One python block owns the whole fold: regex, ordering, and the ledger's
|
|
49
|
+
# structure are all easier to get right in one language than split across awk,
|
|
50
|
+
# sed and shell — and an awk interval regex silently matched nothing, which
|
|
51
|
+
# duplicated the entire ledger.
|
|
52
|
+
ROOT="$ROOT" FRAG_DIR="$FRAG_DIR" LEDGER="$LEDGER" DRY="$DRY" CHECK="$CHECK" python3 - <<'PY'
|
|
53
|
+
import os, re, sys, glob
|
|
54
|
+
|
|
55
|
+
root = os.environ["ROOT"]
|
|
56
|
+
frag_dir = os.environ["FRAG_DIR"]
|
|
57
|
+
ledger = os.environ["LEDGER"]
|
|
58
|
+
dry = os.environ["DRY"] == "1"
|
|
59
|
+
check = os.environ["CHECK"] == "1"
|
|
60
|
+
|
|
61
|
+
HEADING = re.compile(r"^## (\d{4}-\d{2}-\d{2}) ")
|
|
62
|
+
|
|
63
|
+
# --- fragments -------------------------------------------------------------
|
|
64
|
+
frags = []
|
|
65
|
+
if os.path.isdir(frag_dir):
|
|
66
|
+
for p in sorted(glob.glob(os.path.join(frag_dir, "*.md"))):
|
|
67
|
+
if os.path.basename(p) == "README.md":
|
|
68
|
+
continue
|
|
69
|
+
frags.append(p)
|
|
70
|
+
|
|
71
|
+
if not frags:
|
|
72
|
+
if check:
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
print('changelog-assemble[1]{fragments,folded,state}:')
|
|
75
|
+
print(' "0","0","none pending"')
|
|
76
|
+
sys.exit(0)
|
|
77
|
+
|
|
78
|
+
# A fragment must begin with its dated heading, or folding it would corrupt the
|
|
79
|
+
# ledger's structure.
|
|
80
|
+
bad = []
|
|
81
|
+
for p in frags:
|
|
82
|
+
first = open(p, encoding="utf-8").readline()
|
|
83
|
+
if not HEADING.match(first):
|
|
84
|
+
bad.append(os.path.relpath(p, root))
|
|
85
|
+
if bad:
|
|
86
|
+
for b in bad:
|
|
87
|
+
print(f'error: fragment has no dated heading: {b}', file=sys.stderr)
|
|
88
|
+
print('help: first line must be "## YYYY-MM-DD — title"', file=sys.stderr)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
def frag_date(p):
|
|
92
|
+
m = re.match(r"^(\d{4}-\d{2}-\d{2})", os.path.basename(p))
|
|
93
|
+
return m.group(1) if m else "0000-00-00"
|
|
94
|
+
|
|
95
|
+
# newest first; ties broken by name for determinism
|
|
96
|
+
frags.sort(key=lambda p: (frag_date(p), os.path.basename(p)), reverse=True)
|
|
97
|
+
|
|
98
|
+
if check:
|
|
99
|
+
print(f'changelog-assemble[{len(frags)}]{{fragment,state}}:')
|
|
100
|
+
for p in frags:
|
|
101
|
+
print(f' "{os.path.relpath(p, root)}","unfolded"')
|
|
102
|
+
print(f'error: {len(frags)} changelog fragment(s) not folded into CHANGELOG.md', file=sys.stderr)
|
|
103
|
+
print('help: run bin/changelog-assemble.sh', file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
if dry:
|
|
107
|
+
print(f'changelog-assemble[{len(frags)}]{{fragment,date}}:')
|
|
108
|
+
for p in frags:
|
|
109
|
+
print(f' "{os.path.relpath(p, root)}","{frag_date(p)}"')
|
|
110
|
+
print('changelog-assemble: dry run — nothing written')
|
|
111
|
+
sys.exit(0)
|
|
112
|
+
|
|
113
|
+
# --- the ledger ------------------------------------------------------------
|
|
114
|
+
text = open(ledger, encoding="utf-8").read()
|
|
115
|
+
lines = text.split("\n")
|
|
116
|
+
|
|
117
|
+
# preamble = everything before the first dated entry (the `# CHANGELOG` head)
|
|
118
|
+
split = None
|
|
119
|
+
for i, ln in enumerate(lines):
|
|
120
|
+
if HEADING.match(ln):
|
|
121
|
+
split = i
|
|
122
|
+
break
|
|
123
|
+
if split is None:
|
|
124
|
+
preamble = lines[:] # no dated entry yet: the whole file is preamble
|
|
125
|
+
existing = []
|
|
126
|
+
else:
|
|
127
|
+
preamble = lines[:split]
|
|
128
|
+
existing = lines[split:]
|
|
129
|
+
|
|
130
|
+
# trim trailing blanks from the preamble, then one blank separator
|
|
131
|
+
while preamble and preamble[-1].strip() == "":
|
|
132
|
+
preamble.pop()
|
|
133
|
+
|
|
134
|
+
# --- assemble: fragments first (newest at top), then the existing ledger ----
|
|
135
|
+
out = list(preamble)
|
|
136
|
+
out.append("")
|
|
137
|
+
for p in frags:
|
|
138
|
+
body = open(p, encoding="utf-8").read().rstrip("\n").split("\n")
|
|
139
|
+
out.extend(body)
|
|
140
|
+
out.append("")
|
|
141
|
+
out.append("")
|
|
142
|
+
out.extend(existing)
|
|
143
|
+
|
|
144
|
+
# collapse runs of 3+ blank lines to two (entry separation only — never content)
|
|
145
|
+
t = "\n".join(out)
|
|
146
|
+
t = re.sub(r"\n{3,}", "\n\n", t).rstrip("\n") + "\n"
|
|
147
|
+
|
|
148
|
+
open(ledger, "w", encoding="utf-8").write(t)
|
|
149
|
+
for p in frags:
|
|
150
|
+
os.remove(p)
|
|
151
|
+
|
|
152
|
+
print('changelog-assemble[1]{fragments,folded,ledger}:')
|
|
153
|
+
print(f' "{len(frags)}","{len(frags)}","CHANGELOG.md"')
|
|
154
|
+
PY
|