Compare commits
2 Commits
feat/f4-ma
...
feat/p3-co
| Author | SHA1 | Date | |
|---|---|---|---|
| d3c5daf12d | |||
| 319e21ddc0 |
@@ -25,10 +25,12 @@ steps:
|
|||||||
commands:
|
commands:
|
||||||
- apk add --no-cache bash
|
- apk add --no-cache bash
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||||
# Resident line-count ceiling over framework-owned resident files
|
# L0 resident-token budget: keep the Constitution + dispatcher small.
|
||||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
- |
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
for f in CONSTITUTION.md AGENTS.md; do
|
||||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
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
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
|
|||||||
@@ -4,23 +4,6 @@
|
|||||||
variables:
|
variables:
|
||||||
- &node_image 'node:22-alpine'
|
- &node_image 'node:22-alpine'
|
||||||
- &enable_pnpm 'corepack enable'
|
- &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:
|
when:
|
||||||
- branch: [main]
|
- branch: [main]
|
||||||
@@ -43,15 +26,6 @@ steps:
|
|||||||
|
|
||||||
publish-npm:
|
publish-npm:
|
||||||
image: *node_image
|
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:
|
environment:
|
||||||
NPM_TOKEN:
|
NPM_TOKEN:
|
||||||
from_secret: gitea_token
|
from_secret: gitea_token
|
||||||
@@ -117,7 +91,6 @@ steps:
|
|||||||
|
|
||||||
build-gateway:
|
build-gateway:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -143,7 +116,6 @@ steps:
|
|||||||
|
|
||||||
build-appservice:
|
build-appservice:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
@@ -169,7 +141,6 @@ steps:
|
|||||||
|
|
||||||
build-web:
|
build-web:
|
||||||
image: gcr.io/kaniko-project/executor:debug
|
image: gcr.io/kaniko-project/executor:debug
|
||||||
when: *image_build_when
|
|
||||||
environment:
|
environment:
|
||||||
REGISTRY_USER:
|
REGISTRY_USER:
|
||||||
from_secret: gitea_username
|
from_secret: gitea_username
|
||||||
|
|||||||
@@ -45,28 +45,3 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
|||||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
- 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.
|
- 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.
|
- 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.
|
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
# Constitution Alpha — Definition-of-Done checklist + release notes
|
|
||||||
|
|
||||||
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
|
|
||||||
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
|
|
||||||
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
|
|
||||||
|
|
||||||
## DoD §8 green-checklist
|
|
||||||
|
|
||||||
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
|
|
||||||
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
|
|
||||||
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
|
|
||||||
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
|
|
||||||
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
|
|
||||||
| 5 | `rails/`→`tools/` in **both** template families | ✅ | P2 #572 |
|
|
||||||
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md` → **neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
|
|
||||||
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
|
|
||||||
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
|
|
||||||
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
|
|
||||||
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
|
|
||||||
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
|
|
||||||
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
|
|
||||||
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
|
|
||||||
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
|
|
||||||
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
|
|
||||||
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
|
|
||||||
|
|
||||||
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
|
|
||||||
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
|
|
||||||
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
|
|
||||||
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
|
|
||||||
|
|
||||||
**Gate to flip 12–14 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
|
|
||||||
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
|
|
||||||
|
|
||||||
### Mosaic Framework Constitution — Alpha
|
|
||||||
|
|
||||||
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
|
|
||||||
operating layer**. It separates the non-negotiable law from operator identity, makes
|
|
||||||
customization survive upgrades, and wires the guarantees into CI.
|
|
||||||
|
|
||||||
**Highlights**
|
|
||||||
|
|
||||||
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
|
|
||||||
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
|
|
||||||
it. Governance model in `constitution/LAYER-MODEL.md`.
|
|
||||||
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
|
|
||||||
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
|
|
||||||
**blocking** in CI so re-contamination can't merge.
|
|
||||||
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
|
|
||||||
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
|
|
||||||
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md` —
|
|
||||||
never silently lost. Verified by a 5-fixture matrix across **both** installers.
|
|
||||||
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
|
|
||||||
the contract per harness, so customization reaches the model as one pre-merged blob.
|
|
||||||
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
|
|
||||||
tiered injection with a byte-equal Tier-3 fallback read.
|
|
||||||
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
|
|
||||||
unit test; sanitization gate — all blocking.
|
|
||||||
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
|
|
||||||
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
|
|
||||||
|
|
||||||
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
|
|
||||||
|
|
||||||
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
|
|
||||||
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
|
|
||||||
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
|
|
||||||
verification are **v2**.
|
|
||||||
|
|
||||||
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
|
|
||||||
aiguide #8 (umbrella #542).
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# 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.35–0.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 | R1–R5, R8, R10, R17 |
|
|
||||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
|
||||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
|
||||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
|
||||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
|
||||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
|
||||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
|
||||||
|
|
||||||
## Work division (proposed — confirm with dragon-lin)
|
|
||||||
|
|
||||||
- **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 P4–P6 (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).
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
# PRD — Fleet Phase 2: Operator Observability
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
|
||||||
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
|
||||||
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
|
||||||
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The durable tmux fleet runs on the isolated `mosaic-factory` socket. That isolation
|
|
||||||
(which protects the operator's default tmux) makes the fleet **invisible** to default
|
|
||||||
tooling, and truth is split across three planes no single command joins — systemd
|
|
||||||
(`systemctl --user`), tmux (`-L mosaic-factory`), and the process tree (`pstree`).
|
|
||||||
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
|
|
||||||
confirms only keystroke injection, not acceptance. Net: the operator has near-zero
|
|
||||||
observability and no safe way to watch a session.
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
|
|
||||||
1. One command shows the **whole fleet's** real state, joining all three planes.
|
|
||||||
2. **Liveness is truthful**: healthy = answered a heartbeat, not "pane alive".
|
|
||||||
3. The operator can **watch** any session read-only without disrupting it.
|
|
||||||
4. `send` reports **delivered-and-accepted**, not just injected.
|
|
||||||
5. Every record/address carries **`tenant_id` + `host`** (zero foreclosure for multi-tenant/multi-host).
|
|
||||||
|
|
||||||
## Non-goals (this phase)
|
|
||||||
|
|
||||||
- No webUI (Phase 5; rides federation for cross-host).
|
|
||||||
- No `fleetd` daemon or persistent history store.
|
|
||||||
- No real-runtime swap (Phase 3) — instrument the live **dogfood stub** fleet.
|
|
||||||
- No cross-host aggregation yet (addressing is host-tagged but queries stay local).
|
|
||||||
|
|
||||||
## Functional requirements
|
|
||||||
|
|
||||||
| ID | Requirement |
|
|
||||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
|
|
||||||
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. |
|
|
||||||
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. |
|
|
||||||
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
|
||||||
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
|
|
||||||
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
|
|
||||||
|
|
||||||
## Heartbeat protocol v1
|
|
||||||
|
|
||||||
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a
|
|
||||||
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`.
|
|
||||||
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
|
|
||||||
on a fixed interval (default 15s) and on demand when probed.
|
|
||||||
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`.
|
|
||||||
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
|
|
||||||
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
|
|
||||||
full-screen TUIs alike (no `capture-pane` dependency).
|
|
||||||
- `ASSUMPTION:` file-based heartbeat (vs in-pane echo) — chosen because it is TUI-safe and
|
|
||||||
uid-scoped, fitting per-tenant isolation. Open to an OTEL-span variant in Phase 3 (MVP-X6).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-factory` with correct
|
|
||||||
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
|
|
||||||
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
|
|
||||||
- Killing one agent's pane flips its row to dead/stale within one `interval`.
|
|
||||||
- `agent watch` shows live output and provably cannot type into the pane; detaching
|
|
||||||
leaves the agent's window size unchanged.
|
|
||||||
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane.
|
|
||||||
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus
|
|
||||||
`pnpm --filter @mosaicstack/mosaic test`.
|
|
||||||
- Independent review passed; dogfood evidence captured against the live fleet.
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
|
|
||||||
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
|
|
||||||
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
|
|
||||||
exact tmux/systemd command construction and JSON shape (tenant+host present).
|
|
||||||
- Situational: run against the live `mosaic-factory` fleet; capture `fleet ps` output,
|
|
||||||
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
|
|
||||||
|
|
||||||
## Known limitations
|
|
||||||
|
|
||||||
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft
|
|
||||||
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
|
|
||||||
TUIs is best-effort only; those runtimes may not use the same input-line indicator.
|
|
||||||
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
|
|
||||||
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then
|
|
||||||
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
|
|
||||||
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if
|
|
||||||
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
|
|
||||||
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
|
|
||||||
definitive result, it fails closed: exit 1 with "no pane change after send". This
|
|
||||||
eliminates false 'unverifiable' failures for slow/loaded TUIs that were previously
|
|
||||||
caused by the old fixed 300 ms single-capture. Definitive acceptance ultimately
|
|
||||||
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
|
|
||||||
poll is the best signal available against an opaque TUI for Phase-2.
|
|
||||||
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
|
|
||||||
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify`
|
|
||||||
returns non-zero with an "unverifiable" message rather than silently succeeding. This
|
|
||||||
is an intentional fail-closed design (FR-5).
|
|
||||||
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the
|
|
||||||
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead
|
|
||||||
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s
|
|
||||||
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach.
|
|
||||||
The grouped session shares the agent's windows but has independent sizing, so the
|
|
||||||
agent's window is never affected. `tmux attach` is still interactive and requires
|
|
||||||
inherited stdio; the `interactiveRunner` handles TTY passthrough.
|
|
||||||
|
|
||||||
## Surfaces & parity (MVP-X1)
|
|
||||||
|
|
||||||
CLI lands this phase. TUI surface follows in the `packages/mosaic` wizard; webUI in
|
|
||||||
Phase 5 via federation. PRD records the parity debt explicitly so it is not lost.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
|
||||||
|
|
||||||
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
|
||||||
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
|
||||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
|
||||||
>
|
|
||||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
|
||||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
|
||||||
|
|
||||||
| id | status | description | depends_on | agent | pr | notes |
|
|
||||||
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
|
|
||||||
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
|
|
||||||
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
|
|
||||||
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-factory; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
|
|
||||||
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
|
|
||||||
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
|
|
||||||
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
|
|
||||||
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
|
|
||||||
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
|
|
||||||
| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main |
|
|
||||||
|
|
||||||
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
|
|
||||||
|
|
||||||
```
|
|
||||||
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
|
|
||||||
```
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
# Mosaic Fleet — North Star
|
|
||||||
|
|
||||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
|
||||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
|
||||||
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
|
||||||
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
|
||||||
|
|
||||||
## Vision
|
|
||||||
|
|
||||||
A **customizable, multi-tenant fleet of always-on AI agents** — each defined by role,
|
|
||||||
materialized as a durable, joinable runtime session, coordinated by the proven
|
|
||||||
orchestrator/worker model, and observable end-to-end across hosts. Coding today;
|
|
||||||
finance, analytics, research as roster entries tomorrow — same primitives, different
|
|
||||||
roster. The fleet is the **agent-session execution layer** of the Mosaic Stack MVP:
|
|
||||||
the thing federation makes reachable across hosts and the webUI/TUI/CLI make visible.
|
|
||||||
|
|
||||||
The USC tmux PoC (durable sessions + `agent-send` comms) proved the model. This
|
|
||||||
workstream makes it an official, observable, multi-tenant Mosaic Stack capability.
|
|
||||||
|
|
||||||
## The Fleet as means of production (bootstrapping)
|
|
||||||
|
|
||||||
The Fleet has a **dual role**, and that is the point:
|
|
||||||
|
|
||||||
- **As product** — a multi-tenant agent-fleet capability of Mosaic Stack (this workstream).
|
|
||||||
- **As means of production** — the orchestrator/worker fleet that _actually builds the
|
|
||||||
entire MVP_ (federation W1, webUI, TUI, CLI, and the Fleet itself).
|
|
||||||
|
|
||||||
We are **building the system that builds the system.** Every other MVP workstream is
|
|
||||||
delivered _by_ the fleet, so fleet observability and control are not merely product
|
|
||||||
features — they are the **operational floor of the whole delivery effort**. If we cannot
|
|
||||||
see and steer the agents, we cannot trust what they ship. This is why Phase 2
|
|
||||||
(observability) leads: it is the instrument panel for the factory, dogfooded on the live
|
|
||||||
fleet that is, recursively, building Mosaic Stack.
|
|
||||||
|
|
||||||
The discipline that makes great power safe is the same gate chain the fleet enforces:
|
|
||||||
independent review before merge, green CI, honest completion, decide-and-inform cadence,
|
|
||||||
and no irreversible action without authority. The bootstrap is only as trustworthy as
|
|
||||||
those gates.
|
|
||||||
|
|
||||||
## Alignment with MVP cross-cutting requirements
|
|
||||||
|
|
||||||
The Fleet inherits — does not re-invent — the MVP's hard requirements:
|
|
||||||
|
|
||||||
| MVP req | What it means for the Fleet |
|
|
||||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
|
|
||||||
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage |
|
|
||||||
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
|
|
||||||
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push |
|
|
||||||
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
|
|
||||||
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
|
|
||||||
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
|
|
||||||
|
|
||||||
## The stack — where every concern lives
|
|
||||||
|
|
||||||
One **definition** is the source of truth; the **session** is how it runs.
|
|
||||||
|
|
||||||
| Layer | Owner | Phase-2 reality | Destination |
|
|
||||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
|
|
||||||
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
|
|
||||||
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
|
|
||||||
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
|
|
||||||
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
|
|
||||||
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
|
|
||||||
| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 |
|
|
||||||
|
|
||||||
## Operating model (inherited, not reinvented)
|
|
||||||
|
|
||||||
The AI-guide law stands: one accountable **orchestrator**, isolated **workers** that
|
|
||||||
stop at PR-open, the serialized **gate chain** (independent review → green CI →
|
|
||||||
diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a durable
|
|
||||||
**board** so missions survive session death. The Fleet is the infrastructure _under_
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. Carry **`tenant_id` + `host`** in schema and message addressing — even with one of each today.
|
|
||||||
2. Treat **isolation socket ≠ invisibility** — anything isolated is surfaced by one command.
|
|
||||||
3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive".
|
|
||||||
4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb.
|
|
||||||
|
|
||||||
## Observation model
|
|
||||||
|
|
||||||
| Verb | Behavior |
|
|
||||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
||||||
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
|
|
||||||
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
|
||||||
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
|
|
||||||
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected |
|
|
||||||
|
|
||||||
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
|
|
||||||
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane`
|
|
||||||
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
|
|
||||||
> verbs above restore "join and observe" safely.
|
|
||||||
|
|
||||||
## Phased roadmap
|
|
||||||
|
|
||||||
| Phase | Outcome | Status |
|
|
||||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
|
||||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
|
||||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
|
||||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: **orchestrator + 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 |
|
|
||||||
|
|
||||||
## Decisions of record (2026-06-20, with Jason)
|
|
||||||
|
|
||||||
- Agent model: **config defines, session runs** (gateway = definition/identity/auth; tmux = runtime).
|
|
||||||
- Tenancy: **multi-tenant from the start**; isolation = **per-tenant Linux uid**.
|
|
||||||
- Health: **heartbeat required** (dogfood stub implements the protocol now).
|
|
||||||
- Lifecycle: **hybrid** — core always-on + ephemeral workers per lane.
|
|
||||||
- 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)
|
|
||||||
|
|
||||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
|
||||||
finance, researcher) = persona + skills + tools on top of a runtime, shipped as a
|
|
||||||
starter role library in the framework.
|
|
||||||
- `ASSUMPTION:` the cross-host control plane is the **federation** layer (W1), not a
|
|
||||||
separate `fleetd` daemon.
|
|
||||||
- `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in
|
|
||||||
`docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to
|
|
||||||
the MVP orchestrator, not written by this workstream.
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# Scratchpad — Fleet Phase 2: Observability (W-FLEET)
|
|
||||||
|
|
||||||
> Append-only. Mission `mvp-20260312` / workstream W-FLEET.
|
|
||||||
> Lead: Jarvis (Claude) at `W-jarvis:mos-claude-18`. Coordinating with `jwoltje@dragon-lin:coder0-0`.
|
|
||||||
|
|
||||||
## Mission prompt (2026-06-20)
|
|
||||||
|
|
||||||
Establish the north star for the Mosaic Fleet feature and prepare Phase-2 observability
|
|
||||||
for delivery. The USC tmux PoC is the proven base. Jason granted lead authority:
|
|
||||||
"The fleet is a great way to actually build the MVP — we are building the system that
|
|
||||||
builds the system." Dogfood actual agent construction + ad-hoc deployment; coordinate
|
|
||||||
with a second agent on `dragon-lin`.
|
|
||||||
|
|
||||||
## Decisions of record (with Jason, 2026-06-20)
|
|
||||||
|
|
||||||
- Agent model: config defines, session runs (gateway = definition/identity/auth; tmux = runtime).
|
|
||||||
- Tenancy: multi-tenant from the start; isolation = per-tenant Linux uid.
|
|
||||||
- Health: heartbeat required; dogfood stub implements protocol now.
|
|
||||||
- Lifecycle: hybrid (core always-on + ephemeral workers).
|
|
||||||
- Observation: read-only default, opt-in takeover.
|
|
||||||
- Multi-host: designed-for day one; control plane rides federation (W1), not a bespoke broker.
|
|
||||||
- Delivery: CLI-first, dogfood on the live stub fleet; webUI deferred to Phase 5.
|
|
||||||
- Fleet is dual-role: product AND means of production (bootstrapping the MVP).
|
|
||||||
- Code review = **dual-engine**: Claude **and** gpt-5.5/Codex, run together (Jason: the
|
|
||||||
combination produces the best results). Launch reviewers via `mosaic yolo pi` / `codex`
|
|
||||||
(proven path) or `~/.config/mosaic/tools/codex/codex-code-review.sh`. Applies to all
|
|
||||||
code-review gates incl. FLEET-OBS-008. Per Jason 2026-06-20.
|
|
||||||
- Worktree discipline: do fleet work in `~/src/mosaicstack-stack-worktrees/<branch>`, NOT
|
|
||||||
the shared main checkout — concurrent processes mutate `main` there (learned 2026-06-20).
|
|
||||||
|
|
||||||
## Environment facts (verified 2026-06-20)
|
|
||||||
|
|
||||||
- Fleet is live on `W-jarvis` (uid 1000, `jarvis`, `Linger=yes`) on tmux socket
|
|
||||||
`mosaic-factory`: `_holder`, `canary-pi`, `dogfood-coder`, `dogfood-orchestrator`,
|
|
||||||
`dogfood-reviewer`. All panes run `~/.config/mosaic/fleet/dogfood-agent.py` (stub),
|
|
||||||
including `canary-pi` (roster says runtime=pi → **drift**).
|
|
||||||
- Holder + `mosaic-agent@*` units are `active (exited)` but `UnitFileState=disabled`
|
|
||||||
(reboot loses fleet → boot-enable gap to surface).
|
|
||||||
- Observation blocked by: isolated socket (hidden from default `tmux ls`), `capture-pane`
|
|
||||||
blank for TUIs, `attach` being read-write + resizing.
|
|
||||||
- Second agent: `jwoltje@dragon-lin`, session `coder0-0` (group `coder0`), running `node`,
|
|
||||||
default socket. ssh forward reach confirmed.
|
|
||||||
|
|
||||||
## Governance / collision-safety
|
|
||||||
|
|
||||||
- `mosaicstack-stack` has active mission `mvp-20260312` with single-writer locks on
|
|
||||||
`docs/MISSION-MANIFEST.md`, `docs/TASKS.md`, `docs/scratchpads/mvp-20260312.md`.
|
|
||||||
- This workstream touches NONE of those. All Fleet docs scoped under `docs/fleet/` +
|
|
||||||
this scratchpad. Rollup row proposed, not written.
|
|
||||||
|
|
||||||
## Session log
|
|
||||||
|
|
||||||
- 2026-06-20: Researched AI guide + fleet code + live state. Established north star with
|
|
||||||
Jason (8 forks decided). Branched `feat/fleet-observability`. Persisted
|
|
||||||
`docs/fleet/{north-star.md,PRD.md,TASKS.md}` + this scratchpad. Next: establish comms
|
|
||||||
with dragon-lin coder, commit docs, begin Phase-2 delivery (heartbeat + `fleet ps`).
|
|
||||||
- 2026-06-20 (session 2): Built Phase-2 CLI via worker (commit ab47831): `fleet ps`,
|
|
||||||
`agent watch`, `agent send --verify`, 62 tests. LIVE-verified `fleet ps` on
|
|
||||||
mosaic-factory — correctly flagged canary-pi DRIFT + BOOT-ENABLE, tenant_id+host in JSON.
|
|
||||||
Heartbeat responder added to dogfood-agent.py (FLEET-OBS-002) — `fleet ps` HB now
|
|
||||||
`healthy` for all 4 agents.
|
|
||||||
- Coordination: dual-engine-reviewed (Claude+Codex) and merged framework PRs #572
|
|
||||||
(sanitization gate) + #575 (CONSTITUTION extraction) as Lead. Codex caught an Alpine
|
|
||||||
blocker on #572 (refuted by CI); Claude caught a CI-breaking format failure on #575.
|
|
||||||
- **FINDINGS (north-star / Phase-3 blockers):**
|
|
||||||
1. Ad-hoc `mosaic yolo {codex,pi}` via `start-agent-session.sh` DIE immediately in a
|
|
||||||
detached tmux pane (codex: "stdin is not a terminal"; pi: same). Only the python stub
|
|
||||||
survives. => Real runtimes have NEVER run durably in the fleet. Launch path (PATH/TTY
|
|
||||||
in the detached shell) must be fixed before Phase-3 real-runtime swap. `fleet ps`
|
|
||||||
caught both dead panes instantly (tool validated).
|
|
||||||
2. `MOSAIC_AGENT_NAME` (set in systemd EnvironmentFile) is NOT propagated into tmux's
|
|
||||||
global env, so agents defaulted to `unknown`. Worked around in dogfood-agent.py via
|
|
||||||
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.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
# P5 — Overlay composer + cross-harness (compose-contract)
|
|
||||||
|
|
||||||
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
|
|
||||||
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
|
|
||||||
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
|
|
||||||
|
|
||||||
## Locked design (sequential-thinking)
|
|
||||||
|
|
||||||
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
|
|
||||||
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
|
|
||||||
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
|
|
||||||
|
|
||||||
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
|
|
||||||
labeled sections; base files keep their existing residency.
|
|
||||||
|
|
||||||
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
|
|
||||||
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
|
|
||||||
load-on-demand, so only the small delta is injected — not the full base prose).
|
|
||||||
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
|
|
||||||
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
|
|
||||||
layers first, operator overlays at recency.
|
|
||||||
- Base-only is automatic when a `.local` file is absent (`readOptional`).
|
|
||||||
|
|
||||||
## Plan
|
|
||||||
|
|
||||||
| # | Task | File |
|
|
||||||
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
|
|
||||||
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
|
|
||||||
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
|
|
||||||
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
|
|
||||||
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
|
|
||||||
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
|
|
||||||
|
|
||||||
## Deferred to P6
|
|
||||||
|
|
||||||
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
|
|
||||||
alpha tag `mosaic-vX.Y.Z-alpha`.
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
|
|
||||||
- [ ] Implementation (tasks 1–5)
|
|
||||||
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
|
|
||||||
|
|
||||||
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
|
|
||||||
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
|
|
||||||
|
|
||||||
## Delivered (in-repo)
|
|
||||||
|
|
||||||
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
|
|
||||||
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
|
|
||||||
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
|
|
||||||
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
|
|
||||||
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
|
|
||||||
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
|
|
||||||
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
|
|
||||||
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
|
|
||||||
|
|
||||||
## Remaining
|
|
||||||
|
|
||||||
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
|
|
||||||
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Alpha DoD (DESIGN §8): all phases P0–P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
|
|
||||||
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
# Contributing to the Mosaic Framework
|
|
||||||
|
|
||||||
The Mosaic framework is the open-source agent-operating layer that deploys to
|
|
||||||
`~/.config/mosaic/`. It is designed to be **forked and customized** — but the
|
|
||||||
shared core must stay operator-neutral, deduplicated, and upgrade-safe. This
|
|
||||||
guide is the contract for changing framework-owned files.
|
|
||||||
|
|
||||||
> Governance model and layer rationale: `constitution/LAYER-MODEL.md` (source-only).
|
|
||||||
> Requirements & phase history: `docs/design/framework-constitution/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The layer model (where does my change go?)
|
|
||||||
|
|
||||||
| Layer | What | Owner | On upgrade | File(s) |
|
|
||||||
| ------ | ------------------------------------------------------------- | ---------------- | --------------------------------------- | -------------------------------------------- |
|
|
||||||
| **L0** | Constitution — the non-negotiable law (hard gates) | Framework | **Overwritten** | `CONSTITUTION.md` |
|
|
||||||
| **L1** | Standards & guides — how to do the work well | Framework | Overwritten; user delta → `*.local.md` | `STANDARDS.md`, `guides/*` |
|
|
||||||
| **L2** | Persona (SOUL) — agent name, tone, role | User (init) | **Never overwritten** | `SOUL.md` (+ optional `SOUL.local.md`) |
|
|
||||||
| **L3** | Operator (USER) — human identity, prefs, policy | User (init) | **Never overwritten** | `USER.md` (+ optional `USER.local.md`) |
|
|
||||||
| **L4** | Project / runtime mechanism — per-repo deltas; harness wiring | Repo / framework | Project user-owned; runtime overwritten | `<repo>/AGENTS.md`, `runtime/<h>/RUNTIME.md` |
|
|
||||||
|
|
||||||
**The one sentence a user can rely on:** edit `SOUL.md` / `USER.md` and the
|
|
||||||
`.local.md` overlays — they survive every upgrade. To change framework behavior,
|
|
||||||
add a `.local.md` overlay; never edit a framework-owned file in place.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Operator hygiene (PII / secrets prohibition) — **blocking**
|
|
||||||
|
|
||||||
Framework-owned files ship publicly. They **must not** contain:
|
|
||||||
|
|
||||||
- Operator or personal identity (names, handles, pronouns, accessibility notes).
|
|
||||||
- Private `$HOME` paths, private hostnames, or domains.
|
|
||||||
- Secrets, tokens, or credentials (use `~/.config/mosaic/credentials.json`; the
|
|
||||||
hook URL soft-degrades via `${OPENBRAIN_URL}`).
|
|
||||||
|
|
||||||
This is enforced by `tools/quality/scripts/verify-sanitized.sh`, wired **blocking**
|
|
||||||
in CI (`.woodpecker/ci.yml`). It runs two rule classes: structural (private-`$HOME`
|
|
||||||
defaults, dead paths, unrendered tokens) and a labeled current-contaminant denylist.
|
|
||||||
Run it locally before pushing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Operator-specific behavior belongs in **your** `SOUL.md`/`USER.md`/`*.local.md`,
|
|
||||||
never in the shared core. (The "framework-PR firewall" in `CONSTITUTION.md` §4
|
|
||||||
states this as law for agents opening framework PRs.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Dedup rule — one source, everyone references it
|
|
||||||
|
|
||||||
Hard gates live in **`CONSTITUTION.md` (L0) only**. `AGENTS.md`, `STANDARDS.md`,
|
|
||||||
and every `runtime/<h>/RUNTIME.md` **reference** the law — they never restate it.
|
|
||||||
Restating a gate is a defect: it creates two sources that drift. If you find a
|
|
||||||
gate duplicated outside L0, delete the copy and point to L0.
|
|
||||||
|
|
||||||
`AGENTS.md` is a thin dispatcher (load order + guide router + the tier-aware
|
|
||||||
self-load). Keep it that way; new procedure goes in `guides/*` (on-demand), not
|
|
||||||
in the resident core.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Resident line-count ceiling — **blocking**
|
|
||||||
|
|
||||||
The framework-owned files injected by value (`CONSTITUTION.md`, `AGENTS.md`, each
|
|
||||||
`runtime/<h>/RUNTIME.md`) are budgeted by **line count** — never by word count
|
|
||||||
(a word cap forces paraphrasing the law, the exact drift vector we removed).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Wired blocking in CI. Gate **wording** stays intact; if a file legitimately needs
|
|
||||||
more lines, raise its ceiling in the script deliberately (in the same PR, with
|
|
||||||
rationale). The per-harness _total_ resident prompt (which also sums the user's
|
|
||||||
`SOUL.md`/`USER.md`) is a `mosaic doctor` runtime advisory — CI cannot see user
|
|
||||||
files, so it is out of CI scope by design (DESIGN §7).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Dual-installer parity rule
|
|
||||||
|
|
||||||
Two installers seed and migrate `~/.config/mosaic/`:
|
|
||||||
|
|
||||||
- **`framework/install.sh`** (bash) — the canonical installer.
|
|
||||||
- **`packages/mosaic/src/config/file-adapter.ts`** (TS) — the wizard path.
|
|
||||||
|
|
||||||
**Any change to seed lists, overwrite/preserve semantics, or migration MUST land
|
|
||||||
in BOTH**, validated by the **shared fixture suite**:
|
|
||||||
|
|
||||||
- `framework/tools/quality/scripts/test-install-migration.sh` (bash matrix)
|
|
||||||
- `packages/mosaic/src/config/file-adapter.test.ts` (vitest)
|
|
||||||
|
|
||||||
Both assert the same behavior: framework-owned files overwrite (backup-once to
|
|
||||||
`*.pre-constitution.bak`); user-seeded files seed-if-absent; `SOUL.md`/`USER.md`/
|
|
||||||
`*.local.md`/`credentials` are preserved. A change in one installer without the
|
|
||||||
other (and its fixtures) is incomplete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Adding a harness adapter
|
|
||||||
|
|
||||||
A harness (runtime) is wired by:
|
|
||||||
|
|
||||||
1. `runtime/<h>/RUNTIME.md` — **mechanism only** (subagent syntax, hook/MCP wiring,
|
|
||||||
injection method). No restated gates (see §3).
|
|
||||||
2. Launcher emission in `src/commands/launch.ts` — how the composed contract reaches
|
|
||||||
the harness (system-prompt append vs. instructions file). Add the harness to the
|
|
||||||
`RuntimeName` union and the runtime-path map.
|
|
||||||
3. `mosaic compose-contract <harness>` works automatically once the runtime path
|
|
||||||
exists (it composes base + `*.local.md` overlays for that harness).
|
|
||||||
|
|
||||||
Then add a row to the compliance matrix (§8) and mark which gates are mechanical
|
|
||||||
vs. resident-only for the new harness.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Re-contamination rule
|
|
||||||
|
|
||||||
A green sanitization gate is not permanent. Before every PR:
|
|
||||||
|
|
||||||
- Do not reintroduce operator identity, private paths, or secrets (§2).
|
|
||||||
- Do not copy a gate out of L0 (§3).
|
|
||||||
- Do not add an unrendered template token or a dead path to a shipped file.
|
|
||||||
|
|
||||||
If `verify-sanitized.sh` goes red, that diff **is** your worklist — fix it, don't
|
|
||||||
suppress it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. Harness × gate compliance matrix
|
|
||||||
|
|
||||||
How each gate is enforced per harness. **Mechanical** = a hook/CI check the agent
|
|
||||||
cannot bypass. **Resident** = injected contract prose (strong, but not a hard stop).
|
|
||||||
**CI** = repo-side, harness-independent.
|
|
||||||
|
|
||||||
| Gate / mechanism | Claude | Codex | OpenCode | Pi |
|
|
||||||
| --------------------------------------------- | ----------- | ---------------- | ---------------- | ---------------- |
|
|
||||||
| Contract injection (resident-by-value) | append SP | instructions | `AGENTS.md` | append SP |
|
|
||||||
| Operator overlays (`*.local`, composed) | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Bare-launch self-load (Tier-3, read L0) | ✅ | ✅ | ✅ | ✅ |
|
|
||||||
| Sanitization (no PII) — `verify-sanitized` | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
|
||||||
| Resident budget ceiling | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
|
||||||
| Migration parity (5-fixture, both installers) | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
|
||||||
| `no-memory-write` (PreToolUse hook) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
|
||||||
| QA / typecheck (PostToolUse hooks) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
|
||||||
| Native heartbeat (fleet `ps` model/status) | sidecar | sidecar | sidecar | **native ✅** |
|
|
||||||
|
|
||||||
⚠️ **Hook-parity gap (tracked, v2):** the mechanical PreToolUse/PostToolUse hooks
|
|
||||||
exist for Claude Code only. On Codex/OpenCode/Pi those gates are currently enforced
|
|
||||||
by the resident contract + CI, not by a per-tool hook. Closing hook parity is a
|
|
||||||
**v2** item, not part of this alpha.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Known limitations (accepted residual risks)
|
|
||||||
|
|
||||||
These are accepted with rationale (DESIGN §9); they are documented, not bugs:
|
|
||||||
|
|
||||||
- **Bare-launch overlays are base-only.** A harness started without `mosaic` never
|
|
||||||
ran the composer, so `*.local.md` overlays are not applied. Mitigated by the
|
|
||||||
unconditional Tier-3 self-load + the `mosaic doctor` nudge in `AGENTS.md`; not
|
|
||||||
eliminated. Relaunch via `mosaic <harness>` to pick up overlays.
|
|
||||||
- **Bare-launch drift is undetected by `mosaic doctor`** (the launcher never ran).
|
|
||||||
- **Codex/OpenCode/Pi hook parity** is a tracked v2 gap (§8).
|
|
||||||
- **Live-launch cross-harness verification** is v2; the alpha verifies the composer
|
|
||||||
by unit test (per-tier anchor + Tier-3 byte-equality), not a live launch.
|
|
||||||
|
|
||||||
**Deferred to v2 (explicit):** `constitution/` deploy directory; capability JSON
|
|
||||||
adapters; 3-way merge; `policy/*.md` composition; per-layer version stamps as a
|
|
||||||
migration driver.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. PR checklist
|
|
||||||
|
|
||||||
- [ ] No operator identity / private paths / secrets (`verify-sanitized.sh` green).
|
|
||||||
- [ ] No gate restated outside `CONSTITUTION.md` (§3).
|
|
||||||
- [ ] Resident budget green (`check-resident-budget.sh`).
|
|
||||||
- [ ] Seed/migration changes landed in **both** installers + shared fixtures (§5).
|
|
||||||
- [ ] New harness → compliance-matrix row updated (§8).
|
|
||||||
- [ ] `prettier --check` + `pnpm lint` + `pnpm typecheck` + `pnpm test` green.
|
|
||||||
@@ -9,10 +9,7 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
|||||||
1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime
|
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**
|
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
|
(a harness started without `mosaic`, so the law is NOT in your context), read
|
||||||
`~/.config/mosaic/CONSTITUTION.md` now, before your first action. A bare launch also gets
|
`~/.config/mosaic/CONSTITUTION.md` now, before your first action.
|
||||||
**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).
|
2. Read `SOUL.md` (agent persona — small, once).
|
||||||
3. Read project-local `AGENTS.md` / `CLAUDE.md` if present (these may only make behavior stricter).
|
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.
|
4. Read guides ONLY as triggered by the table below — pull role-relevant depth on demand, not up front.
|
||||||
@@ -73,9 +70,6 @@ Skills, hooks, MCP, and plugins are force multipliers you MUST use when applicab
|
|||||||
## Missing core file
|
## Missing core file
|
||||||
|
|
||||||
If `CONSTITUTION.md`, `AGENTS.md`, `SOUL.md`, or the runtime contract is missing, stop and report it.
|
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
|
## Session Closure
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
The irreducible, non-negotiable law for every Mosaic agent on every harness.
|
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. There is
|
**Framework-owned.** This file is overwritten verbatim on every upgrade — do not edit it. To change
|
||||||
**no `CONSTITUTION.local.md`**: hard gates are not locally overridable. A lower layer may only make
|
behavior, add a `.local.md` overlay or a `policy/` file (tighten-only; see `constitution/LAYER-MODEL.md`).
|
||||||
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
|
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.
|
guard"), the runtime adapter binds it to a concrete tool and states whether absence is a hard stop.
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
# 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).
|
|
||||||
@@ -113,35 +113,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,23 +19,13 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||||
|
|
||||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
# Files/dirs preserved across upgrades (never overwritten).
|
||||||
# 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.
|
# User-created content in these paths survives rsync --delete.
|
||||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
PRESERVE_PATHS=("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.
|
# Current framework schema version — bump this when the layout changes.
|
||||||
# The migration system uses this to run upgrade steps.
|
# The migration system uses this to run upgrade steps.
|
||||||
FRAMEWORK_VERSION=3
|
FRAMEWORK_VERSION=2
|
||||||
|
|
||||||
# ─── colours ──────────────────────────────────────────────────────────────────
|
# ─── colours ──────────────────────────────────────────────────────────────────
|
||||||
if [[ -t 1 ]]; then
|
if [[ -t 1 ]]; then
|
||||||
@@ -50,47 +40,6 @@ warn() { echo -e " ${YELLOW}⚠${RESET} $1" >&2; }
|
|||||||
fail() { echo -e " ${RED}✗${RESET} $1" >&2; }
|
fail() { echo -e " ${RED}✗${RESET} $1" >&2; }
|
||||||
step() { echo -e "\n${BOLD}$1${RESET}"; }
|
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 ──────────────────────────────────────────────────────────────────
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
is_existing_install() {
|
is_existing_install() {
|
||||||
@@ -164,14 +113,11 @@ sync_framework() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if command -v rsync >/dev/null 2>&1; then
|
if command -v rsync >/dev/null 2>&1; then
|
||||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version")
|
||||||
|
|
||||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
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
|
for path in "${PRESERVE_PATHS[@]}"; do
|
||||||
rsync_args+=(--exclude "/$path")
|
rsync_args+=(--exclude "$path")
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -191,7 +137,7 @@ sync_framework() {
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
|
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" -exec rm -rf {} +
|
||||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||||
rm -rf "$TARGET_DIR/.git"
|
rm -rf "$TARGET_DIR/.git"
|
||||||
|
|
||||||
@@ -249,15 +195,10 @@ run_migrations() {
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Migration: v2 → v3 (Constitution split) ───────────────────────────────
|
# ── Future migrations go here ──────────────────────────────────────────────
|
||||||
# CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten
|
# if [[ "$from_version" -lt 3 ]]; then
|
||||||
# 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
|
# fi
|
||||||
# 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
@@ -275,25 +216,29 @@ else
|
|||||||
ok "Install mode: overwrite"
|
ok "Install mode: overwrite"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Snapshot before any destructive file operation; restore on interrupt/failure.
|
|
||||||
make_snapshot
|
|
||||||
trap 'restore_snapshot' ERR INT TERM
|
|
||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
# Ensure persistent directories exist
|
# Ensure persistent directories exist
|
||||||
mkdir -p "$TARGET_DIR/memory"
|
mkdir -p "$TARGET_DIR/memory"
|
||||||
mkdir -p "$TARGET_DIR/credentials"
|
mkdir -p "$TARGET_DIR/credentials"
|
||||||
|
|
||||||
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
# Seed defaults — copy framework contract files from defaults/ to framework
|
||||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
# root if not already present. These ship with sensible defaults but must
|
||||||
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
|
# never be overwritten once the user has customized them.
|
||||||
#
|
#
|
||||||
# This list must match the framework-contract whitelist in
|
# This list must match the framework-contract whitelist in
|
||||||
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
|
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
|
||||||
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
|
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
|
||||||
# by `mosaic init` from templates with user-supplied values.
|
# by `mosaic init` from templates with user-supplied values.
|
||||||
reconcile_framework_files
|
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
|
||||||
|
|
||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||||
@@ -304,18 +249,6 @@ ok "Framework synced to $TARGET_DIR"
|
|||||||
# Run migrations before post-install (migrations may remove old bin/ etc.)
|
# Run migrations before post-install (migrations may remove old bin/ etc.)
|
||||||
run_migrations
|
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"
|
step "Post-install tasks"
|
||||||
|
|
||||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||||
|
|||||||
@@ -9,16 +9,8 @@
|
|||||||
* 4. Memory routing — remind agent to use ~/.config/mosaic/memory/
|
* 4. Memory routing — remind agent to use ~/.config/mosaic/memory/
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
|
||||||
import { Type } from 'typebox';
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
||||||
import {
|
|
||||||
existsSync,
|
|
||||||
readFileSync,
|
|
||||||
writeFileSync,
|
|
||||||
unlinkSync,
|
|
||||||
mkdirSync,
|
|
||||||
renameSync,
|
|
||||||
} from 'node:fs';
|
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { execSync, spawnSync } from 'node:child_process';
|
import { execSync, spawnSync } from 'node:child_process';
|
||||||
@@ -33,57 +25,6 @@ const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mo
|
|||||||
// Helpers
|
// 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 {
|
function safeRead(filePath: string): string | null {
|
||||||
try {
|
try {
|
||||||
return readFileSync(filePath, 'utf-8');
|
return readFileSync(filePath, 'utf-8');
|
||||||
@@ -246,9 +187,6 @@ function buildMissionSummary(cwd: string, mission: ActiveMission): string {
|
|||||||
|
|
||||||
export default function register(pi: ExtensionAPI) {
|
export default function register(pi: ExtensionAPI) {
|
||||||
let sessionCwd = process.cwd();
|
let sessionCwd = process.cwd();
|
||||||
let hbStatus: 'ok' | 'busy' = 'ok';
|
|
||||||
let hbModel: string | null = null;
|
|
||||||
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
// ── Session Start ─────────────────────────────────────────────────────
|
// ── Session Start ─────────────────────────────────────────────────────
|
||||||
pi.on('session_start', async (_event, ctx) => {
|
pi.on('session_start', async (_event, ctx) => {
|
||||||
@@ -269,39 +207,10 @@ export default function register(pi: ExtensionAPI) {
|
|||||||
} else {
|
} else {
|
||||||
ctx.ui.notify('Mosaic framework loaded', 'info');
|
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Turn lifecycle → accurate busy/ok heartbeat ───────────────────────
|
// ── Session End ───────────────────────────────────────────────────────
|
||||||
pi.on('turn_start', async (_event, ctx) => {
|
pi.on('session_end', 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
|
// Run repo session-end hook
|
||||||
runRepoHook(sessionCwd, 'session-end');
|
runRepoHook(sessionCwd, 'session-end');
|
||||||
|
|
||||||
@@ -343,32 +252,4 @@ 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 },
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,13 +274,6 @@ detect_existing_config
|
|||||||
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
|
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
|
||||||
echo ""
|
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 AGENT_NAME "What name should agents use" "Assistant"
|
||||||
prompt_if_empty ROLE_DESCRIPTION "Agent role description" "execution partner and visibility engine"
|
prompt_if_empty ROLE_DESCRIPTION "Agent role description" "execution partner and visibility engine"
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
|
|||||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
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
|
if [ -z "$AGENT_NAME" ]; then
|
||||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||||
@@ -28,132 +26,5 @@ if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
|||||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
|
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
|
||||||
fi
|
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"
|
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
|
|
||||||
|
|||||||
@@ -6,43 +6,22 @@ START="$SCRIPT_DIR/start-agent-session.sh"
|
|||||||
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||||
AGENT="agent-$RANDOM"
|
AGENT="agent-$RANDOM"
|
||||||
WORKDIR=$(mktemp -d)
|
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() {
|
fail() {
|
||||||
echo "FAIL: $*" >&2
|
echo "FAIL: $*" >&2
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Test 1: basic session creation with workdir check ─────────────────────────
|
|
||||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||||
"$START" "$AGENT"
|
"$START" "$AGENT"
|
||||||
|
|
||||||
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||||
# 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=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||||
[ "$actual_dir" = "$WORKDIR" ] && break
|
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir"
|
||||||
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_TMUX_SOCKET="$SOCKET" \
|
||||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||||
@@ -50,310 +29,4 @@ MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
|||||||
|
|
||||||
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
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"
|
echo "ok - start-agent-session"
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# check-resident-budget.sh — resident line-count ceiling (R9 / DESIGN §7).
|
|
||||||
#
|
|
||||||
# Budgets the *container* (line count) of the framework-owned files that are
|
|
||||||
# injected into every agent's context by value — the Constitution (L0), the
|
|
||||||
# AGENTS dispatcher, and each runtime RUNTIME.md slice. Gate *wording* is never
|
|
||||||
# capped (a word cap forces paraphrasing law — the exact drift vector P3 killed);
|
|
||||||
# only the file's line count is bounded, so prose creep is caught in review.
|
|
||||||
#
|
|
||||||
# This is the CI-enforceable half of the budget. The per-harness *total* resident
|
|
||||||
# prompt (which also includes user-generated SOUL.md/USER.md and the per-tier
|
|
||||||
# slice) is summed by `mosaic doctor` as a runtime advisory — CI cannot see user
|
|
||||||
# files, so it is deliberately out of scope here (DESIGN §7).
|
|
||||||
#
|
|
||||||
# Usage: check-resident-budget.sh [--self-test]
|
|
||||||
# Exit: 0 = all within budget · 1 = a file exceeds its ceiling · 2 = self-test failed
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
|
||||||
|
|
||||||
# Per-file ceilings (lines). Headroom above current counts; tighten as files settle.
|
|
||||||
# Format: "<relative-path>:<max-lines>"
|
|
||||||
CEILINGS=(
|
|
||||||
"defaults/CONSTITUTION.md:120"
|
|
||||||
"defaults/AGENTS.md:120"
|
|
||||||
"runtime/claude/RUNTIME.md:90"
|
|
||||||
"runtime/codex/RUNTIME.md:90"
|
|
||||||
"runtime/opencode/RUNTIME.md:90"
|
|
||||||
"runtime/pi/RUNTIME.md:90"
|
|
||||||
)
|
|
||||||
|
|
||||||
# check_file <abs-path> <max> → echoes "<n>"; returns 0 if n<=max, 1 otherwise.
|
|
||||||
check_file() {
|
|
||||||
local path="$1" max="$2" n
|
|
||||||
n=$(wc -l <"$path" 2>/dev/null || echo 0)
|
|
||||||
n=$((n + 0))
|
|
||||||
echo "$n"
|
|
||||||
[ "$n" -le "$max" ]
|
|
||||||
}
|
|
||||||
|
|
||||||
run_budget() {
|
|
||||||
local fail=0 rel max abs n
|
|
||||||
printf '%-32s %8s %8s %s\n' "FILE" "LINES" "CEILING" "STATUS"
|
|
||||||
for entry in "${CEILINGS[@]}"; do
|
|
||||||
rel="${entry%%:*}"
|
|
||||||
max="${entry##*:}"
|
|
||||||
abs="$FW/$rel"
|
|
||||||
if [ ! -f "$abs" ]; then
|
|
||||||
printf '%-32s %8s %8s %s\n' "$rel" "-" "$max" "MISSING"
|
|
||||||
fail=1
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
n=$(check_file "$abs" "$max")
|
|
||||||
if [ "$n" -le "$max" ]; then
|
|
||||||
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "ok"
|
|
||||||
else
|
|
||||||
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "OVER BUDGET"
|
|
||||||
fail=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
return "$fail"
|
|
||||||
}
|
|
||||||
|
|
||||||
self_test() {
|
|
||||||
local tmp rc
|
|
||||||
tmp=$(mktemp)
|
|
||||||
# 3 lines, ceiling 5 → within budget (rc 0)
|
|
||||||
printf 'a\nb\nc\n' >"$tmp"
|
|
||||||
check_file "$tmp" 5 >/dev/null
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 0 ]; then echo "self-test FAIL: under-budget file flagged"; rm -f "$tmp"; return 2; fi
|
|
||||||
# 6 lines, ceiling 5 → over budget (rc 1)
|
|
||||||
printf 'a\nb\nc\nd\ne\nf\n' >"$tmp"
|
|
||||||
check_file "$tmp" 5 >/dev/null
|
|
||||||
rc=$?
|
|
||||||
if [ "$rc" -ne 1 ]; then echo "self-test FAIL: over-budget file not flagged"; rm -f "$tmp"; return 2; fi
|
|
||||||
rm -f "$tmp"
|
|
||||||
echo "self-test OK"
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if [ "${1:-}" = "--self-test" ]; then
|
|
||||||
self_test
|
|
||||||
exit $?
|
|
||||||
fi
|
|
||||||
|
|
||||||
if run_budget; then
|
|
||||||
echo "Resident budget: all framework-owned resident files within ceiling."
|
|
||||||
exit 0
|
|
||||||
else
|
|
||||||
echo "Resident budget EXCEEDED — trim prose or raise the ceiling deliberately (see DESIGN §7)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
#!/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 ]
|
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
# 2. STRUCTURAL (private $HOME default in *.sh) — scanned everywhere EXCEPT examples/,
|
# 2. STRUCTURAL (private $HOME default in *.sh) — scanned everywhere EXCEPT examples/,
|
||||||
# because worked example overlays/personas legitimately show placeholder paths.
|
# because worked example overlays/personas legitimately show placeholder paths.
|
||||||
#
|
#
|
||||||
# File types: *.md, *.sh, *.ps1, *.json, *.yml/*.yaml, *.toml, *.env, *.service, and the CLI scripts under
|
# File types: *.md, *.sh, *.ps1, *.json, and the extensionless CLI scripts under
|
||||||
# tools/_scripts/. Excludes node_modules/ and this gate file.
|
# tools/_scripts/. Excludes node_modules/ and this gate file.
|
||||||
#
|
#
|
||||||
# NOTE: '\bPDA\b' intentionally matches "PDA-friendly" (the contamination removed in P2);
|
# 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).
|
# Identity scope = ALL shipped text files (examples/ INCLUDED).
|
||||||
_files_identity() {
|
_files_identity() {
|
||||||
find . -type f \
|
find . -type f \
|
||||||
\( -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/*' \) \
|
\( -name '*.md' -o -name '*.sh' -o -name '*.ps1' -o -name '*.json' -o -path '*/tools/_scripts/*' \) \
|
||||||
-not -path '*/node_modules/*' -not -path "./$SELF_REL" -print0
|
-not -path '*/node_modules/*' -not -path "./$SELF_REL" -print0
|
||||||
}
|
}
|
||||||
# Structural scope = shipped scripts, examples/ EXCLUDED.
|
# Structural scope = shipped scripts, examples/ EXCLUDED.
|
||||||
@@ -53,15 +53,9 @@ _selftest() {
|
|||||||
local tmp; tmp="$(mktemp -d)" || return 1
|
local tmp; tmp="$(mktemp -d)" || return 1
|
||||||
printf 'contact jason.woltje at jarvis-brain (PDA-friendly)\n' > "$tmp/planted.md"
|
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 '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
|
local rc=0
|
||||||
grep -qIEi "$DENYLIST" "$tmp/planted.md" || { echo "✗ SELF-TEST: identity denylist regex broken" >&2; rc=1; }
|
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; }
|
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
|
rm -rf "$tmp"; return $rc
|
||||||
}
|
}
|
||||||
_selftest || exit 2
|
_selftest || exit 2
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.39",
|
"version": "0.0.34",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ import {
|
|||||||
checkForAllUpdates,
|
checkForAllUpdates,
|
||||||
formatAllPackagesTable,
|
formatAllPackagesTable,
|
||||||
getInstallAllCommand,
|
getInstallAllCommand,
|
||||||
runFrameworkReseed,
|
|
||||||
readRosterAgentNames,
|
|
||||||
buildRelaunchCommands,
|
|
||||||
FRAMEWORK_RESEED_PACKAGE,
|
|
||||||
} from './runtime/update-checker.js';
|
} from './runtime/update-checker.js';
|
||||||
import { runWizard } from './wizard.js';
|
import { runWizard } from './wizard.js';
|
||||||
import { ClackPrompter } from './prompter/clack-prompter.js';
|
import { ClackPrompter } from './prompter/clack-prompter.js';
|
||||||
@@ -408,12 +404,7 @@ program
|
|||||||
.command('update')
|
.command('update')
|
||||||
.description('Check for and install Mosaic CLI updates')
|
.description('Check for and install Mosaic CLI updates')
|
||||||
.option('--check', 'Check only, do not install')
|
.option('--check', 'Check only, do not install')
|
||||||
.option(
|
.action(async (opts: { check?: boolean }) => {
|
||||||
'--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
|
// checkForAllUpdates imported statically above
|
||||||
const { execSync } = await import('node:child_process');
|
const { execSync } = await import('node:child_process');
|
||||||
|
|
||||||
@@ -451,51 +442,6 @@ program
|
|||||||
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
console.error('\nUpdate failed. Try manually: bash tools/install.sh');
|
||||||
process.exit(1);
|
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 ─────────────────────────────────────────────────────────────
|
// ─── wizard ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
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
File diff suppressed because it is too large
Load Diff
@@ -291,23 +291,12 @@ function buildPrdBlock(): string {
|
|||||||
|
|
||||||
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
// ─── 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> = {
|
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||||
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
||||||
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
||||||
opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
|
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||||
pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'),
|
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeFile = runtimeContractPaths[runtime];
|
const runtimeFile = runtimeContractPaths[runtime];
|
||||||
@@ -342,55 +331,27 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
||||||
// pre-constitution installs that have not been re-seeded yet. Injected by
|
// pre-constitution installs that have not been re-seeded yet.
|
||||||
// value verbatim so the bare-launch fallback read is byte-equal (R8).
|
const constitution = readOptional(join(MOSAIC_HOME, 'CONSTITUTION.md'));
|
||||||
const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md'));
|
|
||||||
if (constitution) parts.push(constitution);
|
if (constitution) parts.push(constitution);
|
||||||
|
|
||||||
// AGENTS.md
|
// AGENTS.md
|
||||||
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
|
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
||||||
|
|
||||||
// USER.md (+ USER.local.md operator overlay, appended directly under the
|
// USER.md
|
||||||
// profile its base owns).
|
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
||||||
const user = readOptional(join(mosaicHome, 'USER.md'));
|
|
||||||
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
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
|
// TOOLS.md
|
||||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
|
||||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
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
|
// Runtime-specific contract
|
||||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||||
|
|
||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
|
||||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
|
||||||
return composeContract(runtime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function writeSessionLock(runtime: string): void {
|
function writeSessionLock(runtime: string): void {
|
||||||
@@ -1015,22 +976,6 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
launchRuntime(runtime, extraArgs, yolo);
|
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)
|
// Coord (mission orchestrator)
|
||||||
program
|
program
|
||||||
.command('coord')
|
.command('coord')
|
||||||
|
|||||||
@@ -99,8 +99,11 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
it('preserves existing contract files — never overwrites user customization', async () => {
|
||||||
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
|
// 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.
|
||||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||||
|
|
||||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||||
@@ -109,50 +112,18 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
|||||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
await adapter.syncFramework('keep');
|
await adapter.syncFramework('keep');
|
||||||
|
|
||||||
// User-seeded TOOLS.md is preserved.
|
|
||||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
|
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
|
||||||
'# user-customized TOOLS\n',
|
'# user-customized TOOLS\n',
|
||||||
);
|
);
|
||||||
// Framework-owned AGENTS.md is overwritten from defaults/ ...
|
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe(
|
||||||
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',
|
'# user-customized AGENTS\n',
|
||||||
);
|
);
|
||||||
// Framework-owned STANDARDS.md (absent) gets installed.
|
// And the missing contract file still gets seeded.
|
||||||
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
|
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
|
||||||
'# STANDARDS default',
|
'# 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 () => {
|
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||||
rmSync(fixture.defaultsDir, { recursive: true });
|
rmSync(fixture.defaultsDir, { recursive: true });
|
||||||
|
|
||||||
|
|||||||
@@ -13,17 +13,12 @@ import { join } from 'node:path';
|
|||||||
* This list must match the explicit seed loop in
|
* This list must match the explicit seed loop in
|
||||||
* packages/mosaic/framework/install.sh.
|
* packages/mosaic/framework/install.sh.
|
||||||
*/
|
*/
|
||||||
// Framework-owned contract files: re-copied from defaults/ on every upgrade (a
|
export const DEFAULT_SEED_FILES = [
|
||||||
// divergent existing copy is backed up once to <file>.pre-constitution.bak first).
|
'CONSTITUTION.md',
|
||||||
// MUST match FRAMEWORK_OWNED in packages/mosaic/framework/install.sh (append-friendly).
|
'AGENTS.md',
|
||||||
export const FRAMEWORK_OWNED_FILES = ['CONSTITUTION.md', 'AGENTS.md', 'STANDARDS.md'] as const;
|
'STANDARDS.md',
|
||||||
|
'TOOLS.md',
|
||||||
// User-seeded contract files: written once on first install, then owned by the user.
|
] as const;
|
||||||
// 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 { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
|
||||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||||
@@ -164,7 +159,6 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
const preservePaths =
|
const preservePaths =
|
||||||
action === 'keep' || action === 'reconfigure'
|
action === 'keep' || action === 'reconfigure'
|
||||||
? [
|
? [
|
||||||
'CONSTITUTION.md',
|
|
||||||
'AGENTS.md',
|
'AGENTS.md',
|
||||||
'SOUL.md',
|
'SOUL.md',
|
||||||
'USER.md',
|
'USER.md',
|
||||||
@@ -181,10 +175,10 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
excludeGit: true,
|
excludeGit: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reconcile framework-contract files from framework/defaults/ into the mosaic
|
// Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md)
|
||||||
// home root: framework-owned files (CONSTITUTION/AGENTS/STANDARDS) are overwritten
|
// from framework/defaults/ into the mosaic home root if they don't
|
||||||
// every upgrade (backup-once); user-seeded files (TOOLS) are written on first
|
// exist yet. These are written on first install only and are never
|
||||||
// install only. Mirrors reconcile_framework_files() in install.sh.
|
// overwritten afterwards — the user may have customized them.
|
||||||
//
|
//
|
||||||
// SOUL.md and USER.md are deliberately NOT seeded here. They are
|
// SOUL.md and USER.md are deliberately NOT seeded here. They are
|
||||||
// generated from templates by the soul/user wizard stages with
|
// generated from templates by the soul/user wizard stages with
|
||||||
@@ -192,22 +186,7 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
// identity flow and leak placeholder content into the mosaic home.
|
// identity flow and leak placeholder content into the mosaic home.
|
||||||
const defaultsDir = join(this.sourceDir, 'defaults');
|
const defaultsDir = join(this.sourceDir, 'defaults');
|
||||||
if (existsSync(defaultsDir)) {
|
if (existsSync(defaultsDir)) {
|
||||||
// Framework-owned: overwrite from defaults/ every sync; back up a divergent
|
for (const entry of DEFAULT_SEED_FILES) {
|
||||||
// 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 src = join(defaultsDir, entry);
|
||||||
const dest = join(this.mosaicHome, entry);
|
const dest = join(this.mosaicHome, entry);
|
||||||
if (existsSync(dest)) continue;
|
if (existsSync(dest)) continue;
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
||||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import {
|
|
||||||
buildReseedCommand,
|
|
||||||
buildRelaunchCommands,
|
|
||||||
readRosterAgentNames,
|
|
||||||
runFrameworkReseed,
|
|
||||||
} from './update-checker.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
|
||||||
* durable agents so shipped launcher/runtime changes activate. These cover the
|
|
||||||
* pure builders + the missing-installer guard (the exec path is integration).
|
|
||||||
*/
|
|
||||||
|
|
||||||
describe('buildReseedCommand', () => {
|
|
||||||
it('invokes the package install.sh in data-safe sync-only keep mode', () => {
|
|
||||||
const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic');
|
|
||||||
expect(out.installer).toBe('/pkg/framework/install.sh');
|
|
||||||
expect(out.command).toBe('bash /pkg/framework/install.sh');
|
|
||||||
expect(out.env).toEqual({
|
|
||||||
MOSAIC_SYNC_ONLY: '1',
|
|
||||||
MOSAIC_INSTALL_MODE: 'keep',
|
|
||||||
MOSAIC_HOME: '/home/u/.config/mosaic',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('buildRelaunchCommands', () => {
|
|
||||||
it('builds a systemctl --user restart per agent unit', () => {
|
|
||||||
expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'],
|
|
||||||
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is empty for an empty roster', () => {
|
|
||||||
expect(buildRelaunchCommands([])).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('readRosterAgentNames', () => {
|
|
||||||
let home: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
home = mkdtempSync(join(tmpdir(), 'mosaic-roster-'));
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
rmSync(home, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns [] when no roster exists', () => {
|
|
||||||
expect(readRosterAgentNames(home)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts agent names from roster.yaml', () => {
|
|
||||||
mkdirSync(join(home, 'fleet'), { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
join(home, 'fleet', 'roster.yaml'),
|
|
||||||
[
|
|
||||||
'version: 1',
|
|
||||||
'agents:',
|
|
||||||
' - name: orchestrator',
|
|
||||||
' runtime: pi',
|
|
||||||
' - name: coder0',
|
|
||||||
' runtime: claude',
|
|
||||||
' - name: "reviewer-1"',
|
|
||||||
' runtime: codex',
|
|
||||||
].join('\n') + '\n',
|
|
||||||
);
|
|
||||||
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('runFrameworkReseed', () => {
|
|
||||||
it('reports not-ok (not throw) when the installer is absent', () => {
|
|
||||||
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
|
||||||
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
|
||||||
expect(res.ok).toBe(false);
|
|
||||||
expect(res.reason).toContain('installer not found');
|
|
||||||
rmSync(missing, { recursive: true, force: true });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -16,8 +16,7 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { execSync } from 'node:child_process';
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { dirname, join, resolve } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -454,98 +453,6 @@ export function getInstallAllCommand(outdated: PackageUpdateResult[]): string {
|
|||||||
return `npm i -g ${pkgs.join(' ')}`;
|
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.
|
* Format a table showing all packages with their current/latest versions.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user