Compare commits

...

27 Commits

Author SHA1 Message Date
c6735d9949 feat(fleet): F4 Phase 1 — orchestrator chat connector abstraction + Matrix design (#616)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
From the north-star (#613) orchestrator-chat-connector decision: make the
orchestrator's chat channel a pluggable, user-chosen connector (tmux | discord
| matrix peers) and add Matrix (local homeserver) — starting with the small
uniform abstraction so connectors drop in without touching fleet core.

Phase 1 (this PR — design + scaffold):
- src/fleet/connectors/types.ts: OrchestratorConnector interface (send /
  subscribe / health per the Lead's spec) + message/config types; thread-aware
  via optional threadId so Matrix threads + the future first-party Mosaic
  Discord plugin fit without an interface change. 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 (gateway pattern), never the roster.
- docs/fleet/f4-matrix-connector.md: interface, config, Matrix client-server API
  mapping, Conduit-default local homeserver, phasing, back-compat.

Self-contained connectors/ module — NO fleet.ts changes, so independent of the
in-flight stacked fleet-config PR (#615).

Verified: 7 connector tests green; tsc/eslint/prettier/sanitize clean; schema
valid JSON.

Refs #616, #613

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsgTQzV5YUGk1JtCLP4B83
2026-06-22 08:17:23 -05:00
2bf66136e4 feat(fleet): enhancer role + two-agent floor (orchestrator + enhancer) (#615)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 13:15:59 +00:00
4434c3c481 docs(fleet): orchestrator+enhancer two-agent floor + role library + Discord plugin north-star (#613)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 13:15:05 +00:00
dd0a0d38c6 ci(publish): gate kaniko image builds + publish on changed paths (CI throughput) (#619)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 13:14:31 +00:00
d46ac40890 fix(fleet): boot-survival symmetry — disable-on-remove + add-enable + init-R5 (#612)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 08:12:58 +00:00
8ddd48c843 feat(mosaic): mosaic update re-seeds framework + relaunches agents (R13) (#610)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 03:34:05 +00:00
528700ceea feat(framework): P6 — docs + compliance matrix + resident-budget CI (#607)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/tag/publish Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 02:20:35 +00:00
32f4215461 chore(release): bump @mosaicstack/mosaic 0.0.38 -> 0.0.39 (#608)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-22 02:16:12 +00:00
23343bb7f0 feat(mosaic): P5 — overlay composer (compose-contract + *.local overlays) (#605)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 02:16:05 +00:00
c8b2dab0ca chore(release): bump @mosaicstack/mosaic 0.0.37 -> 0.0.38 (#603)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
2026-06-22 01:48:27 +00:00
6dbe452a9f fix(fleet): watch viewer-session leak + workdir test settle-race (#601)
Some checks failed
ci/woodpecker/push/publish Pipeline was canceled
ci/woodpecker/push/ci Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:43:21 +00:00
59c755067e feat(fleet): F3-m2 — native Pi heartbeat + model surface + mosaic_mission_status tool (#602)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:43:18 +00:00
6ffb27787e fix(fleet): complete HB reader/writer consistency + sidecar hardening (#599)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-22 01:22:35 +00:00
130837365f chore(release): bump @mosaicstack/mosaic 0.0.36 -> 0.0.37 (#597)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 23:27:14 +00:00
67df06f1c4 feat(fleet): orchestrator-mutable fleet — fleet add/remove (F5/R9) (#596)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:26:21 +00:00
60a309d5a4 fix(fleet): heartbeat consistency — MOSAIC_HOME path + configurable interval (#595)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:25:53 +00:00
2dc0f24828 docs(fleet): Fleet Suite PRD (init/configure/operate + Mos-on-Discord) (#588)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:17:10 +00:00
31e7a4d25e docs(framework): P4.1 — fix stale install.sh comments + cmp-equal early-exit (#593)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:12:31 +00:00
ca19d57bba feat(fleet): config-type presets + AI-free init wizard (F1) (#591)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
2026-06-21 23:07:41 +00:00
bb7d549080 feat(framework): P4 — upgrade-safe Constitution migration (both installers) (#590)
Some checks are pending
ci/woodpecker/push/ci Pipeline is pending
ci/woodpecker/push/publish Pipeline is pending
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 23:03:48 +00:00
5bef2c35eb feat(fleet): fleet ps surfaces unmanaged socket sessions (#586)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
2026-06-21 22:37:34 +00:00
2849a8f9db chore(release): bump @mosaicstack/mosaic 0.0.35 -> 0.0.36 (#585)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 21:46:15 +00:00
7ced5588c9 feat(fleet): launcher heartbeat sidecar — HB for all runtimes (pi/claude/codex) (#584)
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-06-21 21:14:20 +00:00
afcbbb302f feat(fleet): auto-enable units on install + drift recognizes wrapped runtimes (#583)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 20:02:19 +00:00
c2c0b5fe8d chore(release): bump @mosaicstack/mosaic 0.0.34 -> 0.0.35 (#582)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 18:59:39 +00:00
c9cfe36204 docs(framework): P3.1 fast-follow — governance wording + gate scope + bare-launch note (#577)
Some checks failed
ci/woodpecker/push/ci Pipeline was canceled
ci/woodpecker/push/publish Pipeline was canceled
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-06-21 18:56:50 +00:00
fc90c89913 fix(fleet): durable runtime PATH for detached agent launch (#581)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was successful
2026-06-21 17:30:40 +00:00
44 changed files with 4643 additions and 119 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -45,3 +45,28 @@ 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 done (abstraction + scaffold). Connector interface (send/subscribe/health) + registry + roster connector schema + design doc; tmux default/back-compat; matrix/discord factories are Phase 2. 7 tests green; no fleet.ts changes. Detail: scratchpads/f4-matrix-connector.md.

View 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 1214 → ✅:** 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).

View File

@@ -0,0 +1,109 @@
# PRD — Mosaic Fleet Suite (init, configure, operate)
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.350.0.37
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
## Mission
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
CLI/TUI and (designed-for) the webUI.
**Immediate tangible goal:** the **"Mos"** orchestrator agent running on `w-jarvis`, reachable
in **Discord channel `1517622518662434996`** (server `1112631390438166618`). Once the fleet is
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**. |
### 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. |
| 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. |
| 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. |
### D. Surfaces, testing, docs
| 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. |
## Architecture / approach
- **Config model:** `roster.yaml` is the source of truth (already exists). Add **presets** (`general`/`coding`/`research`/`hybrid`) as shipped example rosters; `init` selects a preset, always injects the orchestrator, and writes the roster. Re-init = regenerate roster (preserve user/site overrides — mirrors install env-merge from #567).
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
## 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 | R1R5, 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)
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
## Immediate target: Mos on Discord (F2 first slice)
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
## Success criteria
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
- **Mos answers in Discord `1517…`** on w-jarvis.
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
- Docs let a new operator install/configure/use the fleet.
- Re-init + orchestrator mutation work.
## Assumptions (veto-able)
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).

View 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.

View File

@@ -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:
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| 01 | 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 |
@@ -115,6 +146,33 @@ Every artifact, starting Phase 2, MUST:
- Observation: **read-only default, opt-in takeover**.
- Multi-host: **designed-for from day one**; control plane **rides federation (W1)**.
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
Code only** (avoid alternate-harness API pricing). Validated durable recipe:
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the
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)

View 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).

View File

@@ -0,0 +1,19 @@
# 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.

View 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.

View File

@@ -73,3 +73,28 @@ with a second agent on `dragon-lin`.
tmux session-name fallback; the systemd/tmux env handoff needs a real fix.
- Next: rebase on merged main, open Phase-2 PR, dual-engine review, merge, close
`fleet-observability-1`. Defer launch-path + env-propagation fixes to Phase 3.
- 2026-06-21 (session 3): Phase-2 PR #579 merged (3 dual-engine rounds hardened
verify+watch). Then closed the launch-path question with Jason's input — CORRECTING
earlier findings:
- The ad-hoc launch deaths were NOT a fundamental TTY blocker: (a) codex was a stale
version (Jason updated it); (b) pi was misconfigured to Claude auth (Jason removed it;
default is now Codex). The REAL durable-launch bug is **PATH**: the detached tmux
launch shell is login+non-interactive, so it misses `~/.npm-global/bin` (added only in
`~/.bashrc`) -> `mosaic: command not found` (127) -> pane dies. tmux panes inherit the
tmux _server_ env, so PATH must be baked into the pane command.
- **Durable real-agent recipe (validated live on gpt-5.5, Claude-free):**
`mosaic yolo pi --model openai-codex/gpt-5.5:high` — pi tolerates detached tmux; a raw
interactive TUI (codex CLI) exits without an attached client. Status line confirmed
`(openai-codex) gpt-5.5 • high`.
- PATH fix landed in `start-agent-session.sh` (commit 32efc13, branch
feat/fleet-launch-path): derive runtime-bin prefix (MOSAIC_RUNTIME_BIN | npm prefix |
~/.npm-global/bin | ~/.local/bin), bake `export PATH=...; exec <cmd>` into the pane;
`exec` also fixes the drift false-positive. Live-tested under stripped PATH -> durable.
- Boot-survival: Jason ran `systemctl --user enable` (+ linger). TODO: auto-enable in
**fleet init** so operators never have to remember it (agentic-enhancement cycle).
- Future custom Pi harness build: pi cannot self-report its model (track
runtime/model/effort as fleet metadata); drift detection should recognize `node` as
pi's pane command (a node-wrapped pane can currently read as drift).
- Findings recorded in AI Guide playbooks/tmux-fleet.md (aiguide PR #7, merged).
- Policy: avoid Claude outside Claude Code (API pricing for alt-harness use) — fleet
runtimes default to Codex / pi-on-Codex; Claude stays in Claude Code only.

View 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.

View 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 15)
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)

View 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 P0P6 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.

View 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.

View File

@@ -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.
@@ -70,6 +73,9 @@ Skills, hooks, MCP, and plugins are force multipliers you MUST use when applicab
## Missing core file
If `CONSTITUTION.md`, `AGENTS.md`, `SOUL.md`, or the runtime contract is missing, stop and report it.
This agent-facing strictness is intentional and stricter than the launcher: the launcher injects
`CONSTITUTION.md` tolerantly (skipping it if absent so pre-upgrade hosts keep working), but once a host
is re-seeded a genuinely missing core file is a stop-and-report condition — not something to proceed past.
## Session Closure

View File

@@ -2,8 +2,11 @@
The irreducible, non-negotiable law for every Mosaic agent on every harness.
**Framework-owned.** This file is overwritten verbatim on every upgrade — do not edit it. To change
behavior, add a `.local.md` overlay or a `policy/` file (tighten-only; see `constitution/LAYER-MODEL.md`).
**Framework-owned.** This file is overwritten verbatim on every upgrade — do not edit it. There is
**no `CONSTITUTION.local.md`**: hard gates are not locally overridable. A lower layer may only make
behavior _stricter_, never relax or override a gate (see Precedence). Operator customization lives in
other layers — `SOUL.md` / `USER.md` and the tighten-only overlays `STANDARDS.local.md` /
`SOUL.local.md` / `USER.local.md` / `policy/*.md` (see `constitution/LAYER-MODEL.md`).
Authored in **capability verbs**: where a gate names a capability ("structured reasoning", "queue
guard"), the runtime adapter binds it to a concrete tool and states whether absence is a hard stop.

View File

@@ -0,0 +1,36 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~
runtimes:
claude:
reset_command: /clear
pi:
reset_command: /new
agents:
- name: orchestrator
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: coder0
runtime: pi
class: implementer
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true
- name: coder1
runtime: pi
class: implementer
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true
- name: reviewer
runtime: pi
class: reviewer
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true

View File

@@ -0,0 +1,26 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~
runtimes:
claude:
reset_command: /clear
pi:
reset_command: /new
agents:
- name: orchestrator
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: generalist
runtime: pi
class: worker
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true

View File

@@ -0,0 +1,36 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~
runtimes:
claude:
reset_command: /clear
pi:
reset_command: /new
agents:
- name: orchestrator
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: coder0
runtime: pi
class: implementer
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true
- name: researcher0
runtime: pi
class: researcher
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true
- name: reviewer
runtime: pi
class: reviewer
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true

View File

@@ -0,0 +1,36 @@
version: 1
transport: tmux
tmux:
socket_name: mosaic-factory
holder_session: _holder
defaults:
working_directory: ~
runtimes:
claude:
reset_command: /clear
pi:
reset_command: /new
agents:
- name: orchestrator
runtime: claude
class: orchestrator
persistent_persona: true
- name: enhancer
runtime: claude
class: enhancer
persistent_persona: true
- name: researcher0
runtime: pi
class: researcher
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true
- name: researcher1
runtime: pi
class: researcher
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true
- name: analyst
runtime: pi
class: analyst
model_hint: openai-codex/gpt-5.5:high
reset_between_tasks: true

View 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).

View File

@@ -113,6 +113,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" }
}
}
}
}
}
}

View File

@@ -19,13 +19,23 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Files/dirs preserved across upgrades (never overwritten).
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
# User-created content in these paths survives rsync --delete.
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
# USER_SEEDED files are written once on first install, then owned by the user.
# Both lists are APPEND-FRIENDLY — add a new shipped framework file here and to the
# matching list in packages/mosaic/src/config/file-adapter.ts.
FRAMEWORK_OWNED=("CONSTITUTION.md" "AGENTS.md" "STANDARDS.md")
USER_SEEDED=("TOOLS.md")
# Current framework schema version — bump this when the layout changes.
# The migration system uses this to run upgrade steps.
FRAMEWORK_VERSION=2
FRAMEWORK_VERSION=3
# ─── colours ──────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
@@ -40,6 +50,47 @@ warn() { echo -e " ${YELLOW}⚠${RESET} $1" >&2; }
fail() { echo -e " ${RED}${RESET} $1" >&2; }
step() { echo -e "\n${BOLD}$1${RESET}"; }
# ─── snapshot / restore (crash safety for upgrades) ──────────────────────────
SNAPSHOT_DIR=""
make_snapshot() {
is_existing_install || return 0
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
}
restore_snapshot() {
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
fail "Install interrupted/failed — restoring previous state from snapshot"
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
}
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
# user-seeded seed-if-absent.
reconcile_framework_files() {
local defaults="$TARGET_DIR/defaults" f
[[ -d "$defaults" ]] || return 0
for f in "${FRAMEWORK_OWNED[@]}"; do
[[ -f "$defaults/$f" ]] || continue
# Already current — skip to avoid mtime churn.
if [[ -f "$TARGET_DIR/$f" ]] && cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then
continue
fi
if [[ -f "$TARGET_DIR/$f" && ! -f "$TARGET_DIR/${f}.pre-constitution.bak" ]]; then
cp "$TARGET_DIR/$f" "$TARGET_DIR/${f}.pre-constitution.bak"
warn "$f is now framework-owned and was updated; your previous copy is saved as ${f}.pre-constitution.bak — re-apply intended changes as a .local overlay or policy/ file (see CONSTITUTION.md / constitution/LAYER-MODEL.md)."
fi
cp "$defaults/$f" "$TARGET_DIR/$f"
done
for f in "${USER_SEEDED[@]}"; do
[[ -f "$defaults/$f" ]] || continue
if [[ ! -f "$TARGET_DIR/$f" ]]; then
cp "$defaults/$f" "$TARGET_DIR/$f"
ok "Seeded $f from defaults"
fi
done
}
# ─── helpers ──────────────────────────────────────────────────────────────────
is_existing_install() {
@@ -113,11 +164,14 @@ sync_framework() {
fi
if command -v rsync >/dev/null 2>&1; then
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version")
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
if [[ "$INSTALL_MODE" == "keep" ]]; then
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
for path in "${PRESERVE_PATHS[@]}"; do
rsync_args+=(--exclude "$path")
rsync_args+=(--exclude "/$path")
done
fi
@@ -137,7 +191,7 @@ sync_framework() {
done
fi
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" -exec rm -rf {} +
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
rm -rf "$TARGET_DIR/.git"
@@ -195,10 +249,15 @@ run_migrations() {
fi
fi
# ── Future migrations go here ──────────────────────────────────────────────
# if [[ "$from_version" -lt 3 ]]; then
# ...
# fi
# ── Migration: v2 → v3 (Constitution split) ───────────────────────────────
# CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten
# on upgrade). reconcile_framework_files() has already run before this point: it
# backed up any user-edited copy to <file>.pre-constitution.bak and installed the
# new framework version. Nothing further to do here — the advisory was emitted at
# reconcile time. (STANDARDS.local.md composition lands with the overlay composer.)
if [[ "$from_version" -lt 3 ]]; then
ok "Migrated to the Constitution layout (framework-owned CONSTITUTION/AGENTS/STANDARDS)"
fi
}
# ═══════════════════════════════════════════════════════════════════════════════
@@ -216,29 +275,25 @@ else
ok "Install mode: overwrite"
fi
# Snapshot before any destructive file operation; restore on interrupt/failure.
make_snapshot
trap 'restore_snapshot' ERR INT TERM
sync_framework
# Ensure persistent directories exist
mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials"
# Seed defaults — copy framework contract files from defaults/ to framework
# root if not already present. These ship with sensible defaults but must
# never be overwritten once the user has customized them.
# Reconcile contract files from defaults/ into the framework root: framework-owned
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
#
# This list must match the framework-contract whitelist in
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
# by `mosaic init` from templates with user-supplied values.
DEFAULTS_DIR="$TARGET_DIR/defaults"
if [[ -d "$DEFAULTS_DIR" ]]; then
for default_file in CONSTITUTION.md AGENTS.md STANDARDS.md TOOLS.md; do
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
ok "Seeded $default_file from defaults"
fi
done
fi
reconcile_framework_files
# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
@@ -249,6 +304,18 @@ ok "Framework synced to $TARGET_DIR"
# Run migrations before post-install (migrations may remove old bin/ etc.)
run_migrations
# File-system phase complete and consistent — clear the restore trap.
trap - ERR INT TERM
cleanup_snapshot
# Testability / minimal-install hook: stop after the file-system phase, before any
# environment-touching post-install steps (runtime linking, MCP setup, skills, doctor).
if [[ "${MOSAIC_SYNC_ONLY:-0}" == "1" ]]; then
write_framework_version
ok "Sync-only mode: file phase complete"
exit 0
fi
step "Post-install tasks"
SCRIPTS="$TARGET_DIR/tools/_scripts"

View File

@@ -9,8 +9,16 @@
* 4. Memory routing — remind agent to use ~/.config/mosaic/memory/
*/
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
import { Type } from 'typebox';
import {
existsSync,
readFileSync,
writeFileSync,
unlinkSync,
mkdirSync,
renameSync,
} from 'node:fs';
import { join, basename } from 'node:path';
import { homedir } from 'node:os';
import { execSync, spawnSync } from 'node:child_process';
@@ -25,6 +33,57 @@ const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mo
// Helpers
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Native heartbeat (fleet R14/R15)
// ---------------------------------------------------------------------------
// When this agent runs under the Mosaic fleet (MOSAIC_AGENT_NAME set), the
// extension writes its OWN heartbeat in the same .hb contract `fleet ps` reads
// (ts/pid/status[/model]) and touches a `.hb.native` precedence marker so the
// shell sidecar defers. Native HB knows the real turn state (busy/ok), so it is
// more accurate than the pane-PID-only sidecar fallback.
const HB_AGENT_NAME = process.env['MOSAIC_AGENT_NAME'] ?? '';
const HB_RUN_DIR = process.env['MOSAIC_HEARTBEAT_RUN_DIR'] ?? join(MOSAIC_HOME, 'fleet', 'run');
const HB_INTERVAL_MS = (() => {
const s = Number.parseInt(process.env['MOSAIC_HEARTBEAT_INTERVAL'] ?? '', 10);
return Number.isFinite(s) && s > 0 ? s * 1000 : 15_000;
})();
function nativeHbEnabled(): boolean {
return HB_AGENT_NAME.length > 0;
}
function readModelId(ctx: ExtensionContext): string | null {
const m = ctx.model as unknown as { id?: string; name?: string } | undefined;
return m?.id ?? m?.name ?? null;
}
function writeNativeHeartbeat(status: 'ok' | 'busy', model: string | null): void {
if (!nativeHbEnabled()) return;
try {
mkdirSync(HB_RUN_DIR, { recursive: true });
const hb = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb`);
const lines = [`ts=${nowIso()}`, `pid=${process.pid}`, `status=${status}`];
if (model) lines.push(`model=${model}`);
const tmp = `${hb}.tmp.${process.pid}`;
writeFileSync(tmp, lines.join('\n') + '\n');
renameSync(tmp, hb); // atomic replace — fleet ps never reads a partial file
// Precedence marker: tells the shell sidecar that native HB is authoritative.
writeFileSync(join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`), nowIso() + '\n');
} catch {
// Best-effort: never let heartbeat I/O disrupt the Pi session.
}
}
function clearNativeMarker(): void {
if (!nativeHbEnabled()) return;
try {
const m = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`);
if (existsSync(m)) unlinkSync(m); // native stopping — let the sidecar take over
} catch {
/* ignore */
}
}
function safeRead(filePath: string): string | null {
try {
return readFileSync(filePath, 'utf-8');
@@ -187,6 +246,9 @@ function buildMissionSummary(cwd: string, mission: ActiveMission): string {
export default function register(pi: ExtensionAPI) {
let sessionCwd = process.cwd();
let hbStatus: 'ok' | 'busy' = 'ok';
let hbModel: string | null = null;
let hbTimer: ReturnType<typeof setInterval> | null = null;
// ── Session Start ─────────────────────────────────────────────────────
pi.on('session_start', async (_event, ctx) => {
@@ -207,10 +269,39 @@ export default function register(pi: ExtensionAPI) {
} else {
ctx.ui.notify('Mosaic framework loaded', 'info');
}
// Native heartbeat: write immediately, then on an interval. Idle = 'ok';
// turn_start/turn_end flip the status so `fleet ps` reflects real activity.
if (nativeHbEnabled()) {
hbModel = readModelId(ctx);
writeNativeHeartbeat('ok', hbModel);
hbTimer = setInterval(() => writeNativeHeartbeat(hbStatus, hbModel), HB_INTERVAL_MS);
if (typeof hbTimer.unref === 'function') hbTimer.unref();
}
});
// ── Session End ───────────────────────────────────────────────────────
pi.on('session_end', async (_event, _ctx) => {
// ── Turn lifecycle → accurate busy/ok heartbeat ───────────────────────
pi.on('turn_start', async (_event, ctx) => {
hbStatus = 'busy';
hbModel = readModelId(ctx) ?? hbModel;
writeNativeHeartbeat('busy', hbModel);
});
pi.on('turn_end', async (_event, ctx) => {
hbStatus = 'ok';
hbModel = readModelId(ctx) ?? hbModel;
writeNativeHeartbeat('ok', hbModel);
});
// ── Session Shutdown ──────────────────────────────────────────────────
// (The pi API event is 'session_shutdown'; the prior 'session_end' handler
// never fired — fixed here so repo hooks + lock cleanup actually run.)
pi.on('session_shutdown', async (_event, _ctx) => {
if (hbTimer) {
clearInterval(hbTimer);
hbTimer = null;
}
clearNativeMarker();
// Run repo session-end hook
runRepoHook(sessionCwd, 'session-end');
@@ -252,4 +343,32 @@ export default function register(pi: ExtensionAPI) {
}
},
});
// ── Register mosaic_mission_status tool (model-callable) ──────────────
// R14 "proper tool usage": give the agent a first-class tool to load its
// active Mosaic mission, milestone progress, task counts, and latest
// scratchpad — so it self-orients on in-flight work before planning,
// instead of shelling out or guessing. Mirrors the /mosaic-status command
// but returns the summary as tool output the LLM can read.
pi.registerTool({
name: 'mosaic_mission_status',
label: 'Mosaic Mission Status',
description:
'Return the active Mosaic mission, milestone progress, task counts, and latest scratchpad for the current project. Returns a note when no mission is active.',
promptSnippet: 'Read the active Mosaic mission + task state for the current project',
promptGuidelines: [
'Use mosaic_mission_status at the start of a session or task to load the active mission, milestone progress, and open tasks before planning work.',
],
parameters: Type.Object({}),
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
const mission = detectMission(sessionCwd);
const text = mission
? buildMissionSummary(sessionCwd, mission)
: 'No active Mosaic mission in this project.';
return {
content: [{ type: 'text', text }],
details: mission ? { ...mission } : { active: false },
};
},
});
}

View File

@@ -274,6 +274,13 @@ detect_existing_config
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
echo ""
# Fail-closed persona: in non-interactive mode the agent NAME must be supplied
# explicitly (--name) — never silently ship an agent named "Assistant".
if [[ $NON_INTERACTIVE -eq 1 && -z "$AGENT_NAME" ]]; then
echo "[mosaic-init] ERROR: --name (agent name) is required in non-interactive mode." >&2
exit 1
fi
prompt_if_empty AGENT_NAME "What name should agents use" "Assistant"
prompt_if_empty ROLE_DESCRIPTION "Agent role description" "execution partner and visibility engine"

View File

@@ -6,6 +6,8 @@ MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
if [ -z "$AGENT_NAME" ]; then
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
@@ -26,5 +28,132 @@ if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
fi
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
# Precedence:
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
# 2. $(npm config get prefix)/bin (if npm is on PATH)
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
#
# Only directories that already exist are included. The prefix is baked into
# the pane command regardless of what the LAUNCHER process's $PATH contains,
# because the tmux pane inherits the tmux SERVER environment (not this script's
# environment). A dir on the launcher's PATH may be absent from the server PATH,
# so every existing candidate must always be included. Dedup within the
# constructed prefix avoids listing the same dir twice.
_build_runtime_bin_prefix() {
local candidates=()
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
candidates+=("$MOSAIC_RUNTIME_BIN")
fi
if command -v npm >/dev/null 2>&1; then
local npm_prefix
npm_prefix=$(npm config get prefix 2>/dev/null) || true
if [ -n "$npm_prefix" ]; then
candidates+=("${npm_prefix}/bin")
fi
fi
candidates+=("$HOME/.npm-global/bin")
candidates+=("$HOME/.local/bin")
local prefix=""
for dir in "${candidates[@]}"; do
[ -d "$dir" ] || continue
if [ -z "$prefix" ]; then
prefix="$dir"
else
case ":${prefix}:" in
*":${dir}:"*) ;; # already in our prefix — skip
*) prefix="${prefix}:${dir}" ;;
esac
fi
done
printf '%s' "$prefix"
}
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
# ── Build the pane command ────────────────────────────────────────────────────
# The pane command must:
# - Export the augmented PATH so the runtime binary is found.
# - exec the agent command so the runtime is the pane's foreground process
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
#
# Quoting strategy: single-quote the inner shell snippet so that variable
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
# NOW (in this script) because the pane shell inherits the tmux server
# environment, not this script's env.
#
# We build the snippet as a double-quoted here-string embedded in a printf call
# to avoid nested quoting problems.
#
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
# server environment (not this script's, and not the systemd unit's), so the
# name would otherwise be empty in-pane and the runtime's native heartbeat
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
# safe single bash token regardless of the name's characters.
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
else
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; exec ${MOSAIC_AGENT_COMMAND}"
fi
mkdir -p "$MOSAIC_AGENT_WORKDIR"
exec tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "$MOSAIC_AGENT_COMMAND"
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
bash -c "$PANE_SHELL_SNIPPET"
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
PANE_PID=""
for _retry in 1 2 3 4 5; do
PANE_PID=$(tmux -L "$MOSAIC_TMUX_SOCKET" list-panes \
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
[ -n "$PANE_PID" ] && break
sleep 0.2
done
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
_start_heartbeat_sidecar() {
local agent="$1"
local pane_pid="$2"
local run_dir="$3"
local interval="$4"
local hb_file="${run_dir}/${agent}.hb"
mkdir -p "$run_dir"
# Write the sidecar as a self-contained bash one-liner so it carries no
# references to any variables from this script's environment.
local sidecar_script
sidecar_script=$(printf \
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
"$hb_file" "$pane_pid" "$interval")
# setsid + disown ensures the sidecar survives this script exiting.
# stderr/stdout go to /dev/null; failures are non-fatal.
if command -v setsid >/dev/null 2>&1; then
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
else
bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
fi
disown $! 2>/dev/null || true
}
if [ -n "$PANE_PID" ]; then
# Guard: do not let sidecar startup failures abort the launcher (set -e).
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
else
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
fi

View File

@@ -6,22 +6,43 @@ START="$SCRIPT_DIR/start-agent-session.sh"
SOCKET="mosaic-agent-test-$RANDOM-$$"
AGENT="agent-$RANDOM"
WORKDIR=$(mktemp -d)
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$WORKDIR"' EXIT
# Keep a single cleanup trap that accumulates resources.
CLEANUP_DIRS=("$WORKDIR")
CLEANUP_SOCKETS=("$SOCKET")
trap '_cleanup' EXIT
_cleanup() {
for s in "${CLEANUP_SOCKETS[@]:-}"; do
tmux -L "$s" kill-server >/dev/null 2>&1 || true
done
for d in "${CLEANUP_DIRS[@]:-}"; do
rm -rf "$d"
done
}
fail() {
echo "FAIL: $*" >&2
exit 1
}
# ── Test 1: basic session creation with workdir check ─────────────────────────
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
"$START" "$AGENT"
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir"
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
# process establishes its own cwd (the -c start dir). Poll until it settles.
actual_dir=""
for _ in $(seq 1 30); do
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
[ "$actual_dir" = "$WORKDIR" ] && break
sleep 0.1
done
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
@@ -29,4 +50,310 @@ MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
#
# We capture the command the script would hand to tmux by injecting a fake
# 'tmux' shim into PATH. The shim:
# - Intercepts 'new-session' calls and records its arguments to a file.
# - For 'has-session' calls, exits 1 (session does not exist) so the script
# proceeds to launch instead of printing "already running".
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
# test 6 and 7 cover that path). This prevents any real-filesystem side
# effects or leaked background processes.
# - For all other subcommands, exits 0.
#
# Assertions:
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
# b) 'exec' appears so the runtime replaces the wrapper shell.
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
FAKE_BIN=$(mktemp -d)
FAKE_RUNTIME_BIN=$(mktemp -d)
TMUX_ARGS_FILE=$(mktemp)
HB_RUN_DIR3=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
# Write the fake tmux shim (uses only positional args, no sourced vars).
cat > "$FAKE_BIN/tmux" <<SHIM
#!/usr/bin/env bash
# Fake tmux: record new-session args; report has-session as missing.
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ...
if [ "\$subcmd" = "has-session" ]; then
exit 1 # session not found → script will attempt new-session
fi
if [ "\$subcmd" = "new-session" ]; then
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE"
exit 0
fi
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
echo ""
exit 0
fi
exit 0
SHIM
chmod +x "$FAKE_BIN/tmux"
SOCKET3="mosaic-agent-test3-$RANDOM-$$"
AGENT3="agent3-$RANDOM"
WORKDIR3=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR3")
PATH="$FAKE_BIN:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET3" \
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
"$START" "$AGENT3"
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
rm -f "$TMUX_ARGS_FILE"
echo "--- captured tmux new-session args ---"
echo "$all_args"
echo "--- end args ---"
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
# b) exec must appear so the runtime replaces the wrapper shell.
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
TMUX_ARGS_FILE2=$(mktemp)
FAKE_BIN2=$(mktemp -d)
HB_RUN_DIR4=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
cat > "$FAKE_BIN2/tmux" <<SHIM2
#!/usr/bin/env bash
subcmd="\$3"
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
if [ "\$subcmd" = "new-session" ]; then
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
exit 0
fi
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
echo ""
exit 0
fi
exit 0
SHIM2
chmod +x "$FAKE_BIN2/tmux"
SOCKET4="mosaic-agent-test4-$RANDOM-$$"
AGENT4="agent4-$RANDOM"
WORKDIR4=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR4")
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty;
# .npm-global/bin and .local/bin may or may not exist but we just want exec.
PATH="$FAKE_BIN2:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET4" \
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
"$START" "$AGENT4"
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
rm -f "$TMUX_ARGS_FILE2"
rm -rf "$WORKDIR4"
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
#
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
# a candidate because it was already present in the launcher process's $PATH.
# That check was wrong: the pane inherits the tmux SERVER environment, not the
# launcher's env. Even if a dir is on the launcher's PATH it must always be
# baked into the pane's PATH export.
#
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
# then asserting the generated new-session command still exports it.
TMUX_ARGS_FILE5=$(mktemp)
FAKE_BIN5=$(mktemp -d)
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
HB_RUN_DIR5=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
cat > "$FAKE_BIN5/tmux" <<SHIM5
#!/usr/bin/env bash
subcmd="\$3"
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
if [ "\$subcmd" = "new-session" ]; then
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
exit 0
fi
if [ "\$subcmd" = "list-panes" ]; then
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
echo ""
exit 0
fi
exit 0
SHIM5
chmod +x "$FAKE_BIN5/tmux"
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
AGENT5="agent5-$RANDOM"
WORKDIR5=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR5")
CLEANUP_SOCKETS+=("$SOCKET5")
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the
# old (buggy) code would have skipped it. The correct code must still include
# it in the pane PATH export.
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET5" \
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
"$START" "$AGENT5"
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
rm -f "$TMUX_ARGS_FILE5"
rm -rf "$WORKDIR5"
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
echo "$all_args5"
echo "--- end test 5 args ---"
echo "$all_args5" | grep -qF "export PATH=" || \
fail "test5: pane command does not export PATH when candidate is on launcher PATH"
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
#
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
# to appear and check its content.
HB_RUN_DIR=$(mktemp -d)
CLEANUP_DIRS+=("$HB_RUN_DIR")
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
# We need to invoke the script for a NEW agent on the same socket to exercise
# the heartbeat path with a real pane PID.
AGENT6="agent6-$RANDOM"
MOSAIC_TMUX_SOCKET="$SOCKET" \
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
MOSAIC_HEARTBEAT_INTERVAL="1" \
"$START" "$AGENT6"
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
# Wait up to 5 seconds for the heartbeat file to appear.
_waited=0
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
sleep 0.5
_waited=$((_waited + 1))
done
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
hb_content=$(cat "$HB_FILE")
echo "--- test 6: heartbeat file content ---"
echo "$hb_content"
echo "--- end test 6 ---"
# Verify required fields are present.
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \
fail "test6: heartbeat ts field missing or malformed"
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \
fail "test6: heartbeat pid field missing or malformed"
echo "$hb_content" | grep -qF 'status=ok' || \
fail "test6: heartbeat status=ok missing"
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ────────
#
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar
# invocation without needing a real session. A fake setsid shim records its
# arguments so we can assert the sidecar script targets the expected .hb path
# and uses the configured interval.
FAKE_BIN7=$(mktemp -d)
FAKE_RUNTIME_BIN7=$(mktemp -d)
SETSID_ARGS_FILE=$(mktemp)
HB_RUN_DIR7=$(mktemp -d)
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7")
AGENT7="my-fleet-agent-$RANDOM"
INTERVAL7="42"
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID.
cat > "$FAKE_BIN7/tmux" <<SHIM7
#!/usr/bin/env bash
subcmd="\$3"
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
if [ "\$subcmd" = "new-session" ]; then exit 0; fi
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi
exit 0
SHIM7
chmod +x "$FAKE_BIN7/tmux"
# Fake setsid: capture the bash -c <script> argument for inspection, then
# background an actual bash subshell so disown succeeds in the caller.
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM'
#!/usr/bin/env bash
# argv: setsid bash -c <sidecar_script>
# Record the full argument list to the capture file, then exit cleanly.
printf '%s\0' "$@" > __SETSID_ARGS_FILE__
exit 0
SETSID_SHIM
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues).
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
chmod +x "$FAKE_BIN7/setsid"
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
WORKDIR7=$(mktemp -d)
CLEANUP_DIRS+=("$WORKDIR7")
PATH="$FAKE_BIN7:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET7" \
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \
MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \
"$START" "$AGENT7"
# Give the background setsid shim a moment to finish writing the capture file.
sleep 0.5
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
rm -f "$SETSID_ARGS_FILE"
rm -rf "$WORKDIR7"
echo "--- test 7: captured setsid args ---"
echo "$setsid_args"
echo "--- end test 7 ---"
# The sidecar script (bash -c <script>) must reference the correct .hb path.
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb"
echo "$setsid_args" | grep -qF "$expected_hb" || \
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)"
# The sidecar script must use the configured interval.
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
echo "ok - start-agent-session"

View 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

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# test-install-migration.sh — fixture matrix for the v2→v3 (Constitution) upgrade
# migration in install.sh. Runs the installer against throwaway MOSAIC_HOME dirs
# with MOSAIC_SYNC_ONLY=1 (file phase only — no environment-touching post-install)
# and asserts the framework-owned-overwrite + user-preserve + backup semantics.
#
# Mirrors the TS fixture suite in packages/mosaic/src/config/file-adapter.test.ts;
# both installers MUST behave identically.
#
# Usage: bash test-install-migration.sh
set -uo pipefail
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
INSTALL="$FW/install.sh"
DEFA="$FW/defaults"
pass=0; fail=0
chk() { if eval "$2"; then echo "$1"; pass=$((pass + 1)); else echo "$1"; fail=$((fail + 1)); fi; }
run() { MOSAIC_HOME="$1" MOSAIC_INSTALL_MODE="$2" MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >/dev/null 2>&1; }
echo "install.sh v2→v3 migration fixture matrix:"
# F1 — fresh install
T1=$(mktemp -d); run "$T1" overwrite
chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
T2=$(mktemp -d); mkdir -p "$T2/credentials"
printf '# user-edited AGENTS pre-constitution\n' > "$T2/AGENTS.md"
printf '# my persona\n' > "$T2/SOUL.md"
printf 'token\n' > "$T2/credentials/c.json"
echo 2 > "$T2/.framework-version"
run "$T2" keep
chk "F2 legacy-edited: AGENTS overwritten to framework version" "cmp -s '$T2/AGENTS.md' '$DEFA/AGENTS.md'"
chk "F2 legacy-edited: prior AGENTS saved to .pre-constitution.bak" \
"grep -q 'user-edited AGENTS pre-constitution' '$T2/AGENTS.md.pre-constitution.bak'"
chk "F2 legacy-edited: SOUL.md preserved" "grep -q 'my persona' '$T2/SOUL.md'"
chk "F2 legacy-edited: credentials preserved" "grep -q token '$T2/credentials/c.json'"
chk "F2 legacy-edited: CONSTITUTION.md installed" "[ -f '$T2/CONSTITUTION.md' ]"
run "$T2" keep
chk "F2 idempotent: .pre-constitution.bak preserved across a 2nd upgrade" \
"grep -q 'user-edited AGENTS pre-constitution' '$T2/AGENTS.md.pre-constitution.bak'"
# F3 — user-tuned STANDARDS.md
T3=$(mktemp -d); printf '# tuned standards\n' > "$T3/STANDARDS.md"; printf '# persona\n' > "$T3/SOUL.md"; echo 2 > "$T3/.framework-version"
run "$T3" keep
chk "F3 tuned-standard: STANDARDS overwritten" "cmp -s '$T3/STANDARDS.md' '$DEFA/STANDARDS.md'"
chk "F3 tuned-standard: tuned copy backed up" "grep -q 'tuned standards' '$T3/STANDARDS.md.pre-constitution.bak'"
# F4 — unattended / no TTY (stdin closed): must complete without hanging, default to keep
T4=$(mktemp -d); printf '# persona\n' > "$T4/SOUL.md"; printf '# old\n' > "$T4/AGENTS.md"; echo 2 > "$T4/.framework-version"
MOSAIC_HOME="$T4" MOSAIC_SYNC_ONLY=1 bash "$INSTALL" </dev/null >/dev/null 2>&1
chk "F4 no-TTY: completed, AGENTS updated" "cmp -s '$T4/AGENTS.md' '$DEFA/AGENTS.md'"
# F5 — failure path must not corrupt existing data (invalid mode rejected before any file op)
T5=$(mktemp -d); mkdir -p "$T5/credentials"; printf '# orig\n' > "$T5/SOUL.md"; printf 'keepme\n' > "$T5/credentials/c.json"; echo 2 > "$T5/.framework-version"
MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >/dev/null 2>&1; rc=$?
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5"
echo
echo "RESULT: $pass passed, $fail failed"
[ "$fail" -eq 0 ]

View File

@@ -12,7 +12,7 @@
# 2. STRUCTURAL (private $HOME default in *.sh) — scanned everywhere EXCEPT examples/,
# because worked example overlays/personas legitimately show placeholder paths.
#
# File types: *.md, *.sh, *.ps1, *.json, and the extensionless CLI scripts under
# File types: *.md, *.sh, *.ps1, *.json, *.yml/*.yaml, *.toml, *.env, *.service, and the CLI scripts under
# tools/_scripts/. Excludes node_modules/ and this gate file.
#
# NOTE: '\bPDA\b' intentionally matches "PDA-friendly" (the contamination removed in P2);
@@ -39,7 +39,7 @@ cd "$FRAMEWORK_ROOT" || { echo "FRAMEWORK_ROOT not found: $FRAMEWORK_ROOT" >&2;
# Identity scope = ALL shipped text files (examples/ INCLUDED).
_files_identity() {
find . -type f \
\( -name '*.md' -o -name '*.sh' -o -name '*.ps1' -o -name '*.json' -o -path '*/tools/_scripts/*' \) \
\( -name '*.md' -o -name '*.sh' -o -name '*.ps1' -o -name '*.json' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' -o -name '*.env' -o -name '*.service' -o -path '*/tools/_scripts/*' \) \
-not -path '*/node_modules/*' -not -path "./$SELF_REL" -print0
}
# Structural scope = shipped scripts, examples/ EXCLUDED.
@@ -53,9 +53,15 @@ _selftest() {
local tmp; tmp="$(mktemp -d)" || return 1
printf 'contact jason.woltje at jarvis-brain (PDA-friendly)\n' > "$tmp/planted.md"
printf 'X="${VAR:-$HOME/src/whatever/x.json}"\n' > "$tmp/planted.sh"
printf 'name: jason-woltje\n' > "$tmp/planted.yaml"
printf '[Service]\nUser=jarvis\n' > "$tmp/planted.service"
local rc=0
grep -qIEi "$DENYLIST" "$tmp/planted.md" || { echo "✗ SELF-TEST: identity denylist regex broken" >&2; rc=1; }
grep -qIE "$STRUCTURAL_SH" "$tmp/planted.sh" || { echo "✗ SELF-TEST: structural regex broken" >&2; rc=1; }
# Prove the identity scan covers the config formats it claims to (yaml/service/etc).
local n_ext
n_ext=$(find "$tmp" -type f \( -name '*.yaml' -o -name '*.service' \) -print0 | xargs -0 -r grep -lIEi "$DENYLIST" 2>/dev/null | wc -l)
[[ "$n_ext" -eq 2 ]] || { echo "✗ SELF-TEST: identity scan does not cover .yaml/.service extensions" >&2; rc=1; }
rm -rf "$tmp"; return $rc
}
_selftest || exit 2

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaicstack/mosaic",
"version": "0.0.34",
"version": "0.0.39",
"repository": {
"type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -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 ─────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,118 @@
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('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');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
import { constants } from 'node:fs';
import { access, chmod, copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
import { access, chmod, copyFile, mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import { homedir, hostname, userInfo } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawn } from 'node:child_process';
import * as readline from 'node:readline';
import type { Command } from 'commander';
import YAML from 'yaml';
@@ -41,6 +42,11 @@ export interface FleetCommandDeps {
sleepFn?: SleepFn;
mosaicHome?: string;
frameworkRoot?: string;
/**
* Injectable TTY check for `fleet init` wizard. Defaults to process.stdin.isTTY.
* Tests stub this to simulate interactive or non-interactive environments.
*/
isStdinTTY?: boolean;
}
interface RawFleetRoster {
@@ -146,13 +152,16 @@ export function resolveFleetPaths(mosaicHome = defaultMosaicHome()): FleetPaths
}
function defaultMosaicHome(): string {
return join(homedir(), '.config', 'mosaic');
// Honor MOSAIC_HOME so the reader matches the writer sidecar (and the launcher),
// even when MOSAIC_HOME is set in the shell without an explicit --mosaic-home flag.
return process.env.MOSAIC_HOME ?? join(homedir(), '.config', 'mosaic');
}
function assertDefaultMosaicHomeForSystemd(mosaicHome: string): void {
if (resolve(mosaicHome) !== resolve(defaultMosaicHome())) {
const literalHome = join(homedir(), '.config', 'mosaic');
if (resolve(mosaicHome) !== resolve(literalHome)) {
throw new Error(
`install-systemd only supports the default Mosaic home (${defaultMosaicHome()}) because the user systemd units use %h/.config/mosaic paths.`,
`install-systemd only supports the default Mosaic home (${literalHome}) because the user systemd units use %h/.config/mosaic paths.`,
);
}
}
@@ -210,6 +219,102 @@ export function buildFleetServiceCommand(action: FleetServiceAction, agentName?:
return ['systemctl', '--user', action, service];
}
/**
* Returns the systemctl --user enable command for a given unit.
* Used by the install auto-enable step to persist units across reboots.
*/
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.
*/
export function buildEnableLingerCommand(user: string): string[] {
return ['loginctl', 'enable-linger', user];
}
/**
* Enable fleet units for boot-survival after install.
* Non-fatal: if systemctl enable returns non-zero, a warning is printed and we continue.
* If opts.enable === false (--no-enable flag), the whole step is skipped.
*/
export async function enableFleetUnits(
runner: CommandRunner,
roster: FleetRoster,
opts: { enable?: boolean },
): Promise<void> {
if (opts.enable === false) {
return;
}
try {
let succeeded = 0;
let failed = 0;
const holderResult = await runner(
...splitCommand(buildSystemdEnableCommand('mosaic-tmux-holder.service')),
);
if (holderResult.exitCode === 0) {
succeeded++;
} else {
failed++;
process.stderr.write(
`Warning: could not enable mosaic-tmux-holder.service: ${holderResult.stderr || holderResult.stdout || 'non-zero exit'}\n`,
);
}
for (const agent of roster.agents) {
const unit = `mosaic-agent@${agent.name}.service`;
const result = await runner(...splitCommand(buildSystemdEnableCommand(unit)));
if (result.exitCode === 0) {
succeeded++;
} else {
failed++;
process.stderr.write(
`Warning: could not enable ${unit}: ${result.stderr || result.stdout || 'non-zero exit'}\n`,
);
}
}
if (succeeded > 0) {
console.log(`Enabled ${succeeded} unit(s) for boot-survival.`);
}
if (failed > 0) {
process.stderr.write(
`Warning: ${failed} unit(s) could not be enabled (systemctl unavailable?). Run manually if needed.\n`,
);
}
// Best-effort linger
let username: string;
try {
username = userInfo().username;
} catch {
username = process.env['USER'] ?? process.env['LOGNAME'] ?? 'unknown';
}
const lingerResult = await runner(...splitCommand(buildEnableLingerCommand(username)));
if (lingerResult.exitCode !== 0) {
process.stderr.write(
`Hint: run 'loginctl enable-linger ${username}' as root to survive logout.\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: auto-enable step failed unexpectedly: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
}
export function buildAgentSendCommand(
paths: FleetPaths,
agentName: string,
@@ -275,6 +380,16 @@ export function buildAgentTailCommand(
// ---------------------------------------------------------------------------
export const HEARTBEAT_INTERVAL_MS = 15_000;
/**
* Heartbeat interval in ms, honoring MOSAIC_HEARTBEAT_INTERVAL (seconds) so the
* `fleet ps` freshness threshold matches the writer sidecar's actual cadence
* (start-agent-session.sh). Falls back to HEARTBEAT_INTERVAL_MS (15s).
*/
export function heartbeatIntervalMs(): number {
const sec = Number.parseInt(process.env.MOSAIC_HEARTBEAT_INTERVAL ?? '', 10);
return Number.isFinite(sec) && sec > 0 ? sec * 1000 : HEARTBEAT_INTERVAL_MS;
}
export const HEARTBEAT_HEALTHY_MULTIPLIER = 3;
export interface HeartbeatInfo {
@@ -284,6 +399,8 @@ export interface HeartbeatInfo {
/** healthy | stale | unknown */
health: 'healthy' | 'stale' | 'unknown';
ageMs: number | null;
/** Model id the runtime self-reported in its heartbeat (native HB only), else null. */
model: string | null;
}
export interface AgentPsRow {
@@ -302,6 +419,10 @@ export interface AgentPsRow {
driftFlag: boolean;
/** active but UnitFileState=disabled */
bootEnableWarning: boolean;
/** true = came from roster; false = found on socket but not in roster */
managed: boolean;
/** "roster" = defined in roster.yaml; "socket" = discovered via tmux list-sessions */
source: 'roster' | 'socket';
}
/**
@@ -344,11 +465,34 @@ export function buildTmuxListPanesCommand(
];
}
/**
* Returns the tmux list-sessions command to enumerate all sessions on a socket.
* Format: `tmux -L <socket> list-sessions -F '#{session_name}'`
* Used to discover ad-hoc sessions that are not in the roster.
*/
export function buildTmuxListSessionsCommand(socketName = DEFAULT_SOCKET_NAME): string[] {
return ['tmux', '-L', socketName, 'list-sessions', '-F', '#{session_name}'];
}
/**
* Parse the output of `tmux list-sessions -F '#{session_name}'` into an array of session names.
* Returns an empty array on empty/blank output.
*/
export function parseTmuxListSessions(output: string): string[] {
return output
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
}
/**
* Returns the heartbeat file path for an agent.
*/
export function heartbeatPath(agentName: string, mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'run', `${agentName}.hb`);
// Honor MOSAIC_HEARTBEAT_RUN_DIR (the writer sidecar's override); otherwise the
// canonical <mosaicHome>/fleet/run. Keeps reader and writer on the same path.
const runDir = process.env.MOSAIC_HEARTBEAT_RUN_DIR ?? join(mosaicHome, 'fleet', 'run');
return join(runDir, `${agentName}.hb`);
}
/**
@@ -357,15 +501,17 @@ export function heartbeatPath(agentName: string, mosaicHome = defaultMosaicHome(
* ts=<iso8601>
* pid=<pid>
* status=<ok|busy>
* model=<model-id> (optional — native runtime heartbeats self-report it)
*/
export function parseHeartbeat(content: string | null, nowMs = Date.now()): HeartbeatInfo {
if (content === null) {
return { ts: null, pid: null, status: null, health: 'unknown', ageMs: null };
return { ts: null, pid: null, status: null, health: 'unknown', ageMs: null, model: null };
}
const lines = content.split('\n');
let ts: Date | null = null;
let pid: number | null = null;
let status: 'ok' | 'busy' | null = null;
let model: string | null = null;
for (const line of lines) {
const [key, ...rest] = line.split('=');
const val = rest.join('=').trim();
@@ -377,16 +523,18 @@ export function parseHeartbeat(content: string | null, nowMs = Date.now()): Hear
if (Number.isFinite(n)) pid = n;
} else if (key === 'status' && (val === 'ok' || val === 'busy')) {
status = val;
} else if (key === 'model' && val) {
model = val;
}
}
const thresholdMs = HEARTBEAT_INTERVAL_MS * HEARTBEAT_HEALTHY_MULTIPLIER;
const thresholdMs = heartbeatIntervalMs() * HEARTBEAT_HEALTHY_MULTIPLIER;
let health: 'healthy' | 'stale' | 'unknown' = 'unknown';
let ageMs: number | null = null;
if (ts !== null) {
ageMs = nowMs - ts.getTime();
health = ageMs <= thresholdMs ? 'healthy' : 'stale';
}
return { ts, pid, status, health, ageMs };
return { ts, pid, status, health, ageMs, model };
}
/**
@@ -437,32 +585,41 @@ export function parseTmuxListPanes(
return { pid, command, dead, idleSeconds };
}
/**
* Maps each known runtime to the set of acceptable pane commands.
* A pane running any of these commands for the given runtime is NOT considered drifted.
* Runtimes launched via `mosaic yolo` wrap in node, so 'node' is acceptable for most.
* The dogfood runtime accepts python3/python (the canary-pi dogfood stub).
*/
export const RUNTIME_ACCEPTABLE_COMMANDS: Record<string, readonly string[]> = {
claude: ['claude', 'node'],
codex: ['codex', 'node'],
opencode: ['opencode', 'node'],
pi: ['pi', 'node'],
dogfood: ['python3', 'python'],
};
/**
* Determine if there is a runtime drift: roster says one runtime but the pane
* is actually running something from a different runtime. We detect this by
* checking if the pane command doesn't match a known canonical command for the
* checking if the pane command doesn't match a known acceptable command for the
* roster's declared runtime.
*
* Known canonical commands per runtime:
* claude → claude
* codex → codex
* opencode → opencode
* pi → pi
* Known acceptable commands per runtime (see RUNTIME_ACCEPTABLE_COMMANDS):
* claude → claude, node (node covers mosaic yolo wrapper)
* codex → codex, node
* opencode → opencode, node
* pi → pi, node (python3 still flags drift for canary-pi dogfood stub)
* dogfood → python3, python
*
* If the pane is running something else (e.g., python3/dogfood-agent.py) for
* an agent whose roster runtime is "pi", that's a drift.
*/
export function detectDrift(rosterRuntime: string, paneCommand: string | null): boolean {
if (!paneCommand) return false;
const knownCommands: Record<string, string[]> = {
claude: ['claude'],
codex: ['codex'],
opencode: ['opencode'],
pi: ['pi'],
};
const expected = knownCommands[rosterRuntime];
if (!expected) return false;
return !expected.includes(paneCommand);
const acceptable = RUNTIME_ACCEPTABLE_COMMANDS[rosterRuntime];
if (!acceptable) return false;
return !acceptable.includes(paneCommand);
}
/**
@@ -679,19 +836,42 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
cmd
.command('init')
.description('Initialize a local fleet roster')
.option('--profile <name>', 'Roster profile: minimal or local-canary', 'minimal')
.option(
'--profile <name>',
`Roster profile: ${FLEET_PROFILES.join(', ')} (skips interactive wizard)`,
)
.option('--write', 'Write the roster to Mosaic home')
.option('--force', 'Overwrite an existing roster when used with --write')
.action(async (opts: { profile: string; write?: boolean; force?: boolean }) => {
.action(async (opts: { profile?: string; write?: boolean; force?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const profile = parseInitProfile(opts.profile);
const source = join(frameworkRoot, 'fleet', 'examples', `${profile}.yaml`);
let profile: FleetProfile;
if (opts.profile !== undefined) {
// Explicit --profile flag: validate and use it (non-interactive path).
profile = parseInitProfile(opts.profile);
} else {
// No --profile: use wizard when stdin is a TTY, else default to 'general'.
const isTTY = deps.isStdinTTY ?? process.stdin.isTTY ?? false;
if (isTTY) {
profile = await promptFleetProfile();
} else {
process.stderr.write(
'Note: stdin is not a TTY; defaulting to fleet profile "general". ' +
'Use --profile <name> to select a different preset.\n',
);
profile = 'general';
}
}
const source = join(frameworkRoot, 'fleet', 'examples', resolvePresetFilename(profile));
const content = await readFile(source, 'utf8');
if (!opts.write) {
console.log(content.trimEnd());
return;
}
const destination = commandOpts.roster ?? activePaths.rosterPath;
if (!opts.force && (await canRead(destination))) {
throw new Error(
@@ -700,18 +880,57 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
}
await mkdir(dirname(destination), { recursive: true });
await writeFile(destination, content);
console.log(`Wrote fleet roster: ${destination}`);
// 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);
const enhancerCount = countEnhancers(written);
if (profile === 'minimal') {
console.log(
`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 - enhancerCount;
console.log(
`Initialized ${profile} fleet: 1 orchestrator + ${enhancerCount} enhancer(s) + ` +
`${workerCount} worker(s). Next: mosaic fleet install`,
);
}
});
cmd
.command('install')
.description('Install local fleet tools and user systemd units')
.action(async () => installFleet(cmd, frameworkRoot));
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
const roster = await loadRosterForCommand(cmd);
await enableFleetUnits(runner, roster, opts);
});
cmd
.command('install-systemd')
.description('Install local fleet tools and user systemd units')
.action(async () => installFleet(cmd, frameworkRoot));
.option('--no-enable', 'Skip enabling units for boot-survival')
.action(async (opts: { enable?: boolean }) => {
await installFleet(cmd, frameworkRoot);
const roster = await loadRosterForCommand(cmd);
await enableFleetUnits(runner, roster, opts);
});
for (const action of ['start', 'stop', 'restart'] as const) {
cmd
@@ -791,7 +1010,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
cmd
.command('ps')
.description('Show real-time status for all roster agents (systemd + tmux + heartbeat)')
.description(
'Show real-time status for all roster agents and unmanaged socket sessions (systemd + tmux + heartbeat)',
)
.option('--json', 'Print JSON array')
.action(async (opts: { json?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
@@ -802,6 +1023,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
const rows: AgentPsRow[] = [];
// Build the set of roster agent names for quick lookup when filtering socket sessions.
const rosterAgentNames = new Set(roster.agents.map((a) => a.name));
for (const agent of roster.agents) {
// systemd show
const showResult = await runner(...splitCommand(buildSystemdShowCommand(agent.name)));
@@ -842,9 +1066,75 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
heartbeat: hb,
driftFlag,
bootEnableWarning,
managed: true,
source: 'roster',
});
}
// Enumerate all live sessions on the socket to surface unmanaged (ad-hoc) sessions.
// If list-sessions fails (socket not up), silently skip — show roster rows only.
try {
const listSessionsResult = await runner(
...splitCommand(buildTmuxListSessionsCommand(roster.tmux.socketName)),
);
if (listSessionsResult.exitCode === 0) {
const socketSessions = parseTmuxListSessions(listSessionsResult.stdout);
const holderSession = roster.tmux.holderSession;
for (const sessionName of socketSessions) {
// Skip roster agents (already in rows) and the holder session (infrastructure).
if (rosterAgentNames.has(sessionName) || sessionName === holderSession) {
continue;
}
// tmux list-panes for pane info
const panesResult = await runner(
...splitCommand(buildTmuxListPanesCommand(sessionName, roster.tmux.socketName)),
);
const paneInfo = parseTmuxListPanes(panesResult.stdout, nowMs);
// heartbeat — try reading the .hb file using the same path convention
const hbFile = heartbeatPath(sessionName, activePaths.mosaicHome);
let hbContent: string | null = null;
try {
hbContent = await readFile(hbFile, 'utf8');
} catch {
hbContent = null;
}
const hb = parseHeartbeat(hbContent, nowMs);
// systemd — check if mosaic-agent@<name>.service exists (usually inactive for ad-hoc)
const showResult = await runner(...splitCommand(buildSystemdShowCommand(sessionName)));
const sysInfo = parseSystemdShow(showResult.stdout);
const bootEnableWarning =
sysInfo.ActiveState === 'active' && sysInfo.UnitFileState === 'disabled';
rows.push({
name: sessionName,
tenant_id,
host,
// runtime unknown — not in roster
runtime: 'unknown',
systemdActive: sysInfo.ActiveState,
systemdEnabled: sysInfo.UnitFileState,
paneAlive: !paneInfo.dead,
panePid: paneInfo.pid,
paneCommand: paneInfo.command,
idleSeconds: paneInfo.idleSeconds,
heartbeat: hb,
// No roster runtime to compare — drift is not meaningful for unmanaged sessions
driftFlag: false,
bootEnableWarning,
managed: false,
source: 'socket',
});
}
}
} catch {
// list-sessions failed (socket missing or permission error) — show roster rows only
}
if (opts.json) {
console.log(JSON.stringify(rows, null, 2));
return;
@@ -861,6 +1151,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
'PID'.padEnd(8),
'IDLE'.padEnd(8),
'HB'.padEnd(12),
'MODEL'.padEnd(22),
'FLAGS',
].join(' ');
console.log(header);
@@ -875,7 +1166,9 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
row.heartbeat.ageMs !== null
? `${Math.round(row.heartbeat.ageMs / 1000)}s/${row.heartbeat.health}`
: `unknown`;
const model = row.heartbeat.model ?? '-';
const flags: string[] = [];
if (!row.managed) flags.push('UNMANAGED');
if (row.driftFlag) flags.push('DRIFT');
if (row.bootEnableWarning) flags.push('BOOT-ENABLE');
@@ -890,12 +1183,157 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
pid.padEnd(8),
idle.padEnd(8),
hbAge.padEnd(12),
model.padEnd(22),
flags.join(','),
].join(' '),
);
}
});
cmd
.command('add <name>')
.description('Add a new agent to the fleet roster and optionally start it')
.requiredOption('--runtime <runtime>', `Agent runtime (${VALID_FLEET_RUNTIMES.join(', ')})`)
.requiredOption('--class <class>', 'Agent class (e.g. worker, orchestrator, canary)')
.option('--model <hint>', 'Model hint for the agent')
.option('--working-dir <path>', 'Working directory for the agent')
.option('--no-start', 'Skip starting the agent after adding')
.action(
async (
name: string,
opts: {
runtime: string;
class: string;
model?: string;
workingDir?: string;
start: boolean;
},
) => {
if (!VALID_FLEET_RUNTIMES.includes(opts.runtime)) {
throw new Error(
`Invalid runtime "${opts.runtime}". Valid runtimes: ${VALID_FLEET_RUNTIMES.join(', ')}.`,
);
}
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadFleetRoster(rosterPath);
const newAgent: FleetAgent = {
name,
runtime: opts.runtime,
className: opts.class,
...(opts.workingDir !== undefined && { workingDirectory: opts.workingDir }),
...(opts.model !== undefined && { modelHint: opts.model }),
};
const updatedRoster = addAgentToRoster(roster, newAgent);
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
const envPath = join(activePaths.agentEnvDir, `${name}.env`);
const existingEnv = (await canRead(envPath)) ? await readFile(envPath, 'utf8') : undefined;
await mkdir(activePaths.agentEnvDir, { recursive: true });
await writeFile(
envPath,
mergeAgentEnv(generateAgentEnv(updatedRoster, newAgent), existingEnv),
);
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.`);
} else {
console.log(`Agent queued (--no-start); run: mosaic fleet start ${name}`);
}
},
);
cmd
.command('remove <name>')
.description('Remove an agent from the fleet roster')
.option('--keep-files', 'Skip deleting env and heartbeat files')
.action(async (name: string, opts: { keepFiles?: boolean }) => {
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
const roster = await loadFleetRoster(rosterPath);
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
const updatedRoster = removeAgentFromRoster(roster, name);
// Stop agent (non-fatal)
try {
const stopResult = await runner(...splitCommand(buildFleetServiceCommand('stop', name)));
if (stopResult.exitCode !== 0) {
process.stderr.write(
`Warning: could not stop mosaic-agent@${name}.service: ${stopResult.stderr || stopResult.stdout || 'non-zero exit'}\n`,
);
}
} catch (err) {
process.stderr.write(
`Warning: stop command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
);
}
// 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));
// Delete env and heartbeat files (best-effort, non-fatal)
if (!opts.keepFiles) {
try {
await unlink(join(activePaths.agentEnvDir, `${name}.env`));
} catch {
// best-effort
}
try {
await unlink(heartbeatPath(name, activePaths.mosaicHome));
} catch {
// best-effort
}
}
console.log(`Removed ${name} from the fleet.`);
});
return cmd;
}
@@ -1075,15 +1513,19 @@ export function registerFleetAgentCommands(
await runChecked(runner, buildAgentWatchCreateViewerCommand(agent, viewerName, socketName));
let exitCode = 0;
try {
const [bin, args] = splitCommand(buildAgentWatchAttachCommand(viewerName, socketName));
const exitCode = await iRunner(bin, args);
// Best-effort cleanup of the viewer session regardless of how the user detached.
// Errors here are intentionally suppressed — the agent session is unaffected.
exitCode = await iRunner(bin, args);
} finally {
// ALWAYS clean up the viewer session — even if attach threw or the process was
// interrupted — so stale grouped *-watch-* sessions never accumulate. Errors here
// are intentionally suppressed; the agent session is unaffected.
const killResult = await runner(
...splitCommand(buildAgentWatchKillViewerCommand(viewerName, socketName)),
);
void killResult; // result is intentionally ignored
void killResult;
}
if (exitCode !== 0) {
process.exitCode = exitCode;
@@ -1466,11 +1908,213 @@ function splitCommand(command: string[]): [string, string[]] {
return [bin, args];
}
function parseInitProfile(profile: string): 'minimal' | 'local-canary' {
if (profile === 'minimal' || profile === 'local-canary') {
return profile;
/** All supported fleet profile names. */
export type FleetProfile =
| 'general'
| 'coding'
| 'research'
| 'hybrid'
| 'minimal'
| 'local-canary';
/** The list of all valid fleet profile names, for wizard menus and error messages. */
export const FLEET_PROFILES: readonly FleetProfile[] = [
'general',
'coding',
'research',
'hybrid',
'minimal',
'local-canary',
];
/**
* Maps a fleet profile name to its example YAML filename (without the path).
* Pure function — testable without I/O.
*/
export function resolvePresetFilename(profile: FleetProfile): string {
return `${profile}.yaml`;
}
/**
* Validate and normalise a fleet profile name string.
* Throws with a clear message on unknown values.
*/
export function parseInitProfile(profile: string): FleetProfile {
if ((FLEET_PROFILES as readonly string[]).includes(profile)) {
return profile as FleetProfile;
}
throw new Error(`Unsupported fleet profile "${profile}". Use: minimal, local-canary.`);
throw new Error(`Unsupported fleet profile "${profile}". Use: ${FLEET_PROFILES.join(', ')}.`);
}
/**
* Count orchestrator agents in a parsed roster.
* Returns the count; callers assert === 1.
*/
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',
'claude',
'codex',
'opencode',
'dogfood',
];
/**
* Add a new agent to a fleet roster (immutable — returns a new FleetRoster).
* Throws on invalid name, duplicate name.
*/
export function addAgentToRoster(roster: FleetRoster, agent: FleetAgent): FleetRoster {
if (!agent.name || !/^[A-Za-z0-9_.-]+$/.test(agent.name)) {
throw new Error(`Invalid fleet agent name: ${agent.name || '<empty>'}`);
}
if (roster.agents.some((a) => a.name === agent.name)) {
throw new Error(`Agent "${agent.name}" already exists in the fleet roster.`);
}
return {
...roster,
agents: [...roster.agents, agent],
};
}
/**
* Remove an agent from a fleet roster (immutable — returns a new FleetRoster).
* Throws if the agent is not found, or if removal would leave zero orchestrators.
*/
export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetRoster {
const agent = roster.agents.find((a) => a.name === name);
if (!agent) {
throw new Error(`Agent "${name}" is not in the fleet roster.`);
}
const remaining = roster.agents.filter((a) => a.name !== name);
const remainingOrchCount = remaining.filter((a) => a.className === 'orchestrator').length;
if (remainingOrchCount === 0 && agent.className === 'orchestrator') {
throw new Error(
`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,
};
}
/**
* Serialize a FleetRoster to YAML text (snake_case keys).
* The output is parseable by loadFleetRoster.
*/
export function serializeRosterToYaml(roster: FleetRoster): string {
const agents = roster.agents.map((agent) => {
const raw: Record<string, unknown> = {
name: agent.name,
runtime: agent.runtime,
class: agent.className,
};
if (agent.workingDirectory !== undefined) {
raw['working_directory'] = agent.workingDirectory;
}
if (agent.modelHint !== undefined) {
raw['model_hint'] = agent.modelHint;
}
if (agent.persistentPersona !== undefined) {
raw['persistent_persona'] = agent.persistentPersona;
}
if (agent.resetBetweenTasks !== undefined) {
raw['reset_between_tasks'] = agent.resetBetweenTasks;
}
if (agent.kickstartTemplate !== undefined) {
raw['kickstart_template'] = agent.kickstartTemplate;
}
return raw;
});
const runtimes: Record<string, { reset_command: string }> = {};
for (const [runtime, config] of Object.entries(roster.runtimes)) {
runtimes[runtime] = { reset_command: config.resetCommand };
}
const raw: Record<string, unknown> = {
version: roster.version,
transport: roster.transport,
tmux: {
socket_name: roster.tmux.socketName,
holder_session: roster.tmux.holderSession,
},
defaults: {
working_directory: roster.defaults.workingDirectory,
},
runtimes,
agents,
};
return YAML.stringify(raw);
}
/**
* Prompt interactively for a fleet profile via stdin readline.
* AI-free: no LLM calls — pure readline menu.
* Resolves with the chosen profile string, or rejects on I/O error.
*/
function promptFleetProfile(): Promise<FleetProfile> {
return new Promise((resolve, reject) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const menu = [
'',
'Choose a fleet configuration type:',
' 1) general — orchestrator + generalist worker',
' 2) coding — orchestrator + coder0 + coder1 + reviewer',
' 3) research — orchestrator + researcher0 + researcher1 + analyst',
' 4) hybrid — orchestrator + coder0 + researcher0 + reviewer',
' 5) minimal — single canary-pi agent (no orchestrator)',
' 6) local-canary — legacy canary preset with lead + coder + reviewer',
'',
].join('\n');
process.stdout.write(menu);
rl.question('Enter number or name [1]: ', (answer) => {
rl.close();
const trimmed = answer.trim();
// Map numeric shortcut → name
const byNumber: Record<string, FleetProfile> = {
'1': 'general',
'2': 'coding',
'3': 'research',
'4': 'hybrid',
'5': 'minimal',
'6': 'local-canary',
'': 'general', // default on empty enter
};
if (trimmed in byNumber) {
resolve(byNumber[trimmed]!);
return;
}
try {
resolve(parseInitProfile(trimmed));
} catch (err) {
reject(err);
}
});
});
}
function writeCommandOutput(result: CommandResult): void {

View File

@@ -291,12 +291,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 +342,55 @@ 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'));
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 +1015,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')

View File

@@ -99,11 +99,8 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
);
});
it('preserves existing contract files — never overwrites user customization', async () => {
// Also plant a root-level AGENTS.md in sourceDir so that `syncDirectory`
// itself (not just the seed loop) has something to try to overwrite.
// Without this, the test would silently pass even if preserve semantics
// were broken in syncDirectory.
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
@@ -112,18 +109,50 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
await adapter.syncFramework('keep');
// User-seeded TOOLS.md is preserved.
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
'# user-customized TOOLS\n',
);
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe(
// Framework-owned AGENTS.md is overwritten from defaults/ ...
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
// ... and the user's prior copy is backed up exactly once.
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe(
'# user-customized AGENTS\n',
);
// And the missing contract file still gets seeded.
// Framework-owned STANDARDS.md (absent) gets installed.
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
'# STANDARDS default',
);
});
it('backs up a divergent framework-owned file only once (idempotent across re-sync)', async () => {
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
await adapter.syncFramework('keep'); // 1st: backup created, AGENTS overwritten
await adapter.syncFramework('keep'); // 2nd: AGENTS already == default, no new backup
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe(
'# user-customized AGENTS\n',
);
});
it('preserves SOUL.md and credentials through a framework-owned overwrite', async () => {
writeFileSync(join(fixture.mosaicHome, 'SOUL.md'), '# my persona\n');
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
mkdirSync(join(fixture.mosaicHome, 'credentials'), { recursive: true });
writeFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'token\n');
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
await adapter.syncFramework('keep');
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf-8')).toBe('# my persona\n');
expect(readFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'utf-8')).toBe(
'token\n',
);
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
});
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
rmSync(fixture.defaultsDir, { recursive: true });

View File

@@ -13,12 +13,17 @@ import { join } from 'node:path';
* This list must match the explicit seed loop in
* packages/mosaic/framework/install.sh.
*/
export const DEFAULT_SEED_FILES = [
'CONSTITUTION.md',
'AGENTS.md',
'STANDARDS.md',
'TOOLS.md',
] as const;
// Framework-owned contract files: re-copied from defaults/ on every upgrade (a
// divergent existing copy is backed up once to <file>.pre-constitution.bak first).
// MUST match FRAMEWORK_OWNED in packages/mosaic/framework/install.sh (append-friendly).
export const FRAMEWORK_OWNED_FILES = ['CONSTITUTION.md', 'AGENTS.md', 'STANDARDS.md'] as const;
// User-seeded contract files: written once on first install, then owned by the user.
// MUST match USER_SEEDED in packages/mosaic/framework/install.sh.
export const USER_SEEDED_FILES = ['TOOLS.md'] as const;
// Union, retained for callers/tests that assert the full seed set on a fresh install.
export const DEFAULT_SEED_FILES = [...FRAMEWORK_OWNED_FILES, ...USER_SEEDED_FILES] as const;
import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
@@ -159,6 +164,7 @@ export class FileConfigAdapter implements ConfigService {
const preservePaths =
action === 'keep' || action === 'reconfigure'
? [
'CONSTITUTION.md',
'AGENTS.md',
'SOUL.md',
'USER.md',
@@ -175,10 +181,10 @@ export class FileConfigAdapter implements ConfigService {
excludeGit: true,
});
// Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md)
// from framework/defaults/ into the mosaic home root if they don't
// exist yet. These are written on first install only and are never
// overwritten afterwards — the user may have customized them.
// Reconcile framework-contract files from framework/defaults/ into the mosaic
// home root: framework-owned files (CONSTITUTION/AGENTS/STANDARDS) are overwritten
// every upgrade (backup-once); user-seeded files (TOOLS) are written on first
// install only. Mirrors reconcile_framework_files() in install.sh.
//
// SOUL.md and USER.md are deliberately NOT seeded here. They are
// generated from templates by the soul/user wizard stages with
@@ -186,7 +192,22 @@ export class FileConfigAdapter implements ConfigService {
// identity flow and leak placeholder content into the mosaic home.
const defaultsDir = join(this.sourceDir, 'defaults');
if (existsSync(defaultsDir)) {
for (const entry of DEFAULT_SEED_FILES) {
// Framework-owned: overwrite from defaults/ every sync; back up a divergent
// existing copy ONCE to <file>.pre-constitution.bak before the first overwrite.
for (const entry of FRAMEWORK_OWNED_FILES) {
const src = join(defaultsDir, entry);
const dest = join(this.mosaicHome, entry);
if (!existsSync(src) || !statSync(src).isFile()) continue;
// Already current — skip to avoid mtime churn.
if (existsSync(dest) && readFileSync(src).equals(readFileSync(dest))) continue;
const bak = `${dest}.pre-constitution.bak`;
if (existsSync(dest) && !existsSync(bak)) {
copyFileSync(dest, bak);
}
copyFileSync(src, dest);
}
// User-seeded: write only if absent.
for (const entry of USER_SEEDED_FILES) {
const src = join(defaultsDir, entry);
const dest = join(this.mosaicHome, entry);
if (existsSync(dest)) continue;

View 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);
});
});

View 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();
}

View 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';

View 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 });
});
});

View File

@@ -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.
*/