Compare commits
22 Commits
feat/p3-1-
...
v0.0.39-al
| Author | SHA1 | Date | |
|---|---|---|---|
| 528700ceea | |||
| 32f4215461 | |||
| 23343bb7f0 | |||
| c8b2dab0ca | |||
| 6dbe452a9f | |||
| 59c755067e | |||
| 6ffb27787e | |||
| 130837365f | |||
| 67df06f1c4 | |||
| 60a309d5a4 | |||
| 2dc0f24828 | |||
| 31e7a4d25e | |||
| ca19d57bba | |||
| bb7d549080 | |||
| 5bef2c35eb | |||
| 2849a8f9db | |||
| 7ced5588c9 | |||
| afcbbb302f | |||
| c2c0b5fe8d | |||
| c9cfe36204 | |||
| fc90c89913 | |||
| af2eede7a9 |
@@ -25,12 +25,10 @@ steps:
|
||||
commands:
|
||||
- apk add --no-cache bash
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||
# L0 resident-token budget: keep the Constitution + dispatcher small.
|
||||
- |
|
||||
for f in CONSTITUTION.md AGENTS.md; do
|
||||
n=$(wc -l < "packages/mosaic/framework/defaults/$f")
|
||||
if [ "$n" -gt 120 ]; then echo "L0 budget exceeded: defaults/$f is $n lines (max 120)"; exit 1; fi
|
||||
done
|
||||
# Resident line-count ceiling over framework-owned resident files
|
||||
# (Constitution + dispatcher + each RUNTIME.md slice). See DESIGN §7 / R9.
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh --self-test
|
||||
- bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
|
||||
@@ -45,3 +45,12 @@ Active workstream is **W1 — Federation v1**. Workers should:
|
||||
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).
|
||||
- Cut always-injected contract AGENTS+TOOLS+RUNTIME 8,827→4,122 tok (−53%); all 12 hard gates intact.
|
||||
- Validation: deterministic gate-checklist PASS; headless A/B thin 7/9 vs monolith 5/9. Detail: scratchpads/contract-thin-core.md.
|
||||
|
||||
## P5 — Overlay composer + cross-harness (#604) — feat/p5-overlay-composer
|
||||
|
||||
- Status: MERGED to main (#605). R7 (compose-contract) + R8 (cross-harness) + R9 (composer test).
|
||||
- `composeContract({harness, mosaicHome})` pure fn + `.local` overlay deltas-by-value; `mosaic compose-contract <harness>` command; AGENTS bare-launch nudge; composer spec (per-tier anchor + Tier-3 byte-equality). Detail: scratchpads/p5-overlay-composer.md.
|
||||
|
||||
## P6 — Docs, compliance matrix, alpha tag (#606) — feat/p6-docs-compliance-alpha
|
||||
|
||||
- Status: in-repo deliverables done (CONTRIBUTING.md + harness×gate compliance matrix + check-resident-budget.sh + CI wiring + ALPHA-DOD.md). Remaining: alpha tag v0.0.39-alpha (Lead, post-merge). aiguide reconcile merged (#8). Detail: scratchpads/p6-docs-compliance-alpha.md.
|
||||
|
||||
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).
|
||||
109
docs/fleet/PRD-fleet-suite.md
Normal file
109
docs/fleet/PRD-fleet-suite.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# PRD — Mosaic Fleet Suite (init, configure, operate)
|
||||
|
||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312` · **Phase:** 3→4 productization
|
||||
> **North star:** [docs/fleet/north-star.md](./north-star.md) · prior: Phase-2 observability (#579), durable launch (#581), real-agent enablement (#583/#584/#586), releases 0.0.35–0.0.37
|
||||
> **Lead:** Jarvis @ `w-jarvis`. **Collaborator:** coder agent @ `dragon-lin` (jwoltje@10.1.10.37:coder0-0).
|
||||
> Owner of this file: Fleet workstream lead. Does not modify MVP single-writer control-plane files.
|
||||
|
||||
## Mission
|
||||
|
||||
Turn the proven fleet primitives into a **user-installable, AI-free-configurable fleet product**:
|
||||
a user runs `mosaic fleet init`, answers a few questions (general / coding / research / hybrid),
|
||||
gets a recommended set of agents plus one always-on orchestrator wired for chat-ops, and can
|
||||
operate, mutate, re-create, and observe the fleet — over tmux today and Matrix tomorrow — from
|
||||
CLI/TUI and (designed-for) the webUI.
|
||||
|
||||
**Immediate tangible goal:** the **"Mos"** orchestrator agent running on `w-jarvis`, reachable
|
||||
in **Discord channel `1517622518662434996`** (server `1112631390438166618`). Once the fleet is
|
||||
functional, we use the fleet itself to continue the work.
|
||||
|
||||
## Requirements
|
||||
|
||||
### A. Configure-without-AI CLI
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| R1 | `mosaic fleet` command set is functional end-to-end (init/install/start/stop/status/ps/verify + agent verbs). |
|
||||
| R2 | `mosaic fleet init` is an interactive, **AI-free** CLI wizard. |
|
||||
| R3 | Init asks the **configuration type**: `general`, `coding`, `research`, `hybrid`, … (extensible). |
|
||||
| R4 | Based on the answer, the fleet is populated with a **recommended set of agents** (a preset). |
|
||||
| R5 | **Exactly one main orchestrator agent** is always configured, regardless of type. |
|
||||
| R10 | A set of **recommended configurations (presets)** ships for easy duplication. |
|
||||
| R8 | User can **re-create** the fleet when config needs change (idempotent re-init / reconfigure). |
|
||||
| R17 | Fleet controls are **simple and intuitive**. |
|
||||
|
||||
### B. Comms & orchestrator chat-ops
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| R6 | Init can wire the orchestrator to a chat connector — **Telegram / Discord / Matrix / Slack** — for command + comms. |
|
||||
| R7 | Designed with the end-goal of **Matrix comms on a locally-controlled server**. |
|
||||
| R16 | Fleet supports **tmux AND Matrix** comms, **user-configurable** at init or any time. Not all users want Matrix. |
|
||||
| R19 | **"Mos" orchestrator on Discord** (`chan 1517622518662434996` / `srv 1112631390438166618`) on `w-jarvis` — the first live target. |
|
||||
|
||||
### C. Runtime, health, lifecycle
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ---------------------------------------------------------------------------------- |
|
||||
| R9 | Fleet is **mutable by the orchestrator agent** — add/remove agents per need. |
|
||||
| R13 | Fleet **gracefully handles Pi + Claude harness updates** — keep harnesses current. |
|
||||
| R14 | The **Pi harness is customized** for proper tool usage, etc. |
|
||||
| R15 | **Agent heartbeat** properly configured for **Claude AND GPT/Pi** agents. |
|
||||
|
||||
### D. Surfaces, testing, docs
|
||||
|
||||
| ID | Requirement |
|
||||
| --- | ----------------------------------------------------------------------------------- |
|
||||
| R18 | Fleet built so the **webUI can view / monitor / terminate / butt-in** on a session. |
|
||||
| R11 | Installed and **tested on both `w-jarvis` and `dragon-lin`**. |
|
||||
| R12 | **Documentation**: how to install, configure, and use the fleet. |
|
||||
|
||||
## Architecture / approach
|
||||
|
||||
- **Config model:** `roster.yaml` is the source of truth (already exists). Add **presets** (`general`/`coding`/`research`/`hybrid`) as shipped example rosters; `init` selects a preset, always injects the orchestrator, and writes the roster. Re-init = regenerate roster (preserve user/site overrides — mirrors install env-merge from #567).
|
||||
- **Orchestrator agent:** always present; carries the chat connector config (connector type + target IDs) so it can be commanded over chat. tmux is the substrate; the connector bridges chat ↔ the orchestrator session.
|
||||
- **Comms layers (R16):** (1) **tmux** inter-agent (`agent-send`, proven) — default, always available. (2) **chat connector** for human↔orchestrator (Discord now; Matrix the strategic target). (3) **Matrix** as the locally-controlled cross-agent bus (future). Connector is pluggable + reconfigurable.
|
||||
- **Heartbeat (R15):** runtime-agnostic launcher sidecar already covers pi/claude/codex (#584). Refine per-runtime (native HB) with the **custom Pi harness** (R14) + a Claude path.
|
||||
- **Updates (R13):** `mosaic update` (CLI) + a fleet-aware harness-update step that refreshes pi/claude/codex and re-launches agents safely (drain → update → relaunch via the durable launcher).
|
||||
- **webUI (R18):** the fleet exposes machine-readable state (`fleet ps --json` already carries tenant/host/heartbeat/managed) + control verbs (start/stop/watch/send); webUI consumes these (control plane rides federation per north star). Ensure a stable JSON contract + a terminate/attach(butt-in) path.
|
||||
|
||||
## Phases (incremental, each shippable)
|
||||
|
||||
| Phase | Deliverable | Notes |
|
||||
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
|
||||
| **F1 Presets + init wizard** | preset rosters (general/coding/research/hybrid) + always-orchestrator + AI-free `fleet init` selecting a preset; re-init idempotent | R1–R5, R8, R10, R17 |
|
||||
| **F2 Connector + Mos-on-Discord** | orchestrator chat-connector config (Discord first) + **Mos live on Discord `1517…`/`1112…`** on w-jarvis | R6, R19, partial R16 |
|
||||
| **F3 Heartbeat + harness** | HB confirmed for claude + pi/gpt; **custom Pi harness** (tool usage, native HB, model self-report); graceful harness updates | R13, R14, R15 |
|
||||
| **F4 Matrix + comms toggle** | Matrix connector (local server) + user toggle tmux/Matrix at init/anytime | R7, R16 |
|
||||
| **F5 Orchestrator-mutable fleet** | orchestrator can add/remove agents at runtime | R9 |
|
||||
| **F6 webUI hooks** | stable JSON contract + terminate/attach surface for webUI view/monitor/terminate/butt-in | R18 |
|
||||
| **F7 Test + docs** | install+test on w-jarvis AND dragon-lin; user docs (install/configure/use) | R11, R12 (runs alongside every phase) |
|
||||
|
||||
## Work division (proposed — confirm with dragon-lin)
|
||||
|
||||
- **Jarvis @ w-jarvis (Lead):** F1 presets+wizard, F2 connector+Mos-on-Discord, F5 mutability, F6 webUI hooks; merge authority + dual-engine reviews; co-testing on w-jarvis.
|
||||
- **coder @ dragon-lin:** F3 custom Pi harness + harness-update flow (pi/codex-savvy); plus its in-flight constitution P4–P6 (P4 installer rework underpins `fleet init`/updates — coordinate the install path). Co-testing on dragon-lin (R11).
|
||||
- **Shared:** F4 Matrix (whoever has bandwidth); F7 testing/docs continuous.
|
||||
|
||||
## Immediate target: Mos on Discord (F2 first slice)
|
||||
|
||||
The discord plugin is available (`~/.claude.json`). Path: configure the **orchestrator** as a durable
|
||||
fleet session running Claude Code with the discord plugin bridged to channel `1517622518662434996`
|
||||
(server `1112631390438166618`) on w-jarvis, with the existing Discord Bridge Protocol (ack within
|
||||
~3s, reply via `mcp__discord__reply`, no `AskUserQuestion`). Heartbeat via the launcher sidecar.
|
||||
|
||||
## Success criteria
|
||||
|
||||
- A non-AI user can `mosaic fleet init`, pick a type, and get a working fleet + orchestrator.
|
||||
- **Mos answers in Discord `1517…`** on w-jarvis.
|
||||
- Fleet runs + is observable (`fleet ps`) on **both** w-jarvis and dragon-lin.
|
||||
- Harness updates handled gracefully; HB healthy for claude + pi/gpt agents.
|
||||
- Docs let a new operator install/configure/use the fleet.
|
||||
- Re-init + orchestrator mutation work.
|
||||
|
||||
## Assumptions (veto-able)
|
||||
|
||||
- `ASSUMPTION:` presets ship as example rosters under the framework (`fleet/examples/*.yaml`), selected by `init`.
|
||||
- `ASSUMPTION:` chat connectors are pluggable; Discord first (target exists), Matrix is the strategic default later.
|
||||
- `ASSUMPTION:` "Mos" = a Claude Code orchestrator session with the discord plugin (reuses the documented Discord Bridge Protocol).
|
||||
- `ASSUMPTION:` per north star, runtimes default to Codex/pi-on-Codex for workers; the orchestrator "Mos" runs Claude Code (in Claude Code, which is allowed).
|
||||
109
docs/fleet/PRD.md
Normal file
109
docs/fleet/PRD.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# PRD — Fleet Phase 2: Operator Observability
|
||||
|
||||
> **Workstream:** W-FLEET under `mvp-20260312` · **Phase:** 2
|
||||
> **North star:** [docs/fleet/north-star.md](./north-star.md)
|
||||
> **Source umbrella PRD:** [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||
> **Tracks task:** `fleet-observability-1` — restore operator observability into fleet agent sessions.
|
||||
|
||||
## Problem
|
||||
|
||||
The durable tmux fleet runs on the isolated `mosaic-factory` socket. That isolation
|
||||
(which protects the operator's default tmux) makes the fleet **invisible** to default
|
||||
tooling, and truth is split across three planes no single command joins — systemd
|
||||
(`systemctl --user`), tmux (`-L mosaic-factory`), and the process tree (`pstree`).
|
||||
`agent tail` (`capture-pane`) returns **blank for full-screen TUIs**, and `agent send`
|
||||
confirms only keystroke injection, not acceptance. Net: the operator has near-zero
|
||||
observability and no safe way to watch a session.
|
||||
|
||||
## Goals
|
||||
|
||||
1. One command shows the **whole fleet's** real state, joining all three planes.
|
||||
2. **Liveness is truthful**: healthy = answered a heartbeat, not "pane alive".
|
||||
3. The operator can **watch** any session read-only without disrupting it.
|
||||
4. `send` reports **delivered-and-accepted**, not just injected.
|
||||
5. Every record/address carries **`tenant_id` + `host`** (zero foreclosure for multi-tenant/multi-host).
|
||||
|
||||
## Non-goals (this phase)
|
||||
|
||||
- No webUI (Phase 5; rides federation for cross-host).
|
||||
- No `fleetd` daemon or persistent history store.
|
||||
- No real-runtime swap (Phase 3) — instrument the live **dogfood stub** fleet.
|
||||
- No cross-host aggregation yet (addressing is host-tagged but queries stay local).
|
||||
|
||||
## Functional requirements
|
||||
|
||||
| ID | Requirement |
|
||||
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FR-1 | `mosaic fleet ps [--json]` prints one row per roster agent joining: name · tenant · host · runtime · systemd(active/enabled) · pane(alive/dead) · pid · idle · **last-heartbeat age** · **drift** flag (roster runtime ≠ actual pane command) · **boot-enable** warning (active but `UnitFileState=disabled`). |
|
||||
| FR-2 | **Heartbeat protocol v1** (see below); `dogfood-agent.py` implements the responder. `fleet ps` issues probes (or reads last-seen) and reports health per FR-1. |
|
||||
| FR-3 | `mosaic agent watch <name>` opens a **read-only** view of the pane (grouped session or `tmux attach -r`) that cannot send keystrokes and does not shrink the agent's window. |
|
||||
| FR-4 | `mosaic agent attach <name>` remains the **explicit** interactive-takeover path (separate verb, documented as the only one that can type). |
|
||||
| FR-5 | `mosaic agent send <name> --verify` confirms the message was **accepted** (not left as an unsubmitted draft) and returns non-zero if delivery cannot be verified. |
|
||||
| FR-6 | All structured output (`--json`) includes `tenant_id` and `host` fields. |
|
||||
|
||||
## Heartbeat protocol v1
|
||||
|
||||
- **Probe:** operator/`fleet ps` writes a sentinel line to the agent's input or a
|
||||
well-known per-agent heartbeat file path `~/.config/mosaic/fleet/run/<agent>.hb`.
|
||||
- **Response:** the runtime updates `<agent>.hb` with `ts=<iso8601> pid=<pid> status=<ok|busy>`
|
||||
on a fixed interval (default 15s) and on demand when probed.
|
||||
- **Health rule:** `healthy` if `now - ts <= 3 × interval`; else `stale`; missing file = `unknown`.
|
||||
- **Contract:** every runtime (dogfood stub now; claude/codex/pi/opencode in Phase 3)
|
||||
MUST emit the heartbeat. The protocol is file-based so it works for headless stubs and
|
||||
full-screen TUIs alike (no `capture-pane` dependency).
|
||||
- `ASSUMPTION:` file-based heartbeat (vs in-pane echo) — chosen because it is TUI-safe and
|
||||
uid-scoped, fitting per-tenant isolation. Open to an OTEL-span variant in Phase 3 (MVP-X6).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `mosaic fleet ps` shows all 5 live sessions on `mosaic-factory` with correct
|
||||
pane/pid/idle and flags the dogfood **drift** (`canary-pi` runtime=pi but pane runs
|
||||
`dogfood-agent.py`) and the **boot-enable** gap (active but disabled).
|
||||
- Killing one agent's pane flips its row to dead/stale within one `interval`.
|
||||
- `agent watch` shows live output and provably cannot type into the pane; detaching
|
||||
leaves the agent's window size unchanged.
|
||||
- `agent send --verify` returns success on an accepting pane and non-zero on a wedged/draft pane.
|
||||
- Quality gates green: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`, plus
|
||||
`pnpm --filter @mosaicstack/mosaic test`.
|
||||
- Independent review passed; dogfood evidence captured against the live fleet.
|
||||
|
||||
## Test plan
|
||||
|
||||
- Unit/CLI specs in `packages/mosaic/src/commands/fleet.spec.ts` (and a new
|
||||
`fleet-ps`/`watch`/`send-verify` spec) using the injected `CommandRunner` to assert
|
||||
exact tmux/systemd command construction and JSON shape (tenant+host present).
|
||||
- Situational: run against the live `mosaic-factory` fleet; capture `fleet ps` output,
|
||||
a kill-and-detect cycle, a read-only `watch`, and a `send --verify` pass/fail pair.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **Verify heuristic is best-effort:** `agent send --verify` uses a `>` -prefix draft
|
||||
heuristic that is specific to pi/claude TUIs. Draft detection for codex and opencode
|
||||
TUIs is best-effort only; those runtimes may not use the same input-line indicator.
|
||||
- **Pane-change check is the best Phase-2 signal; verify now polls up to a bounded
|
||||
timeout:** `agent send --verify` captures a BEFORE snapshot, sends the message, then
|
||||
polls `capture-pane` every ~400 ms up to a configurable total timeout (default ~6 s,
|
||||
controlled by `--verify-timeout <ms>`). On each poll it runs classifySendResult: if
|
||||
the pane shows 'accepted' or 'draft' the loop exits immediately; while the result is
|
||||
'unverifiable' (no pane change yet) it keeps polling. After the timeout with no
|
||||
definitive result, it fails closed: exit 1 with "no pane change after send". This
|
||||
eliminates false 'unverifiable' failures for slow/loaded TUIs that were previously
|
||||
caused by the old fixed 300 ms single-capture. Definitive acceptance ultimately
|
||||
requires a runtime acknowledgement (Phase-3 heartbeat-ack); the bounded pane-change
|
||||
poll is the best signal available against an opaque TUI for Phase-2.
|
||||
- **Blank AFTER capture fails closed:** Full-screen TUIs (claude, codex, opencode, pi)
|
||||
render blank for `tmux capture-pane`. When the AFTER snapshot is empty, `send --verify`
|
||||
returns non-zero with an "unverifiable" message rather than silently succeeding. This
|
||||
is an intentional fail-closed design (FR-5).
|
||||
- **`agent watch` uses a grouped viewer session:** `tmux attach -r` directly against the
|
||||
agent session lets the viewer terminal shrink the agent's window. `agent watch` instead
|
||||
creates a throwaway grouped session (`tmux new-session -d -t '=<agent>' -s
|
||||
'<agent>-watch-<pid>'`), attaches read-only to that session, and kills it on detach.
|
||||
The grouped session shares the agent's windows but has independent sizing, so the
|
||||
agent's window is never affected. `tmux attach` is still interactive and requires
|
||||
inherited stdio; the `interactiveRunner` handles TTY passthrough.
|
||||
|
||||
## Surfaces & parity (MVP-X1)
|
||||
|
||||
CLI lands this phase. TUI surface follows in the `packages/mosaic` wizard; webUI in
|
||||
Phase 5 via federation. PRD records the parity debt explicitly so it is not lost.
|
||||
27
docs/fleet/TASKS.md
Normal file
27
docs/fleet/TASKS.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Tasks — W-FLEET (Fleet) Phase 2: Observability
|
||||
|
||||
> Workstream task file for the Fleet. Single-writer: Fleet workstream lead (orchestrator).
|
||||
> Workers read but never modify. This is **not** the MVP rollup (`docs/TASKS.md`) — a
|
||||
> rollup row is proposed to the MVP orchestrator, not written here.
|
||||
>
|
||||
> Mission: `mvp-20260312` · PRD: [docs/fleet/PRD.md](./PRD.md) · North star: [docs/fleet/north-star.md](./north-star.md)
|
||||
> Status: `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
||||
|
||||
| id | status | description | depends_on | agent | pr | notes |
|
||||
| ------------- | ----------- | ------------------------------------------------------------------------------------------------------------------ | --------------------- | ----------- | --- | ----------------------------------------------------------------------------------------------------------------------------- |
|
||||
| FLEET-OBS-000 | done | Plan: north-star + Phase-2 PRD + workstream scaffolding | — | lead | — | persisted 2026-06-20 on `feat/fleet-observability` |
|
||||
| FLEET-OBS-001 | done | Heartbeat protocol v1 spec finalized in PRD + framework doc | FLEET-OBS-000 | lead | — | file-based `~/.config/mosaic/fleet/run/<agent>.hb`; spec in PRD |
|
||||
| FLEET-OBS-002 | in-progress | Implement heartbeat responder in `dogfood-agent.py` | FLEET-OBS-001 | fleet-coder | — | dispatched to ad-hoc `mosaic yolo` fleet agent (dogfood) |
|
||||
| FLEET-OBS-003 | done | `mosaic fleet ps` — join systemd+tmux+proc+idle+heartbeat; tenant+host tagged; drift + boot-enable flags; `--json` | FLEET-OBS-001 | worker | — | commit ab47831; LIVE-verified on mosaic-factory; caught canary-pi DRIFT + BOOT-ENABLE. Polish: idleSeconds parse returns null |
|
||||
| FLEET-OBS-004 | done | `mosaic agent watch <name>` — read-only join (no resize, no keystrokes) | FLEET-OBS-000 | worker | — | `attach -r`; verb wired |
|
||||
| FLEET-OBS-005 | done | `mosaic agent send --verify` — delivery/acceptance receipt | FLEET-OBS-000 | worker | — | --verify flag; draft-heuristic verify |
|
||||
| FLEET-OBS-006 | done | CLI specs for ps/watch/send-verify (tenant+host shape, command construction) | FLEET-OBS-003,004,005 | worker | — | 62 tests green (31 new); re-verified by lead |
|
||||
| FLEET-OBS-007 | not-started | Framework doc: fleet observability guide + verbs | FLEET-OBS-003,004,005 | lead | — | `docs/guides/` or `framework/tools/.../README` |
|
||||
| FLEET-OBS-008 | not-started | Independent review + dogfood verification on live fleet | FLEET-OBS-002..007 | reviewer | — | author ≠ reviewer; capture evidence in scratchpad |
|
||||
| FLEET-OBS-009 | not-started | Open PR → green CI (queue guard) → squash-merge → close `fleet-observability-1` | FLEET-OBS-008 | lead | — | trunk merge; no direct push to main |
|
||||
|
||||
## Proposed MVP rollup row (for the MVP orchestrator — not written by this workstream)
|
||||
|
||||
```
|
||||
| W-FLEET | in-progress | Fleet (agent-session execution layer) | Phase 2/5 | docs/fleet/TASKS.md | observability dogfooded on live stub fleet; control plane rides federation (W1) |
|
||||
```
|
||||
133
docs/fleet/north-star.md
Normal file
133
docs/fleet/north-star.md
Normal file
@@ -0,0 +1,133 @@
|
||||
# Mosaic Fleet — North Star
|
||||
|
||||
> **Workstream:** W-FLEET (Fleet) under mission `mvp-20260312`
|
||||
> **Umbrella:** [docs/MISSION-MANIFEST.md](../MISSION-MANIFEST.md) · [docs/PRD.md](../PRD.md) (Mosaic Stack v0.1.0)
|
||||
> **Status:** doctrine — authored 2026-06-20. Owner of this file: Fleet workstream lead.
|
||||
> This document does **not** modify the MVP rollup; a rollup row is proposed, not written here.
|
||||
|
||||
## Vision
|
||||
|
||||
A **customizable, multi-tenant fleet of always-on AI agents** — each defined by role,
|
||||
materialized as a durable, joinable runtime session, coordinated by the proven
|
||||
orchestrator/worker model, and observable end-to-end across hosts. Coding today;
|
||||
finance, analytics, research as roster entries tomorrow — same primitives, different
|
||||
roster. The fleet is the **agent-session execution layer** of the Mosaic Stack MVP:
|
||||
the thing federation makes reachable across hosts and the webUI/TUI/CLI make visible.
|
||||
|
||||
The USC tmux PoC (durable sessions + `agent-send` comms) proved the model. This
|
||||
workstream makes it an official, observable, multi-tenant Mosaic Stack capability.
|
||||
|
||||
## The Fleet as means of production (bootstrapping)
|
||||
|
||||
The Fleet has a **dual role**, and that is the point:
|
||||
|
||||
- **As product** — a multi-tenant agent-fleet capability of Mosaic Stack (this workstream).
|
||||
- **As means of production** — the orchestrator/worker fleet that _actually builds the
|
||||
entire MVP_ (federation W1, webUI, TUI, CLI, and the Fleet itself).
|
||||
|
||||
We are **building the system that builds the system.** Every other MVP workstream is
|
||||
delivered _by_ the fleet, so fleet observability and control are not merely product
|
||||
features — they are the **operational floor of the whole delivery effort**. If we cannot
|
||||
see and steer the agents, we cannot trust what they ship. This is why Phase 2
|
||||
(observability) leads: it is the instrument panel for the factory, dogfooded on the live
|
||||
fleet that is, recursively, building Mosaic Stack.
|
||||
|
||||
The discipline that makes great power safe is the same gate chain the fleet enforces:
|
||||
independent review before merge, green CI, honest completion, decide-and-inform cadence,
|
||||
and no irreversible action without authority. The bootstrap is only as trustworthy as
|
||||
those gates.
|
||||
|
||||
## Alignment with MVP cross-cutting requirements
|
||||
|
||||
The Fleet inherits — does not re-invent — the MVP's hard requirements:
|
||||
|
||||
| MVP req | What it means for the Fleet |
|
||||
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| MVP-X1 three-surface parity | fleet observability/control reachable via **CLI + TUI + webUI** (CLI first; webUI is required for parity, not optional) |
|
||||
| MVP-X2 multi-tenant isolation | one tenant = one **Linux uid** (own `systemd --user`, socket, `~/.config/mosaic`); no cross-tenant leakage |
|
||||
| MVP-X3 auth (BetterAuth/SSO) | operator→fleet and cross-host views are auth-gated through the platform's existing auth |
|
||||
| MVP-X4 quality gates | `pnpm typecheck`/`lint`/`format:check` green before any push |
|
||||
| MVP-X5 federated topology | cross-host fleet visibility rides the **federation** boundary (W1), not a bespoke broker |
|
||||
| MVP-X6 OTEL tracing | heartbeats, sends, and lifecycle events emit spans; `traceparent` crosses the federation boundary |
|
||||
| MVP-X7 trunk merge | branch from `main`, squash-merge via PR, never push to `main` |
|
||||
|
||||
## The stack — where every concern lives
|
||||
|
||||
One **definition** is the source of truth; the **session** is how it runs.
|
||||
|
||||
| Layer | Owner | Phase-2 reality | Destination |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------- |
|
||||
| **Definition + identity + auth** | gateway / `mosaic-as` (scoped tokens, #541) | `roster.yaml` (tenant-tagged) | one definition; `mosaic agent --new` materializes it |
|
||||
| **Tenancy boundary** | **Linux uid per tenant** (linger, own `systemd --user`, own socket, own `~/.config/mosaic`) | one tenant: `jarvis` = tenant zero | uid-per-tenant; federation aggregates across hosts |
|
||||
| **Runtime** | per-tenant tmux session on isolated socket | dogfood stub sessions (live now on `mosaic-factory`) | claude/codex/pi/opencode TUIs |
|
||||
| **Liveness** | **heartbeat protocol** every runtime answers | protocol defined + dogfood stub answers it | all runtimes answer; "healthy" ≠ "pane alive" |
|
||||
| **Observation** | read-only `watch` (native tmux) + `pipe-pane` stream | CLI `watch`/`ps`; explicit opt-in `attach` for control | + auth-gated webUI streams |
|
||||
| **Control plane** | **federation** across hosts × tenants | records already carry `tenant_id` + `host` | federated gateways expose fleet state; webUI in Phase 5 |
|
||||
|
||||
## Operating model (inherited, not reinvented)
|
||||
|
||||
The AI-guide law stands: one accountable **orchestrator**, isolated **workers** that
|
||||
stop at PR-open, the serialized **gate chain** (independent review → green CI →
|
||||
diff-sanity → squash-merge → verify), **decide-and-inform** cadence, and a durable
|
||||
**board** so missions survive session death. The Fleet is the infrastructure _under_
|
||||
this model. See `mosaicstack-aiguide` whitepapers 01 (inter-agent comms) and 03
|
||||
(orchestration model) for the rationale.
|
||||
|
||||
## Invariants — "maximal vision, incremental delivery, zero foreclosure"
|
||||
|
||||
Every artifact, starting Phase 2, MUST:
|
||||
|
||||
1. Carry **`tenant_id` + `host`** in schema and message addressing — even with one of each today.
|
||||
2. Treat **isolation socket ≠ invisibility** — anything isolated is surfaced by one command.
|
||||
3. Define **healthy = answered a heartbeat within N seconds**, never just "pane alive".
|
||||
4. Make **observation read-only by default**; control is an explicit, separate, opt-in verb.
|
||||
|
||||
## Observation model
|
||||
|
||||
| Verb | Behavior |
|
||||
| ----------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `mosaic fleet ps` | one table joining systemd + tmux + process + idle + last-heartbeat, with drift + boot-enable flags |
|
||||
| `mosaic agent watch <name>` | **read-only** join (grouped session / `-r`), no resize tyranny, no keystrokes |
|
||||
| `mosaic agent attach <name>` | explicit interactive takeover (the only path that can type) |
|
||||
| `mosaic agent send <name> --verify` | confirms message **accepted**, not merely keystroke-injected |
|
||||
|
||||
> Why the current PoC blocks observation: sessions live on the isolated `mosaic-factory`
|
||||
> socket (invisible to default `tmux ls`), the only sanctioned read is `capture-pane`
|
||||
> (blank for full-screen TUIs), and `attach` is read-write + resizes the session. The
|
||||
> verbs above restore "join and observe" safely.
|
||||
|
||||
## Phased roadmap
|
||||
|
||||
| Phase | Outcome | Status |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
|
||||
| 0–1 | tmux PoC, hardening, published CLI v0.0.34 (#565–#568) | ✅ done |
|
||||
| **2 — Observability** | `fleet ps` (host+tenant aware join), heartbeat protocol + dogfood stub answers it, `agent watch` (read-only), `agent send --verify` receipts | ▶ now |
|
||||
| 3 — Real runtimes | claude/codex/pi/opencode answer heartbeat; **hybrid lifecycle** (core always-on: orchestrator+reviewer; ephemeral workers per lane) | planned |
|
||||
| 4 — Unified definition | one agent schema in gateway; `mosaic agent --new` → materialized per-tenant session; uid-tenant provisioning | planned |
|
||||
| 5 — Control plane | federation-backed cross-host × cross-tenant fleet view; **webUI** (surface chosen then) for MVP-X1 parity | planned |
|
||||
|
||||
## Decisions of record (2026-06-20, with Jason)
|
||||
|
||||
- Agent model: **config defines, session runs** (gateway = definition/identity/auth; tmux = runtime).
|
||||
- Tenancy: **multi-tenant from the start**; isolation = **per-tenant Linux uid**.
|
||||
- Health: **heartbeat required** (dogfood stub implements the protocol now).
|
||||
- Lifecycle: **hybrid** — core always-on + ephemeral workers per lane.
|
||||
- Observation: **read-only default, opt-in takeover**.
|
||||
- Multi-host: **designed-for from day one**; control plane **rides federation (W1)**.
|
||||
- Delivery: **CLI-first now**, dogfood against the live stub fleet; webUI deferred to Phase 5.
|
||||
- Runtimes: fleet agents default to **Codex / pi-on-Codex**; **Claude is reserved for Claude
|
||||
Code only** (avoid alternate-harness API pricing). Validated durable recipe:
|
||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high`. Durable detached launch requires the
|
||||
runtime-bin on PATH (baked into the pane command) + boot-survival (`enable` + linger),
|
||||
which `fleet init` should automate.
|
||||
|
||||
## Assumptions (veto-able)
|
||||
|
||||
- `ASSUMPTION:` first-class runtimes = claude, codex, pi, opencode; a "role" (analyst,
|
||||
finance, researcher) = persona + skills + tools on top of a runtime, shipped as a
|
||||
starter role library in the framework.
|
||||
- `ASSUMPTION:` the cross-host control plane is the **federation** layer (W1), not a
|
||||
separate `fleetd` daemon.
|
||||
- `ASSUMPTION:` Fleet is workstream **W-FLEET** under `mvp-20260312`; a rollup row in
|
||||
`docs/TASKS.md` and a workstream declaration in `MISSION-MANIFEST.md` are proposed to
|
||||
the MVP orchestrator, not written by this workstream.
|
||||
100
docs/scratchpads/fleet-observability-phase2.md
Normal file
100
docs/scratchpads/fleet-observability-phase2.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Scratchpad — Fleet Phase 2: Observability (W-FLEET)
|
||||
|
||||
> Append-only. Mission `mvp-20260312` / workstream W-FLEET.
|
||||
> Lead: Jarvis (Claude) at `W-jarvis:mos-claude-18`. Coordinating with `jwoltje@dragon-lin:coder0-0`.
|
||||
|
||||
## Mission prompt (2026-06-20)
|
||||
|
||||
Establish the north star for the Mosaic Fleet feature and prepare Phase-2 observability
|
||||
for delivery. The USC tmux PoC is the proven base. Jason granted lead authority:
|
||||
"The fleet is a great way to actually build the MVP — we are building the system that
|
||||
builds the system." Dogfood actual agent construction + ad-hoc deployment; coordinate
|
||||
with a second agent on `dragon-lin`.
|
||||
|
||||
## Decisions of record (with Jason, 2026-06-20)
|
||||
|
||||
- Agent model: config defines, session runs (gateway = definition/identity/auth; tmux = runtime).
|
||||
- Tenancy: multi-tenant from the start; isolation = per-tenant Linux uid.
|
||||
- Health: heartbeat required; dogfood stub implements protocol now.
|
||||
- Lifecycle: hybrid (core always-on + ephemeral workers).
|
||||
- Observation: read-only default, opt-in takeover.
|
||||
- Multi-host: designed-for day one; control plane rides federation (W1), not a bespoke broker.
|
||||
- Delivery: CLI-first, dogfood on the live stub fleet; webUI deferred to Phase 5.
|
||||
- Fleet is dual-role: product AND means of production (bootstrapping the MVP).
|
||||
- Code review = **dual-engine**: Claude **and** gpt-5.5/Codex, run together (Jason: the
|
||||
combination produces the best results). Launch reviewers via `mosaic yolo pi` / `codex`
|
||||
(proven path) or `~/.config/mosaic/tools/codex/codex-code-review.sh`. Applies to all
|
||||
code-review gates incl. FLEET-OBS-008. Per Jason 2026-06-20.
|
||||
- Worktree discipline: do fleet work in `~/src/mosaicstack-stack-worktrees/<branch>`, NOT
|
||||
the shared main checkout — concurrent processes mutate `main` there (learned 2026-06-20).
|
||||
|
||||
## Environment facts (verified 2026-06-20)
|
||||
|
||||
- Fleet is live on `W-jarvis` (uid 1000, `jarvis`, `Linger=yes`) on tmux socket
|
||||
`mosaic-factory`: `_holder`, `canary-pi`, `dogfood-coder`, `dogfood-orchestrator`,
|
||||
`dogfood-reviewer`. All panes run `~/.config/mosaic/fleet/dogfood-agent.py` (stub),
|
||||
including `canary-pi` (roster says runtime=pi → **drift**).
|
||||
- Holder + `mosaic-agent@*` units are `active (exited)` but `UnitFileState=disabled`
|
||||
(reboot loses fleet → boot-enable gap to surface).
|
||||
- Observation blocked by: isolated socket (hidden from default `tmux ls`), `capture-pane`
|
||||
blank for TUIs, `attach` being read-write + resizing.
|
||||
- Second agent: `jwoltje@dragon-lin`, session `coder0-0` (group `coder0`), running `node`,
|
||||
default socket. ssh forward reach confirmed.
|
||||
|
||||
## Governance / collision-safety
|
||||
|
||||
- `mosaicstack-stack` has active mission `mvp-20260312` with single-writer locks on
|
||||
`docs/MISSION-MANIFEST.md`, `docs/TASKS.md`, `docs/scratchpads/mvp-20260312.md`.
|
||||
- This workstream touches NONE of those. All Fleet docs scoped under `docs/fleet/` +
|
||||
this scratchpad. Rollup row proposed, not written.
|
||||
|
||||
## Session log
|
||||
|
||||
- 2026-06-20: Researched AI guide + fleet code + live state. Established north star with
|
||||
Jason (8 forks decided). Branched `feat/fleet-observability`. Persisted
|
||||
`docs/fleet/{north-star.md,PRD.md,TASKS.md}` + this scratchpad. Next: establish comms
|
||||
with dragon-lin coder, commit docs, begin Phase-2 delivery (heartbeat + `fleet ps`).
|
||||
- 2026-06-20 (session 2): Built Phase-2 CLI via worker (commit ab47831): `fleet ps`,
|
||||
`agent watch`, `agent send --verify`, 62 tests. LIVE-verified `fleet ps` on
|
||||
mosaic-factory — correctly flagged canary-pi DRIFT + BOOT-ENABLE, tenant_id+host in JSON.
|
||||
Heartbeat responder added to dogfood-agent.py (FLEET-OBS-002) — `fleet ps` HB now
|
||||
`healthy` for all 4 agents.
|
||||
- Coordination: dual-engine-reviewed (Claude+Codex) and merged framework PRs #572
|
||||
(sanitization gate) + #575 (CONSTITUTION extraction) as Lead. Codex caught an Alpine
|
||||
blocker on #572 (refuted by CI); Claude caught a CI-breaking format failure on #575.
|
||||
- **FINDINGS (north-star / Phase-3 blockers):**
|
||||
1. Ad-hoc `mosaic yolo {codex,pi}` via `start-agent-session.sh` DIE immediately in a
|
||||
detached tmux pane (codex: "stdin is not a terminal"; pi: same). Only the python stub
|
||||
survives. => Real runtimes have NEVER run durably in the fleet. Launch path (PATH/TTY
|
||||
in the detached shell) must be fixed before Phase-3 real-runtime swap. `fleet ps`
|
||||
caught both dead panes instantly (tool validated).
|
||||
2. `MOSAIC_AGENT_NAME` (set in systemd EnvironmentFile) is NOT propagated into tmux's
|
||||
global env, so agents defaulted to `unknown`. Worked around in dogfood-agent.py via
|
||||
tmux session-name fallback; the systemd/tmux env handoff needs a real fix.
|
||||
- Next: rebase on merged main, open Phase-2 PR, dual-engine review, merge, close
|
||||
`fleet-observability-1`. Defer launch-path + env-propagation fixes to Phase 3.
|
||||
- 2026-06-21 (session 3): Phase-2 PR #579 merged (3 dual-engine rounds hardened
|
||||
verify+watch). Then closed the launch-path question with Jason's input — CORRECTING
|
||||
earlier findings:
|
||||
- The ad-hoc launch deaths were NOT a fundamental TTY blocker: (a) codex was a stale
|
||||
version (Jason updated it); (b) pi was misconfigured to Claude auth (Jason removed it;
|
||||
default is now Codex). The REAL durable-launch bug is **PATH**: the detached tmux
|
||||
launch shell is login+non-interactive, so it misses `~/.npm-global/bin` (added only in
|
||||
`~/.bashrc`) -> `mosaic: command not found` (127) -> pane dies. tmux panes inherit the
|
||||
tmux _server_ env, so PATH must be baked into the pane command.
|
||||
- **Durable real-agent recipe (validated live on gpt-5.5, Claude-free):**
|
||||
`mosaic yolo pi --model openai-codex/gpt-5.5:high` — pi tolerates detached tmux; a raw
|
||||
interactive TUI (codex CLI) exits without an attached client. Status line confirmed
|
||||
`(openai-codex) gpt-5.5 • high`.
|
||||
- PATH fix landed in `start-agent-session.sh` (commit 32efc13, branch
|
||||
feat/fleet-launch-path): derive runtime-bin prefix (MOSAIC_RUNTIME_BIN | npm prefix |
|
||||
~/.npm-global/bin | ~/.local/bin), bake `export PATH=...; exec <cmd>` into the pane;
|
||||
`exec` also fixes the drift false-positive. Live-tested under stripped PATH -> durable.
|
||||
- Boot-survival: Jason ran `systemctl --user enable` (+ linger). TODO: auto-enable in
|
||||
**fleet init** so operators never have to remember it (agentic-enhancement cycle).
|
||||
- Future custom Pi harness build: pi cannot self-report its model (track
|
||||
runtime/model/effort as fleet metadata); drift detection should recognize `node` as
|
||||
pi's pane command (a node-wrapped pane can currently read as drift).
|
||||
- Findings recorded in AI Guide playbooks/tmux-fleet.md (aiguide PR #7, merged).
|
||||
- Policy: avoid Claude outside Claude Code (API pricing for alt-harness use) — fleet
|
||||
runtimes default to Codex / pi-on-Codex; Claude stays in Claude Code only.
|
||||
43
docs/scratchpads/p5-overlay-composer.md
Normal file
43
docs/scratchpads/p5-overlay-composer.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# P5 — Overlay composer + cross-harness (compose-contract)
|
||||
|
||||
- **Issue:** #604 · **Branch:** `feat/p5-overlay-composer` · **Lineage:** #542 → constitution alpha
|
||||
- **Requirements:** R7 (compose-contract) + R8 (cross-harness) + R9 (composer test)
|
||||
- **Design of record:** `docs/design/framework-constitution/{DESIGN.md §3.2, PRD.md §4}` (on `feat/framework-constitution-alpha`)
|
||||
|
||||
## Locked design (sequential-thinking)
|
||||
|
||||
Current `launch.ts` assembly (`buildComposedPrompt`) injects by value: mission + PRD + hard-gate +
|
||||
CONSTITUTION + AGENTS + USER + TOOLS + runtime. It does **not** inject SOUL or STANDARDS (those are
|
||||
read-on-demand per the gutted AGENTS dispatcher), and has no `.local` overlay support.
|
||||
|
||||
**Decision (ASSUMPTION — recorded for the PR):** overlays are injected as **deltas by value** under
|
||||
labeled sections; base files keep their existing residency.
|
||||
|
||||
- `USER.local.md` → appended directly under the `# User Profile` block (USER is injected).
|
||||
- `SOUL.local.md` + `STANDARDS.local.md` → a trailing `# Operator Overlays` section (their bases are
|
||||
load-on-demand, so only the small delta is injected — not the full base prose).
|
||||
- **Why:** honors DESIGN §3.2 ("model gets one pre-merged blob, no read-merge ritual") while preserving
|
||||
the P3 byte-budget tiering (don't re-inject large SOUL/STANDARDS prose). Precedence order kept: base
|
||||
layers first, operator overlays at recency.
|
||||
- Base-only is automatic when a `.local` file is absent (`readOptional`).
|
||||
|
||||
## Plan
|
||||
|
||||
| # | Task | File |
|
||||
| --- | ------------------------------------------------------------------------------------------------------ | --------------------------------------- |
|
||||
| 1 | Extract `composeContract({harness, mosaicHome})` pure fn; `buildComposedPrompt` delegates | `src/commands/launch.ts` |
|
||||
| 2 | Overlay logic (USER.local under profile; SOUL/STANDARDS.local in `# Operator Overlays`) | `src/commands/launch.ts` |
|
||||
| 3 | `mosaic compose-contract <harness>` command → prints blob to stdout | `src/commands/launch.ts` |
|
||||
| 4 | Bare-launch overlay nudge in self-load fallback | `framework/defaults/AGENTS.md` |
|
||||
| 5 | `compose-contract.spec.ts`: per-tier anchor, Tier-3 byte-equality, overlay present/absent, per-harness | `src/commands/compose-contract.spec.ts` |
|
||||
|
||||
## Deferred to P6
|
||||
|
||||
CONTRIBUTING.md + harness×gate compliance matrix; resident line-count CI ceiling; `aiguide` reconcile;
|
||||
alpha tag `mosaic-vX.Y.Z-alpha`.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Phase scaffold (branch, issue #604, scratchpad, TASKS)
|
||||
- [ ] Implementation (tasks 1–5)
|
||||
- [ ] prettier + vitest green; PR via wrapper → Lead (rides 0.0.39; 0.0.38 mid-cut)
|
||||
29
docs/scratchpads/p6-docs-compliance-alpha.md
Normal file
29
docs/scratchpads/p6-docs-compliance-alpha.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# P6 — Docs, compliance matrix, alpha tag (constitution capstone)
|
||||
|
||||
- **Issue:** #606 · **Branch:** `feat/p6-docs-compliance-alpha` · **Lineage:** #542
|
||||
- **Requirements:** R9 (resident line-count ceiling) + R10 (CONTRIBUTING + compliance matrix + aiguide) + alpha tag
|
||||
|
||||
## Delivered (in-repo)
|
||||
|
||||
- `framework/CONTRIBUTING.md` — layer model, operator-hygiene/PII prohibition, dedup rule, resident
|
||||
budget, **dual-installer parity rule**, adding-a-harness, re-contamination rule, **harness×gate
|
||||
compliance matrix** (hook-parity gap marked ⚠️ tracked-v2), known-limitations (§9 residuals), PR checklist.
|
||||
- `framework/tools/quality/scripts/check-resident-budget.sh` — line-count ceiling over framework-owned
|
||||
resident files (CONSTITUTION + AGENTS + each runtime/\*/RUNTIME.md); `--self-test`; replaces the crude
|
||||
inline ci.yml loop. Wired blocking in `.woodpecker/ci.yml`.
|
||||
- Composer unit test (R9) already runs via `pnpm test`; `verify-sanitized.sh` (P1) already wired.
|
||||
|
||||
## Verification
|
||||
|
||||
- Sanitization gate green (CONTRIBUTING is operator-neutral). Resident-budget self-test + real run green.
|
||||
- prettier clean. Current resident counts: CONSTITUTION 96, AGENTS 83, RUNTIME max 75 — all < ceiling.
|
||||
|
||||
## Remaining
|
||||
|
||||
- [ ] `aiguide` reconcile (separate repo `~/src/aiguide` / mosaicstack/aiguide) — consistency pass vs Constitution.
|
||||
- [ ] Alpha tag `mosaic-vX.Y.Z-alpha` — propose version; Lead cuts after full DoD §8 green + all phases merged.
|
||||
|
||||
## Notes
|
||||
|
||||
- Alpha DoD (DESIGN §8): all phases P0–P6 merged + CI green. P5 (#605) pending merge after 0.0.38 publish.
|
||||
- Hook parity (codex/opencode/pi) = tracked v2 gap, documented in the matrix, not closed here.
|
||||
185
packages/mosaic/framework/CONTRIBUTING.md
Normal file
185
packages/mosaic/framework/CONTRIBUTING.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# Contributing to the Mosaic Framework
|
||||
|
||||
The Mosaic framework is the open-source agent-operating layer that deploys to
|
||||
`~/.config/mosaic/`. It is designed to be **forked and customized** — but the
|
||||
shared core must stay operator-neutral, deduplicated, and upgrade-safe. This
|
||||
guide is the contract for changing framework-owned files.
|
||||
|
||||
> Governance model and layer rationale: `constitution/LAYER-MODEL.md` (source-only).
|
||||
> Requirements & phase history: `docs/design/framework-constitution/`.
|
||||
|
||||
---
|
||||
|
||||
## 1. The layer model (where does my change go?)
|
||||
|
||||
| Layer | What | Owner | On upgrade | File(s) |
|
||||
| ------ | ------------------------------------------------------------- | ---------------- | --------------------------------------- | -------------------------------------------- |
|
||||
| **L0** | Constitution — the non-negotiable law (hard gates) | Framework | **Overwritten** | `CONSTITUTION.md` |
|
||||
| **L1** | Standards & guides — how to do the work well | Framework | Overwritten; user delta → `*.local.md` | `STANDARDS.md`, `guides/*` |
|
||||
| **L2** | Persona (SOUL) — agent name, tone, role | User (init) | **Never overwritten** | `SOUL.md` (+ optional `SOUL.local.md`) |
|
||||
| **L3** | Operator (USER) — human identity, prefs, policy | User (init) | **Never overwritten** | `USER.md` (+ optional `USER.local.md`) |
|
||||
| **L4** | Project / runtime mechanism — per-repo deltas; harness wiring | Repo / framework | Project user-owned; runtime overwritten | `<repo>/AGENTS.md`, `runtime/<h>/RUNTIME.md` |
|
||||
|
||||
**The one sentence a user can rely on:** edit `SOUL.md` / `USER.md` and the
|
||||
`.local.md` overlays — they survive every upgrade. To change framework behavior,
|
||||
add a `.local.md` overlay; never edit a framework-owned file in place.
|
||||
|
||||
---
|
||||
|
||||
## 2. Operator hygiene (PII / secrets prohibition) — **blocking**
|
||||
|
||||
Framework-owned files ship publicly. They **must not** contain:
|
||||
|
||||
- Operator or personal identity (names, handles, pronouns, accessibility notes).
|
||||
- Private `$HOME` paths, private hostnames, or domains.
|
||||
- Secrets, tokens, or credentials (use `~/.config/mosaic/credentials.json`; the
|
||||
hook URL soft-degrades via `${OPENBRAIN_URL}`).
|
||||
|
||||
This is enforced by `tools/quality/scripts/verify-sanitized.sh`, wired **blocking**
|
||||
in CI (`.woodpecker/ci.yml`). It runs two rule classes: structural (private-`$HOME`
|
||||
defaults, dead paths, unrendered tokens) and a labeled current-contaminant denylist.
|
||||
Run it locally before pushing:
|
||||
|
||||
```bash
|
||||
bash packages/mosaic/framework/tools/quality/scripts/verify-sanitized.sh
|
||||
```
|
||||
|
||||
Operator-specific behavior belongs in **your** `SOUL.md`/`USER.md`/`*.local.md`,
|
||||
never in the shared core. (The "framework-PR firewall" in `CONSTITUTION.md` §4
|
||||
states this as law for agents opening framework PRs.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Dedup rule — one source, everyone references it
|
||||
|
||||
Hard gates live in **`CONSTITUTION.md` (L0) only**. `AGENTS.md`, `STANDARDS.md`,
|
||||
and every `runtime/<h>/RUNTIME.md` **reference** the law — they never restate it.
|
||||
Restating a gate is a defect: it creates two sources that drift. If you find a
|
||||
gate duplicated outside L0, delete the copy and point to L0.
|
||||
|
||||
`AGENTS.md` is a thin dispatcher (load order + guide router + the tier-aware
|
||||
self-load). Keep it that way; new procedure goes in `guides/*` (on-demand), not
|
||||
in the resident core.
|
||||
|
||||
---
|
||||
|
||||
## 4. Resident line-count ceiling — **blocking**
|
||||
|
||||
The framework-owned files injected by value (`CONSTITUTION.md`, `AGENTS.md`, each
|
||||
`runtime/<h>/RUNTIME.md`) are budgeted by **line count** — never by word count
|
||||
(a word cap forces paraphrasing the law, the exact drift vector we removed).
|
||||
|
||||
```bash
|
||||
bash packages/mosaic/framework/tools/quality/scripts/check-resident-budget.sh
|
||||
```
|
||||
|
||||
Wired blocking in CI. Gate **wording** stays intact; if a file legitimately needs
|
||||
more lines, raise its ceiling in the script deliberately (in the same PR, with
|
||||
rationale). The per-harness _total_ resident prompt (which also sums the user's
|
||||
`SOUL.md`/`USER.md`) is a `mosaic doctor` runtime advisory — CI cannot see user
|
||||
files, so it is out of CI scope by design (DESIGN §7).
|
||||
|
||||
---
|
||||
|
||||
## 5. Dual-installer parity rule
|
||||
|
||||
Two installers seed and migrate `~/.config/mosaic/`:
|
||||
|
||||
- **`framework/install.sh`** (bash) — the canonical installer.
|
||||
- **`packages/mosaic/src/config/file-adapter.ts`** (TS) — the wizard path.
|
||||
|
||||
**Any change to seed lists, overwrite/preserve semantics, or migration MUST land
|
||||
in BOTH**, validated by the **shared fixture suite**:
|
||||
|
||||
- `framework/tools/quality/scripts/test-install-migration.sh` (bash matrix)
|
||||
- `packages/mosaic/src/config/file-adapter.test.ts` (vitest)
|
||||
|
||||
Both assert the same behavior: framework-owned files overwrite (backup-once to
|
||||
`*.pre-constitution.bak`); user-seeded files seed-if-absent; `SOUL.md`/`USER.md`/
|
||||
`*.local.md`/`credentials` are preserved. A change in one installer without the
|
||||
other (and its fixtures) is incomplete.
|
||||
|
||||
---
|
||||
|
||||
## 6. Adding a harness adapter
|
||||
|
||||
A harness (runtime) is wired by:
|
||||
|
||||
1. `runtime/<h>/RUNTIME.md` — **mechanism only** (subagent syntax, hook/MCP wiring,
|
||||
injection method). No restated gates (see §3).
|
||||
2. Launcher emission in `src/commands/launch.ts` — how the composed contract reaches
|
||||
the harness (system-prompt append vs. instructions file). Add the harness to the
|
||||
`RuntimeName` union and the runtime-path map.
|
||||
3. `mosaic compose-contract <harness>` works automatically once the runtime path
|
||||
exists (it composes base + `*.local.md` overlays for that harness).
|
||||
|
||||
Then add a row to the compliance matrix (§8) and mark which gates are mechanical
|
||||
vs. resident-only for the new harness.
|
||||
|
||||
---
|
||||
|
||||
## 7. Re-contamination rule
|
||||
|
||||
A green sanitization gate is not permanent. Before every PR:
|
||||
|
||||
- Do not reintroduce operator identity, private paths, or secrets (§2).
|
||||
- Do not copy a gate out of L0 (§3).
|
||||
- Do not add an unrendered template token or a dead path to a shipped file.
|
||||
|
||||
If `verify-sanitized.sh` goes red, that diff **is** your worklist — fix it, don't
|
||||
suppress it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Harness × gate compliance matrix
|
||||
|
||||
How each gate is enforced per harness. **Mechanical** = a hook/CI check the agent
|
||||
cannot bypass. **Resident** = injected contract prose (strong, but not a hard stop).
|
||||
**CI** = repo-side, harness-independent.
|
||||
|
||||
| Gate / mechanism | Claude | Codex | OpenCode | Pi |
|
||||
| --------------------------------------------- | ----------- | ---------------- | ---------------- | ---------------- |
|
||||
| Contract injection (resident-by-value) | append SP | instructions | `AGENTS.md` | append SP |
|
||||
| Operator overlays (`*.local`, composed) | ✅ | ✅ | ✅ | ✅ |
|
||||
| Bare-launch self-load (Tier-3, read L0) | ✅ | ✅ | ✅ | ✅ |
|
||||
| Sanitization (no PII) — `verify-sanitized` | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||
| Resident budget ceiling | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||
| Migration parity (5-fixture, both installers) | CI ✅ | CI ✅ | CI ✅ | CI ✅ |
|
||||
| `no-memory-write` (PreToolUse hook) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
||||
| QA / typecheck (PostToolUse hooks) | **mech ✅** | resident-only ⚠️ | resident-only ⚠️ | resident-only ⚠️ |
|
||||
| Native heartbeat (fleet `ps` model/status) | sidecar | sidecar | sidecar | **native ✅** |
|
||||
|
||||
⚠️ **Hook-parity gap (tracked, v2):** the mechanical PreToolUse/PostToolUse hooks
|
||||
exist for Claude Code only. On Codex/OpenCode/Pi those gates are currently enforced
|
||||
by the resident contract + CI, not by a per-tool hook. Closing hook parity is a
|
||||
**v2** item, not part of this alpha.
|
||||
|
||||
---
|
||||
|
||||
## 9. Known limitations (accepted residual risks)
|
||||
|
||||
These are accepted with rationale (DESIGN §9); they are documented, not bugs:
|
||||
|
||||
- **Bare-launch overlays are base-only.** A harness started without `mosaic` never
|
||||
ran the composer, so `*.local.md` overlays are not applied. Mitigated by the
|
||||
unconditional Tier-3 self-load + the `mosaic doctor` nudge in `AGENTS.md`; not
|
||||
eliminated. Relaunch via `mosaic <harness>` to pick up overlays.
|
||||
- **Bare-launch drift is undetected by `mosaic doctor`** (the launcher never ran).
|
||||
- **Codex/OpenCode/Pi hook parity** is a tracked v2 gap (§8).
|
||||
- **Live-launch cross-harness verification** is v2; the alpha verifies the composer
|
||||
by unit test (per-tier anchor + Tier-3 byte-equality), not a live launch.
|
||||
|
||||
**Deferred to v2 (explicit):** `constitution/` deploy directory; capability JSON
|
||||
adapters; 3-way merge; `policy/*.md` composition; per-layer version stamps as a
|
||||
migration driver.
|
||||
|
||||
---
|
||||
|
||||
## 10. PR checklist
|
||||
|
||||
- [ ] No operator identity / private paths / secrets (`verify-sanitized.sh` green).
|
||||
- [ ] No gate restated outside `CONSTITUTION.md` (§3).
|
||||
- [ ] Resident budget green (`check-resident-budget.sh`).
|
||||
- [ ] Seed/migration changes landed in **both** installers + shared fixtures (§5).
|
||||
- [ ] New harness → compliance-matrix row updated (§8).
|
||||
- [ ] `prettier --check` + `pnpm lint` + `pnpm typecheck` + `pnpm test` green.
|
||||
@@ -9,7 +9,10 @@ overwritten on upgrade. (Layer model: `constitution/LAYER-MODEL.md`.)
|
||||
1. Your context already includes `CONSTITUTION.md` + `USER.md` + the TOOLS index + the runtime
|
||||
contract (injected by `mosaic` launch) — do not re-read those. **If you were launched bare**
|
||||
(a harness started without `mosaic`, so the law is NOT in your context), read
|
||||
`~/.config/mosaic/CONSTITUTION.md` now, before your first action.
|
||||
`~/.config/mosaic/CONSTITUTION.md` now, before your first action. A bare launch also gets
|
||||
**base contracts only** — operator overlays (`*.local.md`) are composed by the launcher, so if
|
||||
`SOUL.local.md`/`USER.local.md`/`STANDARDS.local.md` exist, relaunch via `mosaic <harness>` (or run
|
||||
`mosaic doctor`) to pick them up.
|
||||
2. Read `SOUL.md` (agent persona — small, once).
|
||||
3. Read project-local `AGENTS.md` / `CLAUDE.md` if present (these may only make behavior stricter).
|
||||
4. Read guides ONLY as triggered by the table below — pull role-relevant depth on demand, not up front.
|
||||
|
||||
32
packages/mosaic/framework/fleet/examples/coding.yaml
Normal file
32
packages/mosaic/framework/fleet/examples/coding.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~
|
||||
runtimes:
|
||||
claude:
|
||||
reset_command: /clear
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: coder0
|
||||
runtime: pi
|
||||
class: implementer
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
- name: coder1
|
||||
runtime: pi
|
||||
class: implementer
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
- name: reviewer
|
||||
runtime: pi
|
||||
class: reviewer
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
22
packages/mosaic/framework/fleet/examples/general.yaml
Normal file
22
packages/mosaic/framework/fleet/examples/general.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~
|
||||
runtimes:
|
||||
claude:
|
||||
reset_command: /clear
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: generalist
|
||||
runtime: pi
|
||||
class: worker
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
32
packages/mosaic/framework/fleet/examples/hybrid.yaml
Normal file
32
packages/mosaic/framework/fleet/examples/hybrid.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~
|
||||
runtimes:
|
||||
claude:
|
||||
reset_command: /clear
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: coder0
|
||||
runtime: pi
|
||||
class: implementer
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
- name: researcher0
|
||||
runtime: pi
|
||||
class: researcher
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
- name: reviewer
|
||||
runtime: pi
|
||||
class: reviewer
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
32
packages/mosaic/framework/fleet/examples/research.yaml
Normal file
32
packages/mosaic/framework/fleet/examples/research.yaml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: 1
|
||||
transport: tmux
|
||||
tmux:
|
||||
socket_name: mosaic-factory
|
||||
holder_session: _holder
|
||||
defaults:
|
||||
working_directory: ~
|
||||
runtimes:
|
||||
claude:
|
||||
reset_command: /clear
|
||||
pi:
|
||||
reset_command: /new
|
||||
agents:
|
||||
- name: orchestrator
|
||||
runtime: claude
|
||||
class: orchestrator
|
||||
persistent_persona: true
|
||||
- name: researcher0
|
||||
runtime: pi
|
||||
class: researcher
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
- name: researcher1
|
||||
runtime: pi
|
||||
class: researcher
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
- name: analyst
|
||||
runtime: pi
|
||||
class: analyst
|
||||
model_hint: openai-codex/gpt-5.5:high
|
||||
reset_between_tasks: true
|
||||
@@ -19,13 +19,23 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||
|
||||
# Files/dirs preserved across upgrades (never overwritten).
|
||||
# Files/dirs protected from rsync --delete during sync. NOTE: framework-owned
|
||||
# entries (CONSTITUTION/AGENTS/STANDARDS) ARE re-applied afterward by
|
||||
# reconcile_framework_files (overwrite + backup-once); the rest stay user-owned.
|
||||
# User-created content in these paths survives rsync --delete.
|
||||
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
||||
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
||||
|
||||
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
|
||||
# user must not edit them; a divergent copy is backed up once before overwrite).
|
||||
# USER_SEEDED files are written once on first install, then owned by the user.
|
||||
# Both lists are APPEND-FRIENDLY — add a new shipped framework file here and to the
|
||||
# matching list in packages/mosaic/src/config/file-adapter.ts.
|
||||
FRAMEWORK_OWNED=("CONSTITUTION.md" "AGENTS.md" "STANDARDS.md")
|
||||
USER_SEEDED=("TOOLS.md")
|
||||
|
||||
# Current framework schema version — bump this when the layout changes.
|
||||
# The migration system uses this to run upgrade steps.
|
||||
FRAMEWORK_VERSION=2
|
||||
FRAMEWORK_VERSION=3
|
||||
|
||||
# ─── colours ──────────────────────────────────────────────────────────────────
|
||||
if [[ -t 1 ]]; then
|
||||
@@ -40,6 +50,47 @@ warn() { echo -e " ${YELLOW}⚠${RESET} $1" >&2; }
|
||||
fail() { echo -e " ${RED}✗${RESET} $1" >&2; }
|
||||
step() { echo -e "\n${BOLD}$1${RESET}"; }
|
||||
|
||||
# ─── snapshot / restore (crash safety for upgrades) ──────────────────────────
|
||||
SNAPSHOT_DIR=""
|
||||
make_snapshot() {
|
||||
is_existing_install || return 0
|
||||
SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
|
||||
cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true
|
||||
}
|
||||
restore_snapshot() {
|
||||
[[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
|
||||
fail "Install interrupted/failed — restoring previous state from snapshot"
|
||||
rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR"
|
||||
cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true
|
||||
}
|
||||
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }
|
||||
|
||||
# Reconcile contract files after sync: framework-owned overwrite (backup-once),
|
||||
# user-seeded seed-if-absent.
|
||||
reconcile_framework_files() {
|
||||
local defaults="$TARGET_DIR/defaults" f
|
||||
[[ -d "$defaults" ]] || return 0
|
||||
for f in "${FRAMEWORK_OWNED[@]}"; do
|
||||
[[ -f "$defaults/$f" ]] || continue
|
||||
# Already current — skip to avoid mtime churn.
|
||||
if [[ -f "$TARGET_DIR/$f" ]] && cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then
|
||||
continue
|
||||
fi
|
||||
if [[ -f "$TARGET_DIR/$f" && ! -f "$TARGET_DIR/${f}.pre-constitution.bak" ]]; then
|
||||
cp "$TARGET_DIR/$f" "$TARGET_DIR/${f}.pre-constitution.bak"
|
||||
warn "$f is now framework-owned and was updated; your previous copy is saved as ${f}.pre-constitution.bak — re-apply intended changes as a .local overlay or policy/ file (see CONSTITUTION.md / constitution/LAYER-MODEL.md)."
|
||||
fi
|
||||
cp "$defaults/$f" "$TARGET_DIR/$f"
|
||||
done
|
||||
for f in "${USER_SEEDED[@]}"; do
|
||||
[[ -f "$defaults/$f" ]] || continue
|
||||
if [[ ! -f "$TARGET_DIR/$f" ]]; then
|
||||
cp "$defaults/$f" "$TARGET_DIR/$f"
|
||||
ok "Seeded $f from defaults"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
is_existing_install() {
|
||||
@@ -113,11 +164,14 @@ sync_framework() {
|
||||
fi
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version")
|
||||
local rsync_args=(-a --delete --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak")
|
||||
|
||||
if [[ "$INSTALL_MODE" == "keep" ]]; then
|
||||
# Anchor to the transfer root (leading /) so we preserve the TOP-LEVEL
|
||||
# ~/.config/mosaic/<file> without also excluding defaults/<file> from sync
|
||||
# (reconcile_framework_files needs the freshly-synced defaults/ copies).
|
||||
for path in "${PRESERVE_PATHS[@]}"; do
|
||||
rsync_args+=(--exclude "$path")
|
||||
rsync_args+=(--exclude "/$path")
|
||||
done
|
||||
fi
|
||||
|
||||
@@ -137,7 +191,7 @@ sync_framework() {
|
||||
done
|
||||
fi
|
||||
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" -exec rm -rf {} +
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" -exec rm -rf {} +
|
||||
cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
|
||||
rm -rf "$TARGET_DIR/.git"
|
||||
|
||||
@@ -195,10 +249,15 @@ run_migrations() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Future migrations go here ──────────────────────────────────────────────
|
||||
# if [[ "$from_version" -lt 3 ]]; then
|
||||
# ...
|
||||
# fi
|
||||
# ── Migration: v2 → v3 (Constitution split) ───────────────────────────────
|
||||
# CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten
|
||||
# on upgrade). reconcile_framework_files() has already run before this point: it
|
||||
# backed up any user-edited copy to <file>.pre-constitution.bak and installed the
|
||||
# new framework version. Nothing further to do here — the advisory was emitted at
|
||||
# reconcile time. (STANDARDS.local.md composition lands with the overlay composer.)
|
||||
if [[ "$from_version" -lt 3 ]]; then
|
||||
ok "Migrated to the Constitution layout (framework-owned CONSTITUTION/AGENTS/STANDARDS)"
|
||||
fi
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -216,29 +275,25 @@ else
|
||||
ok "Install mode: overwrite"
|
||||
fi
|
||||
|
||||
# Snapshot before any destructive file operation; restore on interrupt/failure.
|
||||
make_snapshot
|
||||
trap 'restore_snapshot' ERR INT TERM
|
||||
|
||||
sync_framework
|
||||
|
||||
# Ensure persistent directories exist
|
||||
mkdir -p "$TARGET_DIR/memory"
|
||||
mkdir -p "$TARGET_DIR/credentials"
|
||||
|
||||
# Seed defaults — copy framework contract files from defaults/ to framework
|
||||
# root if not already present. These ship with sensible defaults but must
|
||||
# never be overwritten once the user has customized them.
|
||||
# Reconcile contract files from defaults/ into the framework root: framework-owned
|
||||
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
|
||||
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
|
||||
#
|
||||
# This list must match the framework-contract whitelist in
|
||||
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
|
||||
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
|
||||
# by `mosaic init` from templates with user-supplied values.
|
||||
DEFAULTS_DIR="$TARGET_DIR/defaults"
|
||||
if [[ -d "$DEFAULTS_DIR" ]]; then
|
||||
for default_file in CONSTITUTION.md AGENTS.md STANDARDS.md TOOLS.md; do
|
||||
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
|
||||
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
|
||||
ok "Seeded $default_file from defaults"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
reconcile_framework_files
|
||||
|
||||
# Ensure tool scripts are executable
|
||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||
@@ -249,6 +304,18 @@ ok "Framework synced to $TARGET_DIR"
|
||||
# Run migrations before post-install (migrations may remove old bin/ etc.)
|
||||
run_migrations
|
||||
|
||||
# File-system phase complete and consistent — clear the restore trap.
|
||||
trap - ERR INT TERM
|
||||
cleanup_snapshot
|
||||
|
||||
# Testability / minimal-install hook: stop after the file-system phase, before any
|
||||
# environment-touching post-install steps (runtime linking, MCP setup, skills, doctor).
|
||||
if [[ "${MOSAIC_SYNC_ONLY:-0}" == "1" ]]; then
|
||||
write_framework_version
|
||||
ok "Sync-only mode: file phase complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
step "Post-install tasks"
|
||||
|
||||
SCRIPTS="$TARGET_DIR/tools/_scripts"
|
||||
|
||||
@@ -9,8 +9,16 @@
|
||||
* 4. Memory routing — remind agent to use ~/.config/mosaic/memory/
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI } from '@mariozechner/pi-coding-agent';
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
|
||||
import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
|
||||
import { Type } from 'typebox';
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
mkdirSync,
|
||||
renameSync,
|
||||
} from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { execSync, spawnSync } from 'node:child_process';
|
||||
@@ -25,6 +33,57 @@ const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mo
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native heartbeat (fleet R14/R15)
|
||||
// ---------------------------------------------------------------------------
|
||||
// When this agent runs under the Mosaic fleet (MOSAIC_AGENT_NAME set), the
|
||||
// extension writes its OWN heartbeat in the same .hb contract `fleet ps` reads
|
||||
// (ts/pid/status[/model]) and touches a `.hb.native` precedence marker so the
|
||||
// shell sidecar defers. Native HB knows the real turn state (busy/ok), so it is
|
||||
// more accurate than the pane-PID-only sidecar fallback.
|
||||
const HB_AGENT_NAME = process.env['MOSAIC_AGENT_NAME'] ?? '';
|
||||
const HB_RUN_DIR = process.env['MOSAIC_HEARTBEAT_RUN_DIR'] ?? join(MOSAIC_HOME, 'fleet', 'run');
|
||||
const HB_INTERVAL_MS = (() => {
|
||||
const s = Number.parseInt(process.env['MOSAIC_HEARTBEAT_INTERVAL'] ?? '', 10);
|
||||
return Number.isFinite(s) && s > 0 ? s * 1000 : 15_000;
|
||||
})();
|
||||
|
||||
function nativeHbEnabled(): boolean {
|
||||
return HB_AGENT_NAME.length > 0;
|
||||
}
|
||||
|
||||
function readModelId(ctx: ExtensionContext): string | null {
|
||||
const m = ctx.model as unknown as { id?: string; name?: string } | undefined;
|
||||
return m?.id ?? m?.name ?? null;
|
||||
}
|
||||
|
||||
function writeNativeHeartbeat(status: 'ok' | 'busy', model: string | null): void {
|
||||
if (!nativeHbEnabled()) return;
|
||||
try {
|
||||
mkdirSync(HB_RUN_DIR, { recursive: true });
|
||||
const hb = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb`);
|
||||
const lines = [`ts=${nowIso()}`, `pid=${process.pid}`, `status=${status}`];
|
||||
if (model) lines.push(`model=${model}`);
|
||||
const tmp = `${hb}.tmp.${process.pid}`;
|
||||
writeFileSync(tmp, lines.join('\n') + '\n');
|
||||
renameSync(tmp, hb); // atomic replace — fleet ps never reads a partial file
|
||||
// Precedence marker: tells the shell sidecar that native HB is authoritative.
|
||||
writeFileSync(join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`), nowIso() + '\n');
|
||||
} catch {
|
||||
// Best-effort: never let heartbeat I/O disrupt the Pi session.
|
||||
}
|
||||
}
|
||||
|
||||
function clearNativeMarker(): void {
|
||||
if (!nativeHbEnabled()) return;
|
||||
try {
|
||||
const m = join(HB_RUN_DIR, `${HB_AGENT_NAME}.hb.native`);
|
||||
if (existsSync(m)) unlinkSync(m); // native stopping — let the sidecar take over
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function safeRead(filePath: string): string | null {
|
||||
try {
|
||||
return readFileSync(filePath, 'utf-8');
|
||||
@@ -187,6 +246,9 @@ function buildMissionSummary(cwd: string, mission: ActiveMission): string {
|
||||
|
||||
export default function register(pi: ExtensionAPI) {
|
||||
let sessionCwd = process.cwd();
|
||||
let hbStatus: 'ok' | 'busy' = 'ok';
|
||||
let hbModel: string | null = null;
|
||||
let hbTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// ── Session Start ─────────────────────────────────────────────────────
|
||||
pi.on('session_start', async (_event, ctx) => {
|
||||
@@ -207,10 +269,39 @@ export default function register(pi: ExtensionAPI) {
|
||||
} else {
|
||||
ctx.ui.notify('Mosaic framework loaded', 'info');
|
||||
}
|
||||
|
||||
// Native heartbeat: write immediately, then on an interval. Idle = 'ok';
|
||||
// turn_start/turn_end flip the status so `fleet ps` reflects real activity.
|
||||
if (nativeHbEnabled()) {
|
||||
hbModel = readModelId(ctx);
|
||||
writeNativeHeartbeat('ok', hbModel);
|
||||
hbTimer = setInterval(() => writeNativeHeartbeat(hbStatus, hbModel), HB_INTERVAL_MS);
|
||||
if (typeof hbTimer.unref === 'function') hbTimer.unref();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Session End ───────────────────────────────────────────────────────
|
||||
pi.on('session_end', async (_event, _ctx) => {
|
||||
// ── Turn lifecycle → accurate busy/ok heartbeat ───────────────────────
|
||||
pi.on('turn_start', async (_event, ctx) => {
|
||||
hbStatus = 'busy';
|
||||
hbModel = readModelId(ctx) ?? hbModel;
|
||||
writeNativeHeartbeat('busy', hbModel);
|
||||
});
|
||||
pi.on('turn_end', async (_event, ctx) => {
|
||||
hbStatus = 'ok';
|
||||
hbModel = readModelId(ctx) ?? hbModel;
|
||||
writeNativeHeartbeat('ok', hbModel);
|
||||
});
|
||||
|
||||
// ── Session Shutdown ──────────────────────────────────────────────────
|
||||
// (The pi API event is 'session_shutdown'; the prior 'session_end' handler
|
||||
// never fired — fixed here so repo hooks + lock cleanup actually run.)
|
||||
pi.on('session_shutdown', async (_event, _ctx) => {
|
||||
if (hbTimer) {
|
||||
clearInterval(hbTimer);
|
||||
hbTimer = null;
|
||||
}
|
||||
clearNativeMarker();
|
||||
|
||||
// Run repo session-end hook
|
||||
runRepoHook(sessionCwd, 'session-end');
|
||||
|
||||
@@ -252,4 +343,32 @@ export default function register(pi: ExtensionAPI) {
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ── Register mosaic_mission_status tool (model-callable) ──────────────
|
||||
// R14 "proper tool usage": give the agent a first-class tool to load its
|
||||
// active Mosaic mission, milestone progress, task counts, and latest
|
||||
// scratchpad — so it self-orients on in-flight work before planning,
|
||||
// instead of shelling out or guessing. Mirrors the /mosaic-status command
|
||||
// but returns the summary as tool output the LLM can read.
|
||||
pi.registerTool({
|
||||
name: 'mosaic_mission_status',
|
||||
label: 'Mosaic Mission Status',
|
||||
description:
|
||||
'Return the active Mosaic mission, milestone progress, task counts, and latest scratchpad for the current project. Returns a note when no mission is active.',
|
||||
promptSnippet: 'Read the active Mosaic mission + task state for the current project',
|
||||
promptGuidelines: [
|
||||
'Use mosaic_mission_status at the start of a session or task to load the active mission, milestone progress, and open tasks before planning work.',
|
||||
],
|
||||
parameters: Type.Object({}),
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
|
||||
const mission = detectMission(sessionCwd);
|
||||
const text = mission
|
||||
? buildMissionSummary(sessionCwd, mission)
|
||||
: 'No active Mosaic mission in this project.';
|
||||
return {
|
||||
content: [{ type: 'text', text }],
|
||||
details: mission ? { ...mission } : { active: false },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -274,6 +274,13 @@ detect_existing_config
|
||||
echo "[mosaic-init] Generating SOUL.md — agent identity contract"
|
||||
echo ""
|
||||
|
||||
# Fail-closed persona: in non-interactive mode the agent NAME must be supplied
|
||||
# explicitly (--name) — never silently ship an agent named "Assistant".
|
||||
if [[ $NON_INTERACTIVE -eq 1 && -z "$AGENT_NAME" ]]; then
|
||||
echo "[mosaic-init] ERROR: --name (agent name) is required in non-interactive mode." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prompt_if_empty AGENT_NAME "What name should agents use" "Assistant"
|
||||
prompt_if_empty ROLE_DESCRIPTION "Agent role description" "execution partner and visibility engine"
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ MOSAIC_TMUX_SOCKET=${MOSAIC_TMUX_SOCKET:-mosaic-factory}
|
||||
MOSAIC_AGENT_RUNTIME=${MOSAIC_AGENT_RUNTIME:-pi}
|
||||
MOSAIC_AGENT_WORKDIR=${MOSAIC_AGENT_WORKDIR:-$HOME}
|
||||
MOSAIC_AGENT_COMMAND=${MOSAIC_AGENT_COMMAND:-}
|
||||
MOSAIC_HEARTBEAT_RUN_DIR=${MOSAIC_HEARTBEAT_RUN_DIR:-${MOSAIC_HOME:-$HOME/.config/mosaic}/fleet/run}
|
||||
MOSAIC_HEARTBEAT_INTERVAL=${MOSAIC_HEARTBEAT_INTERVAL:-15}
|
||||
|
||||
if [ -z "$AGENT_NAME" ]; then
|
||||
echo "ERROR: agent name argument or MOSAIC_AGENT_NAME is required" >&2
|
||||
@@ -26,5 +28,132 @@ if [ -z "$MOSAIC_AGENT_COMMAND" ]; then
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo $MOSAIC_AGENT_RUNTIME"
|
||||
fi
|
||||
|
||||
# ── Derive a runtime-bin PATH prefix ─────────────────────────────────────────
|
||||
# Precedence:
|
||||
# 1. $MOSAIC_RUNTIME_BIN (explicit override)
|
||||
# 2. $(npm config get prefix)/bin (if npm is on PATH)
|
||||
# 3. Fallbacks: $HOME/.npm-global/bin and $HOME/.local/bin
|
||||
#
|
||||
# Only directories that already exist are included. The prefix is baked into
|
||||
# the pane command regardless of what the LAUNCHER process's $PATH contains,
|
||||
# because the tmux pane inherits the tmux SERVER environment (not this script's
|
||||
# environment). A dir on the launcher's PATH may be absent from the server PATH,
|
||||
# so every existing candidate must always be included. Dedup within the
|
||||
# constructed prefix avoids listing the same dir twice.
|
||||
_build_runtime_bin_prefix() {
|
||||
local candidates=()
|
||||
|
||||
if [ -n "${MOSAIC_RUNTIME_BIN:-}" ]; then
|
||||
candidates+=("$MOSAIC_RUNTIME_BIN")
|
||||
fi
|
||||
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
local npm_prefix
|
||||
npm_prefix=$(npm config get prefix 2>/dev/null) || true
|
||||
if [ -n "$npm_prefix" ]; then
|
||||
candidates+=("${npm_prefix}/bin")
|
||||
fi
|
||||
fi
|
||||
|
||||
candidates+=("$HOME/.npm-global/bin")
|
||||
candidates+=("$HOME/.local/bin")
|
||||
|
||||
local prefix=""
|
||||
for dir in "${candidates[@]}"; do
|
||||
[ -d "$dir" ] || continue
|
||||
if [ -z "$prefix" ]; then
|
||||
prefix="$dir"
|
||||
else
|
||||
case ":${prefix}:" in
|
||||
*":${dir}:"*) ;; # already in our prefix — skip
|
||||
*) prefix="${prefix}:${dir}" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
|
||||
printf '%s' "$prefix"
|
||||
}
|
||||
|
||||
MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
|
||||
|
||||
# ── Build the pane command ────────────────────────────────────────────────────
|
||||
# The pane command must:
|
||||
# - Export the augmented PATH so the runtime binary is found.
|
||||
# - exec the agent command so the runtime is the pane's foreground process
|
||||
# (makes `fleet ps` pane_current_command check reliable; no DRIFT false-positive).
|
||||
#
|
||||
# Quoting strategy: single-quote the inner shell snippet so that variable
|
||||
# references in MOSAIC_AGENT_COMMAND are NOT expanded here — they expand inside
|
||||
# the pane shell. However, MOSAIC_RUNTIME_BIN_PREFIX and PATH must be expanded
|
||||
# NOW (in this script) because the pane shell inherits the tmux server
|
||||
# environment, not this script's env.
|
||||
#
|
||||
# We build the snippet as a double-quoted here-string embedded in a printf call
|
||||
# to avoid nested quoting problems.
|
||||
#
|
||||
# MOSAIC_AGENT_NAME must also be exported INTO the pane: panes inherit the tmux
|
||||
# server environment (not this script's, and not the systemd unit's), so the
|
||||
# name would otherwise be empty in-pane and the runtime's native heartbeat
|
||||
# (which gates on MOSAIC_AGENT_NAME) would never fire. %q-quote it so it is a
|
||||
# safe single bash token regardless of the name's characters.
|
||||
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
|
||||
|
||||
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
else
|
||||
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; exec ${MOSAIC_AGENT_COMMAND}"
|
||||
fi
|
||||
|
||||
mkdir -p "$MOSAIC_AGENT_WORKDIR"
|
||||
exec tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" "$MOSAIC_AGENT_COMMAND"
|
||||
|
||||
# ── Launch the tmux session (no exec — we continue to wire the heartbeat) ────
|
||||
tmux -L "$MOSAIC_TMUX_SOCKET" new-session -d -s "$AGENT_NAME" -c "$MOSAIC_AGENT_WORKDIR" \
|
||||
bash -c "$PANE_SHELL_SNIPPET"
|
||||
|
||||
# ── Resolve the pane PID (retry briefly to let the session initialise) ────────
|
||||
PANE_PID=""
|
||||
for _retry in 1 2 3 4 5; do
|
||||
PANE_PID=$(tmux -L "$MOSAIC_TMUX_SOCKET" list-panes \
|
||||
-t "=${AGENT_NAME}:0.0" -F '#{pane_pid}' 2>/dev/null || true)
|
||||
[ -n "$PANE_PID" ] && break
|
||||
sleep 0.2
|
||||
done
|
||||
|
||||
# ── Spawn the heartbeat sidecar (detached, best-effort) ──────────────────────
|
||||
# The sidecar writes ~/.config/mosaic/fleet/run/<AGENT>.hb atomically while the
|
||||
# pane process is alive, then exits so the file goes stale (fleet ps shows stale
|
||||
# then PANE=dead). It is runtime-agnostic: it only cares about the pane PID.
|
||||
_start_heartbeat_sidecar() {
|
||||
local agent="$1"
|
||||
local pane_pid="$2"
|
||||
local run_dir="$3"
|
||||
local interval="$4"
|
||||
local hb_file="${run_dir}/${agent}.hb"
|
||||
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
# Write the sidecar as a self-contained bash one-liner so it carries no
|
||||
# references to any variables from this script's environment.
|
||||
local sidecar_script
|
||||
sidecar_script=$(printf \
|
||||
'hb=%q; pid=%q; iv=%q; mkdir -p "$(dirname "$hb")"; while kill -0 "$pid" 2>/dev/null; do nat="$hb.native"; if [ -f "$nat" ] && [ "$(( $(date +%%s) - $(stat -c %%Y "$nat" 2>/dev/null || echo 0) ))" -lt "$(( iv * 2 ))" ]; then sleep "$iv"; continue; fi; tmp="$hb.tmp.$$"; printf "ts=%%s\npid=%%s\nstatus=ok\n" "$(date +%%Y-%%m-%%dT%%H:%%M:%%S%%z)" "$pid" > "$tmp" && mv "$tmp" "$hb"; sleep "$iv"; done' \
|
||||
"$hb_file" "$pane_pid" "$interval")
|
||||
|
||||
# setsid + disown ensures the sidecar survives this script exiting.
|
||||
# stderr/stdout go to /dev/null; failures are non-fatal.
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||
else
|
||||
bash -c "$sidecar_script" </dev/null >/dev/null 2>&1 &
|
||||
fi
|
||||
disown $! 2>/dev/null || true
|
||||
}
|
||||
|
||||
if [ -n "$PANE_PID" ]; then
|
||||
# Guard: do not let sidecar startup failures abort the launcher (set -e).
|
||||
_start_heartbeat_sidecar "$AGENT_NAME" "$PANE_PID" \
|
||||
"$MOSAIC_HEARTBEAT_RUN_DIR" "$MOSAIC_HEARTBEAT_INTERVAL" || \
|
||||
echo "WARNING: heartbeat sidecar could not be started for $AGENT_NAME" >&2
|
||||
else
|
||||
echo "WARNING: could not resolve pane PID for $AGENT_NAME — heartbeat sidecar not started" >&2
|
||||
fi
|
||||
|
||||
@@ -6,22 +6,43 @@ START="$SCRIPT_DIR/start-agent-session.sh"
|
||||
SOCKET="mosaic-agent-test-$RANDOM-$$"
|
||||
AGENT="agent-$RANDOM"
|
||||
WORKDIR=$(mktemp -d)
|
||||
trap 'tmux -L "$SOCKET" kill-server >/dev/null 2>&1 || true; rm -rf "$WORKDIR"' EXIT
|
||||
|
||||
# Keep a single cleanup trap that accumulates resources.
|
||||
CLEANUP_DIRS=("$WORKDIR")
|
||||
CLEANUP_SOCKETS=("$SOCKET")
|
||||
trap '_cleanup' EXIT
|
||||
_cleanup() {
|
||||
for s in "${CLEANUP_SOCKETS[@]:-}"; do
|
||||
tmux -L "$s" kill-server >/dev/null 2>&1 || true
|
||||
done
|
||||
for d in "${CLEANUP_DIRS[@]:-}"; do
|
||||
rm -rf "$d"
|
||||
done
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "FAIL: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── Test 1: basic session creation with workdir check ─────────────────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
"$START" "$AGENT"
|
||||
|
||||
tmux -L "$SOCKET" has-session -t "=$AGENT:0.0" || fail "agent session was not created"
|
||||
# Retry: pane_current_path briefly reflects the tmux server's cwd until the pane
|
||||
# process establishes its own cwd (the -c start dir). Poll until it settles.
|
||||
actual_dir=""
|
||||
for _ in $(seq 1 30); do
|
||||
actual_dir=$(tmux -L "$SOCKET" display-message -p -t "=$AGENT:0.0" '#{pane_current_path}')
|
||||
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir"
|
||||
[ "$actual_dir" = "$WORKDIR" ] && break
|
||||
sleep 0.1
|
||||
done
|
||||
[ "$actual_dir" = "$WORKDIR" ] || fail "agent workdir mismatch: $actual_dir (expected $WORKDIR)"
|
||||
|
||||
# ── Test 2: idempotency (duplicate start prints 'already running') ─────────────
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
@@ -29,4 +50,310 @@ MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
|
||||
grep -qF 'already running' /tmp/mosaic-start-agent-idempotent.out || fail "duplicate start was not idempotent"
|
||||
|
||||
# ── Test 3: runtime-bin PATH prefix is baked into the pane command ────────────
|
||||
#
|
||||
# We capture the command the script would hand to tmux by injecting a fake
|
||||
# 'tmux' shim into PATH. The shim:
|
||||
# - Intercepts 'new-session' calls and records its arguments to a file.
|
||||
# - For 'has-session' calls, exits 1 (session does not exist) so the script
|
||||
# proceeds to launch instead of printing "already running".
|
||||
# - For 'list-panes' calls, returns empty so PANE_PID stays unset and the
|
||||
# heartbeat sidecar is NOT spawned (heartbeat is not the focus of this test;
|
||||
# test 6 and 7 cover that path). This prevents any real-filesystem side
|
||||
# effects or leaked background processes.
|
||||
# - For all other subcommands, exits 0.
|
||||
#
|
||||
# Assertions:
|
||||
# a) 'export PATH=' with the synthetic MOSAIC_RUNTIME_BIN prefix appears.
|
||||
# b) 'exec' appears so the runtime replaces the wrapper shell.
|
||||
# c) MOSAIC_AGENT_COMMAND with flags is forwarded intact.
|
||||
|
||||
FAKE_BIN=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN=$(mktemp -d)
|
||||
TMUX_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN" "$FAKE_RUNTIME_BIN" "$HB_RUN_DIR3")
|
||||
|
||||
# Write the fake tmux shim (uses only positional args, no sourced vars).
|
||||
cat > "$FAKE_BIN/tmux" <<SHIM
|
||||
#!/usr/bin/env bash
|
||||
# Fake tmux: record new-session args; report has-session as missing.
|
||||
subcmd="\$3" # argv: tmux -L <socket> <subcmd> ...
|
||||
if [ "\$subcmd" = "has-session" ]; then
|
||||
exit 1 # session not found → script will attempt new-session
|
||||
fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE"
|
||||
exit 0
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM
|
||||
chmod +x "$FAKE_BIN/tmux"
|
||||
|
||||
SOCKET3="mosaic-agent-test3-$RANDOM-$$"
|
||||
AGENT3="agent3-$RANDOM"
|
||||
WORKDIR3=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR3")
|
||||
|
||||
PATH="$FAKE_BIN:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET3" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
|
||||
"$START" "$AGENT3"
|
||||
|
||||
all_args=$(cat "$TMUX_ARGS_FILE" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE"
|
||||
|
||||
echo "--- captured tmux new-session args ---"
|
||||
echo "$all_args"
|
||||
echo "--- end args ---"
|
||||
|
||||
# a) PATH prefix containing FAKE_RUNTIME_BIN must appear.
|
||||
echo "$all_args" | grep -qF "export PATH=" || fail "pane command does not export PATH"
|
||||
echo "$all_args" | grep -qF "$FAKE_RUNTIME_BIN" || fail "pane command does not include MOSAIC_RUNTIME_BIN in PATH prefix"
|
||||
|
||||
# b) exec must appear so the runtime replaces the wrapper shell.
|
||||
echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
|
||||
|
||||
# c) Full MOSAIC_AGENT_COMMAND (with flags) must be forwarded.
|
||||
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
|
||||
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
|
||||
|
||||
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
|
||||
TMUX_ARGS_FILE2=$(mktemp)
|
||||
FAKE_BIN2=$(mktemp -d)
|
||||
HB_RUN_DIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN2" "$HB_RUN_DIR4")
|
||||
|
||||
cat > "$FAKE_BIN2/tmux" <<SHIM2
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE2"
|
||||
exit 0
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM2
|
||||
chmod +x "$FAKE_BIN2/tmux"
|
||||
|
||||
SOCKET4="mosaic-agent-test4-$RANDOM-$$"
|
||||
AGENT4="agent4-$RANDOM"
|
||||
WORKDIR4=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR4")
|
||||
|
||||
# MOSAIC_RUNTIME_BIN points to a non-existent dir so prefix will be empty;
|
||||
# .npm-global/bin and .local/bin may or may not exist but we just want exec.
|
||||
PATH="$FAKE_BIN2:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET4" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR4" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="/nonexistent-dir-$$" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR4" \
|
||||
"$START" "$AGENT4"
|
||||
|
||||
all_args4=$(cat "$TMUX_ARGS_FILE2" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE2"
|
||||
rm -rf "$WORKDIR4"
|
||||
|
||||
echo "$all_args4" | grep -qF "exec " || fail "pane command (no prefix dirs) does not use exec"
|
||||
echo "$all_args4" | grep -qF "mosaic yolo pi" || fail "pane command does not include agent command when no prefix"
|
||||
|
||||
# ── Test 5: candidate dir already in LAUNCHER $PATH is still baked into pane ──
|
||||
#
|
||||
# Regression guard for the bug where _build_runtime_bin_prefix() used to skip
|
||||
# a candidate because it was already present in the launcher process's $PATH.
|
||||
# That check was wrong: the pane inherits the tmux SERVER environment, not the
|
||||
# launcher's env. Even if a dir is on the launcher's PATH it must always be
|
||||
# baked into the pane's PATH export.
|
||||
#
|
||||
# We prove this by setting PATH to include FAKE_RUNTIME_BIN5 (the candidate),
|
||||
# then asserting the generated new-session command still exports it.
|
||||
TMUX_ARGS_FILE5=$(mktemp)
|
||||
FAKE_BIN5=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN5=$(mktemp -d) # this dir IS on the launcher's PATH below
|
||||
HB_RUN_DIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN5" "$FAKE_RUNTIME_BIN5" "$HB_RUN_DIR5")
|
||||
|
||||
cat > "$FAKE_BIN5/tmux" <<SHIM5
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then
|
||||
printf '%s\n' "\$@" > "$TMUX_ARGS_FILE5"
|
||||
exit 0
|
||||
fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then
|
||||
# Return empty: no sidecar spawned (heartbeat is not the focus of this test).
|
||||
echo ""
|
||||
exit 0
|
||||
fi
|
||||
exit 0
|
||||
SHIM5
|
||||
chmod +x "$FAKE_BIN5/tmux"
|
||||
|
||||
SOCKET5="mosaic-agent-test5-$RANDOM-$$"
|
||||
AGENT5="agent5-$RANDOM"
|
||||
WORKDIR5=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR5")
|
||||
CLEANUP_SOCKETS+=("$SOCKET5")
|
||||
|
||||
# FAKE_RUNTIME_BIN5 is deliberately placed on the LAUNCHER PATH so that the
|
||||
# old (buggy) code would have skipped it. The correct code must still include
|
||||
# it in the pane PATH export.
|
||||
PATH="$FAKE_BIN5:$FAKE_RUNTIME_BIN5:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET5" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR5" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN5" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR5" \
|
||||
"$START" "$AGENT5"
|
||||
|
||||
all_args5=$(cat "$TMUX_ARGS_FILE5" 2>/dev/null || true)
|
||||
rm -f "$TMUX_ARGS_FILE5"
|
||||
rm -rf "$WORKDIR5"
|
||||
|
||||
echo "--- test 5: launcher-PATH candidate must still appear in pane export ---"
|
||||
echo "$all_args5"
|
||||
echo "--- end test 5 args ---"
|
||||
|
||||
echo "$all_args5" | grep -qF "export PATH=" || \
|
||||
fail "test5: pane command does not export PATH when candidate is on launcher PATH"
|
||||
echo "$all_args5" | grep -qF "$FAKE_RUNTIME_BIN5" || \
|
||||
fail "test5: candidate dir (already on launcher PATH) was NOT baked into pane PATH — regression"
|
||||
|
||||
# ── Test 6: heartbeat sidecar — pane PID resolved + .hb file written ──────────
|
||||
#
|
||||
# Uses a real tmux session (same socket as test 1 which already has $AGENT) so
|
||||
# list-panes returns a real pane PID. We override MOSAIC_HEARTBEAT_RUN_DIR to
|
||||
# a temp dir and set a 1-second interval, then wait up to 3 s for the .hb file
|
||||
# to appear and check its content.
|
||||
|
||||
HB_RUN_DIR=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$HB_RUN_DIR")
|
||||
|
||||
# Re-use the session+agent created in Test 1 (still alive on $SOCKET / $AGENT).
|
||||
# We need to invoke the script for a NEW agent on the same socket to exercise
|
||||
# the heartbeat path with a real pane PID.
|
||||
AGENT6="agent6-$RANDOM"
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR" \
|
||||
MOSAIC_AGENT_COMMAND='bash --noprofile --norc -i' \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="1" \
|
||||
"$START" "$AGENT6"
|
||||
|
||||
HB_FILE="$HB_RUN_DIR/${AGENT6}.hb"
|
||||
|
||||
# Wait up to 5 seconds for the heartbeat file to appear.
|
||||
_waited=0
|
||||
until [ -f "$HB_FILE" ] || [ "$_waited" -ge 5 ]; do
|
||||
sleep 0.5
|
||||
_waited=$((_waited + 1))
|
||||
done
|
||||
|
||||
[ -f "$HB_FILE" ] || fail "test6: heartbeat file not written at $HB_FILE within 5s"
|
||||
|
||||
hb_content=$(cat "$HB_FILE")
|
||||
echo "--- test 6: heartbeat file content ---"
|
||||
echo "$hb_content"
|
||||
echo "--- end test 6 ---"
|
||||
|
||||
# Verify required fields are present.
|
||||
echo "$hb_content" | grep -qE '^ts=[0-9]{4}-[0-9]{2}-[0-9]{2}T' || \
|
||||
fail "test6: heartbeat ts field missing or malformed"
|
||||
echo "$hb_content" | grep -qE '^pid=[0-9]+' || \
|
||||
fail "test6: heartbeat pid field missing or malformed"
|
||||
echo "$hb_content" | grep -qF 'status=ok' || \
|
||||
fail "test6: heartbeat status=ok missing"
|
||||
|
||||
# ── Test 7: heartbeat sidecar — targets correct .hb path per agent name ────────
|
||||
#
|
||||
# Uses the fake-tmux shim approach (like tests 3-5) to capture the sidecar
|
||||
# invocation without needing a real session. A fake setsid shim records its
|
||||
# arguments so we can assert the sidecar script targets the expected .hb path
|
||||
# and uses the configured interval.
|
||||
|
||||
FAKE_BIN7=$(mktemp -d)
|
||||
FAKE_RUNTIME_BIN7=$(mktemp -d)
|
||||
SETSID_ARGS_FILE=$(mktemp)
|
||||
HB_RUN_DIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$FAKE_BIN7" "$FAKE_RUNTIME_BIN7" "$HB_RUN_DIR7")
|
||||
|
||||
AGENT7="my-fleet-agent-$RANDOM"
|
||||
INTERVAL7="42"
|
||||
|
||||
# Fake tmux: has-session → not found; new-session → ok; list-panes → known PID.
|
||||
cat > "$FAKE_BIN7/tmux" <<SHIM7
|
||||
#!/usr/bin/env bash
|
||||
subcmd="\$3"
|
||||
if [ "\$subcmd" = "has-session" ]; then exit 1; fi
|
||||
if [ "\$subcmd" = "new-session" ]; then exit 0; fi
|
||||
if [ "\$subcmd" = "list-panes" ]; then echo "88888"; exit 0; fi
|
||||
exit 0
|
||||
SHIM7
|
||||
chmod +x "$FAKE_BIN7/tmux"
|
||||
|
||||
# Fake setsid: capture the bash -c <script> argument for inspection, then
|
||||
# background an actual bash subshell so disown succeeds in the caller.
|
||||
cat > "$FAKE_BIN7/setsid" <<'SETSID_SHIM'
|
||||
#!/usr/bin/env bash
|
||||
# argv: setsid bash -c <sidecar_script>
|
||||
# Record the full argument list to the capture file, then exit cleanly.
|
||||
printf '%s\0' "$@" > __SETSID_ARGS_FILE__
|
||||
exit 0
|
||||
SETSID_SHIM
|
||||
# Patch the placeholder with the real capture-file path (avoids heredoc expansion issues).
|
||||
sed -i "s|__SETSID_ARGS_FILE__|${SETSID_ARGS_FILE}|g" "$FAKE_BIN7/setsid"
|
||||
chmod +x "$FAKE_BIN7/setsid"
|
||||
|
||||
SOCKET7="mosaic-agent-test7-$RANDOM-$$"
|
||||
WORKDIR7=$(mktemp -d)
|
||||
CLEANUP_DIRS+=("$WORKDIR7")
|
||||
|
||||
PATH="$FAKE_BIN7:$PATH" \
|
||||
MOSAIC_TMUX_SOCKET="$SOCKET7" \
|
||||
MOSAIC_AGENT_WORKDIR="$WORKDIR7" \
|
||||
MOSAIC_AGENT_RUNTIME="pi" \
|
||||
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN7" \
|
||||
MOSAIC_AGENT_COMMAND="mosaic yolo pi" \
|
||||
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR7" \
|
||||
MOSAIC_HEARTBEAT_INTERVAL="$INTERVAL7" \
|
||||
"$START" "$AGENT7"
|
||||
|
||||
# Give the background setsid shim a moment to finish writing the capture file.
|
||||
sleep 0.5
|
||||
|
||||
setsid_args=$(cat "$SETSID_ARGS_FILE" 2>/dev/null | tr '\0' '\n' || true)
|
||||
rm -f "$SETSID_ARGS_FILE"
|
||||
rm -rf "$WORKDIR7"
|
||||
|
||||
echo "--- test 7: captured setsid args ---"
|
||||
echo "$setsid_args"
|
||||
echo "--- end test 7 ---"
|
||||
|
||||
# The sidecar script (bash -c <script>) must reference the correct .hb path.
|
||||
expected_hb="${HB_RUN_DIR7}/${AGENT7}.hb"
|
||||
echo "$setsid_args" | grep -qF "$expected_hb" || \
|
||||
fail "test7: sidecar script does not reference correct .hb path ($expected_hb)"
|
||||
|
||||
# The sidecar script must use the configured interval.
|
||||
echo "$setsid_args" | grep -qF "$INTERVAL7" || \
|
||||
fail "test7: sidecar script does not reference configured interval ($INTERVAL7)"
|
||||
|
||||
echo "ok - start-agent-session"
|
||||
|
||||
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
|
||||
67
packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
Executable file
67
packages/mosaic/framework/tools/quality/scripts/test-install-migration.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-install-migration.sh — fixture matrix for the v2→v3 (Constitution) upgrade
|
||||
# migration in install.sh. Runs the installer against throwaway MOSAIC_HOME dirs
|
||||
# with MOSAIC_SYNC_ONLY=1 (file phase only — no environment-touching post-install)
|
||||
# and asserts the framework-owned-overwrite + user-preserve + backup semantics.
|
||||
#
|
||||
# Mirrors the TS fixture suite in packages/mosaic/src/config/file-adapter.test.ts;
|
||||
# both installers MUST behave identically.
|
||||
#
|
||||
# Usage: bash test-install-migration.sh
|
||||
set -uo pipefail
|
||||
|
||||
FW="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" # packages/mosaic/framework
|
||||
INSTALL="$FW/install.sh"
|
||||
DEFA="$FW/defaults"
|
||||
|
||||
pass=0; fail=0
|
||||
chk() { if eval "$2"; then echo " ✓ $1"; pass=$((pass + 1)); else echo " ✗ $1"; fail=$((fail + 1)); fi; }
|
||||
run() { MOSAIC_HOME="$1" MOSAIC_INSTALL_MODE="$2" MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >/dev/null 2>&1; }
|
||||
|
||||
echo "install.sh v2→v3 migration fixture matrix:"
|
||||
|
||||
# F1 — fresh install
|
||||
T1=$(mktemp -d); run "$T1" overwrite
|
||||
chk "F1 fresh: CONSTITUTION/AGENTS/STANDARDS/TOOLS seeded" \
|
||||
"[ -f '$T1/CONSTITUTION.md' ] && [ -f '$T1/AGENTS.md' ] && [ -f '$T1/STANDARDS.md' ] && [ -f '$T1/TOOLS.md' ]"
|
||||
chk "F1 fresh: AGENTS == shipped default" "cmp -s '$T1/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||
chk "F1 fresh: framework-version stamped 3" "[ \"\$(cat '$T1/.framework-version' 2>/dev/null)\" = 3 ]"
|
||||
|
||||
# F2 — legacy install with a user-edited AGENTS.md (the sanctioned pre-constitution customization)
|
||||
T2=$(mktemp -d); mkdir -p "$T2/credentials"
|
||||
printf '# user-edited AGENTS pre-constitution\n' > "$T2/AGENTS.md"
|
||||
printf '# my persona\n' > "$T2/SOUL.md"
|
||||
printf 'token\n' > "$T2/credentials/c.json"
|
||||
echo 2 > "$T2/.framework-version"
|
||||
run "$T2" keep
|
||||
chk "F2 legacy-edited: AGENTS overwritten to framework version" "cmp -s '$T2/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||
chk "F2 legacy-edited: prior AGENTS saved to .pre-constitution.bak" \
|
||||
"grep -q 'user-edited AGENTS pre-constitution' '$T2/AGENTS.md.pre-constitution.bak'"
|
||||
chk "F2 legacy-edited: SOUL.md preserved" "grep -q 'my persona' '$T2/SOUL.md'"
|
||||
chk "F2 legacy-edited: credentials preserved" "grep -q token '$T2/credentials/c.json'"
|
||||
chk "F2 legacy-edited: CONSTITUTION.md installed" "[ -f '$T2/CONSTITUTION.md' ]"
|
||||
run "$T2" keep
|
||||
chk "F2 idempotent: .pre-constitution.bak preserved across a 2nd upgrade" \
|
||||
"grep -q 'user-edited AGENTS pre-constitution' '$T2/AGENTS.md.pre-constitution.bak'"
|
||||
|
||||
# F3 — user-tuned STANDARDS.md
|
||||
T3=$(mktemp -d); printf '# tuned standards\n' > "$T3/STANDARDS.md"; printf '# persona\n' > "$T3/SOUL.md"; echo 2 > "$T3/.framework-version"
|
||||
run "$T3" keep
|
||||
chk "F3 tuned-standard: STANDARDS overwritten" "cmp -s '$T3/STANDARDS.md' '$DEFA/STANDARDS.md'"
|
||||
chk "F3 tuned-standard: tuned copy backed up" "grep -q 'tuned standards' '$T3/STANDARDS.md.pre-constitution.bak'"
|
||||
|
||||
# F4 — unattended / no TTY (stdin closed): must complete without hanging, default to keep
|
||||
T4=$(mktemp -d); printf '# persona\n' > "$T4/SOUL.md"; printf '# old\n' > "$T4/AGENTS.md"; echo 2 > "$T4/.framework-version"
|
||||
MOSAIC_HOME="$T4" MOSAIC_SYNC_ONLY=1 bash "$INSTALL" </dev/null >/dev/null 2>&1
|
||||
chk "F4 no-TTY: completed, AGENTS updated" "cmp -s '$T4/AGENTS.md' '$DEFA/AGENTS.md'"
|
||||
|
||||
# F5 — failure path must not corrupt existing data (invalid mode rejected before any file op)
|
||||
T5=$(mktemp -d); mkdir -p "$T5/credentials"; printf '# orig\n' > "$T5/SOUL.md"; printf 'keepme\n' > "$T5/credentials/c.json"; echo 2 > "$T5/.framework-version"
|
||||
MOSAIC_HOME="$T5" MOSAIC_INSTALL_MODE=bogus MOSAIC_SYNC_ONLY=1 bash "$INSTALL" >/dev/null 2>&1; rc=$?
|
||||
chk "F5 failure: invalid mode rejected (nonzero exit)" "[ $rc -ne 0 ]"
|
||||
chk "F5 failure: SOUL + credentials intact" "grep -q orig '$T5/SOUL.md' && grep -q keepme '$T5/credentials/c.json'"
|
||||
|
||||
rm -rf "$T1" "$T2" "$T3" "$T4" "$T5"
|
||||
echo
|
||||
echo "RESULT: $pass passed, $fail failed"
|
||||
[ "$fail" -eq 0 ]
|
||||
@@ -53,9 +53,15 @@ _selftest() {
|
||||
local tmp; tmp="$(mktemp -d)" || return 1
|
||||
printf 'contact jason.woltje at jarvis-brain (PDA-friendly)\n' > "$tmp/planted.md"
|
||||
printf 'X="${VAR:-$HOME/src/whatever/x.json}"\n' > "$tmp/planted.sh"
|
||||
printf 'name: jason-woltje\n' > "$tmp/planted.yaml"
|
||||
printf '[Service]\nUser=jarvis\n' > "$tmp/planted.service"
|
||||
local rc=0
|
||||
grep -qIEi "$DENYLIST" "$tmp/planted.md" || { echo "✗ SELF-TEST: identity denylist regex broken" >&2; rc=1; }
|
||||
grep -qIE "$STRUCTURAL_SH" "$tmp/planted.sh" || { echo "✗ SELF-TEST: structural regex broken" >&2; rc=1; }
|
||||
# Prove the identity scan covers the config formats it claims to (yaml/service/etc).
|
||||
local n_ext
|
||||
n_ext=$(find "$tmp" -type f \( -name '*.yaml' -o -name '*.service' \) -print0 | xargs -0 -r grep -lIEi "$DENYLIST" 2>/dev/null | wc -l)
|
||||
[[ "$n_ext" -eq 2 ]] || { echo "✗ SELF-TEST: identity scan does not cover .yaml/.service extensions" >&2; rc=1; }
|
||||
rm -rf "$tmp"; return $rc
|
||||
}
|
||||
_selftest || exit 2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaicstack/mosaic",
|
||||
"version": "0.0.34",
|
||||
"version": "0.0.39",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -291,12 +291,23 @@ function buildPrdBlock(): string {
|
||||
|
||||
// ─── Runtime prompt builder ──────────────────────────────────────────────────
|
||||
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
/**
|
||||
* Compose the full runtime contract for a harness: the resident-by-value core
|
||||
* (CONSTITUTION + AGENTS + USER + TOOLS + runtime) plus operator overlays
|
||||
* (`*.local.md` deltas), merged in precedence order so the model gets one
|
||||
* pre-merged blob (DESIGN §3.2 / R7). Overlays are injected as deltas by value;
|
||||
* base files keep their existing residency (USER injected; SOUL/STANDARDS are
|
||||
* load-on-demand, so only their small `.local` deltas are injected here).
|
||||
*
|
||||
* `mosaicHome` is parameterized for testability; production callers use the
|
||||
* module-level default.
|
||||
*/
|
||||
export function composeContract(runtime: RuntimeName, mosaicHome: string = MOSAIC_HOME): string {
|
||||
const runtimeContractPaths: Record<RuntimeName, string> = {
|
||||
claude: join(MOSAIC_HOME, 'runtime', 'claude', 'RUNTIME.md'),
|
||||
codex: join(MOSAIC_HOME, 'runtime', 'codex', 'RUNTIME.md'),
|
||||
opencode: join(MOSAIC_HOME, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||
pi: join(MOSAIC_HOME, 'runtime', 'pi', 'RUNTIME.md'),
|
||||
claude: join(mosaicHome, 'runtime', 'claude', 'RUNTIME.md'),
|
||||
codex: join(mosaicHome, 'runtime', 'codex', 'RUNTIME.md'),
|
||||
opencode: join(mosaicHome, 'runtime', 'opencode', 'RUNTIME.md'),
|
||||
pi: join(mosaicHome, 'runtime', 'pi', 'RUNTIME.md'),
|
||||
};
|
||||
|
||||
const runtimeFile = runtimeContractPaths[runtime];
|
||||
@@ -331,27 +342,55 @@ For required push/merge/issue-close/release actions, execute without routine con
|
||||
`);
|
||||
|
||||
// CONSTITUTION.md (L0 — the non-negotiable law; lead with it). Tolerant of
|
||||
// pre-constitution installs that have not been re-seeded yet.
|
||||
const constitution = readOptional(join(MOSAIC_HOME, 'CONSTITUTION.md'));
|
||||
// pre-constitution installs that have not been re-seeded yet. Injected by
|
||||
// value verbatim so the bare-launch fallback read is byte-equal (R8).
|
||||
const constitution = readOptional(join(mosaicHome, 'CONSTITUTION.md'));
|
||||
if (constitution) parts.push(constitution);
|
||||
|
||||
// AGENTS.md
|
||||
parts.push(readFileSync(join(MOSAIC_HOME, 'AGENTS.md'), 'utf-8'));
|
||||
parts.push(readFileSync(join(mosaicHome, 'AGENTS.md'), 'utf-8'));
|
||||
|
||||
// USER.md
|
||||
const user = readOptional(join(MOSAIC_HOME, 'USER.md'));
|
||||
// USER.md (+ USER.local.md operator overlay, appended directly under the
|
||||
// profile its base owns).
|
||||
const user = readOptional(join(mosaicHome, 'USER.md'));
|
||||
if (user) parts.push('\n\n# User Profile\n\n' + user);
|
||||
const userLocal = readOptional(join(mosaicHome, 'USER.local.md'));
|
||||
if (userLocal.trim()) {
|
||||
parts.push('\n\n## Operator Overlay (USER.local.md)\n\n' + userLocal);
|
||||
}
|
||||
|
||||
// TOOLS.md
|
||||
const tools = readOptional(join(MOSAIC_HOME, 'TOOLS.md'));
|
||||
const tools = readOptional(join(mosaicHome, 'TOOLS.md'));
|
||||
if (tools) parts.push('\n\n# Machine Tools\n\n' + tools);
|
||||
|
||||
// Operator overlays whose base layers are load-on-demand (SOUL, STANDARDS):
|
||||
// inject only the small `.local` delta by value so the customization reaches
|
||||
// the model without re-injecting the full base prose (preserves the byte
|
||||
// budget). Absent `.local` files → base-only, automatically (R7 §3.2).
|
||||
const overlayBlocks: string[] = [];
|
||||
const soulLocal = readOptional(join(mosaicHome, 'SOUL.local.md'));
|
||||
if (soulLocal.trim()) {
|
||||
overlayBlocks.push('## Persona Overlay (SOUL.local.md)\n\n' + soulLocal.trim());
|
||||
}
|
||||
const standardsLocal = readOptional(join(mosaicHome, 'STANDARDS.local.md'));
|
||||
if (standardsLocal.trim()) {
|
||||
overlayBlocks.push('## Standards Overlay (STANDARDS.local.md)\n\n' + standardsLocal.trim());
|
||||
}
|
||||
if (overlayBlocks.length > 0) {
|
||||
parts.push('\n\n# Operator Overlays\n\n' + overlayBlocks.join('\n\n'));
|
||||
}
|
||||
|
||||
// Runtime-specific contract
|
||||
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/** @deprecated internal alias — use composeContract. Retained for call-site clarity. */
|
||||
function buildRuntimePrompt(runtime: RuntimeName): string {
|
||||
return composeContract(runtime);
|
||||
}
|
||||
|
||||
// ─── Session lock ────────────────────────────────────────────────────────────
|
||||
|
||||
function writeSessionLock(runtime: string): void {
|
||||
@@ -976,6 +1015,22 @@ export function registerLaunchCommands(program: Command): void {
|
||||
launchRuntime(runtime, extraArgs, yolo);
|
||||
});
|
||||
|
||||
// compose-contract — emit the composed runtime contract (base + operator
|
||||
// overlays) for a harness to stdout, without launching. For inspection,
|
||||
// `mosaic doctor`, diffing, and the composer test (R7).
|
||||
program
|
||||
.command('compose-contract <harness>')
|
||||
.description('Print the composed runtime contract (base + *.local overlays) for a harness')
|
||||
.action((harness: string) => {
|
||||
const valid: RuntimeName[] = ['claude', 'codex', 'opencode', 'pi'];
|
||||
if (!valid.includes(harness as RuntimeName)) {
|
||||
console.error(`Unknown harness '${harness}'. Expected one of: ${valid.join(', ')}.`);
|
||||
process.exitCode = 64;
|
||||
return;
|
||||
}
|
||||
process.stdout.write(composeContract(harness as RuntimeName));
|
||||
});
|
||||
|
||||
// Coord (mission orchestrator)
|
||||
program
|
||||
.command('coord')
|
||||
|
||||
@@ -99,11 +99,8 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves existing contract files — never overwrites user customization', async () => {
|
||||
// Also plant a root-level AGENTS.md in sourceDir so that `syncDirectory`
|
||||
// itself (not just the seed loop) has something to try to overwrite.
|
||||
// Without this, the test would silently pass even if preserve semantics
|
||||
// were broken in syncDirectory.
|
||||
it('overwrites framework-owned files (backup-once) but preserves user-seeded files', async () => {
|
||||
// Plant a root-level AGENTS.md in sourceDir so syncDirectory's preserve is exercised.
|
||||
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||
|
||||
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||
@@ -112,18 +109,50 @@ describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
|
||||
// User-seeded TOOLS.md is preserved.
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
|
||||
'# user-customized TOOLS\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe(
|
||||
// Framework-owned AGENTS.md is overwritten from defaults/ ...
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||
// ... and the user's prior copy is backed up exactly once.
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe(
|
||||
'# user-customized AGENTS\n',
|
||||
);
|
||||
// And the missing contract file still gets seeded.
|
||||
// Framework-owned STANDARDS.md (absent) gets installed.
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
|
||||
'# STANDARDS default',
|
||||
);
|
||||
});
|
||||
|
||||
it('backs up a divergent framework-owned file only once (idempotent across re-sync)', async () => {
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
|
||||
await adapter.syncFramework('keep'); // 1st: backup created, AGENTS overwritten
|
||||
await adapter.syncFramework('keep'); // 2nd: AGENTS already == default, no new backup
|
||||
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md.pre-constitution.bak'), 'utf-8')).toBe(
|
||||
'# user-customized AGENTS\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves SOUL.md and credentials through a framework-owned overwrite', async () => {
|
||||
writeFileSync(join(fixture.mosaicHome, 'SOUL.md'), '# my persona\n');
|
||||
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||
mkdirSync(join(fixture.mosaicHome, 'credentials'), { recursive: true });
|
||||
writeFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'token\n');
|
||||
|
||||
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||
await adapter.syncFramework('keep');
|
||||
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'SOUL.md'), 'utf-8')).toBe('# my persona\n');
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'credentials', 'c.json'), 'utf-8')).toBe(
|
||||
'token\n',
|
||||
);
|
||||
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe('# AGENTS default\n');
|
||||
});
|
||||
|
||||
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||
rmSync(fixture.defaultsDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -13,12 +13,17 @@ import { join } from 'node:path';
|
||||
* This list must match the explicit seed loop in
|
||||
* packages/mosaic/framework/install.sh.
|
||||
*/
|
||||
export const DEFAULT_SEED_FILES = [
|
||||
'CONSTITUTION.md',
|
||||
'AGENTS.md',
|
||||
'STANDARDS.md',
|
||||
'TOOLS.md',
|
||||
] as const;
|
||||
// Framework-owned contract files: re-copied from defaults/ on every upgrade (a
|
||||
// divergent existing copy is backed up once to <file>.pre-constitution.bak first).
|
||||
// MUST match FRAMEWORK_OWNED in packages/mosaic/framework/install.sh (append-friendly).
|
||||
export const FRAMEWORK_OWNED_FILES = ['CONSTITUTION.md', 'AGENTS.md', 'STANDARDS.md'] as const;
|
||||
|
||||
// User-seeded contract files: written once on first install, then owned by the user.
|
||||
// MUST match USER_SEEDED in packages/mosaic/framework/install.sh.
|
||||
export const USER_SEEDED_FILES = ['TOOLS.md'] as const;
|
||||
|
||||
// Union, retained for callers/tests that assert the full seed set on a fresh install.
|
||||
export const DEFAULT_SEED_FILES = [...FRAMEWORK_OWNED_FILES, ...USER_SEEDED_FILES] as const;
|
||||
import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
|
||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||
@@ -159,6 +164,7 @@ export class FileConfigAdapter implements ConfigService {
|
||||
const preservePaths =
|
||||
action === 'keep' || action === 'reconfigure'
|
||||
? [
|
||||
'CONSTITUTION.md',
|
||||
'AGENTS.md',
|
||||
'SOUL.md',
|
||||
'USER.md',
|
||||
@@ -175,10 +181,10 @@ export class FileConfigAdapter implements ConfigService {
|
||||
excludeGit: true,
|
||||
});
|
||||
|
||||
// Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md)
|
||||
// from framework/defaults/ into the mosaic home root if they don't
|
||||
// exist yet. These are written on first install only and are never
|
||||
// overwritten afterwards — the user may have customized them.
|
||||
// Reconcile framework-contract files from framework/defaults/ into the mosaic
|
||||
// home root: framework-owned files (CONSTITUTION/AGENTS/STANDARDS) are overwritten
|
||||
// every upgrade (backup-once); user-seeded files (TOOLS) are written on first
|
||||
// install only. Mirrors reconcile_framework_files() in install.sh.
|
||||
//
|
||||
// SOUL.md and USER.md are deliberately NOT seeded here. They are
|
||||
// generated from templates by the soul/user wizard stages with
|
||||
@@ -186,7 +192,22 @@ export class FileConfigAdapter implements ConfigService {
|
||||
// identity flow and leak placeholder content into the mosaic home.
|
||||
const defaultsDir = join(this.sourceDir, 'defaults');
|
||||
if (existsSync(defaultsDir)) {
|
||||
for (const entry of DEFAULT_SEED_FILES) {
|
||||
// Framework-owned: overwrite from defaults/ every sync; back up a divergent
|
||||
// existing copy ONCE to <file>.pre-constitution.bak before the first overwrite.
|
||||
for (const entry of FRAMEWORK_OWNED_FILES) {
|
||||
const src = join(defaultsDir, entry);
|
||||
const dest = join(this.mosaicHome, entry);
|
||||
if (!existsSync(src) || !statSync(src).isFile()) continue;
|
||||
// Already current — skip to avoid mtime churn.
|
||||
if (existsSync(dest) && readFileSync(src).equals(readFileSync(dest))) continue;
|
||||
const bak = `${dest}.pre-constitution.bak`;
|
||||
if (existsSync(dest) && !existsSync(bak)) {
|
||||
copyFileSync(dest, bak);
|
||||
}
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
// User-seeded: write only if absent.
|
||||
for (const entry of USER_SEEDED_FILES) {
|
||||
const src = join(defaultsDir, entry);
|
||||
const dest = join(this.mosaicHome, entry);
|
||||
if (existsSync(dest)) continue;
|
||||
|
||||
Reference in New Issue
Block a user