Compare commits
13 Commits
release/mo
...
release/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd977d78cf | ||
| 095e19443b | |||
| fabc413407 | |||
| 858d90329d | |||
| 2bf66136e4 | |||
| 4434c3c481 | |||
| dd0a0d38c6 | |||
| d46ac40890 | |||
| 8ddd48c843 | |||
| 528700ceea | |||
| 32f4215461 | |||
| 23343bb7f0 | |||
| c8b2dab0ca |
@@ -25,12 +25,10 @@ steps:
|
||||
commands:
|
||||
- apk add --no-cache bash
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||
# L0 resident-token budget: keep the Constitution + dispatcher small.
|
||||
- |
|
||||
for f in CONSTITUTION.md AGENTS.md; do
|
||||
n=$(wc -l < "packages/mosaic/framework/defaults/$f")
|
||||
if [ "$n" -gt 120 ]; then echo "L0 budget exceeded: defaults/$f is $n lines (max 120)"; exit 1; fi
|
||||
done
|
||||
# Resident line-count ceiling over framework-owned resident files
|
||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
|
||||
@@ -4,6 +4,23 @@
|
||||
variables:
|
||||
- &node_image 'node:22-alpine'
|
||||
- &enable_pnpm 'corepack enable'
|
||||
# Heavy kaniko image builds (~25 min) — gate them so a merge that only touches
|
||||
# the npm-only CLI (@mosaicstack/mosaic) or docs does NOT rebuild the platform
|
||||
# images (gateway/appservice/web do not depend on @mosaicstack/mosaic). Releases
|
||||
# (tags) always build everything. Exclude-list keeps the default SAFE: any
|
||||
# non-excluded change still builds, so no transitive dep can silently go stale.
|
||||
# (Woodpecker: `when` entries are OR'd; `path` applies to push/PR only — hence
|
||||
# the separate `event: tag` entry.)
|
||||
- &image_build_when
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
exclude:
|
||||
- 'packages/mosaic/**'
|
||||
- 'docs/**'
|
||||
- '**/*.md'
|
||||
- '.woodpecker/**'
|
||||
|
||||
when:
|
||||
- branch: [main]
|
||||
@@ -26,6 +43,15 @@ steps:
|
||||
|
||||
publish-npm:
|
||||
image: *node_image
|
||||
# Publish only when a publishable package changed (or on a release tag); a
|
||||
# pure-docs merge runs no publish. Cheap step, but gated for cleanliness.
|
||||
when:
|
||||
- event: tag
|
||||
- event: [push, manual]
|
||||
branch: main
|
||||
path:
|
||||
include:
|
||||
- 'packages/**'
|
||||
environment:
|
||||
NPM_TOKEN:
|
||||
from_secret: gitea_token
|
||||
@@ -91,6 +117,7 @@ steps:
|
||||
|
||||
build-gateway:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -116,6 +143,7 @@ steps:
|
||||
|
||||
build-appservice:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
@@ -141,6 +169,7 @@ steps:
|
||||
|
||||
build-web:
|
||||
image: gcr.io/kaniko-project/executor:debug
|
||||
when: *image_build_when
|
||||
environment:
|
||||
REGISTRY_USER:
|
||||
from_secret: gitea_username
|
||||
|
||||
@@ -45,3 +45,32 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
||||
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
||||
|
||||
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
|
||||
|
||||
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
|
||||
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
|
||||
|
||||
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
|
||||
|
||||
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
|
||||
|
||||
## F3-m3 — mosaic update re-seeds framework + relaunches agents (#609) — feat/f3-m3-update-reseed
|
||||
|
||||
- Status: implemented + tested. Closes R13: `mosaic update` now re-seeds the framework (data-safe MOSAIC_SYNC_ONLY) after the CLI install so shipped launcher/runtime changes activate; `--relaunch` restarts rostered agents; `--no-reseed` opts out. Detail: scratchpads/f3-m3-update-reseed.md.
|
||||
|
||||
## Fleet-polish bundle — boot-survival symmetry (#611) — feat/fleet-polish-bundle
|
||||
|
||||
- Status: MERGED to main. disable-on-remove (boot-resurrection bug, TDD) + add-enable + init-R5 hard guarantee. 4 new + 147 existing fleet tests green. Detail: scratchpads/fleet-polish-bundle.md.
|
||||
|
||||
## Fleet enhancer role + two-agent floor (#614) — feat/fleet-enhancer-floor
|
||||
|
||||
- Status: MERGED to main. enhancer added to 4 presets; init guarantees 1 orchestrator + >=1 enhancer; remove protects the sole enhancer; enhancer role doc. 155 fleet tests green. Detail: scratchpads/fleet-enhancer-floor.md.
|
||||
|
||||
## F4 — Orchestrator chat connector + Matrix (#616) — feat/f4-matrix-connector
|
||||
|
||||
- Status: Phase 1 MERGED (#617: connector interface send/subscribe/health + registry + roster schema + design). Phase 2a (#618): Matrix CS-API client + factory. 20 connector tests green; no fleet.ts changes. Remaining Phase 2: init/configure connector-selection UX + roster wiring, systemd launch wiring, Conduit deploy guide. Detail: scratchpads/f4-matrix-connector.md.
|
||||
|
||||
## Fleet onboarding-injection — comms cheat-sheet + peer roster (#620) — feat/fleet-comms-onboarding
|
||||
|
||||
- Status: implemented + tested. Injects # Fleet Comms (peer roster + cross-host agent-send commands + FLIP-reply + --verify) into each spawned fleet agent via composeContract; optional per-agent host/ssh/socket roster fields (socket: named → -L, unset → default socket no -L). 10 + 2 tests green. Detail: scratchpads/fleet-comms-onboarding.md.
|
||||
|
||||
75
docs/design/framework-constitution/ALPHA-DOD.md
Normal file
75
docs/design/framework-constitution/ALPHA-DOD.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Constitution Alpha — Definition-of-Done checklist + release notes
|
||||
|
||||
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
|
||||
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
|
||||
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
|
||||
|
||||
## DoD §8 green-checklist
|
||||
|
||||
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
|
||||
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
|
||||
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
|
||||
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
|
||||
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
|
||||
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
|
||||
| 5 | `rails/`→`tools/` in **both** template families | ✅ | P2 #572 |
|
||||
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md` → **neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
|
||||
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
|
||||
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
|
||||
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
|
||||
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
|
||||
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
|
||||
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
|
||||
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
|
||||
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
|
||||
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
|
||||
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
|
||||
|
||||
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
|
||||
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
|
||||
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
|
||||
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
|
||||
|
||||
**Gate to flip 12–14 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
|
||||
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
|
||||
|
||||
---
|
||||
|
||||
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
|
||||
|
||||
### Mosaic Framework Constitution — Alpha
|
||||
|
||||
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
|
||||
operating layer**. It separates the non-negotiable law from operator identity, makes
|
||||
customization survive upgrades, and wires the guarantees into CI.
|
||||
|
||||
**Highlights**
|
||||
|
||||
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
|
||||
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
|
||||
it. Governance model in `constitution/LAYER-MODEL.md`.
|
||||
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
|
||||
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
|
||||
**blocking** in CI so re-contamination can't merge.
|
||||
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
|
||||
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
|
||||
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md` —
|
||||
never silently lost. Verified by a 5-fixture matrix across **both** installers.
|
||||
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
|
||||
the contract per harness, so customization reaches the model as one pre-merged blob.
|
||||
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
|
||||
tiered injection with a byte-equal Tier-3 fallback read.
|
||||
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
|
||||
unit test; sanitization gate — all blocking.
|
||||
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
|
||||
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
|
||||
|
||||
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
|
||||
|
||||
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
|
||||
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
|
||||
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
|
||||
verification are **v2**.
|
||||
|
||||
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
|
||||
aiguide #8 (umbrella #542).
|
||||
@@ -20,39 +20,43 @@ functional, we use the fleet itself to continue the work.
|
||||
## Requirements
|
||||
|
||||
### A. Configure-without-AI CLI
|
||||
| ID | Requirement |
|
||||
|---|---|
|
||||
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
||||
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
||||
| R17 | Fleet controls are **simple and intuitive**. |
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
||||
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
||||
| R17 | Fleet controls are **simple and intuitive**. |
|
||||
|
||||
### B. Comms & orchestrator chat-ops
|
||||
| ID | Requirement |
|
||||
|---|---|
|
||||
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
||||
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
|
||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
||||
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
|
||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
||||
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
|
||||
|
||||
### C. Runtime, health, lifecycle
|
||||
| ID | Requirement |
|
||||
|---|---|
|
||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ---------------------------------------------------------------------------------- |
|
||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
||||
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
||||
|
||||
### D. Surfaces, testing, docs
|
||||
| ID | Requirement |
|
||||
|---|---|
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ----------------------------------------------------------------------------------- |
|
||||
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
|
||||
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
||||
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
||||
|
||||
## Architecture / approach
|
||||
|
||||
@@ -65,15 +69,15 @@ functional, we use the fleet itself to continue the work.
|
||||
|
||||
## Phases (incremental, each shippable)
|
||||
|
||||
| Phase | Deliverable | Notes |
|
||||
|---|---|---|
|
||||
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 |
|
||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
||||
| Phase | Deliverable | Notes |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 |
|
||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
||||
|
||||
## Work division (proposed — confirm with dragon-lin)
|
||||
|
||||
|
||||
92
docs/fleet/f4-matrix-connector.md
Normal file
92
docs/fleet/f4-matrix-connector.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# F4 — Orchestrator chat connector + Matrix (local homeserver)
|
||||
|
||||
> **Issue:** #616 · **Doctrine:** `docs/fleet/north-star.md` (#613) — orchestrator-chat-connector decision.
|
||||
> **Status:** Phase 1 (abstraction + scaffold) in this PR; Phase 2+ are follow-ups (below).
|
||||
|
||||
## Goal
|
||||
|
||||
The fleet **orchestrator** is the operator's single point of contact. The north-star makes the
|
||||
chat channel a **user-chosen connector** — tmux today, Discord live ("Mos"), with Matrix /
|
||||
Telegram / Slack configurable. F4 adds **Matrix** (local homeserver) as a **peer** connector and,
|
||||
first, the small **connector abstraction** that makes connectors pluggable without touching fleet
|
||||
core.
|
||||
|
||||
## The abstraction (Phase 1 — this PR)
|
||||
|
||||
Connectors implement one small, uniform interface (`src/fleet/connectors/types.ts`):
|
||||
|
||||
```ts
|
||||
interface OrchestratorConnector {
|
||||
readonly kind: 'tmux' | 'discord' | 'matrix';
|
||||
send(message: OutboundMessage): Promise<SendResult>; // orchestrator → human
|
||||
subscribe(handler: (m: InboundMessage) => void): Unsubscribe; // human → orchestrator
|
||||
health(): Promise<ConnectorHealth>; // reachable + authenticated
|
||||
}
|
||||
```
|
||||
|
||||
- **send / subscribe / health** — the only surface fleet core depends on. `SendResult` is the
|
||||
ack half; `health()` is the liveness half.
|
||||
- **Thread-aware by metadata** — `OutboundMessage.threadId` / `InboundMessage.threadId` are
|
||||
optional, so thread-capable connectors (Matrix rooms/threads, the future first-party Mosaic
|
||||
Discord plugin) fit **without an interface change**.
|
||||
- **Registry** (`registry.ts`) — implementations register a factory by kind; `createConnector(config)`
|
||||
resolves one from roster config. Phase 1 ships the registry + `resolveConnectorKind` (defaults
|
||||
`tmux` when a roster declares no connector — **back-compat**); the factories land in Phase 2.
|
||||
|
||||
### Config model
|
||||
|
||||
A roster may carry an optional `connector` block (`roster.schema.json`); absent ⇒ tmux.
|
||||
|
||||
```yaml
|
||||
connector:
|
||||
kind: matrix # tmux | discord | matrix
|
||||
matrix:
|
||||
homeserver_url: https://matrix.example.internal
|
||||
user_id: '@mos:example.internal'
|
||||
room_id: '!abc:example.internal'
|
||||
```
|
||||
|
||||
**Secrets are never in the roster.** `MATRIX_ACCESS_TOKEN` / `DISCORD_BOT_TOKEN` come from the
|
||||
environment (the gateway env-config pattern that already masks them). The sanitization gate would
|
||||
reject a token committed to a shipped file anyway.
|
||||
|
||||
## Matrix connector (Phase 2)
|
||||
|
||||
The connector speaks the **Matrix client-server API** directly over HTTPS (`fetch` — no SDK needed
|
||||
for MVP), so it is **homeserver-agnostic**:
|
||||
|
||||
| Op | Matrix CS-API |
|
||||
| ----------- | ------------------------------------------------------------------------ |
|
||||
| `send` | `PUT /_matrix/client/v3/rooms/{roomId}/send/m.room.message/{txnId}` |
|
||||
| `subscribe` | `GET /_matrix/client/v3/sync` (long-poll, `since` token) → room timeline |
|
||||
| `health` | `GET /_matrix/client/versions` (reachable) + `…/account/whoami` (authed) |
|
||||
| threads | `m.thread` relations ↔ `threadId` |
|
||||
|
||||
## Local homeserver (infra, not connector code)
|
||||
|
||||
Strategic default: a **self-hosted** homeserver on our own infra — no third-party gateway.
|
||||
|
||||
- **Default: Conduit** (Rust, single binary, low resource) — trivial to stand up for a fleet/dev
|
||||
homeserver.
|
||||
- **Alternative: Synapse** (mature, feature-complete) for scale.
|
||||
|
||||
The connector only needs `homeserver_url` + `user_id` + `room_id` + an access token, so the
|
||||
homeserver choice is a **deployment** concern (a Phase-2 deploy guide), not connector code.
|
||||
|
||||
## Phasing
|
||||
|
||||
| Phase | Scope | This PR |
|
||||
| ----- | --------------------------------------------------------------------------------------- | ------- |
|
||||
| **1** | Connector interface + types, registry + kind resolution, roster `connector` schema, doc | ✅ yes |
|
||||
| 2 | Matrix CS-API client (fetch-based send/sync/health) + registered factory + tests | follow |
|
||||
| 2 | `fleet init` / `configure` connector-selection UX; roster parse wires the block | follow |
|
||||
| 2 | systemd launch wiring so the orchestrator starts on the chosen connector | follow |
|
||||
| 3 | Conduit deploy guide; first-party Mosaic Discord (threads) registers as a connector | follow |
|
||||
|
||||
## Back-compat & boundaries
|
||||
|
||||
- Existing rosters (no `connector`) resolve to tmux — **zero change**.
|
||||
- Fleet core never branches on connector kind; it depends only on the interface.
|
||||
- Cross-host reach rides the **federation** layer (W1), not a bespoke broker (north-star assumption).
|
||||
- Phase 1 touches **no** `fleet.ts` core (a self-contained `connectors/` module), so it is
|
||||
independent of the in-flight fleet-config PRs.
|
||||
@@ -73,6 +73,37 @@ diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a d
|
||||
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
||||
(orchestration model) for the rationale.
|
||||
|
||||
## Fleet roster — the two-agent floor and the role library
|
||||
|
||||
A fleet is **never a single agent**. The minimum viable fleet is **two**:
|
||||
|
||||
| Role | Mandate | Boundaries |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **Orchestrator** | The user's **single point of contact**. Owns the general flow, keeps agentic actions on-target, and **adds/removes agents from the fleet at will** to meet goals and user needs. Exactly **one** per fleet (the existing R5 invariant). | Delegates source work; never the sole worker. |
|
||||
| **Enhancer** | The fleet's **continuous-improvement loop**. Monitors fleet activity, analyzes for enhancements/optimizations, builds a **plan of remediation**, and — **with the orchestrator** — upgrades fleet capability: tool creation/repair, skills, harness improvements, and **bug reports filed to Mosaic Stack** for proper remediation. Recommends which agents are needed. | **Does not code, review code, or perform delivery tasks.** Improvement and diagnosis only. |
|
||||
|
||||
> **Why two, not one:** the orchestrator drives delivery; the enhancer makes the fleet
|
||||
> _get better at delivering_ over time. The enhancer is how the fleet self-heals its tools,
|
||||
> skills, and harnesses, and how real defects flow back to Mosaic Stack as bug reports.
|
||||
> Together they are the irreducible core — every other role is added on demand.
|
||||
|
||||
A **general** fleet starts at this floor: the orchestrator (advised by the enhancer)
|
||||
materializes whatever roles prove necessary over the mission's life. Specialized presets
|
||||
(coding, research, etc.) seed additional roles up front, but all reduce to the same two-agent
|
||||
spine plus an on-demand **role library**:
|
||||
|
||||
| Role profile | Purpose |
|
||||
| ------------------- | --------------------------------------------------------------------------------- |
|
||||
| **orchestrator** | point of contact, flow control, fleet composition (1 per fleet) |
|
||||
| **enhancer** | fleet monitoring, optimization, tool/skill/harness upgrades, upstream bug reports |
|
||||
| **coder** | implementation (worker; stops at PR-open) |
|
||||
| **code review** | independent code review gate |
|
||||
| **security review** | security/auth/secret review gate |
|
||||
| **research** | investigation, synthesis, options analysis |
|
||||
| **board** | deliberation panel — moonshot, contrarian, technical, business, financial lenses |
|
||||
| **operations** | infra, deploy, health, incident response |
|
||||
| _…extensible_ | new profiles added as missions demand (orchestrator + enhancer decide) |
|
||||
|
||||
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
||||
|
||||
Every artifact, starting Phase 2, MUST:
|
||||
@@ -102,7 +133,7 @@ Every artifact, starting Phase 2, MUST:
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: orchestrator+reviewer; ephemeral workers per lane) | planned |
|
||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + enhancer**; ephemeral workers per lane) | planned |
|
||||
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning | planned |
|
||||
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity | planned |
|
||||
|
||||
@@ -121,6 +152,28 @@ Every artifact, starting Phase 2, MUST:
|
||||
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
||||
which `fleet init` should automate.
|
||||
|
||||
## Decisions of record (2026-06-22, with Jason)
|
||||
|
||||
- **Two-agent floor:** every fleet has, at minimum, an **orchestrator** and an **enhancer**.
|
||||
The orchestrator is the user's point of contact and composes the fleet; the enhancer runs the
|
||||
continuous-improvement loop (monitor → analyze → remediate → upgrade tools/skills/harness →
|
||||
file Mosaic Stack bug reports) and **does not code or review**.
|
||||
- **Role library:** orchestrator, enhancer, coder, code review, security review, research,
|
||||
board (moonshot/contrarian/technical/business/financial), operations — extensible; the
|
||||
orchestrator (advised by the enhancer) adds roles as missions demand.
|
||||
- **Orchestrator chat connector:** the orchestrator is reachable over a user-chosen connector
|
||||
(tmux now; Telegram/Discord/Matrix/Slack configurable). Validated live: **"Mos" orchestrator
|
||||
on Discord** via the Claude Code discord channel plugin (w-jarvis).
|
||||
|
||||
## Future enhancements (north-star, post-MVP — not on the MVP track)
|
||||
|
||||
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly
|
||||
implements the basic Discord functions **and native Discord threads**. Threads let a user
|
||||
separate conversation topics with the orchestrator (the pattern proven by the Hermes agent).
|
||||
A major enhancement over the current third-party channel plugin; **not required for the MVP**,
|
||||
but a committed north-star target. `ASSUMPTION:` ships as a Mosaic-owned plugin so the fleet
|
||||
controls Discord UX (threads, reactions, attachments, per-thread context) end-to-end.
|
||||
|
||||
## Assumptions (veto-able)
|
||||
|
||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
||||
|
||||
29
docs/scratchpads/f3-m3-update-reseed.md
Normal file
29
docs/scratchpads/f3-m3-update-reseed.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13)
|
||||
|
||||
- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed`
|
||||
|
||||
## Gap (found in 0.0.39 production validation)
|
||||
|
||||
`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's
|
||||
bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays
|
||||
DORMANT until a re-seed — operators get the new CLI on a stale framework.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in
|
||||
`MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`,
|
||||
`readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent).
|
||||
- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the
|
||||
framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or
|
||||
print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`.
|
||||
|
||||
## Flow
|
||||
|
||||
`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the
|
||||
native harness for every operator.
|
||||
|
||||
## Verification
|
||||
|
||||
- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard).
|
||||
- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean.
|
||||
- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation).
|
||||
30
docs/scratchpads/f4-matrix-connector.md
Normal file
30
docs/scratchpads/f4-matrix-connector.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# F4 — Orchestrator chat connector + Matrix (#616)
|
||||
|
||||
- **Issue:** #616 · **Branch:** `feat/f4-matrix-connector` (off main; independent of #615) · **Doctrine:** north-star #613.
|
||||
|
||||
## Phase 1 (this PR) — abstraction + scaffold
|
||||
|
||||
- `src/fleet/connectors/types.ts`: `OrchestratorConnector` (send/subscribe/health) + message/config types; thread-aware via optional `threadId`; `DEFAULT_CONNECTOR_KIND=tmux`.
|
||||
- `src/fleet/connectors/registry.ts`: extensible factory registry; `resolveConnectorKind` (defaults tmux, back-compat); `createConnector` throws `ConnectorNotImplementedError` until Phase 2 registers factories.
|
||||
- `roster.schema.json`: optional `connector` block (tmux|discord|matrix; matrix homeserver/user/room; secrets via env, never roster).
|
||||
- Design doc `docs/fleet/f4-matrix-connector.md`: interface, config, Matrix CS-API mapping, Conduit-default infra, phasing.
|
||||
- **No fleet.ts changes** → self-contained, zero conflict with stacked #615.
|
||||
|
||||
## Verification
|
||||
|
||||
- 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema valid JSON.
|
||||
|
||||
## Phase 2+ (follow-ups, in the doc)
|
||||
|
||||
Matrix CS-API client (fetch send/sync/health) + factory; init/configure connector-selection UX + roster-parse wiring; systemd launch wiring; Conduit deploy guide; first-party Mosaic Discord (threads) as a connector.
|
||||
|
||||
## Phase 2a (feat/f4-matrix-client, stacked on #617) — Matrix CS-API client
|
||||
|
||||
- `src/fleet/connectors/matrix.ts`: `MatrixConnector implements OrchestratorConnector` over the Matrix
|
||||
client-server API (injectable fetch, no SDK). `send` → PUT m.room.message (thread-aware); `subscribe`
|
||||
→ /sync long-poll loop using the pure `parseSyncResponse`; `health` → /versions + /whoami.
|
||||
`registerMatrixConnector(env)` registers the factory (token from MATRIX_ACCESS_TOKEN, never roster).
|
||||
- Pure helpers `buildMessageBody` + `parseSyncResponse` make send/receive unit-testable.
|
||||
- 13 Matrix tests + 7 registry = 20 connector tests green; tsc/eslint/prettier clean.
|
||||
- Remaining Phase 2: init/configure connector-selection UX + roster-parse wiring (touches fleet.ts —
|
||||
after #615); systemd launch wiring; Conduit deploy guide.
|
||||
31
docs/scratchpads/fleet-comms-onboarding.md
Normal file
31
docs/scratchpads/fleet-comms-onboarding.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Fleet onboarding-injection — comms cheat-sheet + peer roster (#620)
|
||||
|
||||
- **Issue:** #620 · **Branch:** `feat/fleet-comms-onboarding` (off main). Root cause of Mos's failed first send.
|
||||
|
||||
## What
|
||||
|
||||
Inject a `# Fleet Comms` block into each spawned fleet agent's system prompt (via composeContract — the
|
||||
runtime-agnostic path every `mosaic yolo <runtime>` agent hits), so it boots knowing how to reach peers.
|
||||
|
||||
- `src/fleet/comms-onboarding.ts` (standalone, no fleet.ts coupling):
|
||||
- `parseRosterAgents` (name/class/host/ssh, lenient), `renderPeerReach` (same-host `-s` vs cross-host
|
||||
`-H <ssh> -s`), `buildFleetCommsBlock` (self [host:session] identity + agent-send path + peer table +
|
||||
FLIP-to-reply + `agent send --verify`=ACCEPTED), `readFleetCommsBlock` (reads roster.yaml; '' if not a member).
|
||||
- `composeContract` appends it only when MOSAIC_AGENT_NAME is set + the agent is in the roster.
|
||||
- `roster.schema.json`: optional per-agent `host` + `ssh` (cross-host addresses; manual = pre-federation
|
||||
stopgap, federation/W1 auto-discovers later).
|
||||
|
||||
## Acceptance criteria (Mos) — all covered
|
||||
|
||||
1. own [host:session] + agent-send path + peer roster ✓
|
||||
2. cross-host correctness: local→`-s` (no -H); remote→`-H <ssh> -s` ✓ (concrete coder0-0@dragon-lin)
|
||||
3. FLIP-the-preamble reply rule ✓
|
||||
4. `agent send --verify` = ACCEPTED ✓
|
||||
5. no `-L` (default socket); matches live tooling ✓
|
||||
|
||||
## Verification
|
||||
|
||||
- 10 onboarding unit tests (parse, render local/remote/fallback/equal-host, build, situational read) +
|
||||
2 composeContract situational tests (injects for fleet agent w/ correct cross-host addr; no-op when
|
||||
MOSAIC_AGENT_NAME unset). tsc/eslint/prettier/sanitize clean.
|
||||
- Post-merge validation: Mos spawns a real w-jarvis agent → first-try reach to coder0-0@dragon-lin + a local peer.
|
||||
26
docs/scratchpads/fleet-enhancer-floor.md
Normal file
26
docs/scratchpads/fleet-enhancer-floor.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Fleet enhancer role + two-agent floor (#614)
|
||||
|
||||
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
|
||||
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
|
||||
|
||||
## Changes
|
||||
|
||||
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
|
||||
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
|
||||
unchanged.
|
||||
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
|
||||
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
|
||||
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
|
||||
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
|
||||
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
|
||||
code or review).
|
||||
|
||||
## Verification
|
||||
|
||||
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
|
||||
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
|
||||
prettier/sanitize clean. TDD on the init guarantee + remove protection.
|
||||
|
||||
## Stacking
|
||||
|
||||
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.
|
||||
20
docs/scratchpads/fleet-polish-bundle.md
Normal file
20
docs/scratchpads/fleet-polish-bundle.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Fleet-polish bundle — boot-survival symmetry (#611)
|
||||
|
||||
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
|
||||
|
||||
## Three fixes
|
||||
|
||||
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
|
||||
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
|
||||
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
|
||||
(best-effort, gated on !--keep-files).
|
||||
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
|
||||
independent of --start) — symmetry with disable-on-remove.
|
||||
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
|
||||
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
|
||||
|
||||
## Verification
|
||||
|
||||
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
|
||||
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
|
||||
- TDD on the disable bug per contract.
|
||||
43
docs/scratchpads/p5-overlay-composer.md
Normal file
43
docs/scratchpads/p5-overlay-composer.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# P5 — Overlay composer + cross-harness (compose-contract)
|
||||
|
||||
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
|
||||
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
|
||||
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
|
||||
|
||||
## Locked design (sequential-thinking)
|
||||
|
||||
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
|
||||
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
|
||||
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
|
||||
|
||||
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
|
||||
labeled sections; base files keep their existing residency.
|
||||
|
||||
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
|
||||
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
|
||||
load-on-demand, so only the small delta is injected — not the full base prose).
|
||||
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
|
||||
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
|
||||
layers first, operator overlays at recency.
|
||||
- Base-only is automatic when a `.local` file is absent (`readOptional`).
|
||||
|
||||
## Plan
|
||||
|
||||
| # | Task | File |
|
||||
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
|
||||
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
|
||||
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
|
||||
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
|
||||
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
|
||||
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
|
||||
|
||||
## Deferred to P6
|
||||
|
||||
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
|
||||
alpha tag `mosaic-vX.Y.Z-alpha`.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
|
||||
- [ ] Implementation (tasks 1–5)
|
||||
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)
|
||||
29
docs/scratchpads/p6-docs-compliance-alpha.md
Normal file
29
docs/scratchpads/p6-docs-compliance-alpha.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
|
||||
|
||||
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
|
||||
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
|
||||
|
||||
## Delivered (in-repo)
|
||||
|
||||
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
|
||||
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
|
||||
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
|
||||
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
|
||||
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
|
||||
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
|
||||
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
|
||||
|
||||
## Verification
|
||||
|
||||
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
|
||||
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
|
||||
|
||||
## Remaining
|
||||
|
||||
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
|
||||
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
|
||||
|
||||
## Notes
|
||||
|
||||
- Alpha DoD (DESIGN §8): all phases P0–P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
|
||||
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.
|
||||
185
packages/mosaic/framework/CONTRIBUTING.md
Normal file
185
packages/mosaic/framework/CONTRIBUTING.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Contributing to the Mosaic Framework
|
||||
|
||||
The Mosaic framework is the open-source agent-operating layer that deploys to
|
||||
`~/.config/mosaic/`. It is designed to be **forked and customized** — but the
|
||||
shared core must stay operator-neutral, deduplicated, and upgrade-safe. This
|
||||
guide is the contract for changing framework-owned files.
|
||||
|
||||
> Governance model and layer rationale: `constitution/LAYER-MODEL.md` (source-only).
|
||||
> Requirements & phase history: `docs/design/framework-constitution/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The layer model (where does my change go?)
|
||||
|
||||
| Layer | What | Owner | On upgrade | File(s) |
|
||||
| ------ | ------------------------------------------------------------- | ---------------- | --------------------------------------- | -------------------------------------------- |
|
||||
| **L0** | Constitution — the non-negotiable law (hard gates) | Framework | **Overwritten** | `CONSTITUTION.md` |
|
||||
| **L1** | Standards & guides — how to do the work well | Framework | Overwritten; user delta → `*.local.md` | `STANDARDS.md`, `guides/*` |
|
||||
| **L2** | Persona (SOUL) — agent name, tone, role | User (init) | **Never overwritten** | `SOUL.md` (+ optional `SOUL.local.md`) |
|
||||
| **L3** | Operator (USER) — human identity, prefs, policy | User (init) | **Never overwritten** | `USER.md` (+ optional `USER.local.md`) |
|
||||
| **L4** | Project / runtime mechanism — per-repo deltas; harness wiring | Repo / framework | Project user-owned; runtime overwritten | `<repo>/AGENTS.md`, `runtime/<h>/RUNTIME.md` |
|
||||
|
||||
**The one sentence a user can rely on:** edit `SOUL.md` / `USER.md` and the
|
||||
`.local.md` overlays — they survive every upgrade. To change framework behavior,
|
||||
add a `.local.md` overlay; never edit a framework-owned file in place.
|
||||
|
||||
---
|
||||
|
||||
## 2. Operator hygiene (PII / secrets prohibition) — **blocking**
|
||||
|
||||
Framework-owned files ship publicly. They **must not** contain:
|
||||
|
||||
- Operator or personal identity (names, handles, pronouns, accessibility notes).
|
||||
- Private `$HOME` paths, private hostnames, or domains.
|
||||
- Secrets, tokens, or credentials (use `~/.config/mosaic/credentials.json`; the
|
||||
hook URL soft-degrades via `${OPENBRAIN_URL}`).
|
||||
|
||||
This is enforced by `tools/quality/scripts/verify-sanitized.sh`, wired **blocking**
|
||||
in CI (`.woodpecker/ci.yml`). It runs two rule classes: structural (private-`$HOME`
|
||||
defaults, dead paths, unrendered tokens) and a labeled current-contaminant denylist.
|
||||
Run it locally before pushing:
|
||||
|
||||
```bash
|
||||
bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||
```
|
||||
|
||||
Operator-specific behavior belongs in **your** `SOUL.md`/`USER.md`/`*.local.md`,
|
||||
never in the shared core. (The "framework-PR firewall" in `CONSTITUTION.md` §4
|
||||
states this as law for agents opening framework PRs.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Dedup rule — one source, everyone references it
|
||||
|
||||
Hard gates live in **`CONSTITUTION.md` (L0) only**. `AGENTS.md`, `STANDARDS.md`,
|
||||
and every `runtime/<h>/RUNTIME.md` **reference** the law — they never restate it.
|
||||
Restating a gate is a defect: it creates two sources that drift. If you find a
|
||||
gate duplicated outside L0, delete the copy and point to L0.
|
||||
|
||||
`AGENTS.md` is a thin dispatcher (load order + guide router + the tier-aware
|
||||
self-load). Keep it that way; new procedure goes in `guides/*` (on-demand), not
|
||||
in the resident core.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resident line-count ceiling — **blocking**
|
||||
|
||||
The framework-owned files injected by value (`CONSTITUTION.md`, `AGENTS.md`, each
|
||||
`runtime/<h>/RUNTIME.md`) are budgeted by **line count** — never by word count
|
||||
(a word cap forces paraphrasing the law, the exact drift vector we removed).
|
||||
|
||||
```bash
|
||||
bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
```
|
||||
|
||||
Wired blocking in CI. Gate **wording** stays intact; if a file legitimately needs
|
||||
more lines, raise its ceiling in the script deliberately (in the same PR, with
|
||||
rationale). The per-harness _total_ resident prompt (which also sums the user's
|
||||
`SOUL.md`/`USER.md`) is a `mosaic doctor` runtime advisory — CI cannot see user
|
||||
files, so it is out of CI scope by design (DESIGN §7).
|
||||
|
||||
---
|
||||
|
||||
## 5. Dual-installer parity rule
|
||||
|
||||
Two installers seed and migrate `~/.config/mosaic/`:
|
||||
|
||||
- **`framework/install.sh`** (bash) — the canonical installer.
|
||||
- **`packages/mosaic/src/config/file-adapter.ts`** (TS) — the wizard path.
|
||||
|
||||
**Any change to seed lists, overwrite/preserve semantics, or migration MUST land
|
||||
in BOTH**, validated by the **shared fixture suite**:
|
||||
|
||||
- `framework/tools/quality/scripts/test-install-migration.sh` (bash matrix)
|
||||
- `packages/mosaic/src/config/file-adapter.test.ts` (vitest)
|
||||
|
||||
Both assert the same behavior: framework-owned files overwrite (backup-once to
|
||||
`*.pre-constitution.bak`); user-seeded files seed-if-absent; `SOUL.md`/`USER.md`/
|
||||
`*.local.md`/`credentials` are preserved. A change in one installer without the
|
||||
other (and its fixtures) is incomplete.
|
||||
|
||||
---
|
||||
|
||||
## 6. Adding a harness adapter
|
||||
|
||||
A harness (runtime) is wired by:
|
||||
|
||||
1. `runtime/<h>/RUNTIME.md` — **mechanism only** (subagent syntax, hook/MCP wiring,
|
||||
injection method). No restated gates (see §3).
|
||||
2. Launcher emission in `src/commands/launch.ts` — how the composed contract reaches
|
||||
the harness (system-prompt append vs. instructions file). Add the harness to the
|
||||
`RuntimeName` union and the runtime-path map.
|
||||
3. `mosaic compose-contract <harness>` works automatically once the runtime path
|
||||
exists (it composes base + `*.local.md` overlays for that harness).
|
||||
|
||||
Then add a row to the compliance matrix (§8) and mark which gates are mechanical
|
||||
vs. resident-only for the new harness.
|
||||
|
||||
---
|
||||
|
||||
## 7. Re-contamination rule
|
||||
|
||||
A green sanitization gate is not permanent. Before every PR:
|
||||
|
||||
- Do not reintroduce operator identity, private paths, or secrets (§2).
|
||||
- Do not copy a gate out of L0 (§3).
|
||||
- Do not add an unrendered template token or a dead path to a shipped file.
|
||||
|
||||
If `verify-sanitized.sh` goes red, that diff **is** your worklist — fix it, don't
|
||||
suppress it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Harness × gate compliance matrix
|
||||
|
||||
How each gate is enforced per harness. **Mechanical** = a hook/CI check the agent
|
||||
cannot bypass. **Resident** = injected contract prose (strong, but not a hard stop).
|
||||
**CI** = repo-side, harness-independent.
|
||||
|
||||
| Gate / mechanism | Claude | Codex | OpenCode | Pi |
|
||||
| --------------------------------------------- | ----------- | ---------------- | ---------------- | ---------------- |
|
||||
| Contract injection (resident-by-value) | append SP | instructions | `AGENTS.md` | append SP |
|
||||
| Operator overlays (`*.local`, composed) | ✅ | ✅ | ✅ | ✅ |
|
||||
| Bare-launch self-load (Tier-3, read L0) | ✅ | ✅ | ✅ | ✅ |
|
||||
| Sanitization (no PII) — `verify-sanitized` | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||
| Resident budget ceiling | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||
| Migration parity (5-fixture, both installers) | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||
| `no-memory-write` (PreToolUse hook) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
||||
| QA / typecheck (PostToolUse hooks) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
||||
| Native heartbeat (fleet `ps` model/status) | sidecar | sidecar | sidecar | **native ✅** |
|
||||
|
||||
⚠️ **Hook-parity gap (tracked, v2):** the mechanical PreToolUse/PostToolUse hooks
|
||||
exist for Claude Code only. On Codex/OpenCode/Pi those gates are currently enforced
|
||||
by the resident contract + CI, not by a per-tool hook. Closing hook parity is a
|
||||
**v2** item, not part of this alpha.
|
||||
|
||||
---
|
||||
|
||||
## 9. Known limitations (accepted residual risks)
|
||||
|
||||
These are accepted with rationale (DESIGN §9); they are documented, not bugs:
|
||||
|
||||
- **Bare-launch overlays are base-only.** A harness started without `mosaic` never
|
||||
ran the composer, so `*.local.md` overlays are not applied. Mitigated by the
|
||||
unconditional Tier-3 self-load + the `mosaic doctor` nudge in `AGENTS.md`; not
|
||||
eliminated. Relaunch via `mosaic <harness>` to pick up overlays.
|
||||
- **Bare-launch drift is undetected by `mosaic doctor`** (the launcher never ran).
|
||||
- **Codex/OpenCode/Pi hook parity** is a tracked v2 gap (§8).
|
||||
- **Live-launch cross-harness verification** is v2; the alpha verifies the composer
|
||||
by unit test (per-tier anchor + Tier-3 byte-equality), not a live launch.
|
||||
|
||||
**Deferred to v2 (explicit):** `constitution/` deploy directory; capability JSON
|
||||
adapters; 3-way merge; `policy/*.md` composition; per-layer version stamps as a
|
||||
migration driver.
|
||||
|
||||
---
|
||||
|
||||
## 10. PR checklist
|
||||
|
||||
- [ ] No operator identity / private paths / secrets (`verify-sanitized.sh` green).
|
||||
- [ ] No gate restated outside `CONSTITUTION.md` (§3).
|
||||
- [ ] Resident budget green (`check-resident-budget.sh`).
|
||||
- [ ] Seed/migration changes landed in **both** installers + shared fixtures (§5).
|
||||
- [ ] New harness → compliance-matrix row updated (§8).
|
||||
- [ ] `prettier --check` + `pnpm lint` + `pnpm typecheck` + `pnpm test` green.
|
||||
@@ -9,7 +9,10 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
||||
1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime
|
||||
contract (injected by `mosaic` launch) — do not re-read those. **If you were launched bare**
|
||||
(a harness started without `mosaic`, so the law is NOT in your context), read
|
||||
`~/.config/mosaic/CONSTITUTION.md` now, before your first action.
|
||||
`~/.config/mosaic/CONSTITUTION.md` now, before your first action. A bare launch also gets
|
||||
**base contracts only** — operator overlays (`*.local.md`) are composed by the launcher, so if
|
||||
`SOUL.local.md`/`USER.local.md`/`STANDARDS.local.md` exist, relaunch via `mosaic <harness>` (or run
|
||||
`mosaic doctor`) to pick them up.
|
||||
2. Read `SOUL.md` (agent persona — small, once).
|
||||
3. Read project-local `AGENTS.md` / `CLAUDE.md` if present (these may only make behavior stricter).
|
||||
4. Read guides ONLY as triggered by the table below — pull role-relevant depth on demand, not up front.
|
||||
|
||||
@@ -15,6 +15,10 @@ agents:
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: enhancer
|
||||
runtime: claude
|
||||
class: enhancer
|
||||
persistent_persona: true
|
||||
- name: coder0
|
||||
runtime: pi
|
||||
class: implementer
|
||||
|
||||
@@ -15,6 +15,10 @@ agents:
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: enhancer
|
||||
runtime: claude
|
||||
class: enhancer
|
||||
persistent_persona: true
|
||||
- name: generalist
|
||||
runtime: pi
|
||||
class: worker
|
||||
|
||||
@@ -15,6 +15,10 @@ agents:
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: enhancer
|
||||
runtime: claude
|
||||
class: enhancer
|
||||
persistent_persona: true
|
||||
- name: coder0
|
||||
runtime: pi
|
||||
class: implementer
|
||||
|
||||
@@ -15,6 +15,10 @@ agents:
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: enhancer
|
||||
runtime: claude
|
||||
class: enhancer
|
||||
persistent_persona: true
|
||||
- name: researcher0
|
||||
runtime: pi
|
||||
class: researcher
|
||||
|
||||
41
packages/mosaic/framework/fleet/roles/enhancer.md
Normal file
41
packages/mosaic/framework/fleet/roles/enhancer.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Enhancer — fleet role definition
|
||||
|
||||
The **enhancer** is one half of the fleet's two-agent floor: every fleet runs, at
|
||||
minimum, an **orchestrator** and an **enhancer**. The orchestrator drives delivery;
|
||||
the enhancer makes the fleet _get better at delivering_ over time.
|
||||
|
||||
It is a **core, always-on** agent (`class: enhancer`, `persistent_persona: true`),
|
||||
not an ephemeral per-lane worker.
|
||||
|
||||
## Mandate
|
||||
|
||||
The enhancer runs the fleet's **continuous-improvement loop**:
|
||||
|
||||
1. **Monitor** fleet activity — agents, heartbeats, sessions, throughput, failures.
|
||||
2. **Analyze** for enhancements and optimizations — friction, gaps, recurring defects,
|
||||
missing or broken tools, skill/harness shortfalls.
|
||||
3. **Plan** a remediation: a concrete improvement with rationale and expected effect.
|
||||
4. **Upgrade fleet capability — with the orchestrator** — tool creation/repair, skills,
|
||||
harness improvements. The orchestrator owns fleet composition; the enhancer advises and
|
||||
implements improvements to the _means of production_, not the product.
|
||||
5. **File upstream bug reports** to Mosaic Stack for real defects, so they flow back to the
|
||||
framework for proper remediation rather than being patched over locally.
|
||||
6. **Recommend which agents are needed** — advise the orchestrator on roles to add/remove as
|
||||
the mission evolves.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- **Does NOT write product/source code.**
|
||||
- **Does NOT review code** (that is the code-review / security-review roles).
|
||||
- **Does NOT perform delivery tasks.**
|
||||
|
||||
Improvement and diagnosis only. When the enhancer finds work that requires coding or review,
|
||||
it files it (bug report / recommendation) and the orchestrator materializes the right worker.
|
||||
|
||||
## Why two, not one
|
||||
|
||||
The orchestrator alone optimizes for _this_ delivery; the enhancer optimizes for _every future_
|
||||
delivery — self-healing the fleet's tools, skills, and harnesses, and routing real defects
|
||||
upstream. Together they are the irreducible core; every other role is added on demand.
|
||||
|
||||
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).
|
||||
@@ -81,6 +81,18 @@
|
||||
"class": {
|
||||
"type": "string"
|
||||
},
|
||||
"host": {
|
||||
"description": "Host the agent runs on (hostname or IP). Absent = the fleet host. Used by onboarding-injection to render cross-host comms addresses. Manual cross-host listing is a pre-federation stopgap; federation (W1) auto-discovers later.",
|
||||
"type": "string"
|
||||
},
|
||||
"ssh": {
|
||||
"description": "SSH target (user@host) for a cross-host peer, so onboarding renders the `agent-send.sh -H <user@host>` form. Optional; only needed for agents on a different host than the fleet.",
|
||||
"type": "string"
|
||||
},
|
||||
"socket": {
|
||||
"description": "tmux socket the agent's session runs on. Onboarding renders `-L <socket>` when set; absent = the default socket (no `-L`). Must match the LIVE socket, not blindly inherit the roster's tmux.socket_name.",
|
||||
"type": "string"
|
||||
},
|
||||
"working_directory": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -113,6 +125,35 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"connector": {
|
||||
"description": "Orchestrator chat connector (F4). Optional — absent means tmux (back-compat). Secrets (access/bot tokens) come from the environment, never this file.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"enum": ["tmux", "discord", "matrix"]
|
||||
},
|
||||
"matrix": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["homeserver_url", "user_id", "room_id"],
|
||||
"properties": {
|
||||
"homeserver_url": { "type": "string" },
|
||||
"user_id": { "type": "string" },
|
||||
"room_id": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"discord": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["channel_id"],
|
||||
"properties": {
|
||||
"channel_id": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
93
packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
Executable file
93
packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
Executable file
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-resident-budget.sh — resident line-count ceiling (R9 / DESIGN §7).
|
||||
#
|
||||
# Budgets the *container* (line count) of the framework-owned files that are
|
||||
# injected into every agent's context by value — the Constitution (L0), the
|
||||
# AGENTS dispatcher, and each runtime RUNTIME.md slice. Gate *wording* is never
|
||||
# capped (a word cap forces paraphrasing law — the exact drift vector P3 killed);
|
||||
# only the file's line count is bounded, so prose creep is caught in review.
|
||||
#
|
||||
# This is the CI-enforceable half of the budget. The per-harness *total* resident
|
||||
# prompt (which also includes user-generated SOUL.md/USER.md and the per-tier
|
||||
# slice) is summed by `mosaic doctor` as a runtime advisory — CI cannot see user
|
||||
# files, so it is deliberately out of scope here (DESIGN §7).
|
||||
#
|
||||
# Usage: check-resident-budget.sh [--self-test]
|
||||
# Exit: 0 = all within budget · 1 = a file exceeds its ceiling · 2 = self-test failed
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
|
||||
# Per-file ceilings (lines). Headroom above current counts; tighten as files settle.
|
||||
# Format: "<relative-path>:<max-lines>"
|
||||
CEILINGS=(
|
||||
"defaults/CONSTITUTION.md:120"
|
||||
"defaults/AGENTS.md:120"
|
||||
"runtime/claude/RUNTIME.md:90"
|
||||
"runtime/codex/RUNTIME.md:90"
|
||||
"runtime/opencode/RUNTIME.md:90"
|
||||
"runtime/pi/RUNTIME.md:90"
|
||||
)
|
||||
|
||||
# check_file <abs-path> <max> → echoes "<n>"; returns 0 if n<=max, 1 otherwise.
|
||||
check_file() {
|
||||
local path="$1" max="$2" n
|
||||
n=$(wc -l <"$path" 2>/dev/null || echo 0)
|
||||
n=$((n + 0))
|
||||
echo "$n"
|
||||
[ "$n" -le "$max" ]
|
||||
}
|
||||
|
||||
run_budget() {
|
||||
local fail=0 rel max abs n
|
||||
printf '%-32s %8s %8s %s\n' "FILE" "LINES" "CEILING" "STATUS"
|
||||
for entry in "${CEILINGS[@]}"; do
|
||||
rel="${entry%%:*}"
|
||||
max="${entry##*:}"
|
||||
abs="$FW/$rel"
|
||||
if [ ! -f "$abs" ]; then
|
||||
printf '%-32s %8s %8s %s\n' "$rel" "-" "$max" "MISSING"
|
||||
fail=1
|
||||
continue
|
||||
fi
|
||||
n=$(check_file "$abs" "$max")
|
||||
if [ "$n" -le "$max" ]; then
|
||||
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "ok"
|
||||
else
|
||||
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "OVER BUDGET"
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
return "$fail"
|
||||
}
|
||||
|
||||
self_test() {
|
||||
local tmp rc
|
||||
tmp=$(mktemp)
|
||||
# 3 lines, ceiling 5 → within budget (rc 0)
|
||||
printf 'a\nb\nc\n' >"$tmp"
|
||||
check_file "$tmp" 5 >/dev/null
|
||||
rc=$?
|
||||
if [ "$rc" -ne 0 ]; then echo "self-test FAIL: under-budget file flagged"; rm -f "$tmp"; return 2; fi
|
||||
# 6 lines, ceiling 5 → over budget (rc 1)
|
||||
printf 'a\nb\nc\nd\ne\nf\n' >"$tmp"
|
||||
check_file "$tmp" 5 >/dev/null
|
||||
rc=$?
|
||||
if [ "$rc" -ne 1 ]; then echo "self-test FAIL: over-budget file not flagged"; rm -f "$tmp"; return 2; fi
|
||||
rm -f "$tmp"
|
||||
echo "self-test OK"
|
||||
return 0
|
||||
}
|
||||
|
||||
if [ "${1:-}" = "--self-test" ]; then
|
||||
self_test
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if run_budget; then
|
||||
echo "Resident budget: all framework-owned resident files within ceiling."
|
||||
exit 0
|
||||
else
|
||||
echo "Resident budget EXCEEDED — trim prose or raise the ceiling deliberately (see DESIGN §7)." >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.37",
|
||||
"version": "0.0.40",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
"directory": "packages/mosaic"
|
||||
},
|
||||
"description": "Mosaic agent framework — installation wizard and meta package",
|
||||
"description": "Mosaic agent framework \u2014 installation wizard and meta package",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
checkForAllUpdates,
|
||||
formatAllPackagesTable,
|
||||
getInstallAllCommand,
|
||||
runFrameworkReseed,
|
||||
readRosterAgentNames,
|
||||
buildRelaunchCommands,
|
||||
FRAMEWORK_RESEED_PACKAGE,
|
||||
} from './runtime/update-checker.js';
|
||||
import { runWizard } from './wizard.js';
|
||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||
@@ -404,7 +408,12 @@ program
|
||||
.command('update')
|
||||
.description('Check for and install Mosaic CLI updates')
|
||||
.option('--check', 'Check only, do not install')
|
||||
.action(async (opts: { check?: boolean }) => {
|
||||
.option(
|
||||
'--no-reseed',
|
||||
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||||
)
|
||||
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||||
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
||||
// checkForAllUpdates imported statically above
|
||||
const { execSync } = await import('node:child_process');
|
||||
|
||||
@@ -442,6 +451,51 @@ program
|
||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// F3-m3 / R13: the CLI is updated, but the framework files in
|
||||
// ~/.config/mosaic/ are still the previous version. Re-seed them from the
|
||||
// freshly-installed package so shipped launcher/runtime changes ACTIVATE.
|
||||
// Only when the framework-bearing package itself updated.
|
||||
const mosaicUpdated = outdated.some(
|
||||
(r: { package: string }) => r.package === FRAMEWORK_RESEED_PACKAGE,
|
||||
);
|
||||
if (mosaicUpdated && opts.reseed !== false) {
|
||||
console.log(
|
||||
'\nRe-seeding framework files into ~/.config/mosaic (data-safe; keeps your edits)…',
|
||||
);
|
||||
const reseed = runFrameworkReseed();
|
||||
if (reseed.ok) {
|
||||
console.log('✔ Framework re-seeded.');
|
||||
const agents = readRosterAgentNames();
|
||||
if (agents.length > 0) {
|
||||
if (opts.relaunch) {
|
||||
console.log(
|
||||
`\nRelaunching ${agents.length} fleet agent(s) to pick up the new runtime…`,
|
||||
);
|
||||
for (const restart of buildRelaunchCommands(agents)) {
|
||||
try {
|
||||
execSync(restart.join(' '), { stdio: 'inherit', timeout: 30_000 });
|
||||
} catch {
|
||||
console.error(` ⚠ failed to restart agent — run: ${restart.join(' ')}`);
|
||||
}
|
||||
}
|
||||
console.log('✔ Agents relaunched.');
|
||||
} else {
|
||||
console.log(
|
||||
`\nℹ ${agents.length} fleet agent(s) are still running the previous runtime. ` +
|
||||
'Restart them to activate the update:\n mosaic update --relaunch ' +
|
||||
'(or: mosaic fleet restart <agent>)',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error(
|
||||
`\n⚠ Framework re-seed skipped: ${reseed.reason ?? 'unknown'}.\n` +
|
||||
' Activate manually: bash "$(npm root -g)/@mosaicstack/mosaic/framework/install.sh" ' +
|
||||
'(MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep)',
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||
|
||||
167
packages/mosaic/src/commands/compose-contract.spec.ts
Normal file
167
packages/mosaic/src/commands/compose-contract.spec.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { composeContract } from './launch.js';
|
||||
|
||||
/**
|
||||
* Composer unit test (R7/R8/R9): asserts the launcher-composed runtime contract
|
||||
*
|
||||
* - includes the per-tier anchors (CONSTITUTION / AGENTS / USER / runtime),
|
||||
* - keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3
|
||||
* byte-equality — the bare-launch fallback read must match what is injected),
|
||||
* - merges `*.local.md` operator overlays as deltas-by-value, and omits them
|
||||
* entirely when absent (base-only),
|
||||
* - selects the correct per-harness RUNTIME.md.
|
||||
*
|
||||
* `composeContract` takes `mosaicHome` as a param, so each test runs against an
|
||||
* isolated fixture home. We also chdir to an empty temp cwd so the cwd-relative
|
||||
* mission/PRD blocks contribute nothing (deterministic output).
|
||||
*/
|
||||
|
||||
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
||||
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
||||
const USER = '# operator\n\nName: Test Operator\n';
|
||||
const TOOLS = '# tools index\n';
|
||||
|
||||
function makeHome(): { home: string; root: string } {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
||||
const home = join(root, 'mosaic-home');
|
||||
for (const h of ['claude', 'codex', 'opencode', 'pi']) {
|
||||
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
||||
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
||||
}
|
||||
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
||||
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
||||
writeFileSync(join(home, 'USER.md'), USER);
|
||||
writeFileSync(join(home, 'TOOLS.md'), TOOLS);
|
||||
return { home, root };
|
||||
}
|
||||
|
||||
describe('composeContract — overlay composer', () => {
|
||||
let fixture: ReturnType<typeof makeHome>;
|
||||
let prevCwd: string;
|
||||
let cwdDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = makeHome();
|
||||
prevCwd = process.cwd();
|
||||
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
||||
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(prevCwd);
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
rmSync(cwdDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('injects the fleet comms cheat-sheet for a spawned fleet agent (situational)', () => {
|
||||
// A spawned agent has MOSAIC_AGENT_NAME set + is a member of the roster.
|
||||
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(fixture.home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: 10.1.10.37',
|
||||
' ssh: jwoltje@10.1.10.37',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('# Fleet Comms');
|
||||
expect(out).toMatch(/`\[[^\]]+:enhancer\]`/); // own [host:session] identity (host machine-dependent)
|
||||
// local peer → no -H; cross-host peer → -H ssh
|
||||
expect(out).toContain('-s orchestrator -m "…"');
|
||||
expect(out).toContain('-H jwoltje@10.1.10.37 -s coder0-0 -m "…"');
|
||||
expect(out).not.toContain('-H jwoltje@10.1.10.37 -s orchestrator'); // local stays local
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env['MOSAIC_AGENT_NAME'];
|
||||
else process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT inject fleet comms when MOSAIC_AGENT_NAME is unset (non-fleet launch)', () => {
|
||||
const prev = process.env['MOSAIC_AGENT_NAME'];
|
||||
try {
|
||||
delete process.env['MOSAIC_AGENT_NAME'];
|
||||
expect(composeContract('claude', fixture.home)).not.toContain('# Fleet Comms');
|
||||
} finally {
|
||||
if (prev !== undefined) process.env['MOSAIC_AGENT_NAME'] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||
expect(out).toContain('Mosaic Agent Dispatcher'); // AGENTS
|
||||
expect(out).toContain('# User Profile'); // USER header
|
||||
expect(out).toContain('Name: Test Operator'); // USER body
|
||||
expect(out).toContain('# Runtime-Specific Contract');
|
||||
expect(out).toContain('# claude runtime contract');
|
||||
});
|
||||
|
||||
it('keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3)', () => {
|
||||
const out = composeContract('pi', fixture.home);
|
||||
const onDisk = readFileSync(join(fixture.home, 'CONSTITUTION.md'), 'utf-8');
|
||||
// The injected L0 must be a byte-equal substring of the composed blob, so a
|
||||
// bare-launch fallback read of CONSTITUTION.md matches what was injected.
|
||||
expect(out.includes(onDisk)).toBe(true);
|
||||
});
|
||||
|
||||
it('is base-only when no *.local overlays exist', () => {
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).not.toContain('# Operator Overlays');
|
||||
expect(out).not.toContain('Operator Overlay (USER.local.md)');
|
||||
expect(out).not.toContain('Persona Overlay');
|
||||
expect(out).not.toContain('Standards Overlay');
|
||||
});
|
||||
|
||||
it('merges USER.local.md directly under the operator profile', () => {
|
||||
writeFileSync(join(fixture.home, 'USER.local.md'), 'Prefer terse status updates.\n');
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('## Operator Overlay (USER.local.md)');
|
||||
expect(out).toContain('Prefer terse status updates.');
|
||||
// Overlay appears AFTER its base profile.
|
||||
expect(out.indexOf('# User Profile')).toBeLessThan(
|
||||
out.indexOf('## Operator Overlay (USER.local.md)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('merges SOUL.local.md + STANDARDS.local.md as deltas in the Operator Overlays block', () => {
|
||||
writeFileSync(join(fixture.home, 'SOUL.local.md'), 'Tone: dry and direct.\n');
|
||||
writeFileSync(join(fixture.home, 'STANDARDS.local.md'), 'Require 90% coverage on auth code.\n');
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).toContain('# Operator Overlays');
|
||||
expect(out).toContain('## Persona Overlay (SOUL.local.md)');
|
||||
expect(out).toContain('Tone: dry and direct.');
|
||||
expect(out).toContain('## Standards Overlay (STANDARDS.local.md)');
|
||||
expect(out).toContain('Require 90% coverage on auth code.');
|
||||
});
|
||||
|
||||
it('ignores whitespace-only *.local overlays (no empty overlay section)', () => {
|
||||
writeFileSync(join(fixture.home, 'SOUL.local.md'), ' \n\n');
|
||||
const out = composeContract('claude', fixture.home);
|
||||
expect(out).not.toContain('# Operator Overlays');
|
||||
});
|
||||
|
||||
it('selects a different RUNTIME.md per harness', () => {
|
||||
expect(composeContract('codex', fixture.home)).toContain('# codex runtime contract');
|
||||
expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract');
|
||||
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
||||
});
|
||||
});
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
buildEnableLingerCommand,
|
||||
buildFleetServiceCommand,
|
||||
buildSystemdEnableCommand,
|
||||
buildSystemdDisableCommand,
|
||||
buildSystemdShowCommand,
|
||||
buildTmuxListPanesCommand,
|
||||
buildTmuxListSessionsCommand,
|
||||
classifySendResult,
|
||||
countOrchestrators,
|
||||
countEnhancers,
|
||||
detectDrift,
|
||||
enableFleetUnits,
|
||||
FLEET_PROFILES,
|
||||
@@ -983,6 +985,129 @@ describe('fleet ps — drift detection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
||||
async function rosterHome(agents: string): Promise<string> {
|
||||
const home = await tempDir();
|
||||
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
|
||||
return home;
|
||||
}
|
||||
|
||||
it('buildSystemdDisableCommand returns the systemctl --user disable array', () => {
|
||||
expect(buildSystemdDisableCommand('mosaic-agent@coder0.service')).toEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'disable',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
});
|
||||
|
||||
it('fleet remove DISABLES the unit so a removed agent cannot resurrect on boot', async () => {
|
||||
const home = await rosterHome(
|
||||
[
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
' class: orchestrator',
|
||||
' - name: coder0',
|
||||
' runtime: codex',
|
||||
' class: worker',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
try {
|
||||
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
||||
expect(calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'disable',
|
||||
'mosaic-agent@coder0.service',
|
||||
]);
|
||||
// stop must still happen too
|
||||
expect(calls).toContainEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fleet add ENABLES the new agent unit for boot-survival', async () => {
|
||||
const home = await rosterHome(
|
||||
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||
'\n',
|
||||
) + '\n',
|
||||
);
|
||||
const calls: string[][] = [];
|
||||
const runner: CommandRunner = async (command, args) => {
|
||||
calls.push([command, ...args]);
|
||||
return { stdout: '', stderr: '', exitCode: 0 };
|
||||
};
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'add',
|
||||
'coder1',
|
||||
'--runtime',
|
||||
'codex',
|
||||
'--class',
|
||||
'worker',
|
||||
'--no-start',
|
||||
]);
|
||||
expect(calls).toContainEqual([
|
||||
'systemctl',
|
||||
'--user',
|
||||
'enable',
|
||||
'mosaic-agent@coder1.service',
|
||||
]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('fleet init --write enforces the two-agent floor (1 orchestrator + >=1 enhancer)', async () => {
|
||||
// The general profile must yield exactly one orchestrator AND at least one
|
||||
// enhancer; the guarantee is enforced (not just warned). Happy path writes cleanly.
|
||||
const home = await tempDir();
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerFleetCommand(program, {
|
||||
runner: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||
mosaicHome: home,
|
||||
});
|
||||
try {
|
||||
await program.parseAsync([
|
||||
'node',
|
||||
'mosaic',
|
||||
'fleet',
|
||||
'init',
|
||||
'--profile',
|
||||
'general',
|
||||
'--write',
|
||||
]);
|
||||
const written = await readFile(join(home, 'fleet', 'roster.yaml'), 'utf8');
|
||||
const orchestrators = (written.match(/class:\s*orchestrator/g) ?? []).length;
|
||||
const enhancers = (written.match(/class:\s*enhancer/g) ?? []).length;
|
||||
expect(orchestrators).toBe(1);
|
||||
expect(enhancers).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('fleet install — auto-enable units for boot-survival', () => {
|
||||
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
|
||||
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
|
||||
@@ -2188,47 +2313,63 @@ describe('fleet preset rosters', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('general preset: orchestrator + one generalist worker', async () => {
|
||||
it('general preset: orchestrator + enhancer + one generalist worker', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
|
||||
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer', 'generalist']);
|
||||
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
||||
expect(roster.agents.find((a) => a.name === 'enhancer')?.className).toBe('enhancer');
|
||||
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
||||
});
|
||||
|
||||
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
|
||||
it('coding preset: orchestrator + enhancer + coder0 + coder1 + reviewer', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||
'orchestrator',
|
||||
'enhancer',
|
||||
'coder0',
|
||||
'coder1',
|
||||
'reviewer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
|
||||
it('research preset: orchestrator + enhancer + researcher0 + researcher1 + analyst', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||
'orchestrator',
|
||||
'enhancer',
|
||||
'researcher0',
|
||||
'researcher1',
|
||||
'analyst',
|
||||
]);
|
||||
});
|
||||
|
||||
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
|
||||
it('hybrid preset: orchestrator + enhancer + coder0 + researcher0 + reviewer', async () => {
|
||||
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||
'orchestrator',
|
||||
'enhancer',
|
||||
'coder0',
|
||||
'researcher0',
|
||||
'reviewer',
|
||||
]);
|
||||
});
|
||||
|
||||
it('every non-minimal preset carries an enhancer (two-agent floor)', async () => {
|
||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||
expect(countOrchestrators(roster)).toBe(1);
|
||||
expect(countEnhancers(roster)).toBeGreaterThanOrEqual(1);
|
||||
expect(roster.agents.find((a) => a.className === 'enhancer')?.runtime).toBe('claude');
|
||||
}
|
||||
});
|
||||
|
||||
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
|
||||
// Core agents (orchestrator + enhancer) run claude; only ephemeral workers are pi.
|
||||
const workers = roster.agents.filter(
|
||||
(a) => a.className !== 'orchestrator' && a.className !== 'enhancer',
|
||||
);
|
||||
for (const worker of workers) {
|
||||
expect(worker.runtime).toBe('pi');
|
||||
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
||||
@@ -2370,6 +2511,43 @@ describe('fleet add/remove — pure helpers', () => {
|
||||
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
|
||||
});
|
||||
|
||||
it('countEnhancers counts enhancer-class agents (two-agent floor)', () => {
|
||||
const roster: FleetRoster = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||
{ name: 'coder0', runtime: 'codex', className: 'worker' },
|
||||
],
|
||||
};
|
||||
expect(countEnhancers(roster)).toBe(1);
|
||||
expect(countEnhancers(baseRoster)).toBe(0);
|
||||
});
|
||||
|
||||
it('removeAgentFromRoster throws when removing the sole enhancer (two-agent floor)', () => {
|
||||
const roster: FleetRoster = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||
],
|
||||
};
|
||||
expect(() => removeAgentFromRoster(roster, 'enhancer')).toThrow('sole enhancer');
|
||||
});
|
||||
|
||||
it('removeAgentFromRoster allows removing an enhancer when another remains', () => {
|
||||
const roster: FleetRoster = {
|
||||
...baseRoster,
|
||||
agents: [
|
||||
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||
{ name: 'enhancer2', runtime: 'claude', className: 'enhancer' },
|
||||
],
|
||||
};
|
||||
const updated = removeAgentFromRoster(roster, 'enhancer');
|
||||
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer2']);
|
||||
});
|
||||
|
||||
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
|
||||
const yaml = serializeRosterToYaml(baseRoster);
|
||||
expect(typeof yaml).toBe('string');
|
||||
|
||||
@@ -227,6 +227,15 @@ export function buildSystemdEnableCommand(unit: string): string[] {
|
||||
return ['systemctl', '--user', 'enable', unit];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the systemctl --user disable command for a given unit.
|
||||
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
|
||||
* boot pointing at deleted config (boot-survival symmetry with enable-on-add).
|
||||
*/
|
||||
export function buildSystemdDisableCommand(unit: string): string[] {
|
||||
return ['systemctl', '--user', 'disable', unit];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the loginctl enable-linger command for a given user.
|
||||
* Linger allows user systemd services to survive logout.
|
||||
@@ -872,20 +881,33 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
await mkdir(dirname(destination), { recursive: true });
|
||||
await writeFile(destination, content);
|
||||
|
||||
// Validate: exactly one orchestrator required (R5) — friendly summary on success.
|
||||
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
||||
// one enhancer for every profile except the sanctioned no-orchestrator
|
||||
// `minimal` preset. A mismatch means a corrupted/edited preset — fail hard
|
||||
// rather than write a malformed fleet.
|
||||
const written = await loadFleetRoster(destination);
|
||||
const orchCount = countOrchestrators(written);
|
||||
if (orchCount !== 1) {
|
||||
process.stderr.write(
|
||||
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
|
||||
);
|
||||
const enhancerCount = countEnhancers(written);
|
||||
if (profile === 'minimal') {
|
||||
console.log(
|
||||
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
|
||||
`Initialized ${profile} fleet: ${written.agents.length} agent(s) (no orchestrator). Next: mosaic fleet install`,
|
||||
);
|
||||
} else if (orchCount !== 1) {
|
||||
throw new Error(
|
||||
`Fleet init failed: the "${profile}" roster has ${orchCount} orchestrator agent(s), ` +
|
||||
`expected exactly 1 (R5). The preset may be corrupted — re-install the framework.`,
|
||||
);
|
||||
} else if (enhancerCount < 1) {
|
||||
throw new Error(
|
||||
`Fleet init failed: the "${profile}" roster has no enhancer agent. Every fleet keeps an ` +
|
||||
`orchestrator + enhancer minimum (two-agent floor). The preset may be corrupted — ` +
|
||||
`re-install the framework.`,
|
||||
);
|
||||
} else {
|
||||
const workerCount = written.agents.length - 1;
|
||||
const workerCount = written.agents.length - 1 - enhancerCount;
|
||||
console.log(
|
||||
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`,
|
||||
`Initialized ${profile} fleet: 1 orchestrator + ${enhancerCount} enhancer(s) + ` +
|
||||
`${workerCount} worker(s). Next: mosaic fleet install`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1218,6 +1240,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
|
||||
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
||||
|
||||
// Enable the unit for boot-survival (non-fatal) — symmetry with
|
||||
// disable-on-remove. Independent of --start so a queued agent still
|
||||
// survives a reboot once its unit exists.
|
||||
try {
|
||||
const enableResult = await runner(
|
||||
...splitCommand(buildSystemdEnableCommand(`mosaic-agent@${name}.service`)),
|
||||
);
|
||||
if (enableResult.exitCode !== 0) {
|
||||
process.stderr.write(
|
||||
`Warning: could not enable mosaic-agent@${name}.service: ${enableResult.stderr || enableResult.stdout || 'non-zero exit'}\n`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`Warning: enable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (opts.start !== false) {
|
||||
await runChecked(runner, buildFleetServiceCommand('start', name));
|
||||
console.log(`Started mosaic-agent@${name}.service.`);
|
||||
@@ -1254,6 +1294,26 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
||||
);
|
||||
}
|
||||
|
||||
// Disable the unit (non-fatal) so an enabled instance cannot resurrect on
|
||||
// boot pointing at the now-deleted config — boot-survival symmetry with
|
||||
// enable-on-add. Skipped only when --keep-files keeps the config in place.
|
||||
if (!opts.keepFiles) {
|
||||
try {
|
||||
const disableResult = await runner(
|
||||
...splitCommand(buildSystemdDisableCommand(`mosaic-agent@${name}.service`)),
|
||||
);
|
||||
if (disableResult.exitCode !== 0) {
|
||||
process.stderr.write(
|
||||
`Warning: could not disable mosaic-agent@${name}.service: ${disableResult.stderr || disableResult.stdout || 'non-zero exit'}\n`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`Warning: disable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Write updated roster
|
||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||
|
||||
@@ -1894,6 +1954,15 @@ export function countOrchestrators(roster: FleetRoster): number {
|
||||
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count enhancer agents in a parsed roster. The two-agent floor (north-star)
|
||||
* requires every non-minimal fleet to carry at least one enhancer alongside the
|
||||
* sole orchestrator.
|
||||
*/
|
||||
export function countEnhancers(roster: FleetRoster): number {
|
||||
return roster.agents.filter((a) => a.className === 'enhancer').length;
|
||||
}
|
||||
|
||||
/** Valid runtime identifiers for fleet agents. */
|
||||
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
||||
'pi',
|
||||
@@ -1936,6 +2005,15 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
||||
`Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`,
|
||||
);
|
||||
}
|
||||
// Two-agent floor: never drop the last enhancer (the continuous-improvement
|
||||
// loop). Symmetric with the sole-orchestrator guard.
|
||||
const remainingEnhancerCount = remaining.filter((a) => a.className === 'enhancer').length;
|
||||
if (remainingEnhancerCount === 0 && agent.className === 'enhancer') {
|
||||
throw new Error(
|
||||
`Cannot remove agent "${name}": it is the sole enhancer. Every fleet keeps at least one ` +
|
||||
`enhancer (two-agent floor). Add another enhancer first.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...roster,
|
||||
agents: remaining,
|
||||
|
||||
@@ -19,6 +19,7 @@ import { createRequire } from 'node:module';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, dirname } from 'node:path';
|
||||
import type { Command } from 'commander';
|
||||
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
|
||||
|
||||
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
|
||||
|
||||
@@ -291,12 +292,23 @@ function buildPrdBlock(): string {
|
||||
|
||||
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
||||
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
/**
|
||||
* Compose the full runtime contract for a harness: the resident-by-value core
|
||||
* (CONSTITUTION + AGENTS + USER + TOOLS + runtime) plus operator overlays
|
||||
* (`*.local.md` deltas), merged in precedence order so the model gets one
|
||||
* pre-merged blob (DESIGN §3.2 / R7). Overlays are injected as deltas by value;
|
||||
* base files keep their existing residency (USER injected; SOUL/STANDARDS are
|
||||
* load-on-demand, so only their small `.local` deltas are injected here).
|
||||
*
|
||||
* `mosaicHome` is parameterized for testability; production callers use the
|
||||
* module-level default.
|
||||
*/
|
||||
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
|
||||
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
||||
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
||||
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
||||
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
||||
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
||||
opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||
pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'),
|
||||
};
|
||||
|
||||
const runtimeFile = runtimeContractPaths[runtime];
|
||||
@@ -331,27 +343,61 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
`);
|
||||
|
||||
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
||||
// pre-constitution installs that have not been re-seeded yet.
|
||||
const constitution = readOptional(join(MOSAIC_HOME, 'CONSTITUTION.md'));
|
||||
// pre-constitution installs that have not been re-seeded yet. Injected by
|
||||
// value verbatim so the bare-launch fallback read is byte-equal (R8).
|
||||
const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md'));
|
||||
if (constitution) parts.push(constitution);
|
||||
|
||||
// AGENTS.md
|
||||
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
||||
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
|
||||
|
||||
// USER.md
|
||||
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
||||
// USER.md (+ USER.local.md operator overlay, appended directly under the
|
||||
// profile its base owns).
|
||||
const user = readOptional(join(mosaicHome, 'USER.md'));
|
||||
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
||||
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
|
||||
if (userLocal.trim()) {
|
||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||
}
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
|
||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
|
||||
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
||||
// inject only the small `.local` delta by value so the customization reaches
|
||||
// the model without re-injecting the full base prose (preserves the byte
|
||||
// budget). Absent `.local` files → base-only, automatically (R7 §3.2).
|
||||
const overlayBlocks: string[] = [];
|
||||
const soulLocal = readOptional(join(mosaicHome, 'SOUL.local.md'));
|
||||
if (soulLocal.trim()) {
|
||||
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
|
||||
}
|
||||
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
|
||||
if (standardsLocal.trim()) {
|
||||
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
||||
}
|
||||
if (overlayBlocks.length > 0) {
|
||||
parts.push('\n\n# Operator Overlays\n\n' + overlayBlocks.join('\n\n'));
|
||||
}
|
||||
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
|
||||
// and present in the roster), inject a comms cheat-sheet + peer roster so it
|
||||
// knows how to reach the orchestrator and its peers from its first turn.
|
||||
const fleetComms = readFleetCommsBlock(mosaicHome, process.env['MOSAIC_AGENT_NAME']);
|
||||
if (fleetComms) parts.push('\n\n' + fleetComms);
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
return composeContract(runtime);
|
||||
}
|
||||
|
||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||
|
||||
function writeSessionLock(runtime: string): void {
|
||||
@@ -976,6 +1022,22 @@ export function registerLaunchCommands(program: Command): void {
|
||||
launchRuntime(runtime, extraArgs, yolo);
|
||||
});
|
||||
|
||||
// compose-contract — emit the composed runtime contract (base + operator
|
||||
// overlays) for a harness to stdout, without launching. For inspection,
|
||||
// `mosaic doctor`, diffing, and the composer test (R7).
|
||||
program
|
||||
.command('compose-contract <harness>')
|
||||
.description('Print the composed runtime contract (base + *.local overlays) for a harness')
|
||||
.action((harness: string) => {
|
||||
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||
if (!valid.includes(harness as RuntimeName)) {
|
||||
console.error(`Unknown harness '${harness}'. Expected one of: ${valid.join(', ')}.`);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(composeContract(harness as RuntimeName));
|
||||
});
|
||||
|
||||
// Coord (mission orchestrator)
|
||||
program
|
||||
.command('coord')
|
||||
|
||||
187
packages/mosaic/src/fleet/comms-onboarding.spec.ts
Normal file
187
packages/mosaic/src/fleet/comms-onboarding.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
parseRosterAgents,
|
||||
buildFleetCommsBlock,
|
||||
renderPeerReach,
|
||||
readFleetCommsBlock,
|
||||
type CommsPeer,
|
||||
} from './comms-onboarding.js';
|
||||
|
||||
const ROSTER = [
|
||||
'version: 1',
|
||||
'transport: tmux',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: claude',
|
||||
' class: orchestrator',
|
||||
' - name: enhancer',
|
||||
' runtime: claude',
|
||||
' class: enhancer',
|
||||
' - name: coder0',
|
||||
' runtime: pi',
|
||||
' class: implementer',
|
||||
' # a manually-listed cross-host peer (pre-federation stopgap)',
|
||||
' - name: coder0-0',
|
||||
' runtime: claude',
|
||||
' class: implementer',
|
||||
' host: 10.1.10.37',
|
||||
' ssh: jwoltje@10.1.10.37',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
describe('parseRosterAgents', () => {
|
||||
it('parses name + class + optional host/ssh', () => {
|
||||
const peers = parseRosterAgents(ROSTER);
|
||||
expect(peers.map((p) => p.name)).toEqual(['orchestrator', 'enhancer', 'coder0', 'coder0-0']);
|
||||
expect(peers.find((p) => p.name === 'coder0')).toMatchObject({ className: 'implementer' });
|
||||
expect(peers.find((p) => p.name === 'coder0-0')).toMatchObject({
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
});
|
||||
// local agents have no host/ssh
|
||||
expect(peers.find((p) => p.name === 'orchestrator')!.host).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses an optional per-agent socket', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', ' socket: mosaic-factory'].join('\n'),
|
||||
);
|
||||
expect(peers[0]).toMatchObject({ name: 'a', socket: 'mosaic-factory' });
|
||||
});
|
||||
|
||||
it('stops at the next top-level key', () => {
|
||||
const peers = parseRosterAgents(
|
||||
['agents:', ' - name: a', ' class: worker', 'defaults:', ' working_directory: ~'].join(
|
||||
'\n',
|
||||
),
|
||||
);
|
||||
expect(peers.map((p) => p.name)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderPeerReach — same-host vs cross-host', () => {
|
||||
const send = '/home/u/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
|
||||
it('renders the short form for a same-host peer', () => {
|
||||
const peer: CommsPeer = { name: 'enhancer', className: 'enhancer' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s enhancer -m "…"`);
|
||||
});
|
||||
|
||||
it('renders the -H form for a cross-host peer using ssh', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to host when a cross-host peer has no ssh', () => {
|
||||
const peer: CommsPeer = { name: 'x', className: 'worker', host: '10.0.0.9' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -H 10.0.0.9 -s x -m "…"`);
|
||||
});
|
||||
|
||||
it('treats a peer whose host equals the fleet host as same-host', () => {
|
||||
const peer: CommsPeer = { name: 'y', className: 'worker', host: 'w-jarvis' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s y -m "…"`);
|
||||
});
|
||||
|
||||
it('emits NO -L for an unset/default socket', () => {
|
||||
const peer: CommsPeer = { name: 'lead', className: 'orchestrator' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(`${send} -s lead -m "…"`);
|
||||
});
|
||||
|
||||
it('emits -L <socket> for a named socket', () => {
|
||||
const peer: CommsPeer = { name: 'coder0', className: 'implementer', socket: 'mosaic-factory' };
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-factory -s coder0 -m "…"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('combines -L (named socket) and -H (cross-host) in order', () => {
|
||||
const peer: CommsPeer = {
|
||||
name: 'coder0-0',
|
||||
className: 'implementer',
|
||||
host: '10.1.10.37',
|
||||
ssh: 'jwoltje@10.1.10.37',
|
||||
socket: 'mosaic-factory',
|
||||
};
|
||||
expect(renderPeerReach(peer, 'w-jarvis', send)).toBe(
|
||||
`${send} -L mosaic-factory -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildFleetCommsBlock', () => {
|
||||
const send = '/h/.config/mosaic/tools/tmux/agent-send.sh';
|
||||
const agents = parseRosterAgents(ROSTER);
|
||||
|
||||
it('excludes self, lists peers, flags the orchestrator, and emits both address forms', () => {
|
||||
const block = buildFleetCommsBlock({
|
||||
selfName: 'enhancer',
|
||||
agents,
|
||||
fleetHost: 'w-jarvis',
|
||||
agentSendPath: send,
|
||||
});
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('You are **enhancer**');
|
||||
// criterion 1: agent's own [host:session] identity
|
||||
expect(block).toContain('`[w-jarvis:enhancer]`');
|
||||
// self excluded
|
||||
expect(block).not.toMatch(/\|\s*enhancer\s*\|/);
|
||||
// peers present
|
||||
expect(block).toContain('| orchestrator |');
|
||||
expect(block).toContain('point of contact');
|
||||
// same-host peer short form
|
||||
expect(block).toContain(`${send} -s coder0 -m "…"`);
|
||||
// cross-host peer -H form + host annotation
|
||||
expect(block).toContain(`${send} -H jwoltje@10.1.10.37 -s coder0-0 -m "…"`);
|
||||
expect(block).toContain('host `10.1.10.37`');
|
||||
// conventions
|
||||
expect(block).toContain('FLIP the preamble');
|
||||
expect(block).toContain('ACCEPTED');
|
||||
});
|
||||
|
||||
it('returns empty when the agent has no peers', () => {
|
||||
expect(
|
||||
buildFleetCommsBlock({
|
||||
selfName: 'solo',
|
||||
agents: [{ name: 'solo', className: 'orchestrator' }],
|
||||
fleetHost: 'h',
|
||||
agentSendPath: send,
|
||||
}),
|
||||
).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readFleetCommsBlock — situational (the context a spawned agent gets)', () => {
|
||||
let home: string;
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-comms-'));
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(join(home, 'fleet', 'roster.yaml'), ROSTER);
|
||||
});
|
||||
afterEach(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
it('builds the cheat-sheet with correct peer addresses for a fleet member', () => {
|
||||
const block = readFleetCommsBlock(home, 'orchestrator', 'w-jarvis');
|
||||
expect(block).toContain('# Fleet Comms');
|
||||
expect(block).toContain('| enhancer |');
|
||||
expect(block).toContain(`${join(home, 'tools', 'tmux', 'agent-send.sh')} -s coder0 -m "…"`);
|
||||
expect(block).toContain('-H jwoltje@10.1.10.37 -s coder0-0');
|
||||
expect(block).not.toMatch(/\|\s*orchestrator\s*\|/); // self excluded
|
||||
});
|
||||
|
||||
it('returns empty when MOSAIC_AGENT_NAME is unset, no roster, or agent not a member', () => {
|
||||
expect(readFleetCommsBlock(home, undefined, 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(home, 'stranger', 'w-jarvis')).toBe('');
|
||||
expect(readFleetCommsBlock(mkdtempSync(join(tmpdir(), 'noroster-')), 'orchestrator')).toBe('');
|
||||
});
|
||||
});
|
||||
183
packages/mosaic/src/fleet/comms-onboarding.ts
Normal file
183
packages/mosaic/src/fleet/comms-onboarding.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Fleet onboarding-injection (#620).
|
||||
*
|
||||
* Fleet agents are born not knowing how to reach their peers — the root cause of
|
||||
* a spawned agent's failed first send. When an agent boots via `mosaic yolo
|
||||
* <runtime>` (→ composeContract → system prompt), we append a comms cheat-sheet
|
||||
* + peer roster so it can talk to the orchestrator and other agents immediately.
|
||||
*
|
||||
* Cross-host aware: a peer may carry `host`/`ssh` (a deliberate pre-federation
|
||||
* stopgap — manual cross-host listing; federation/W1 auto-discovers later), so a
|
||||
* w-jarvis agent is born knowing the exact `-H` command to reach a dragon-lin
|
||||
* peer. Same-host peers render the short form.
|
||||
*
|
||||
* Standalone (no fleet.ts import) to keep launch.ts's prompt path free of the
|
||||
* heavy fleet command module. The roster is parsed leniently — the cheat-sheet
|
||||
* is best-effort onboarding, never a hard dependency.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export interface CommsPeer {
|
||||
name: string;
|
||||
/** Roster `class` (orchestrator | enhancer | implementer | worker | …). */
|
||||
className: string;
|
||||
/** Host the peer runs on; absent ⇒ the fleet host (same host). */
|
||||
host?: string;
|
||||
/** SSH target (user@host) for a cross-host peer; renders the `-H` form. */
|
||||
ssh?: string;
|
||||
/** tmux socket the peer's session lives on; absent ⇒ default socket (no `-L`). */
|
||||
socket?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lenient parse of a fleet `roster.yaml` for agent name/class/host/ssh. Avoids a
|
||||
* dependency on the full fleet roster parser; the format is `- name:` list items
|
||||
* with `class:`/`host:`/`ssh:` siblings under `agents:`.
|
||||
*/
|
||||
export function parseRosterAgents(yamlText: string): CommsPeer[] {
|
||||
const peers: CommsPeer[] = [];
|
||||
let current: CommsPeer | null = null;
|
||||
let inAgents = false;
|
||||
const scalar = (line: string, key: string): string | null => {
|
||||
const m = line.match(new RegExp(`^\\s*${key}:\\s*["']?([^"'#]+?)["']?\\s*$`));
|
||||
return m ? (m[1] as string).trim() : null;
|
||||
};
|
||||
for (const rawLine of yamlText.split('\n')) {
|
||||
const line = rawLine.replace(/\s+$/, '');
|
||||
if (/^agents:\s*$/.test(line)) {
|
||||
inAgents = true;
|
||||
continue;
|
||||
}
|
||||
if (!inAgents) continue;
|
||||
// A new top-level key (no leading space) ends the agents block.
|
||||
if (/^\S/.test(line)) break;
|
||||
|
||||
const nameMatch = line.match(/^\s*-\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (nameMatch) {
|
||||
if (current) peers.push(current);
|
||||
current = { name: nameMatch[1] as string, className: 'worker' };
|
||||
continue;
|
||||
}
|
||||
if (!current) continue;
|
||||
const cls = scalar(line, 'class');
|
||||
if (cls) current.className = cls;
|
||||
const host = scalar(line, 'host');
|
||||
if (host) current.host = host;
|
||||
const ssh = scalar(line, 'ssh');
|
||||
if (ssh) current.ssh = ssh;
|
||||
const socket = scalar(line, 'socket');
|
||||
if (socket) current.socket = socket;
|
||||
}
|
||||
if (current) peers.push(current);
|
||||
return peers;
|
||||
}
|
||||
|
||||
export interface FleetCommsOptions {
|
||||
/** This agent's name (it is excluded from its own peer list). */
|
||||
selfName: string;
|
||||
/** All roster agents (including self; filtered out internally). */
|
||||
agents: CommsPeer[];
|
||||
/** Host the fleet runs on (short hostname) — the same-host baseline. */
|
||||
fleetHost: string;
|
||||
/** Absolute path to agent-send.sh in this install. */
|
||||
agentSendPath: string;
|
||||
}
|
||||
|
||||
/** Is this peer on a different host than the fleet baseline? */
|
||||
function isRemote(peer: CommsPeer, fleetHost: string): boolean {
|
||||
return peer.host !== undefined && peer.host !== fleetHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exact agent-send command to reach a peer (session = agent name).
|
||||
* Data-driven per peer: a named `socket` → `-L <socket>`; an unset socket → the
|
||||
* default tmux socket (no `-L`). A cross-host peer adds `-H <ssh|host>`.
|
||||
*/
|
||||
export function renderPeerReach(peer: CommsPeer, fleetHost: string, agentSendPath: string): string {
|
||||
const parts = [agentSendPath];
|
||||
if (peer.socket) parts.push('-L', peer.socket); // unset ⇒ default socket, no -L
|
||||
if (isRemote(peer, fleetHost)) parts.push('-H', peer.ssh ?? (peer.host as string));
|
||||
parts.push('-s', peer.name, '-m', '"…"');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `# Fleet Comms` onboarding block (pure markdown). Returns '' when
|
||||
* the agent has no peers (a single-agent roster has no one to talk to).
|
||||
*/
|
||||
export function buildFleetCommsBlock(opts: FleetCommsOptions): string {
|
||||
const peers = opts.agents.filter((a) => a.name !== opts.selfName);
|
||||
if (peers.length === 0) return '';
|
||||
|
||||
const orchestrator = peers.find((p) => p.className === 'orchestrator');
|
||||
const rows = peers
|
||||
.map((p) => {
|
||||
const where = isRemote(p, opts.fleetHost)
|
||||
? `${p.className} · host \`${p.host}\``
|
||||
: p.className;
|
||||
const role = p.className === 'orchestrator' ? `${where} ← point of contact` : where;
|
||||
return `| ${p.name} | ${role} | \`${renderPeerReach(p, opts.fleetHost, opts.agentSendPath)}\` |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const orchLine = orchestrator
|
||||
? `Your point of contact is **${orchestrator.name}** (the orchestrator) — route questions, ` +
|
||||
`status, and decisions there.`
|
||||
: `This fleet has no orchestrator in its roster; coordinate with your peers directly.`;
|
||||
|
||||
return `# Fleet Comms — reach your peers
|
||||
|
||||
You are **${opts.selfName}** in this fleet. Your comms identity is \`[${opts.fleetHost}:${opts.selfName}]\` —
|
||||
that is the \`<src>\` other agents see and reply to. Reach other agents (durable tmux sessions) with the
|
||||
Mosaic comms tool at \`${opts.agentSendPath}\`. The **Reach** column below is the exact command per peer:
|
||||
same-host peers use the short form (no \`-H\`); cross-host peers include \`-H <user@host>\`.
|
||||
|
||||
## Peers
|
||||
|
||||
| Agent | Role | Reach (session = agent name) |
|
||||
| ----- | ---- | ---------------------------- |
|
||||
${rows}
|
||||
|
||||
${orchLine}
|
||||
|
||||
## Conventions
|
||||
|
||||
- Every message carries a self-identifying preamble \`[<src_host>:<src_session> -> <dst_host>:<dst_session>]\` — \`agent-send.sh\` adds it automatically.
|
||||
- **To reply, FLIP the preamble:** address your reply to the sender's \`src\` (their host:session becomes your \`-s\`/\`-H\`).
|
||||
- \`agent-send.sh\` (a.k.a. \`agent send --verify\`) confirms the message was **ACCEPTED** at the destination prompt — not merely injected. Prefer it for anything that matters.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the fleet roster from `mosaicHome` and build the comms block for
|
||||
* `selfName`. Returns '' when there is no roster, the agent is not in it, or
|
||||
* there are no peers — onboarding is best-effort and never throws.
|
||||
*/
|
||||
export function readFleetCommsBlock(
|
||||
mosaicHome: string,
|
||||
selfName: string | undefined,
|
||||
fleetHost: string = hostname().split('.')[0] || 'localhost',
|
||||
): string {
|
||||
if (!selfName) return '';
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return '';
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
const agents = parseRosterAgents(text);
|
||||
if (!agents.some((a) => a.name === selfName)) return ''; // not a member of this fleet
|
||||
return buildFleetCommsBlock({
|
||||
selfName,
|
||||
agents,
|
||||
fleetHost,
|
||||
agentSendPath: join(mosaicHome, 'tools', 'tmux', 'agent-send.sh'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Default mosaic home (mirrors launch.ts), for callers that don't pass one. */
|
||||
export const DEFAULT_MOSAIC_HOME_FOR_COMMS = join(homedir(), '.config', 'mosaic');
|
||||
184
packages/mosaic/src/fleet/connectors/matrix.spec.ts
Normal file
184
packages/mosaic/src/fleet/connectors/matrix.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
MatrixConnector,
|
||||
buildMessageBody,
|
||||
parseSyncResponse,
|
||||
registerMatrixConnector,
|
||||
type FetchLike,
|
||||
} from './matrix.js';
|
||||
import { createConnector, _resetConnectorRegistry } from './registry.js';
|
||||
import type { MatrixConnectorConfig } from './types.js';
|
||||
|
||||
const CONFIG: MatrixConnectorConfig = {
|
||||
homeserverUrl: 'https://matrix.internal/',
|
||||
userId: '@mos:internal',
|
||||
roomId: '!room:internal',
|
||||
};
|
||||
|
||||
/** A fetch mock that returns queued responses and records calls. */
|
||||
function mockFetch(responses: Array<{ ok?: boolean; status?: number; body?: unknown }>): {
|
||||
fetchImpl: FetchLike;
|
||||
calls: Array<{ url: string; method?: string; body?: string }>;
|
||||
} {
|
||||
const calls: Array<{ url: string; method?: string; body?: string }> = [];
|
||||
let i = 0;
|
||||
const fetchImpl: FetchLike = async (url, init) => {
|
||||
calls.push({ url, method: init?.method, body: init?.body });
|
||||
const r = responses[Math.min(i, responses.length - 1)] ?? {};
|
||||
i += 1;
|
||||
return {
|
||||
ok: r.ok ?? true,
|
||||
status: r.status ?? 200,
|
||||
json: async () => r.body ?? {},
|
||||
text: async () => JSON.stringify(r.body ?? {}),
|
||||
};
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
}
|
||||
|
||||
describe('buildMessageBody', () => {
|
||||
it('builds an m.text event', () => {
|
||||
expect(buildMessageBody({ text: 'hi' })).toEqual({ msgtype: 'm.text', body: 'hi' });
|
||||
});
|
||||
it('adds an m.thread relation when threadId is set', () => {
|
||||
expect(buildMessageBody({ text: 'hi', threadId: '$evt' })).toEqual({
|
||||
msgtype: 'm.text',
|
||||
body: 'hi',
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$evt' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSyncResponse', () => {
|
||||
it('extracts operator messages and skips the orchestrator’s own echoes', () => {
|
||||
const data = {
|
||||
next_batch: 's2',
|
||||
rooms: {
|
||||
join: {
|
||||
'!room:internal': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
sender: '@jason:internal',
|
||||
origin_server_ts: 1_700_000_000_000,
|
||||
content: { body: 'status?' },
|
||||
},
|
||||
{
|
||||
type: 'm.room.message',
|
||||
sender: '@mos:internal', // self — skipped
|
||||
origin_server_ts: 1_700_000_001_000,
|
||||
content: { body: 'working on it' },
|
||||
},
|
||||
{ type: 'm.reaction', sender: '@jason:internal', content: {} }, // non-message
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const msgs = parseSyncResponse(data, '!room:internal', '@mos:internal');
|
||||
expect(msgs).toHaveLength(1);
|
||||
expect(msgs[0]).toMatchObject({ text: 'status?', sender: '@jason:internal' });
|
||||
expect(msgs[0]!.ts).toBe(new Date(1_700_000_000_000).toISOString());
|
||||
});
|
||||
|
||||
it('carries threadId through thread-relments', () => {
|
||||
const data = {
|
||||
rooms: {
|
||||
join: {
|
||||
'!room:internal': {
|
||||
timeline: {
|
||||
events: [
|
||||
{
|
||||
type: 'm.room.message',
|
||||
sender: '@jason:internal',
|
||||
origin_server_ts: 1,
|
||||
content: {
|
||||
body: 'in thread',
|
||||
'm.relates_to': { rel_type: 'm.thread', event_id: '$root' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(parseSyncResponse(data, '!room:internal', '@mos:internal')[0]!.threadId).toBe('$root');
|
||||
});
|
||||
|
||||
it('returns [] for an empty/foreign sync', () => {
|
||||
expect(parseSyncResponse({}, '!room:internal', '@mos:internal')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MatrixConnector', () => {
|
||||
it('throws without an access token', () => {
|
||||
expect(() => new MatrixConnector(CONFIG, { accessToken: '' })).toThrow(/access token/i);
|
||||
});
|
||||
|
||||
it('send PUTs an m.text event and returns the event id', async () => {
|
||||
const { fetchImpl, calls } = mockFetch([{ body: { event_id: '$abc' } }]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const res = await c.send({ text: 'pong' }, 1234);
|
||||
expect(res).toEqual({ delivered: true, messageId: '$abc' });
|
||||
expect(calls[0]!.method).toBe('PUT');
|
||||
expect(calls[0]!.url).toContain(
|
||||
'/_matrix/client/v3/rooms/!room%3Ainternal/send/m.room.message/mosaic-1234-1',
|
||||
);
|
||||
expect(JSON.parse(calls[0]!.body!)).toEqual({ msgtype: 'm.text', body: 'pong' });
|
||||
});
|
||||
|
||||
it('send reports not-delivered on a non-2xx', async () => {
|
||||
const { fetchImpl } = mockFetch([{ ok: false, status: 403 }]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const res = await c.send({ text: 'x' });
|
||||
expect(res.delivered).toBe(false);
|
||||
expect(res.error).toContain('403');
|
||||
});
|
||||
|
||||
it('health reports reachable + authenticated when whoami matches', async () => {
|
||||
const { fetchImpl } = mockFetch([
|
||||
{ body: { versions: ['v1.11'] } }, // /versions
|
||||
{ body: { user_id: '@mos:internal' } }, // /whoami
|
||||
]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const h = await c.health();
|
||||
expect(h.reachable).toBe(true);
|
||||
expect(h.authenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('health flags auth mismatch', async () => {
|
||||
const { fetchImpl } = mockFetch([
|
||||
{ body: {} },
|
||||
{ body: { user_id: '@someone-else:internal' } },
|
||||
]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const h = await c.health();
|
||||
expect(h.reachable).toBe(true);
|
||||
expect(h.authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('health reports unreachable when /versions fails', async () => {
|
||||
const { fetchImpl } = mockFetch([{ ok: false, status: 502 }]);
|
||||
const c = new MatrixConnector(CONFIG, { accessToken: 'tok', fetchImpl });
|
||||
const h = await c.health();
|
||||
expect(h.reachable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerMatrixConnector', () => {
|
||||
beforeEach(() => _resetConnectorRegistry());
|
||||
|
||||
it('registers a matrix factory createConnector can build', () => {
|
||||
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
|
||||
const c = createConnector({ kind: 'matrix', matrix: CONFIG });
|
||||
expect(c.kind).toBe('matrix');
|
||||
});
|
||||
|
||||
it('the factory rejects config missing the matrix block', () => {
|
||||
registerMatrixConnector({ MATRIX_ACCESS_TOKEN: 'tok' } as NodeJS.ProcessEnv);
|
||||
expect(() => createConnector({ kind: 'matrix' })).toThrow(/missing the .matrix. block/i);
|
||||
});
|
||||
});
|
||||
246
packages/mosaic/src/fleet/connectors/matrix.ts
Normal file
246
packages/mosaic/src/fleet/connectors/matrix.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* Matrix connector (F4 Phase 2) — speaks the Matrix client-server API directly
|
||||
* over HTTPS so it is homeserver-agnostic (Conduit default, Synapse alt). No
|
||||
* SDK: a small injectable fetch keeps it dependency-light and unit-testable.
|
||||
*
|
||||
* The access token is supplied by the caller (from the environment —
|
||||
* MATRIX_ACCESS_TOKEN — per the gateway secret pattern), never the roster.
|
||||
*/
|
||||
|
||||
import {
|
||||
type OrchestratorConnector,
|
||||
type OutboundMessage,
|
||||
type InboundMessage,
|
||||
type SendResult,
|
||||
type ConnectorHealth,
|
||||
type MatrixConnectorConfig,
|
||||
type Unsubscribe,
|
||||
} from './types.js';
|
||||
import { registerConnector } from './registry.js';
|
||||
|
||||
/** Minimal fetch surface — avoids a lib.dom dependency and is trivial to mock. */
|
||||
export interface FetchLike {
|
||||
(
|
||||
url: string,
|
||||
init?: { method?: string; headers?: Record<string, string>; body?: string },
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
status: number;
|
||||
json: () => Promise<unknown>;
|
||||
text: () => Promise<string>;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MatrixConnectorOptions {
|
||||
accessToken: string;
|
||||
/** Injectable fetch (defaults to global fetch). */
|
||||
fetchImpl?: FetchLike;
|
||||
/** Long-poll timeout for /sync, ms. */
|
||||
syncTimeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Build the `m.room.message` event content, threading when a threadId is set. */
|
||||
export function buildMessageBody(message: OutboundMessage): Record<string, unknown> {
|
||||
const content: Record<string, unknown> = {
|
||||
msgtype: 'm.text',
|
||||
body: message.text,
|
||||
};
|
||||
if (message.threadId) {
|
||||
content['m.relates_to'] = { rel_type: 'm.thread', event_id: message.threadId };
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
/** Shape of the bits of a /sync response we consume. */
|
||||
interface SyncResponse {
|
||||
next_batch?: string;
|
||||
rooms?: {
|
||||
join?: Record<
|
||||
string,
|
||||
{
|
||||
timeline?: {
|
||||
events?: Array<{
|
||||
type?: string;
|
||||
sender?: string;
|
||||
origin_server_ts?: number;
|
||||
content?: {
|
||||
body?: string;
|
||||
['m.relates_to']?: { rel_type?: string; event_id?: string };
|
||||
};
|
||||
}>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract inbound operator messages from a /sync response for one room,
|
||||
* skipping the orchestrator's own echoes. Pure — the testable core of receive.
|
||||
*/
|
||||
export function parseSyncResponse(
|
||||
data: unknown,
|
||||
roomId: string,
|
||||
selfUserId: string,
|
||||
): InboundMessage[] {
|
||||
const sync = data as SyncResponse;
|
||||
const events = sync.rooms?.join?.[roomId]?.timeline?.events ?? [];
|
||||
const out: InboundMessage[] = [];
|
||||
for (const ev of events) {
|
||||
if (ev.type !== 'm.room.message') continue;
|
||||
if (!ev.sender || ev.sender === selfUserId) continue; // skip our own messages
|
||||
const body = ev.content?.body;
|
||||
if (typeof body !== 'string') continue;
|
||||
const rel = ev.content?.['m.relates_to'];
|
||||
out.push({
|
||||
text: body,
|
||||
sender: ev.sender,
|
||||
ts: new Date(ev.origin_server_ts ?? 0).toISOString(),
|
||||
...(rel?.rel_type === 'm.thread' && rel.event_id ? { threadId: rel.event_id } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class MatrixConnector implements OrchestratorConnector {
|
||||
readonly kind = 'matrix' as const;
|
||||
private readonly fetchImpl: FetchLike;
|
||||
private readonly token: string;
|
||||
private readonly syncTimeoutMs: number;
|
||||
private txnCounter = 0;
|
||||
private stopped = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: MatrixConnectorConfig,
|
||||
opts: MatrixConnectorOptions,
|
||||
) {
|
||||
this.token = opts.accessToken;
|
||||
this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
||||
this.syncTimeoutMs = opts.syncTimeoutMs ?? 30_000;
|
||||
if (!this.token) {
|
||||
throw new Error('MatrixConnector requires an access token (set MATRIX_ACCESS_TOKEN).');
|
||||
}
|
||||
}
|
||||
|
||||
private url(path: string): string {
|
||||
return `${this.config.homeserverUrl.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
|
||||
private authHeaders(): Record<string, string> {
|
||||
return { Authorization: `Bearer ${this.token}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** Monotonic, unique-per-instance transaction id for idempotent sends. */
|
||||
private nextTxnId(nowMs: number): string {
|
||||
this.txnCounter += 1;
|
||||
return `mosaic-${nowMs}-${this.txnCounter}`;
|
||||
}
|
||||
|
||||
async send(message: OutboundMessage, nowMs = Date.now()): Promise<SendResult> {
|
||||
const txnId = this.nextTxnId(nowMs);
|
||||
const path = `/_matrix/client/v3/rooms/${encodeURIComponent(
|
||||
this.config.roomId,
|
||||
)}/send/m.room.message/${encodeURIComponent(txnId)}`;
|
||||
try {
|
||||
const res = await this.fetchImpl(this.url(path), {
|
||||
method: 'PUT',
|
||||
headers: this.authHeaders(),
|
||||
body: JSON.stringify(buildMessageBody(message)),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { delivered: false, error: `Matrix send failed: HTTP ${res.status}` };
|
||||
}
|
||||
const json = (await res.json()) as { event_id?: string };
|
||||
return { delivered: true, ...(json.event_id ? { messageId: json.event_id } : {}) };
|
||||
} catch (err) {
|
||||
return { delivered: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
subscribe(handler: (message: InboundMessage) => void): Unsubscribe {
|
||||
this.stopped = false;
|
||||
let since: string | undefined;
|
||||
const loop = async (): Promise<void> => {
|
||||
while (!this.stopped) {
|
||||
try {
|
||||
const q = new URLSearchParams({ timeout: String(this.syncTimeoutMs) });
|
||||
if (since) q.set('since', since);
|
||||
const res = await this.fetchImpl(this.url(`/_matrix/client/v3/sync?${q.toString()}`), {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
await this.backoff();
|
||||
continue;
|
||||
}
|
||||
const data = await res.json();
|
||||
since = (data as SyncResponse).next_batch ?? since;
|
||||
for (const msg of parseSyncResponse(data, this.config.roomId, this.config.userId)) {
|
||||
handler(msg);
|
||||
}
|
||||
} catch {
|
||||
await this.backoff();
|
||||
}
|
||||
}
|
||||
};
|
||||
void loop();
|
||||
return () => {
|
||||
this.stopped = true;
|
||||
};
|
||||
}
|
||||
|
||||
private backoff(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
}
|
||||
|
||||
async health(): Promise<ConnectorHealth> {
|
||||
try {
|
||||
const versions = await this.fetchImpl(this.url('/_matrix/client/versions'), {
|
||||
method: 'GET',
|
||||
});
|
||||
if (!versions.ok) {
|
||||
return {
|
||||
reachable: false,
|
||||
authenticated: false,
|
||||
detail: `versions HTTP ${versions.status}`,
|
||||
};
|
||||
}
|
||||
const who = await this.fetchImpl(this.url('/_matrix/client/v3/account/whoami'), {
|
||||
method: 'GET',
|
||||
headers: this.authHeaders(),
|
||||
});
|
||||
if (!who.ok) {
|
||||
return { reachable: true, authenticated: false, detail: `whoami HTTP ${who.status}` };
|
||||
}
|
||||
const json = (await who.json()) as { user_id?: string };
|
||||
const authenticated = json.user_id === this.config.userId;
|
||||
return {
|
||||
reachable: true,
|
||||
authenticated,
|
||||
lastSeen: new Date().toISOString(),
|
||||
...(authenticated
|
||||
? {}
|
||||
: { detail: `whoami user ${json.user_id} != ${this.config.userId}` }),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
reachable: false,
|
||||
authenticated: false,
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Matrix connector factory. The token is read from the environment
|
||||
* (MATRIX_ACCESS_TOKEN) at build time, never the roster.
|
||||
*/
|
||||
export function registerMatrixConnector(env: NodeJS.ProcessEnv = process.env): void {
|
||||
registerConnector('matrix', (config) => {
|
||||
if (!config.matrix) {
|
||||
throw new Error('Matrix connector config missing the `matrix` block (homeserver/user/room).');
|
||||
}
|
||||
return new MatrixConnector(config.matrix, { accessToken: env['MATRIX_ACCESS_TOKEN'] ?? '' });
|
||||
});
|
||||
}
|
||||
85
packages/mosaic/src/fleet/connectors/registry.spec.ts
Normal file
85
packages/mosaic/src/fleet/connectors/registry.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
KNOWN_CONNECTOR_KINDS,
|
||||
isKnownConnectorKind,
|
||||
resolveConnectorKind,
|
||||
registerConnector,
|
||||
hasConnector,
|
||||
createConnector,
|
||||
ConnectorNotImplementedError,
|
||||
_resetConnectorRegistry,
|
||||
} from './registry.js';
|
||||
import type { ConnectorConfig, OrchestratorConnector } from './types.js';
|
||||
|
||||
function fakeConnector(kind: 'tmux' | 'discord' | 'matrix'): OrchestratorConnector {
|
||||
return {
|
||||
kind,
|
||||
send: async () => ({ delivered: true, messageId: 'x' }),
|
||||
subscribe: () => () => {},
|
||||
health: async () => ({ reachable: true, authenticated: true }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('connector registry (F4 Phase 1)', () => {
|
||||
beforeEach(() => {
|
||||
_resetConnectorRegistry();
|
||||
});
|
||||
|
||||
it('knows the three peer connector kinds', () => {
|
||||
expect(KNOWN_CONNECTOR_KINDS).toEqual(['tmux', 'discord', 'matrix']);
|
||||
});
|
||||
|
||||
it('isKnownConnectorKind guards correctly', () => {
|
||||
expect(isKnownConnectorKind('matrix')).toBe(true);
|
||||
expect(isKnownConnectorKind('irc')).toBe(false);
|
||||
expect(isKnownConnectorKind(42)).toBe(false);
|
||||
});
|
||||
|
||||
it('resolveConnectorKind defaults to tmux when config is absent (back-compat)', () => {
|
||||
expect(resolveConnectorKind(undefined)).toBe('tmux');
|
||||
expect(resolveConnectorKind({ kind: 'matrix' })).toBe('matrix');
|
||||
});
|
||||
|
||||
it('createConnector throws ConnectorNotImplementedError for an unregistered kind', () => {
|
||||
const cfg: ConnectorConfig = { kind: 'matrix' };
|
||||
expect(() => createConnector(cfg)).toThrow(ConnectorNotImplementedError);
|
||||
expect(() => createConnector(cfg)).toThrow(/not implemented yet/i);
|
||||
});
|
||||
|
||||
it('createConnector with no config resolves the default kind (tmux) and reports it unimplemented in Phase 1', () => {
|
||||
try {
|
||||
createConnector();
|
||||
throw new Error('expected throw');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConnectorNotImplementedError);
|
||||
expect((err as ConnectorNotImplementedError).kind).toBe('tmux');
|
||||
}
|
||||
});
|
||||
|
||||
it('register → has → create resolves a registered factory', () => {
|
||||
expect(hasConnector('matrix')).toBe(false);
|
||||
registerConnector('matrix', (cfg) => fakeConnector(cfg.kind));
|
||||
expect(hasConnector('matrix')).toBe(true);
|
||||
|
||||
const connector = createConnector({ kind: 'matrix' });
|
||||
expect(connector.kind).toBe('matrix');
|
||||
});
|
||||
|
||||
it('passes the config through to the factory', () => {
|
||||
let received: ConnectorConfig | null = null;
|
||||
registerConnector('matrix', (cfg) => {
|
||||
received = cfg;
|
||||
return fakeConnector(cfg.kind);
|
||||
});
|
||||
const cfg: ConnectorConfig = {
|
||||
kind: 'matrix',
|
||||
matrix: {
|
||||
homeserverUrl: 'https://matrix.internal',
|
||||
userId: '@mos:internal',
|
||||
roomId: '!room:internal',
|
||||
},
|
||||
};
|
||||
createConnector(cfg);
|
||||
expect(received).toEqual(cfg);
|
||||
});
|
||||
});
|
||||
76
packages/mosaic/src/fleet/connectors/registry.ts
Normal file
76
packages/mosaic/src/fleet/connectors/registry.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Connector registry (F4 Phase 1).
|
||||
*
|
||||
* A tiny extensible registry so connector implementations (Phase 2: tmux,
|
||||
* Discord, Matrix) register a factory by kind and fleet core resolves one from
|
||||
* roster config without branching on kind. Phase 1 ships the registry + the
|
||||
* config→kind resolution; the connector factories land in Phase 2.
|
||||
*/
|
||||
|
||||
import {
|
||||
type ConnectorConfig,
|
||||
type ConnectorKind,
|
||||
type OrchestratorConnector,
|
||||
DEFAULT_CONNECTOR_KIND,
|
||||
} from './types.js';
|
||||
|
||||
/** The set of connector kinds the framework recognizes. */
|
||||
export const KNOWN_CONNECTOR_KINDS: readonly ConnectorKind[] = ['tmux', 'discord', 'matrix'];
|
||||
|
||||
/** Type guard: is `value` a known connector kind? */
|
||||
export function isKnownConnectorKind(value: unknown): value is ConnectorKind {
|
||||
return typeof value === 'string' && (KNOWN_CONNECTOR_KINDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the connector kind from roster config. Absent config ⇒ the default
|
||||
* (tmux) so existing rosters keep working unchanged (back-compat).
|
||||
*/
|
||||
export function resolveConnectorKind(config?: ConnectorConfig): ConnectorKind {
|
||||
return config?.kind ?? DEFAULT_CONNECTOR_KIND;
|
||||
}
|
||||
|
||||
/** A factory builds a live connector from its validated config. */
|
||||
export type ConnectorFactory = (config: ConnectorConfig) => OrchestratorConnector;
|
||||
|
||||
/** Thrown when no factory is registered for a requested kind. */
|
||||
export class ConnectorNotImplementedError extends Error {
|
||||
constructor(public readonly kind: ConnectorKind) {
|
||||
super(
|
||||
`Connector "${kind}" is not implemented yet. ` +
|
||||
`Register a factory via registerConnector('${kind}', …) (F4 Phase 2).`,
|
||||
);
|
||||
this.name = 'ConnectorNotImplementedError';
|
||||
}
|
||||
}
|
||||
|
||||
const registry = new Map<ConnectorKind, ConnectorFactory>();
|
||||
|
||||
/** Register a connector factory for a kind (idempotent — last registration wins). */
|
||||
export function registerConnector(kind: ConnectorKind, factory: ConnectorFactory): void {
|
||||
registry.set(kind, factory);
|
||||
}
|
||||
|
||||
/** True when a factory is registered for `kind`. */
|
||||
export function hasConnector(kind: ConnectorKind): boolean {
|
||||
return registry.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connector from roster config. Throws `ConnectorNotImplementedError`
|
||||
* when no factory is registered for the resolved kind (the Phase-1 default for
|
||||
* every kind until Phase 2 registers them).
|
||||
*/
|
||||
export function createConnector(config?: ConnectorConfig): OrchestratorConnector {
|
||||
const kind = resolveConnectorKind(config);
|
||||
const factory = registry.get(kind);
|
||||
if (!factory) {
|
||||
throw new ConnectorNotImplementedError(kind);
|
||||
}
|
||||
return factory(config ?? { kind });
|
||||
}
|
||||
|
||||
/** Test/runtime helper: drop all registrations. */
|
||||
export function _resetConnectorRegistry(): void {
|
||||
registry.clear();
|
||||
}
|
||||
111
packages/mosaic/src/fleet/connectors/types.ts
Normal file
111
packages/mosaic/src/fleet/connectors/types.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Orchestrator chat connectors (F4).
|
||||
*
|
||||
* A connector mediates the chat channel between the fleet **orchestrator** and
|
||||
* its human operator. Connectors are PEERS — tmux (default), Discord, Matrix,
|
||||
* and future first-party plugins — selected per fleet, never hardwired. Fleet
|
||||
* core depends only on the small uniform interface below, so a new connector
|
||||
* drops in without touching the fleet.
|
||||
*
|
||||
* The interface is deliberately minimal: send (orchestrator → human),
|
||||
* subscribe (human → orchestrator), health (reachable/authed liveness). Thread
|
||||
* support is optional metadata (`threadId`) so thread-capable connectors
|
||||
* (Matrix rooms/threads, the future Mosaic Discord plugin) fit without an
|
||||
* interface change.
|
||||
*/
|
||||
|
||||
/** The connector kinds shipped/known to the framework. */
|
||||
export type ConnectorKind = 'tmux' | 'discord' | 'matrix';
|
||||
|
||||
/** A message the orchestrator sends out to the human operator. */
|
||||
export interface OutboundMessage {
|
||||
/** Message body (markdown where the connector supports it). */
|
||||
text: string;
|
||||
/** Optional thread/topic id for thread-capable connectors. */
|
||||
threadId?: string;
|
||||
/** Optional attachment references (paths or URLs); connector-dependent. */
|
||||
attachments?: string[];
|
||||
}
|
||||
|
||||
/** A message received from the human operator. */
|
||||
export interface InboundMessage {
|
||||
/** Message body. */
|
||||
text: string;
|
||||
/** Thread/topic id if the connector carries one. */
|
||||
threadId?: string;
|
||||
/** Opaque sender identifier (connector-scoped). */
|
||||
sender: string;
|
||||
/** ISO-8601 timestamp the connector assigns/observes. */
|
||||
ts: string;
|
||||
}
|
||||
|
||||
/** Result of a send — the "ack" half of ack/health. */
|
||||
export interface SendResult {
|
||||
/** True when the connector accepted/delivered the message. */
|
||||
delivered: boolean;
|
||||
/** Connector-assigned message id when available. */
|
||||
messageId?: string;
|
||||
/** Reason when `delivered` is false. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Liveness of a connector — the "health" half of ack/health. */
|
||||
export interface ConnectorHealth {
|
||||
/** The transport endpoint is reachable. */
|
||||
reachable: boolean;
|
||||
/** Credentials are valid / the connector is authenticated. */
|
||||
authenticated: boolean;
|
||||
/** ISO-8601 of the last successful interaction, if any. */
|
||||
lastSeen?: string;
|
||||
/** Human-readable detail (e.g. failure reason). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
/** Unsubscribe handle returned by `subscribe`. */
|
||||
export type Unsubscribe = () => void;
|
||||
|
||||
/**
|
||||
* The uniform contract every orchestrator chat connector implements. Small by
|
||||
* design — send / subscribe / health — so connectors are interchangeable and
|
||||
* fleet core never branches on connector kind.
|
||||
*/
|
||||
export interface OrchestratorConnector {
|
||||
/** Which kind of connector this is. */
|
||||
readonly kind: ConnectorKind;
|
||||
/** Send a message from the orchestrator to the operator. */
|
||||
send(message: OutboundMessage): Promise<SendResult>;
|
||||
/** Subscribe to inbound operator messages; returns an unsubscribe handle. */
|
||||
subscribe(handler: (message: InboundMessage) => void): Unsubscribe;
|
||||
/** Report connector liveness (reachable + authenticated). */
|
||||
health(): Promise<ConnectorHealth>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connector configuration carried by the roster (the `connector` block).
|
||||
* Secrets (access tokens, bot tokens) are NEVER stored here — they come from
|
||||
* the environment (the gateway env-config pattern). Absent config ⇒ tmux.
|
||||
*/
|
||||
export interface ConnectorConfig {
|
||||
kind: ConnectorKind;
|
||||
/** Matrix connector settings (homeserver + room); token via env. */
|
||||
matrix?: MatrixConnectorConfig;
|
||||
/** Discord connector settings (channel); token via env. */
|
||||
discord?: DiscordConnectorConfig;
|
||||
}
|
||||
|
||||
export interface MatrixConnectorConfig {
|
||||
/** Local homeserver base URL, e.g. https://matrix.example.internal */
|
||||
homeserverUrl: string;
|
||||
/** Full Matrix user id of the orchestrator, e.g. @mos:example.internal */
|
||||
userId: string;
|
||||
/** Room id/alias the orchestrator converses in. */
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
export interface DiscordConnectorConfig {
|
||||
/** Channel id the orchestrator converses in. */
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
/** The default connector when a roster declares none (back-compat). */
|
||||
export const DEFAULT_CONNECTOR_KIND: ConnectorKind = 'tmux';
|
||||
85
packages/mosaic/src/runtime/update-checker.reseed.spec.ts
Normal file
85
packages/mosaic/src/runtime/update-checker.reseed.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
buildReseedCommand,
|
||||
buildRelaunchCommands,
|
||||
readRosterAgentNames,
|
||||
runFrameworkReseed,
|
||||
} from './update-checker.js';
|
||||
|
||||
/**
|
||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||
* durable agents so shipped launcher/runtime changes activate. These cover the
|
||||
* pure builders + the missing-installer guard (the exec path is integration).
|
||||
*/
|
||||
|
||||
describe('buildReseedCommand', () => {
|
||||
it('invokes the package install.sh in data-safe sync-only keep mode', () => {
|
||||
const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic');
|
||||
expect(out.installer).toBe('/pkg/framework/install.sh');
|
||||
expect(out.command).toBe('bash /pkg/framework/install.sh');
|
||||
expect(out.env).toEqual({
|
||||
MOSAIC_SYNC_ONLY: '1',
|
||||
MOSAIC_INSTALL_MODE: 'keep',
|
||||
MOSAIC_HOME: '/home/u/.config/mosaic',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildRelaunchCommands', () => {
|
||||
it('builds a systemctl --user restart per agent unit', () => {
|
||||
expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([
|
||||
['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'],
|
||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('is empty for an empty roster', () => {
|
||||
expect(buildRelaunchCommands([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRosterAgentNames', () => {
|
||||
let home: string;
|
||||
|
||||
beforeEach(() => {
|
||||
home = mkdtempSync(join(tmpdir(), 'mosaic-roster-'));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns [] when no roster exists', () => {
|
||||
expect(readRosterAgentNames(home)).toEqual([]);
|
||||
});
|
||||
|
||||
it('extracts agent names from roster.yaml', () => {
|
||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(home, 'fleet', 'roster.yaml'),
|
||||
[
|
||||
'version: 1',
|
||||
'agents:',
|
||||
' - name: orchestrator',
|
||||
' runtime: pi',
|
||||
' - name: coder0',
|
||||
' runtime: claude',
|
||||
' - name: "reviewer-1"',
|
||||
' runtime: codex',
|
||||
].join('\n') + '\n',
|
||||
);
|
||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runFrameworkReseed', () => {
|
||||
it('reports not-ok (not throw) when the installer is absent', () => {
|
||||
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
||||
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toContain('installer not found');
|
||||
rmSync(missing, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -16,7 +16,8 @@
|
||||
import { execSync } from 'node:child_process';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -453,6 +454,98 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
|
||||
return `npm i -g ${pkgs.join(' ')}`;
|
||||
}
|
||||
|
||||
// ─── Post-update framework re-seed + agent relaunch (F3-m3 / R13) ─────────────
|
||||
//
|
||||
// `mosaic update` installs the new npm CLI but, on its own, leaves the framework
|
||||
// files in ~/.config/mosaic/ stale — so shipped launcher/runtime changes (e.g.
|
||||
// the agent-name export + native heartbeat) never ACTIVATE until a re-seed.
|
||||
// These helpers run the package's own install.sh in sync-only mode (the P4
|
||||
// data-safe reconcile: framework-owned overwrite + backup-once; SOUL/USER/
|
||||
// *.local/credentials preserved) and, opt-in, relaunch durable agents.
|
||||
|
||||
/** Resolve the framework/ directory bundled in the installed package. */
|
||||
export function resolveBundledFrameworkRoot(): string {
|
||||
// dist/runtime/update-checker.js → ../../framework (package files: dist + framework)
|
||||
return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
|
||||
}
|
||||
|
||||
export const FRAMEWORK_RESEED_PACKAGE = PKG;
|
||||
|
||||
/**
|
||||
* Build the framework re-seed invocation: the package's install.sh in
|
||||
* sync-only mode (file phase only — no environment-touching post-install),
|
||||
* keep mode (never overwrite user files). Returned as data so it is unit
|
||||
* testable; `runFrameworkReseed` executes it.
|
||||
*/
|
||||
export function buildReseedCommand(
|
||||
frameworkRoot: string,
|
||||
mosaicHome: string,
|
||||
): { installer: string; command: string; env: Record<string, string> } {
|
||||
const installer = join(frameworkRoot, 'install.sh');
|
||||
return {
|
||||
installer,
|
||||
command: `bash ${installer}`,
|
||||
env: {
|
||||
MOSAIC_SYNC_ONLY: '1',
|
||||
MOSAIC_INSTALL_MODE: 'keep',
|
||||
MOSAIC_HOME: mosaicHome,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seed the framework from the freshly-installed package. Returns a result
|
||||
* describing what happened (so callers can message + decide on relaunch).
|
||||
* Best-effort: a missing installer or a non-zero exit is reported, not thrown.
|
||||
*/
|
||||
export function runFrameworkReseed(
|
||||
frameworkRoot = resolveBundledFrameworkRoot(),
|
||||
mosaicHome = join(homedir(), '.config', 'mosaic'),
|
||||
): { ok: boolean; reason?: string } {
|
||||
const { installer, command, env } = buildReseedCommand(frameworkRoot, mosaicHome);
|
||||
if (!existsSync(installer)) {
|
||||
return { ok: false, reason: `installer not found: ${installer}` };
|
||||
}
|
||||
try {
|
||||
execSync(command, { stdio: 'inherit', env: { ...process.env, ...env }, timeout: 120_000 });
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort parse of the fleet roster for agent names (used to relaunch
|
||||
* durable agents after a re-seed). Returns [] when no roster exists.
|
||||
*/
|
||||
export function readRosterAgentNames(mosaicHome = join(homedir(), '.config', 'mosaic')): string[] {
|
||||
const rosterPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||
if (!existsSync(rosterPath)) return [];
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(rosterPath, 'utf-8');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// Roster agents are listed as `- name: <id>` entries under `agents:`.
|
||||
const names: string[] = [];
|
||||
for (const line of text.split('\n')) {
|
||||
const m = line.match(/^\s*-?\s*name:\s*["']?([A-Za-z0-9._-]+)["']?\s*$/);
|
||||
if (m && m[1]) names.push(m[1]);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/** Build the per-agent systemd relaunch commands (drain+relaunch via restart). */
|
||||
export function buildRelaunchCommands(agentNames: string[]): string[][] {
|
||||
return agentNames.map((name) => [
|
||||
'systemctl',
|
||||
'--user',
|
||||
'restart',
|
||||
`mosaic-agent@${name}.service`,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a table showing all packages with their current/latest versions.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user