Compare commits
7 Commits
release/mo
...
feat/fleet
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e73481f0a | |||
| d46ac40890 | |||
| 8ddd48c843 | |||
| 528700ceea | |||
| 32f4215461 | |||
| 23343bb7f0 | |||
| c8b2dab0ca |
@@ -25,12 +25,10 @@ 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
|
||||||
# L0 resident-token budget: keep the Constitution + dispatcher small.
|
# Resident line-count ceiling over framework-owned resident files
|
||||||
- |
|
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||||
for f in CONSTITUTION.md AGENTS.md; do
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||||
n=$(wc -l < "packages/mosaic/framework/defaults/$f")
|
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||||
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
|
||||||
|
|||||||
@@ -45,3 +45,24 @@ 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: implemented + tested. 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 (stacked on #612)
|
||||||
|
|
||||||
|
- Status: implemented + tested. 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.
|
||||||
|
|||||||
75
docs/design/framework-constitution/ALPHA-DOD.md
Normal file
75
docs/design/framework-constitution/ALPHA-DOD.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Constitution Alpha — Definition-of-Done checklist + release notes
|
||||||
|
|
||||||
|
Drafted for the `v0.0.39-alpha` tag (Lead cuts after P5 #605 → P6 #607 → aiguide #8 merge).
|
||||||
|
Maps every DoD §8 acceptance criterion to its merged evidence. Legend:
|
||||||
|
**✅ merged on main** · **⏳ review-ready PR (pending merge)** · **🔲 Lead action**.
|
||||||
|
|
||||||
|
## DoD §8 green-checklist
|
||||||
|
|
||||||
|
| # | Acceptance criterion (DESIGN §8) | Status | Evidence / PR |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------ | ------ | ----------------- |
|
||||||
|
| 1 | MIT `LICENSE` (root + framework) + `"license":"MIT"` in package.json | ✅ | P0 #570 |
|
||||||
|
| 2 | Three credential-path sites + hook URL fast-failed (no private paths in `*.sh`/hooks) | ✅ | P0 #570 |
|
||||||
|
| 3 | `verify-sanitized.sh` (two-class, `*.sh`+`*.md`, self-tested) wired **blocking** in CI | ✅ | P1 #572 |
|
||||||
|
| 4 | Operator data purged from the full set (guides / tools / init-generator) | ✅ | P2 #572 |
|
||||||
|
| 5 | `rails/`→`tools/` in **both** template families | ✅ | P2 #572 |
|
||||||
|
| 6 | `jarvis-loop.json` deleted; `defaults/SOUL.md` → **neutral sanitized persona** (Q10 decision) | ✅ | P2 #572 |
|
||||||
|
| 7 | `CONSTITUTION.md` extracted (gates one place, capability-verb, §1.4 split, no false "already loaded") | ✅ | P3 #575 / #577 |
|
||||||
|
| 8 | `AGENTS.md`/`STANDARDS.md` out of `PRESERVE_PATHS` + seed-semantics → overwrite in **both** installers | ✅ | P4 #590 |
|
||||||
|
| 9 | Snapshot + v2→v3 migration moving user edits to `.local`/`.bak`; `FRAMEWORK_VERSION=3` | ✅ | P4 #590 / #593 |
|
||||||
|
| 10 | `mosaic-init --non-interactive` fail-closed persona | ✅ | P4 #590 |
|
||||||
|
| 11 | **5-fixture migration matrix** green against **both** installers asserting **injected bytes** | ✅ | P4 #590 / #593 |
|
||||||
|
| 12 | `compose-contract` built + composer unit test (per-tier anchor + Tier-3 byte-equality) | ⏳ | P5 #605 |
|
||||||
|
| 13 | Resident line-count ceiling enforced (framework-owned resident files) | ⏳ | P6 #607 |
|
||||||
|
| 14 | `CONTRIBUTING.md` + harness×gate compliance matrix | ⏳ | P6 #607 |
|
||||||
|
| 15 | `aiguide` reconciled with the Constitution | ⏳ | aiguide #8 |
|
||||||
|
| 16 | Each phase PR CI-green; alpha tag pushed + Gitea release published | 🔲 | Lead (post-merge) |
|
||||||
|
|
||||||
|
**Note on #6:** the DoD's literal "delete `defaults/SOUL.md`" was superseded by the resolved
|
||||||
|
**Q10** decision — ship a _neutral, operator-agnostic_ example persona instead of deleting it. Main
|
||||||
|
carries the sanitized 2.6 KB neutral SOUL.md ("Mosaic agent", no operator identity); the sanitization
|
||||||
|
gate confirms it is PII-clean. Criterion met in spirit (no operator persona leaks) via the better option.
|
||||||
|
|
||||||
|
**Gate to flip 12–14 → ✅:** merge P5 #605 → P6 #607 (rebase auto-drops the dup format fix
|
||||||
|
`adc7df2`/`9f6da92`) → aiguide #8, with `ci.yml` terminal-green on the merged head.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Release notes — `v0.0.39-alpha` (Mosaic Framework Constitution, alpha)
|
||||||
|
|
||||||
|
### Mosaic Framework Constitution — Alpha
|
||||||
|
|
||||||
|
This release makes the Mosaic framework a **safe-to-open-source, fork-and-customize agent
|
||||||
|
operating layer**. It separates the non-negotiable law from operator identity, makes
|
||||||
|
customization survive upgrades, and wires the guarantees into CI.
|
||||||
|
|
||||||
|
**Highlights**
|
||||||
|
|
||||||
|
- **Constitution (L0).** The hard gates now live in one place — `CONSTITUTION.md` — authored in
|
||||||
|
capability verbs, with a thin `AGENTS.md` dispatcher that references the law instead of restating
|
||||||
|
it. Governance model in `constitution/LAYER-MODEL.md`.
|
||||||
|
- **Public & sanitized.** MIT-licensed; all operator identity, private paths, and credential sites
|
||||||
|
removed from shipped files. A self-tested `verify-sanitized.sh` gate (two rule classes) runs
|
||||||
|
**blocking** in CI so re-contamination can't merge.
|
||||||
|
- **Upgrade-safe customization.** Framework-owned files overwrite cleanly on upgrade while
|
||||||
|
`SOUL.md`/`USER.md`/`*.local.md`/`credentials` are preserved. The v2→v3 migration snapshots first
|
||||||
|
and moves any user-edited `AGENTS.md`/`STANDARDS.md` to `.pre-constitution.bak`/`.local.md` —
|
||||||
|
never silently lost. Verified by a 5-fixture matrix across **both** installers.
|
||||||
|
- **Operator overlays.** `mosaic compose-contract <harness>` merges your `*.local.md` deltas into
|
||||||
|
the contract per harness, so customization reaches the model as one pre-merged blob.
|
||||||
|
- **Cross-harness.** Single L0 source referenced (never restated) by Claude / Codex / OpenCode / Pi;
|
||||||
|
tiered injection with a byte-equal Tier-3 fallback read.
|
||||||
|
- **Guardrails in CI.** Resident line-count ceiling over framework-owned resident files; composer
|
||||||
|
unit test; sanitization gate — all blocking.
|
||||||
|
- **Docs.** `CONTRIBUTING.md` with the layer model, dual-installer parity rule, and a harness×gate
|
||||||
|
**compliance matrix** (the Codex/OpenCode/Pi hook-parity gap is tracked for v2).
|
||||||
|
|
||||||
|
**Known limitations (accepted, documented in `CONTRIBUTING.md` §9)**
|
||||||
|
|
||||||
|
- Bare launches that bypass `mosaic` get base contracts only (no `*.local` overlays) and are not
|
||||||
|
drift-checked by `mosaic doctor` — mitigated by the unconditional Tier-3 self-load + a nudge.
|
||||||
|
- Codex/OpenCode/Pi mechanical hook parity, `policy/*.md` composition, and live-launch cross-harness
|
||||||
|
verification are **v2**.
|
||||||
|
|
||||||
|
**Phase lineage:** P0 #570 · P1+P2 #572 · P3 #575/#577 · P4 #590/#593 · P5 #605 · P6 #607 ·
|
||||||
|
aiguide #8 (umbrella #542).
|
||||||
@@ -20,39 +20,43 @@ functional, we use the fleet itself to continue the work.
|
|||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### A. Configure-without-AI CLI
|
### A. Configure-without-AI CLI
|
||||||
| ID | Requirement |
|
|
||||||
|---|---|
|
| 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. |
|
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
||||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
||||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
||||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
||||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
||||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
||||||
| R17 | Fleet controls are **simple and intuitive**. |
|
| 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
|
### B. Comms & orchestrator chat-ops
|
||||||
| ID | Requirement |
|
|
||||||
|---|---|
|
| 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**. |
|
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
||||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
| 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. |
|
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
|
||||||
|
|
||||||
### C. Runtime, health, lifecycle
|
### C. Runtime, health, lifecycle
|
||||||
| ID | Requirement |
|
|
||||||
|---|---|
|
| ID | Requirement |
|
||||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
| --- | ---------------------------------------------------------------------------------- |
|
||||||
|
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
||||||
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
||||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
||||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
||||||
|
|
||||||
### D. Surfaces, testing, docs
|
### D. Surfaces, testing, docs
|
||||||
| ID | Requirement |
|
|
||||||
|---|---|
|
| ID | Requirement |
|
||||||
|
| --- | ----------------------------------------------------------------------------------- |
|
||||||
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
|
| 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`**. |
|
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
||||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
||||||
|
|
||||||
## Architecture / approach
|
## Architecture / approach
|
||||||
|
|
||||||
@@ -65,15 +69,15 @@ functional, we use the fleet itself to continue the work.
|
|||||||
|
|
||||||
## Phases (incremental, each shippable)
|
## Phases (incremental, each shippable)
|
||||||
|
|
||||||
| Phase | Deliverable | Notes |
|
| 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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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) |
|
| **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)
|
## Work division (proposed — confirm with dragon-lin)
|
||||||
|
|
||||||
|
|||||||
29
docs/scratchpads/f3-m3-update-reseed.md
Normal file
29
docs/scratchpads/f3-m3-update-reseed.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# F3-m3 — `mosaic update` re-seeds framework + relaunches agents (R13)
|
||||||
|
|
||||||
|
- **Issue:** #609 · **Branch:** `feat/f3-m3-update-reseed`
|
||||||
|
|
||||||
|
## Gap (found in 0.0.39 production validation)
|
||||||
|
|
||||||
|
`mosaic update` installs the new npm CLI but never re-seeds `~/.config/mosaic/` from the package's
|
||||||
|
bundled `framework/`. So the shipped custom Pi harness (agent-name export + native HB, 0.0.39) stays
|
||||||
|
DORMANT until a re-seed — operators get the new CLI on a stale framework.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `update-checker.ts`: `resolveBundledFrameworkRoot()`, `buildReseedCommand()` (install.sh in
|
||||||
|
`MOSAIC_SYNC_ONLY=1 MOSAIC_INSTALL_MODE=keep` — the P4 data-safe reconcile), `runFrameworkReseed()`,
|
||||||
|
`readRosterAgentNames()`, `buildRelaunchCommands()` (systemctl --user restart per agent).
|
||||||
|
- `cli.ts` `update`: after a successful CLI install that includes `@mosaicstack/mosaic`, re-seed the
|
||||||
|
framework (default-on; `--no-reseed` to skip). Then either `--relaunch` (restart rostered agents) or
|
||||||
|
print clear guidance to run `mosaic update --relaunch` / `mosaic fleet restart`.
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
`update CLI → re-seed framework (data-safe) → relaunch agents (opt-in)` — closes R13, activates the
|
||||||
|
native harness for every operator.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 6 new unit tests (reseed command/env, relaunch commands, roster parse, missing-installer guard).
|
||||||
|
- 19 runtime + 26 launch tests still green; tsc/eslint/prettier clean.
|
||||||
|
- Data-safety of the sync is already proven (P4 5-fixture matrix + live dragon-lin validation).
|
||||||
26
docs/scratchpads/fleet-enhancer-floor.md
Normal file
26
docs/scratchpads/fleet-enhancer-floor.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Fleet enhancer role + two-agent floor (#614)
|
||||||
|
|
||||||
|
- **Issue:** #614 · **Branch:** `feat/fleet-enhancer-floor` (stacked on #612 `feat/fleet-polish-bundle`)
|
||||||
|
- **Doctrine:** `docs/fleet/north-star.md` (PR #613) — every fleet = orchestrator + enhancer minimum.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- **Presets** (general, coding, research, hybrid): add `enhancer` (claude, `class: enhancer`,
|
||||||
|
`persistent_persona: true`) as a core always-on agent alongside the orchestrator. minimal/local-canary
|
||||||
|
unchanged.
|
||||||
|
- **fleet.ts**: `countEnhancers` helper; init guarantee extended — non-minimal profiles must yield
|
||||||
|
exactly 1 orchestrator AND >=1 enhancer (hard-fail otherwise); `removeAgentFromRoster` refuses to drop
|
||||||
|
the sole enhancer (symmetric with the sole-orchestrator guard) so the floor holds at runtime, not just init.
|
||||||
|
- **Role doc**: `framework/fleet/roles/enhancer.md` — the enhancer mandate (monitor → analyze → plan →
|
||||||
|
upgrade tools/skills/harness WITH orchestrator → file Mosaic Stack bug reports) + boundaries (does NOT
|
||||||
|
code or review).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 155 fleet tests green (new: countEnhancers; remove-sole-enhancer guard; remove-allows-when-another;
|
||||||
|
init two-agent-floor; every-non-minimal-preset-has-enhancer; updated preset rosters). tsc/eslint/
|
||||||
|
prettier/sanitize clean. TDD on the init guarantee + remove protection.
|
||||||
|
|
||||||
|
## Stacking
|
||||||
|
|
||||||
|
Built on #612's init-R5 code. PR shows #612 + enhancer until #612 merges; then rebase onto main → clean.
|
||||||
20
docs/scratchpads/fleet-polish-bundle.md
Normal file
20
docs/scratchpads/fleet-polish-bundle.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Fleet-polish bundle — boot-survival symmetry (#611)
|
||||||
|
|
||||||
|
- **Issue:** #611 · **Branch:** `feat/fleet-polish-bundle` · From the Lead's Codex symmetry-gap finding.
|
||||||
|
|
||||||
|
## Three fixes
|
||||||
|
|
||||||
|
1. **disable-on-remove (BUG, TDD).** `fleet remove` stopped + deleted roster/env/heartbeat but never
|
||||||
|
`systemctl --user disable mosaic-agent@NAME.service` → a removed-but-enabled unit could resurrect on
|
||||||
|
reboot pointing at deleted config. Fix: `buildSystemdDisableCommand` + disable in `remove`
|
||||||
|
(best-effort, gated on !--keep-files).
|
||||||
|
2. **add-enable.** `fleet add` now enables the new agent's unit for boot-survival (best-effort,
|
||||||
|
independent of --start) — symmetry with disable-on-remove.
|
||||||
|
3. **init-R5 guarantee.** `fleet init --write` now FAILS HARD when a non-minimal profile doesn't yield
|
||||||
|
exactly one orchestrator (was a soft warning). `minimal` (sanctioned no-orchestrator) still allowed.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- 4 new tests (disable builder; remove-invokes-disable; add-invokes-enable; init general → exactly 1
|
||||||
|
orchestrator) + 147 existing fleet tests green (151 total). tsc/eslint/prettier clean.
|
||||||
|
- TDD on the disable bug per contract.
|
||||||
43
docs/scratchpads/p5-overlay-composer.md
Normal file
43
docs/scratchpads/p5-overlay-composer.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# P5 — Overlay composer + cross-harness (compose-contract)
|
||||||
|
|
||||||
|
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
|
||||||
|
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
|
||||||
|
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
|
||||||
|
|
||||||
|
## Locked design (sequential-thinking)
|
||||||
|
|
||||||
|
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
|
||||||
|
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
|
||||||
|
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
|
||||||
|
|
||||||
|
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
|
||||||
|
labeled sections; base files keep their existing residency.
|
||||||
|
|
||||||
|
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
|
||||||
|
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
|
||||||
|
load-on-demand, so only the small delta is injected — not the full base prose).
|
||||||
|
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
|
||||||
|
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
|
||||||
|
layers first, operator overlays at recency.
|
||||||
|
- Base-only is automatic when a `.local` file is absent (`readOptional`).
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
| # | Task | File |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
|
||||||
|
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
|
||||||
|
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
|
||||||
|
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
|
||||||
|
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
|
||||||
|
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
|
||||||
|
|
||||||
|
## Deferred to P6
|
||||||
|
|
||||||
|
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
|
||||||
|
alpha tag `mosaic-vX.Y.Z-alpha`.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
|
||||||
|
- [ ] Implementation (tasks 1–5)
|
||||||
|
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)
|
||||||
29
docs/scratchpads/p6-docs-compliance-alpha.md
Normal file
29
docs/scratchpads/p6-docs-compliance-alpha.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
|
||||||
|
|
||||||
|
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
|
||||||
|
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
|
||||||
|
|
||||||
|
## Delivered (in-repo)
|
||||||
|
|
||||||
|
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
|
||||||
|
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
|
||||||
|
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
|
||||||
|
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
|
||||||
|
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
|
||||||
|
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
|
||||||
|
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
|
||||||
|
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
|
||||||
|
|
||||||
|
## Remaining
|
||||||
|
|
||||||
|
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
|
||||||
|
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Alpha DoD (DESIGN §8): all phases P0–P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
|
||||||
|
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.
|
||||||
185
packages/mosaic/framework/CONTRIBUTING.md
Normal file
185
packages/mosaic/framework/CONTRIBUTING.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Contributing to the Mosaic Framework
|
||||||
|
|
||||||
|
The Mosaic framework is the open-source agent-operating layer that deploys to
|
||||||
|
`~/.config/mosaic/`. It is designed to be **forked and customized** — but the
|
||||||
|
shared core must stay operator-neutral, deduplicated, and upgrade-safe. This
|
||||||
|
guide is the contract for changing framework-owned files.
|
||||||
|
|
||||||
|
> Governance model and layer rationale: `constitution/LAYER-MODEL.md` (source-only).
|
||||||
|
> Requirements & phase history: `docs/design/framework-constitution/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The layer model (where does my change go?)
|
||||||
|
|
||||||
|
| Layer | What | Owner | On upgrade | File(s) |
|
||||||
|
| ------ | ------------------------------------------------------------- | ---------------- | --------------------------------------- | -------------------------------------------- |
|
||||||
|
| **L0** | Constitution — the non-negotiable law (hard gates) | Framework | **Overwritten** | `CONSTITUTION.md` |
|
||||||
|
| **L1** | Standards & guides — how to do the work well | Framework | Overwritten; user delta → `*.local.md` | `STANDARDS.md`, `guides/*` |
|
||||||
|
| **L2** | Persona (SOUL) — agent name, tone, role | User (init) | **Never overwritten** | `SOUL.md` (+ optional `SOUL.local.md`) |
|
||||||
|
| **L3** | Operator (USER) — human identity, prefs, policy | User (init) | **Never overwritten** | `USER.md` (+ optional `USER.local.md`) |
|
||||||
|
| **L4** | Project / runtime mechanism — per-repo deltas; harness wiring | Repo / framework | Project user-owned; runtime overwritten | `<repo>/AGENTS.md`, `runtime/<h>/RUNTIME.md` |
|
||||||
|
|
||||||
|
**The one sentence a user can rely on:** edit `SOUL.md` / `USER.md` and the
|
||||||
|
`.local.md` overlays — they survive every upgrade. To change framework behavior,
|
||||||
|
add a `.local.md` overlay; never edit a framework-owned file in place.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Operator hygiene (PII / secrets prohibition) — **blocking**
|
||||||
|
|
||||||
|
Framework-owned files ship publicly. They **must not** contain:
|
||||||
|
|
||||||
|
- Operator or personal identity (names, handles, pronouns, accessibility notes).
|
||||||
|
- Private `$HOME` paths, private hostnames, or domains.
|
||||||
|
- Secrets, tokens, or credentials (use `~/.config/mosaic/credentials.json`; the
|
||||||
|
hook URL soft-degrades via `${OPENBRAIN_URL}`).
|
||||||
|
|
||||||
|
This is enforced by `tools/quality/scripts/verify-sanitized.sh`, wired **blocking**
|
||||||
|
in CI (`.woodpecker/ci.yml`). It runs two rule classes: structural (private-`$HOME`
|
||||||
|
defaults, dead paths, unrendered tokens) and a labeled current-contaminant denylist.
|
||||||
|
Run it locally before pushing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Operator-specific behavior belongs in **your** `SOUL.md`/`USER.md`/`*.local.md`,
|
||||||
|
never in the shared core. (The "framework-PR firewall" in `CONSTITUTION.md` §4
|
||||||
|
states this as law for agents opening framework PRs.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Dedup rule — one source, everyone references it
|
||||||
|
|
||||||
|
Hard gates live in **`CONSTITUTION.md` (L0) only**. `AGENTS.md`, `STANDARDS.md`,
|
||||||
|
and every `runtime/<h>/RUNTIME.md` **reference** the law — they never restate it.
|
||||||
|
Restating a gate is a defect: it creates two sources that drift. If you find a
|
||||||
|
gate duplicated outside L0, delete the copy and point to L0.
|
||||||
|
|
||||||
|
`AGENTS.md` is a thin dispatcher (load order + guide router + the tier-aware
|
||||||
|
self-load). Keep it that way; new procedure goes in `guides/*` (on-demand), not
|
||||||
|
in the resident core.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Resident line-count ceiling — **blocking**
|
||||||
|
|
||||||
|
The framework-owned files injected by value (`CONSTITUTION.md`, `AGENTS.md`, each
|
||||||
|
`runtime/<h>/RUNTIME.md`) are budgeted by **line count** — never by word count
|
||||||
|
(a word cap forces paraphrasing the law, the exact drift vector we removed).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Wired blocking in CI. Gate **wording** stays intact; if a file legitimately needs
|
||||||
|
more lines, raise its ceiling in the script deliberately (in the same PR, with
|
||||||
|
rationale). The per-harness _total_ resident prompt (which also sums the user's
|
||||||
|
`SOUL.md`/`USER.md`) is a `mosaic doctor` runtime advisory — CI cannot see user
|
||||||
|
files, so it is out of CI scope by design (DESIGN §7).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Dual-installer parity rule
|
||||||
|
|
||||||
|
Two installers seed and migrate `~/.config/mosaic/`:
|
||||||
|
|
||||||
|
- **`framework/install.sh`** (bash) — the canonical installer.
|
||||||
|
- **`packages/mosaic/src/config/file-adapter.ts`** (TS) — the wizard path.
|
||||||
|
|
||||||
|
**Any change to seed lists, overwrite/preserve semantics, or migration MUST land
|
||||||
|
in BOTH**, validated by the **shared fixture suite**:
|
||||||
|
|
||||||
|
- `framework/tools/quality/scripts/test-install-migration.sh` (bash matrix)
|
||||||
|
- `packages/mosaic/src/config/file-adapter.test.ts` (vitest)
|
||||||
|
|
||||||
|
Both assert the same behavior: framework-owned files overwrite (backup-once to
|
||||||
|
`*.pre-constitution.bak`); user-seeded files seed-if-absent; `SOUL.md`/`USER.md`/
|
||||||
|
`*.local.md`/`credentials` are preserved. A change in one installer without the
|
||||||
|
other (and its fixtures) is incomplete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Adding a harness adapter
|
||||||
|
|
||||||
|
A harness (runtime) is wired by:
|
||||||
|
|
||||||
|
1. `runtime/<h>/RUNTIME.md` — **mechanism only** (subagent syntax, hook/MCP wiring,
|
||||||
|
injection method). No restated gates (see §3).
|
||||||
|
2. Launcher emission in `src/commands/launch.ts` — how the composed contract reaches
|
||||||
|
the harness (system-prompt append vs. instructions file). Add the harness to the
|
||||||
|
`RuntimeName` union and the runtime-path map.
|
||||||
|
3. `mosaic compose-contract <harness>` works automatically once the runtime path
|
||||||
|
exists (it composes base + `*.local.md` overlays for that harness).
|
||||||
|
|
||||||
|
Then add a row to the compliance matrix (§8) and mark which gates are mechanical
|
||||||
|
vs. resident-only for the new harness.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Re-contamination rule
|
||||||
|
|
||||||
|
A green sanitization gate is not permanent. Before every PR:
|
||||||
|
|
||||||
|
- Do not reintroduce operator identity, private paths, or secrets (§2).
|
||||||
|
- Do not copy a gate out of L0 (§3).
|
||||||
|
- Do not add an unrendered template token or a dead path to a shipped file.
|
||||||
|
|
||||||
|
If `verify-sanitized.sh` goes red, that diff **is** your worklist — fix it, don't
|
||||||
|
suppress it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Harness × gate compliance matrix
|
||||||
|
|
||||||
|
How each gate is enforced per harness. **Mechanical** = a hook/CI check the agent
|
||||||
|
cannot bypass. **Resident** = injected contract prose (strong, but not a hard stop).
|
||||||
|
**CI** = repo-side, harness-independent.
|
||||||
|
|
||||||
|
| Gate / mechanism | Claude | Codex | OpenCode | Pi |
|
||||||
|
| --------------------------------------------- | ----------- | ---------------- | ---------------- | ---------------- |
|
||||||
|
| Contract injection (resident-by-value) | append SP | instructions | `AGENTS.md` | append SP |
|
||||||
|
| Operator overlays (`*.local`, composed) | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Bare-launch self-load (Tier-3, read L0) | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| Sanitization (no PII) — `verify-sanitized` | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||||
|
| Resident budget ceiling | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||||
|
| Migration parity (5-fixture, both installers) | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||||
|
| `no-memory-write` (PreToolUse hook) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
||||||
|
| QA / typecheck (PostToolUse hooks) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
||||||
|
| Native heartbeat (fleet `ps` model/status) | sidecar | sidecar | sidecar | **native ✅** |
|
||||||
|
|
||||||
|
⚠️ **Hook-parity gap (tracked, v2):** the mechanical PreToolUse/PostToolUse hooks
|
||||||
|
exist for Claude Code only. On Codex/OpenCode/Pi those gates are currently enforced
|
||||||
|
by the resident contract + CI, not by a per-tool hook. Closing hook parity is a
|
||||||
|
**v2** item, not part of this alpha.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Known limitations (accepted residual risks)
|
||||||
|
|
||||||
|
These are accepted with rationale (DESIGN §9); they are documented, not bugs:
|
||||||
|
|
||||||
|
- **Bare-launch overlays are base-only.** A harness started without `mosaic` never
|
||||||
|
ran the composer, so `*.local.md` overlays are not applied. Mitigated by the
|
||||||
|
unconditional Tier-3 self-load + the `mosaic doctor` nudge in `AGENTS.md`; not
|
||||||
|
eliminated. Relaunch via `mosaic <harness>` to pick up overlays.
|
||||||
|
- **Bare-launch drift is undetected by `mosaic doctor`** (the launcher never ran).
|
||||||
|
- **Codex/OpenCode/Pi hook parity** is a tracked v2 gap (§8).
|
||||||
|
- **Live-launch cross-harness verification** is v2; the alpha verifies the composer
|
||||||
|
by unit test (per-tier anchor + Tier-3 byte-equality), not a live launch.
|
||||||
|
|
||||||
|
**Deferred to v2 (explicit):** `constitution/` deploy directory; capability JSON
|
||||||
|
adapters; 3-way merge; `policy/*.md` composition; per-layer version stamps as a
|
||||||
|
migration driver.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. PR checklist
|
||||||
|
|
||||||
|
- [ ] No operator identity / private paths / secrets (`verify-sanitized.sh` green).
|
||||||
|
- [ ] No gate restated outside `CONSTITUTION.md` (§3).
|
||||||
|
- [ ] Resident budget green (`check-resident-budget.sh`).
|
||||||
|
- [ ] Seed/migration changes landed in **both** installers + shared fixtures (§5).
|
||||||
|
- [ ] New harness → compliance-matrix row updated (§8).
|
||||||
|
- [ ] `prettier --check` + `pnpm lint` + `pnpm typecheck` + `pnpm test` green.
|
||||||
@@ -9,7 +9,10 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
|||||||
1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime
|
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.
|
`~/.config/mosaic/CONSTITUTION.md` now, before your first action. A bare launch also gets
|
||||||
|
**base contracts only** — operator overlays (`*.local.md`) are composed by the launcher, so if
|
||||||
|
`SOUL.local.md`/`USER.local.md`/`STANDARDS.local.md` exist, relaunch via `mosaic <harness>` (or run
|
||||||
|
`mosaic doctor`) to pick them up.
|
||||||
2. Read `SOUL.md` (agent persona — small, once).
|
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.
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: coder0
|
- name: coder0
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: implementer
|
class: implementer
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: generalist
|
- name: generalist
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: worker
|
class: worker
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: coder0
|
- name: coder0
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: implementer
|
class: implementer
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ agents:
|
|||||||
runtime: claude
|
runtime: claude
|
||||||
class: orchestrator
|
class: orchestrator
|
||||||
persistent_persona: true
|
persistent_persona: true
|
||||||
|
- name: enhancer
|
||||||
|
runtime: claude
|
||||||
|
class: enhancer
|
||||||
|
persistent_persona: true
|
||||||
- name: researcher0
|
- name: researcher0
|
||||||
runtime: pi
|
runtime: pi
|
||||||
class: researcher
|
class: researcher
|
||||||
|
|||||||
41
packages/mosaic/framework/fleet/roles/enhancer.md
Normal file
41
packages/mosaic/framework/fleet/roles/enhancer.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Enhancer — fleet role definition
|
||||||
|
|
||||||
|
The **enhancer** is one half of the fleet's two-agent floor: every fleet runs, at
|
||||||
|
minimum, an **orchestrator** and an **enhancer**. The orchestrator drives delivery;
|
||||||
|
the enhancer makes the fleet _get better at delivering_ over time.
|
||||||
|
|
||||||
|
It is a **core, always-on** agent (`class: enhancer`, `persistent_persona: true`),
|
||||||
|
not an ephemeral per-lane worker.
|
||||||
|
|
||||||
|
## Mandate
|
||||||
|
|
||||||
|
The enhancer runs the fleet's **continuous-improvement loop**:
|
||||||
|
|
||||||
|
1. **Monitor** fleet activity — agents, heartbeats, sessions, throughput, failures.
|
||||||
|
2. **Analyze** for enhancements and optimizations — friction, gaps, recurring defects,
|
||||||
|
missing or broken tools, skill/harness shortfalls.
|
||||||
|
3. **Plan** a remediation: a concrete improvement with rationale and expected effect.
|
||||||
|
4. **Upgrade fleet capability — with the orchestrator** — tool creation/repair, skills,
|
||||||
|
harness improvements. The orchestrator owns fleet composition; the enhancer advises and
|
||||||
|
implements improvements to the _means of production_, not the product.
|
||||||
|
5. **File upstream bug reports** to Mosaic Stack for real defects, so they flow back to the
|
||||||
|
framework for proper remediation rather than being patched over locally.
|
||||||
|
6. **Recommend which agents are needed** — advise the orchestrator on roles to add/remove as
|
||||||
|
the mission evolves.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- **Does NOT write product/source code.**
|
||||||
|
- **Does NOT review code** (that is the code-review / security-review roles).
|
||||||
|
- **Does NOT perform delivery tasks.**
|
||||||
|
|
||||||
|
Improvement and diagnosis only. When the enhancer finds work that requires coding or review,
|
||||||
|
it files it (bug report / recommendation) and the orchestrator materializes the right worker.
|
||||||
|
|
||||||
|
## Why two, not one
|
||||||
|
|
||||||
|
The orchestrator alone optimizes for _this_ delivery; the enhancer optimizes for _every future_
|
||||||
|
delivery — self-healing the fleet's tools, skills, and harnesses, and routing real defects
|
||||||
|
upstream. Together they are the irreducible core; every other role is added on demand.
|
||||||
|
|
||||||
|
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).
|
||||||
93
packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
Executable file
93
packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
Executable file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# check-resident-budget.sh — resident line-count ceiling (R9 / DESIGN §7).
|
||||||
|
#
|
||||||
|
# Budgets the *container* (line count) of the framework-owned files that are
|
||||||
|
# injected into every agent's context by value — the Constitution (L0), the
|
||||||
|
# AGENTS dispatcher, and each runtime RUNTIME.md slice. Gate *wording* is never
|
||||||
|
# capped (a word cap forces paraphrasing law — the exact drift vector P3 killed);
|
||||||
|
# only the file's line count is bounded, so prose creep is caught in review.
|
||||||
|
#
|
||||||
|
# This is the CI-enforceable half of the budget. The per-harness *total* resident
|
||||||
|
# prompt (which also includes user-generated SOUL.md/USER.md and the per-tier
|
||||||
|
# slice) is summed by `mosaic doctor` as a runtime advisory — CI cannot see user
|
||||||
|
# files, so it is deliberately out of scope here (DESIGN §7).
|
||||||
|
#
|
||||||
|
# Usage: check-resident-budget.sh [--self-test]
|
||||||
|
# Exit: 0 = all within budget · 1 = a file exceeds its ceiling · 2 = self-test failed
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||||
|
|
||||||
|
# Per-file ceilings (lines). Headroom above current counts; tighten as files settle.
|
||||||
|
# Format: "<relative-path>:<max-lines>"
|
||||||
|
CEILINGS=(
|
||||||
|
"defaults/CONSTITUTION.md:120"
|
||||||
|
"defaults/AGENTS.md:120"
|
||||||
|
"runtime/claude/RUNTIME.md:90"
|
||||||
|
"runtime/codex/RUNTIME.md:90"
|
||||||
|
"runtime/opencode/RUNTIME.md:90"
|
||||||
|
"runtime/pi/RUNTIME.md:90"
|
||||||
|
)
|
||||||
|
|
||||||
|
# check_file <abs-path> <max> → echoes "<n>"; returns 0 if n<=max, 1 otherwise.
|
||||||
|
check_file() {
|
||||||
|
local path="$1" max="$2" n
|
||||||
|
n=$(wc -l <"$path" 2>/dev/null || echo 0)
|
||||||
|
n=$((n + 0))
|
||||||
|
echo "$n"
|
||||||
|
[ "$n" -le "$max" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
run_budget() {
|
||||||
|
local fail=0 rel max abs n
|
||||||
|
printf '%-32s %8s %8s %s\n' "FILE" "LINES" "CEILING" "STATUS"
|
||||||
|
for entry in "${CEILINGS[@]}"; do
|
||||||
|
rel="${entry%%:*}"
|
||||||
|
max="${entry##*:}"
|
||||||
|
abs="$FW/$rel"
|
||||||
|
if [ ! -f "$abs" ]; then
|
||||||
|
printf '%-32s %8s %8s %s\n' "$rel" "-" "$max" "MISSING"
|
||||||
|
fail=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
n=$(check_file "$abs" "$max")
|
||||||
|
if [ "$n" -le "$max" ]; then
|
||||||
|
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "ok"
|
||||||
|
else
|
||||||
|
printf '%-32s %8s %8s %s\n' "$rel" "$n" "$max" "OVER BUDGET"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return "$fail"
|
||||||
|
}
|
||||||
|
|
||||||
|
self_test() {
|
||||||
|
local tmp rc
|
||||||
|
tmp=$(mktemp)
|
||||||
|
# 3 lines, ceiling 5 → within budget (rc 0)
|
||||||
|
printf 'a\nb\nc\n' >"$tmp"
|
||||||
|
check_file "$tmp" 5 >/dev/null
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then echo "self-test FAIL: under-budget file flagged"; rm -f "$tmp"; return 2; fi
|
||||||
|
# 6 lines, ceiling 5 → over budget (rc 1)
|
||||||
|
printf 'a\nb\nc\nd\ne\nf\n' >"$tmp"
|
||||||
|
check_file "$tmp" 5 >/dev/null
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 1 ]; then echo "self-test FAIL: over-budget file not flagged"; rm -f "$tmp"; return 2; fi
|
||||||
|
rm -f "$tmp"
|
||||||
|
echo "self-test OK"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "${1:-}" = "--self-test" ]; then
|
||||||
|
self_test
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if run_budget; then
|
||||||
|
echo "Resident budget: all framework-owned resident files within ceiling."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "Resident budget EXCEEDED — trim prose or raise the ceiling deliberately (see DESIGN §7)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.37",
|
"version": "0.0.39",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ 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';
|
||||||
@@ -404,7 +408,12 @@ 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')
|
||||||
.action(async (opts: { check?: boolean }) => {
|
.option(
|
||||||
|
'--no-reseed',
|
||||||
|
'Skip re-seeding framework files into ~/.config/mosaic after the CLI update',
|
||||||
|
)
|
||||||
|
.option('--relaunch', 'Restart durable fleet agents so the new launcher/runtime takes effect')
|
||||||
|
.action(async (opts: { check?: boolean; reseed?: boolean; relaunch?: boolean }) => {
|
||||||
// checkForAllUpdates imported statically above
|
// checkForAllUpdates imported statically above
|
||||||
const { execSync } = await import('node:child_process');
|
const { execSync } = await import('node:child_process');
|
||||||
|
|
||||||
@@ -442,6 +451,51 @@ 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 ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
118
packages/mosaic/src/commands/compose-contract.spec.ts
Normal file
118
packages/mosaic/src/commands/compose-contract.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { composeContract } from './launch.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composer unit test (R7/R8/R9): asserts the launcher-composed runtime contract
|
||||||
|
*
|
||||||
|
* - includes the per-tier anchors (CONSTITUTION / AGENTS / USER / runtime),
|
||||||
|
* - keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3
|
||||||
|
* byte-equality — the bare-launch fallback read must match what is injected),
|
||||||
|
* - merges `*.local.md` operator overlays as deltas-by-value, and omits them
|
||||||
|
* entirely when absent (base-only),
|
||||||
|
* - selects the correct per-harness RUNTIME.md.
|
||||||
|
*
|
||||||
|
* `composeContract` takes `mosaicHome` as a param, so each test runs against an
|
||||||
|
* isolated fixture home. We also chdir to an empty temp cwd so the cwd-relative
|
||||||
|
* mission/PRD blocks contribute nothing (deterministic output).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const CONSTITUTION = '# CONSTITUTION\n\nGATE-1: the non-negotiable law.\n';
|
||||||
|
const AGENTS = '# Mosaic Agent Dispatcher\n\nLoad order + guide router.\n';
|
||||||
|
const USER = '# operator\n\nName: Test Operator\n';
|
||||||
|
const TOOLS = '# tools index\n';
|
||||||
|
|
||||||
|
function makeHome(): { home: string; root: string } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-compose-'));
|
||||||
|
const home = join(root, 'mosaic-home');
|
||||||
|
for (const h of ['claude', 'codex', 'opencode', 'pi']) {
|
||||||
|
mkdirSync(join(home, 'runtime', h), { recursive: true });
|
||||||
|
writeFileSync(join(home, 'runtime', h, 'RUNTIME.md'), `# ${h} runtime contract\n`);
|
||||||
|
}
|
||||||
|
writeFileSync(join(home, 'CONSTITUTION.md'), CONSTITUTION);
|
||||||
|
writeFileSync(join(home, 'AGENTS.md'), AGENTS);
|
||||||
|
writeFileSync(join(home, 'USER.md'), USER);
|
||||||
|
writeFileSync(join(home, 'TOOLS.md'), TOOLS);
|
||||||
|
return { home, root };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('composeContract — overlay composer', () => {
|
||||||
|
let fixture: ReturnType<typeof makeHome>;
|
||||||
|
let prevCwd: string;
|
||||||
|
let cwdDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = makeHome();
|
||||||
|
prevCwd = process.cwd();
|
||||||
|
cwdDir = mkdtempSync(join(tmpdir(), 'mosaic-cwd-'));
|
||||||
|
process.chdir(cwdDir); // neutralize cwd-relative mission/PRD blocks
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.chdir(prevCwd);
|
||||||
|
rmSync(fixture.root, { recursive: true, force: true });
|
||||||
|
rmSync(cwdDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the per-tier anchors and the selected harness runtime', () => {
|
||||||
|
const out = composeContract('claude', fixture.home);
|
||||||
|
expect(out).toContain('GATE-1: the non-negotiable law.'); // L0
|
||||||
|
expect(out).toContain('Mosaic Agent Dispatcher'); // AGENTS
|
||||||
|
expect(out).toContain('# User Profile'); // USER header
|
||||||
|
expect(out).toContain('Name: Test Operator'); // USER body
|
||||||
|
expect(out).toContain('# Runtime-Specific Contract');
|
||||||
|
expect(out).toContain('# claude runtime contract');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the CONSTITUTION block byte-equal to the on-disk file (Tier-3)', () => {
|
||||||
|
const out = composeContract('pi', fixture.home);
|
||||||
|
const onDisk = readFileSync(join(fixture.home, 'CONSTITUTION.md'), 'utf-8');
|
||||||
|
// The injected L0 must be a byte-equal substring of the composed blob, so a
|
||||||
|
// bare-launch fallback read of CONSTITUTION.md matches what was injected.
|
||||||
|
expect(out.includes(onDisk)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is base-only when no *.local overlays exist', () => {
|
||||||
|
const out = composeContract('claude', fixture.home);
|
||||||
|
expect(out).not.toContain('# Operator Overlays');
|
||||||
|
expect(out).not.toContain('Operator Overlay (USER.local.md)');
|
||||||
|
expect(out).not.toContain('Persona Overlay');
|
||||||
|
expect(out).not.toContain('Standards Overlay');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges USER.local.md directly under the operator profile', () => {
|
||||||
|
writeFileSync(join(fixture.home, 'USER.local.md'), 'Prefer terse status updates.\n');
|
||||||
|
const out = composeContract('claude', fixture.home);
|
||||||
|
expect(out).toContain('## Operator Overlay (USER.local.md)');
|
||||||
|
expect(out).toContain('Prefer terse status updates.');
|
||||||
|
// Overlay appears AFTER its base profile.
|
||||||
|
expect(out.indexOf('# User Profile')).toBeLessThan(
|
||||||
|
out.indexOf('## Operator Overlay (USER.local.md)'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges SOUL.local.md + STANDARDS.local.md as deltas in the Operator Overlays block', () => {
|
||||||
|
writeFileSync(join(fixture.home, 'SOUL.local.md'), 'Tone: dry and direct.\n');
|
||||||
|
writeFileSync(join(fixture.home, 'STANDARDS.local.md'), 'Require 90% coverage on auth code.\n');
|
||||||
|
const out = composeContract('claude', fixture.home);
|
||||||
|
expect(out).toContain('# Operator Overlays');
|
||||||
|
expect(out).toContain('## Persona Overlay (SOUL.local.md)');
|
||||||
|
expect(out).toContain('Tone: dry and direct.');
|
||||||
|
expect(out).toContain('## Standards Overlay (STANDARDS.local.md)');
|
||||||
|
expect(out).toContain('Require 90% coverage on auth code.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores whitespace-only *.local overlays (no empty overlay section)', () => {
|
||||||
|
writeFileSync(join(fixture.home, 'SOUL.local.md'), ' \n\n');
|
||||||
|
const out = composeContract('claude', fixture.home);
|
||||||
|
expect(out).not.toContain('# Operator Overlays');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a different RUNTIME.md per harness', () => {
|
||||||
|
expect(composeContract('codex', fixture.home)).toContain('# codex runtime contract');
|
||||||
|
expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract');
|
||||||
|
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,11 +14,13 @@ import {
|
|||||||
buildEnableLingerCommand,
|
buildEnableLingerCommand,
|
||||||
buildFleetServiceCommand,
|
buildFleetServiceCommand,
|
||||||
buildSystemdEnableCommand,
|
buildSystemdEnableCommand,
|
||||||
|
buildSystemdDisableCommand,
|
||||||
buildSystemdShowCommand,
|
buildSystemdShowCommand,
|
||||||
buildTmuxListPanesCommand,
|
buildTmuxListPanesCommand,
|
||||||
buildTmuxListSessionsCommand,
|
buildTmuxListSessionsCommand,
|
||||||
classifySendResult,
|
classifySendResult,
|
||||||
countOrchestrators,
|
countOrchestrators,
|
||||||
|
countEnhancers,
|
||||||
detectDrift,
|
detectDrift,
|
||||||
enableFleetUnits,
|
enableFleetUnits,
|
||||||
FLEET_PROFILES,
|
FLEET_PROFILES,
|
||||||
@@ -983,6 +985,129 @@ describe('fleet ps — drift detection', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fleet-polish bundle — boot-survival symmetry', () => {
|
||||||
|
async function rosterHome(agents: string): Promise<string> {
|
||||||
|
const home = await tempDir();
|
||||||
|
await mkdir(join(home, 'fleet'), { recursive: true });
|
||||||
|
await writeFile(join(home, 'fleet', 'roster.yaml'), agents);
|
||||||
|
return home;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('buildSystemdDisableCommand returns the systemctl --user disable array', () => {
|
||||||
|
expect(buildSystemdDisableCommand('mosaic-agent@coder0.service')).toEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'disable',
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet remove DISABLES the unit so a removed agent cannot resurrect on boot', async () => {
|
||||||
|
const home = await rosterHome(
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'transport: tmux',
|
||||||
|
'agents:',
|
||||||
|
' - name: orchestrator',
|
||||||
|
' runtime: pi',
|
||||||
|
' class: orchestrator',
|
||||||
|
' - name: coder0',
|
||||||
|
' runtime: codex',
|
||||||
|
' class: worker',
|
||||||
|
].join('\n') + '\n',
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
try {
|
||||||
|
await program.parseAsync(['node', 'mosaic', 'fleet', 'remove', 'coder0']);
|
||||||
|
expect(calls).toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'disable',
|
||||||
|
'mosaic-agent@coder0.service',
|
||||||
|
]);
|
||||||
|
// stop must still happen too
|
||||||
|
expect(calls).toContainEqual(['systemctl', '--user', 'stop', 'mosaic-agent@coder0.service']);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet add ENABLES the new agent unit for boot-survival', async () => {
|
||||||
|
const home = await rosterHome(
|
||||||
|
['version: 1', 'transport: tmux', 'agents:', ' - name: coder0', ' runtime: codex'].join(
|
||||||
|
'\n',
|
||||||
|
) + '\n',
|
||||||
|
);
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const runner: CommandRunner = async (command, args) => {
|
||||||
|
calls.push([command, ...args]);
|
||||||
|
return { stdout: '', stderr: '', exitCode: 0 };
|
||||||
|
};
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, { runner, mosaicHome: home });
|
||||||
|
try {
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'add',
|
||||||
|
'coder1',
|
||||||
|
'--runtime',
|
||||||
|
'codex',
|
||||||
|
'--class',
|
||||||
|
'worker',
|
||||||
|
'--no-start',
|
||||||
|
]);
|
||||||
|
expect(calls).toContainEqual([
|
||||||
|
'systemctl',
|
||||||
|
'--user',
|
||||||
|
'enable',
|
||||||
|
'mosaic-agent@coder1.service',
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fleet init --write enforces the two-agent floor (1 orchestrator + >=1 enhancer)', async () => {
|
||||||
|
// The general profile must yield exactly one orchestrator AND at least one
|
||||||
|
// enhancer; the guarantee is enforced (not just warned). Happy path writes cleanly.
|
||||||
|
const home = await tempDir();
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride();
|
||||||
|
registerFleetCommand(program, {
|
||||||
|
runner: async () => ({ stdout: '', stderr: '', exitCode: 0 }),
|
||||||
|
mosaicHome: home,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await program.parseAsync([
|
||||||
|
'node',
|
||||||
|
'mosaic',
|
||||||
|
'fleet',
|
||||||
|
'init',
|
||||||
|
'--profile',
|
||||||
|
'general',
|
||||||
|
'--write',
|
||||||
|
]);
|
||||||
|
const written = await readFile(join(home, 'fleet', 'roster.yaml'), 'utf8');
|
||||||
|
const orchestrators = (written.match(/class:\s*orchestrator/g) ?? []).length;
|
||||||
|
const enhancers = (written.match(/class:\s*enhancer/g) ?? []).length;
|
||||||
|
expect(orchestrators).toBe(1);
|
||||||
|
expect(enhancers).toBeGreaterThanOrEqual(1);
|
||||||
|
} finally {
|
||||||
|
await rm(home, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('fleet install — auto-enable units for boot-survival', () => {
|
describe('fleet install — auto-enable units for boot-survival', () => {
|
||||||
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
|
it('buildSystemdEnableCommand and buildEnableLingerCommand return correct command arrays', () => {
|
||||||
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
|
expect(buildSystemdEnableCommand('mosaic-tmux-holder.service')).toEqual([
|
||||||
@@ -2188,47 +2313,63 @@ describe('fleet preset rosters', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('general preset: orchestrator + one generalist worker', async () => {
|
it('general preset: orchestrator + enhancer + one generalist worker', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'general.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'generalist']);
|
expect(roster.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer', 'generalist']);
|
||||||
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
expect(roster.agents.find((a) => a.name === 'orchestrator')?.runtime).toBe('claude');
|
||||||
|
expect(roster.agents.find((a) => a.name === 'enhancer')?.className).toBe('enhancer');
|
||||||
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
expect(roster.agents.find((a) => a.name === 'generalist')?.runtime).toBe('pi');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('coding preset: orchestrator + coder0 + coder1 + reviewer', async () => {
|
it('coding preset: orchestrator + enhancer + coder0 + coder1 + reviewer', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'coding.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||||
'orchestrator',
|
'orchestrator',
|
||||||
|
'enhancer',
|
||||||
'coder0',
|
'coder0',
|
||||||
'coder1',
|
'coder1',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('research preset: orchestrator + researcher0 + researcher1 + analyst', async () => {
|
it('research preset: orchestrator + enhancer + researcher0 + researcher1 + analyst', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'research.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||||
'orchestrator',
|
'orchestrator',
|
||||||
|
'enhancer',
|
||||||
'researcher0',
|
'researcher0',
|
||||||
'researcher1',
|
'researcher1',
|
||||||
'analyst',
|
'analyst',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hybrid preset: orchestrator + coder0 + researcher0 + reviewer', async () => {
|
it('hybrid preset: orchestrator + enhancer + coder0 + researcher0 + reviewer', async () => {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
const roster = await loadFleetRoster(join(examplesDir, 'hybrid.yaml'));
|
||||||
expect(roster.agents.map((a) => a.name)).toEqual([
|
expect(roster.agents.map((a) => a.name)).toEqual([
|
||||||
'orchestrator',
|
'orchestrator',
|
||||||
|
'enhancer',
|
||||||
'coder0',
|
'coder0',
|
||||||
'researcher0',
|
'researcher0',
|
||||||
'reviewer',
|
'reviewer',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('every non-minimal preset carries an enhancer (two-agent floor)', async () => {
|
||||||
|
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||||
|
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||||
|
expect(countOrchestrators(roster)).toBe(1);
|
||||||
|
expect(countEnhancers(roster)).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(roster.agents.find((a) => a.className === 'enhancer')?.runtime).toBe('claude');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
it('worker agents in new presets use pi runtime with model_hint openai-codex/gpt-5.5:high', async () => {
|
||||||
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
for (const preset of ['general', 'coding', 'research', 'hybrid'] as FleetProfile[]) {
|
||||||
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
const roster = await loadFleetRoster(join(examplesDir, `${preset}.yaml`));
|
||||||
const workers = roster.agents.filter((a) => a.name !== 'orchestrator');
|
// Core agents (orchestrator + enhancer) run claude; only ephemeral workers are pi.
|
||||||
|
const workers = roster.agents.filter(
|
||||||
|
(a) => a.className !== 'orchestrator' && a.className !== 'enhancer',
|
||||||
|
);
|
||||||
for (const worker of workers) {
|
for (const worker of workers) {
|
||||||
expect(worker.runtime).toBe('pi');
|
expect(worker.runtime).toBe('pi');
|
||||||
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
expect(worker.modelHint).toBe('openai-codex/gpt-5.5:high');
|
||||||
@@ -2370,6 +2511,43 @@ describe('fleet add/remove — pure helpers', () => {
|
|||||||
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
|
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator2', 'coder0']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('countEnhancers counts enhancer-class agents (two-agent floor)', () => {
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
...baseRoster,
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||||
|
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||||
|
{ name: 'coder0', runtime: 'codex', className: 'worker' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(countEnhancers(roster)).toBe(1);
|
||||||
|
expect(countEnhancers(baseRoster)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeAgentFromRoster throws when removing the sole enhancer (two-agent floor)', () => {
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
...baseRoster,
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||||
|
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(() => removeAgentFromRoster(roster, 'enhancer')).toThrow('sole enhancer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removeAgentFromRoster allows removing an enhancer when another remains', () => {
|
||||||
|
const roster: FleetRoster = {
|
||||||
|
...baseRoster,
|
||||||
|
agents: [
|
||||||
|
{ name: 'orchestrator', runtime: 'claude', className: 'orchestrator' },
|
||||||
|
{ name: 'enhancer', runtime: 'claude', className: 'enhancer' },
|
||||||
|
{ name: 'enhancer2', runtime: 'claude', className: 'enhancer' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const updated = removeAgentFromRoster(roster, 'enhancer');
|
||||||
|
expect(updated.agents.map((a) => a.name)).toEqual(['orchestrator', 'enhancer2']);
|
||||||
|
});
|
||||||
|
|
||||||
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
|
it('serializeRosterToYaml produces YAML that round-trips through loadFleetRoster', async () => {
|
||||||
const yaml = serializeRosterToYaml(baseRoster);
|
const yaml = serializeRosterToYaml(baseRoster);
|
||||||
expect(typeof yaml).toBe('string');
|
expect(typeof yaml).toBe('string');
|
||||||
|
|||||||
@@ -227,6 +227,15 @@ export function buildSystemdEnableCommand(unit: string): string[] {
|
|||||||
return ['systemctl', '--user', 'enable', unit];
|
return ['systemctl', '--user', 'enable', unit];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the systemctl --user disable command for a given unit.
|
||||||
|
* Used by `fleet remove` so a removed agent's enabled unit cannot resurrect on
|
||||||
|
* boot pointing at deleted config (boot-survival symmetry with enable-on-add).
|
||||||
|
*/
|
||||||
|
export function buildSystemdDisableCommand(unit: string): string[] {
|
||||||
|
return ['systemctl', '--user', 'disable', unit];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the loginctl enable-linger command for a given user.
|
* Returns the loginctl enable-linger command for a given user.
|
||||||
* Linger allows user systemd services to survive logout.
|
* Linger allows user systemd services to survive logout.
|
||||||
@@ -872,20 +881,33 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
await mkdir(dirname(destination), { recursive: true });
|
await mkdir(dirname(destination), { recursive: true });
|
||||||
await writeFile(destination, content);
|
await writeFile(destination, content);
|
||||||
|
|
||||||
// Validate: exactly one orchestrator required (R5) — friendly summary on success.
|
// Guarantee the two-agent floor: exactly one orchestrator AND at least
|
||||||
|
// one enhancer for every profile except the sanctioned no-orchestrator
|
||||||
|
// `minimal` preset. A mismatch means a corrupted/edited preset — fail hard
|
||||||
|
// rather than write a malformed fleet.
|
||||||
const written = await loadFleetRoster(destination);
|
const written = await loadFleetRoster(destination);
|
||||||
const orchCount = countOrchestrators(written);
|
const orchCount = countOrchestrators(written);
|
||||||
if (orchCount !== 1) {
|
const enhancerCount = countEnhancers(written);
|
||||||
process.stderr.write(
|
if (profile === 'minimal') {
|
||||||
`Warning: fleet roster at ${destination} has ${orchCount} orchestrator agent(s) (expected exactly 1).\n`,
|
|
||||||
);
|
|
||||||
console.log(
|
console.log(
|
||||||
`Initialized ${profile} fleet: ${written.agents.length} agent(s). Next: mosaic fleet install`,
|
`Initialized ${profile} fleet: ${written.agents.length} agent(s) (no orchestrator). Next: mosaic fleet install`,
|
||||||
|
);
|
||||||
|
} else if (orchCount !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Fleet init failed: the "${profile}" roster has ${orchCount} orchestrator agent(s), ` +
|
||||||
|
`expected exactly 1 (R5). The preset may be corrupted — re-install the framework.`,
|
||||||
|
);
|
||||||
|
} else if (enhancerCount < 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Fleet init failed: the "${profile}" roster has no enhancer agent. Every fleet keeps an ` +
|
||||||
|
`orchestrator + enhancer minimum (two-agent floor). The preset may be corrupted — ` +
|
||||||
|
`re-install the framework.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const workerCount = written.agents.length - 1;
|
const workerCount = written.agents.length - 1 - enhancerCount;
|
||||||
console.log(
|
console.log(
|
||||||
`Initialized ${profile} fleet: 1 orchestrator + ${workerCount} agent(s). Next: mosaic fleet install`,
|
`Initialized ${profile} fleet: 1 orchestrator + ${enhancerCount} enhancer(s) + ` +
|
||||||
|
`${workerCount} worker(s). Next: mosaic fleet install`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1218,6 +1240,24 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
|
|
||||||
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
console.log(`Added ${name} (${opts.runtime}/${opts.class}) to the fleet.`);
|
||||||
|
|
||||||
|
// Enable the unit for boot-survival (non-fatal) — symmetry with
|
||||||
|
// disable-on-remove. Independent of --start so a queued agent still
|
||||||
|
// survives a reboot once its unit exists.
|
||||||
|
try {
|
||||||
|
const enableResult = await runner(
|
||||||
|
...splitCommand(buildSystemdEnableCommand(`mosaic-agent@${name}.service`)),
|
||||||
|
);
|
||||||
|
if (enableResult.exitCode !== 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: could not enable mosaic-agent@${name}.service: ${enableResult.stderr || enableResult.stdout || 'non-zero exit'}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: enable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.start !== false) {
|
if (opts.start !== false) {
|
||||||
await runChecked(runner, buildFleetServiceCommand('start', name));
|
await runChecked(runner, buildFleetServiceCommand('start', name));
|
||||||
console.log(`Started mosaic-agent@${name}.service.`);
|
console.log(`Started mosaic-agent@${name}.service.`);
|
||||||
@@ -1254,6 +1294,26 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disable the unit (non-fatal) so an enabled instance cannot resurrect on
|
||||||
|
// boot pointing at the now-deleted config — boot-survival symmetry with
|
||||||
|
// enable-on-add. Skipped only when --keep-files keeps the config in place.
|
||||||
|
if (!opts.keepFiles) {
|
||||||
|
try {
|
||||||
|
const disableResult = await runner(
|
||||||
|
...splitCommand(buildSystemdDisableCommand(`mosaic-agent@${name}.service`)),
|
||||||
|
);
|
||||||
|
if (disableResult.exitCode !== 0) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: could not disable mosaic-agent@${name}.service: ${disableResult.stderr || disableResult.stdout || 'non-zero exit'}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
process.stderr.write(
|
||||||
|
`Warning: disable command failed for ${name}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Write updated roster
|
// Write updated roster
|
||||||
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
await writeFile(rosterPath, serializeRosterToYaml(updatedRoster));
|
||||||
|
|
||||||
@@ -1894,6 +1954,15 @@ export function countOrchestrators(roster: FleetRoster): number {
|
|||||||
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
return roster.agents.filter((a) => a.className === 'orchestrator').length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count enhancer agents in a parsed roster. The two-agent floor (north-star)
|
||||||
|
* requires every non-minimal fleet to carry at least one enhancer alongside the
|
||||||
|
* sole orchestrator.
|
||||||
|
*/
|
||||||
|
export function countEnhancers(roster: FleetRoster): number {
|
||||||
|
return roster.agents.filter((a) => a.className === 'enhancer').length;
|
||||||
|
}
|
||||||
|
|
||||||
/** Valid runtime identifiers for fleet agents. */
|
/** Valid runtime identifiers for fleet agents. */
|
||||||
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
export const VALID_FLEET_RUNTIMES: readonly string[] = [
|
||||||
'pi',
|
'pi',
|
||||||
@@ -1936,6 +2005,15 @@ export function removeAgentFromRoster(roster: FleetRoster, name: string): FleetR
|
|||||||
`Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`,
|
`Cannot remove agent "${name}": it is the sole orchestrator. Add another orchestrator first (R5).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Two-agent floor: never drop the last enhancer (the continuous-improvement
|
||||||
|
// loop). Symmetric with the sole-orchestrator guard.
|
||||||
|
const remainingEnhancerCount = remaining.filter((a) => a.className === 'enhancer').length;
|
||||||
|
if (remainingEnhancerCount === 0 && agent.className === 'enhancer') {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot remove agent "${name}": it is the sole enhancer. Every fleet keeps at least one ` +
|
||||||
|
`enhancer (two-agent floor). Add another enhancer first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...roster,
|
...roster,
|
||||||
agents: remaining,
|
agents: remaining,
|
||||||
|
|||||||
@@ -291,12 +291,23 @@ 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(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
||||||
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
||||||
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||||
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeFile = runtimeContractPaths[runtime];
|
const runtimeFile = runtimeContractPaths[runtime];
|
||||||
@@ -331,27 +342,55 @@ For required push/merge/issue-close/release actions, execute without routine con
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
||||||
// pre-constitution installs that have not been re-seeded yet.
|
// pre-constitution installs that have not been re-seeded yet. Injected by
|
||||||
const constitution = readOptional(join(MOSAIC_HOME, 'CONSTITUTION.md'));
|
// value verbatim so the bare-launch fallback read is byte-equal (R8).
|
||||||
|
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(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
|
||||||
|
|
||||||
// USER.md
|
// USER.md (+ USER.local.md operator overlay, appended directly under the
|
||||||
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
// profile its base owns).
|
||||||
|
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(MOSAIC_HOME, 'TOOLS.md'));
|
const tools = readOptional(join(mosaicHome, '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 {
|
||||||
@@ -976,6 +1015,22 @@ 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')
|
||||||
|
|||||||
85
packages/mosaic/src/runtime/update-checker.reseed.spec.ts
Normal file
85
packages/mosaic/src/runtime/update-checker.reseed.spec.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import {
|
||||||
|
buildReseedCommand,
|
||||||
|
buildRelaunchCommands,
|
||||||
|
readRosterAgentNames,
|
||||||
|
runFrameworkReseed,
|
||||||
|
} from './update-checker.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* F3-m3 / R13: `mosaic update` re-seeds the framework + (opt-in) relaunches
|
||||||
|
* durable agents so shipped launcher/runtime changes activate. These cover the
|
||||||
|
* pure builders + the missing-installer guard (the exec path is integration).
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('buildReseedCommand', () => {
|
||||||
|
it('invokes the package install.sh in data-safe sync-only keep mode', () => {
|
||||||
|
const out = buildReseedCommand('/pkg/framework', '/home/u/.config/mosaic');
|
||||||
|
expect(out.installer).toBe('/pkg/framework/install.sh');
|
||||||
|
expect(out.command).toBe('bash /pkg/framework/install.sh');
|
||||||
|
expect(out.env).toEqual({
|
||||||
|
MOSAIC_SYNC_ONLY: '1',
|
||||||
|
MOSAIC_INSTALL_MODE: 'keep',
|
||||||
|
MOSAIC_HOME: '/home/u/.config/mosaic',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildRelaunchCommands', () => {
|
||||||
|
it('builds a systemctl --user restart per agent unit', () => {
|
||||||
|
expect(buildRelaunchCommands(['orchestrator', 'coder0'])).toEqual([
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-agent@orchestrator.service'],
|
||||||
|
['systemctl', '--user', 'restart', 'mosaic-agent@coder0.service'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is empty for an empty roster', () => {
|
||||||
|
expect(buildRelaunchCommands([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('readRosterAgentNames', () => {
|
||||||
|
let home: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
home = mkdtempSync(join(tmpdir(), 'mosaic-roster-'));
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(home, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns [] when no roster exists', () => {
|
||||||
|
expect(readRosterAgentNames(home)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extracts agent names from roster.yaml', () => {
|
||||||
|
mkdirSync(join(home, 'fleet'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(home, 'fleet', 'roster.yaml'),
|
||||||
|
[
|
||||||
|
'version: 1',
|
||||||
|
'agents:',
|
||||||
|
' - name: orchestrator',
|
||||||
|
' runtime: pi',
|
||||||
|
' - name: coder0',
|
||||||
|
' runtime: claude',
|
||||||
|
' - name: "reviewer-1"',
|
||||||
|
' runtime: codex',
|
||||||
|
].join('\n') + '\n',
|
||||||
|
);
|
||||||
|
expect(readRosterAgentNames(home)).toEqual(['orchestrator', 'coder0', 'reviewer-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runFrameworkReseed', () => {
|
||||||
|
it('reports not-ok (not throw) when the installer is absent', () => {
|
||||||
|
const missing = mkdtempSync(join(tmpdir(), 'mosaic-noinstaller-'));
|
||||||
|
const res = runFrameworkReseed(missing, join(missing, 'home'));
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
expect(res.reason).toContain('installer not found');
|
||||||
|
rmSync(missing, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,7 +16,8 @@
|
|||||||
import { execSync } from 'node:child_process';
|
import { 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 { join } from 'node:path';
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -453,6 +454,98 @@ 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