Compare commits
15 Commits
feat/insta
...
docs/mvp-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70f7c49336 | ||
| 66512550df | |||
| 46dd799548 | |||
| 5f03c05523 | |||
| c3f810bbd1 | |||
| b2cbf898d7 | |||
| b2cec8c6ba | |||
| 81c1775a03 | |||
| f64ec12f39 | |||
| 026382325c | |||
| 1bfd8570d6 | |||
| 312acd8bad | |||
| d08b969918 | |||
| 051de0d8a9 | |||
| bd76df1a50 |
@@ -103,12 +103,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/gateway:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:latest"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/gateway.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -128,12 +128,12 @@ steps:
|
|||||||
- mkdir -p /kaniko/.docker
|
- mkdir -p /kaniko/.docker
|
||||||
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
- echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$REGISTRY_USER\",\"password\":\"$REGISTRY_PASS\"}}}" > /kaniko/.docker/config.json
|
||||||
- |
|
- |
|
||||||
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/stack/web:sha-${CI_COMMIT_SHA:0:7}"
|
||||||
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
if [ "$CI_COMMIT_BRANCH" = "main" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:latest"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:latest"
|
||||||
fi
|
fi
|
||||||
if [ -n "$CI_COMMIT_TAG" ]; then
|
if [ -n "$CI_COMMIT_TAG" ]; then
|
||||||
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:$CI_COMMIT_TAG"
|
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG"
|
||||||
fi
|
fi
|
||||||
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
/kaniko/executor --context . --dockerfile docker/web.Dockerfile $DESTINATIONS
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
16
AGENTS.md
16
AGENTS.md
@@ -58,14 +58,14 @@ pnpm typecheck && pnpm lint && pnpm format:check # Quality gates
|
|||||||
|
|
||||||
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
The `agent` column specifies the required model for each task. **This is set at task creation by the orchestrator and must not be changed by workers.**
|
||||||
|
|
||||||
| Value | When to use | Budget |
|
| Value | When to use | Budget |
|
||||||
| -------- | ----------------------------------------------------------- | -------------------------- |
|
| --------- | ----------------------------------------------------------- | -------------------------- |
|
||||||
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
| `codex` | All coding tasks (default for implementation) | OpenAI credits — preferred |
|
||||||
| `glm-5` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
| `glm-5.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits |
|
||||||
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
| `haiku` | Review gates, verify tasks, status checks, docs-only | Cheapest Claude tier |
|
||||||
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
| `sonnet` | Complex planning, multi-file reasoning, architecture review | Claude quota |
|
||||||
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
| `opus` | Major cross-cutting architecture decisions ONLY | Most expensive — minimize |
|
||||||
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
| `—` | No preference / auto-select cheapest capable | Pipeline decides |
|
||||||
|
|
||||||
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
Pipeline crons read this column and spawn accordingly. Workers never modify `docs/TASKS.md` — only the orchestrator writes it.
|
||||||
|
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -7,7 +7,13 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
|
|||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
The installer auto-launches the setup wizard, which walks you through gateway install and verification. Flags for non-interactive use:
|
||||||
@@ -179,8 +185,8 @@ Consent state is persisted in config. Remote upload is a no-op until you run `mo
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
|
git clone git@git.mosaicstack.dev:mosaicstack/stack.git
|
||||||
cd mosaic-stack
|
cd stack
|
||||||
|
|
||||||
# Start infrastructure (Postgres, Valkey, Jaeger)
|
# Start infrastructure (Postgres, Valkey, Jaeger)
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@@ -229,7 +235,7 @@ npm packages are published to the Gitea package registry on main merges.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
mosaic-stack/
|
stack/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
│ ├── gateway/ NestJS API + WebSocket hub (Fastify, Socket.IO, OTEL)
|
||||||
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
│ └── web/ Next.js dashboard (React 19, Tailwind)
|
||||||
@@ -302,7 +308,13 @@ Each stage has a dispatch mode (`exec` for research/review, `yolo` for coding),
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI:
|
Or use the CLI:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "apps/gateway"
|
"directory": "apps/gateway"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,73 +1,116 @@
|
|||||||
# Mission Manifest — Install UX v2
|
# Mission Manifest — MVP
|
||||||
|
|
||||||
> Persistent document tracking full mission scope, status, and session history.
|
> Top-level rollup tracking Mosaic Stack MVP execution.
|
||||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
> Workstreams have their own manifests; this document is the source of truth for MVP scope, status, and history.
|
||||||
|
> Owner: Orchestrator (sole writer).
|
||||||
|
|
||||||
## Mission
|
## Mission
|
||||||
|
|
||||||
**ID:** install-ux-v2-20260405
|
**ID:** mvp-20260312
|
||||||
**Statement:** The install-ux-hardening mission shipped the plumbing (uninstall, masked password, hooks consent, unified flow, headless path), but the first real end-to-end run surfaced a critical regression and a collection of UX failings that make the wizard feel neither quick nor intelligent. This mission closes the bootstrap regression as a hotfix, then rethinks the first-run experience around a provider-first, intent-driven flow with a drill-down main menu and a genuinely fast quick-start.
|
**Statement:** Ship a self-hosted, multi-user AI agent platform that consolidates the user's disparate jarvis-brain usage across home and USC workstations into a single coherent system reachable via three first-class surfaces — webUI, TUI, and CLI — with federation as the data-layer mechanism that makes cross-host agent sessions work in real time without copying user data across the boundary.
|
||||||
**Phase:** Execution
|
**Phase:** Execution (workstream W1 in planning-complete state)
|
||||||
**Current Milestone:** IUV-M03
|
**Current Workstream:** W1 — Federation v1
|
||||||
**Progress:** 2 / 3 milestones
|
**Progress:** 0 / 1 declared workstreams complete (more workstreams will be declared as scope is refined)
|
||||||
**Status:** active
|
**Status:** active (continuous since 2026-03-13)
|
||||||
**Last Updated:** 2026-04-05 (IUV-M02 complete — CORS/FQDN + skill installer rework)
|
**Last Updated:** 2026-04-19 (manifest authored at the rollup level; install-ux-v2 archived; W1 federation planning landed via PR #468)
|
||||||
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
**Source PRD:** [docs/PRD.md](./PRD.md) — Mosaic Stack v0.1.0
|
||||||
|
**Scratchpad:** [docs/scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md) (active since 2026-03-13; 14 prior sessions of phase-based execution)
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
Jarvis (v0.2.0) was a single-host Python/Next.js assistant. The user runs sessions across 3–4 workstations split between home and USC. Today every session reaches back to a single jarvis-brain checkout, which is brittle (offline-hostile, no consolidation, no shared state beyond a single repo). A prior OpenBrain attempt punished offline use, introduced cache/latency/opacity pain, and tightly coupled every session to a remote service.
|
||||||
|
|
||||||
1. **Critical:** admin bootstrap fails with HTTP 400 `property email should not exist` — `bootstrap.controller.ts` uses `import type { BootstrapSetupDto }`, erasing the class at runtime. Nest's `@Body()` falls back to plain `Object` metatype, and ValidationPipe with `forbidNonWhitelisted` rejects every property. One-character fix (drop the `type` keyword), but it blocks the happy path of the release that just shipped.
|
The MVP solution: keep each user's home gateway as the source of truth, connect gateways gateway-to-gateway over mTLS with scoped read-only data exposure, and expose the unified experience through three coherent surfaces:
|
||||||
2. The wizard reports `✔ Wizard complete` and `✔ Done` _after_ the bootstrap 400 — failure only propagates in headless mode (`wizard.ts:147`).
|
|
||||||
3. The gateway port prompt does not prefill `14242` in the input buffer.
|
- **webUI** — the primary visual control plane (Next.js + React 19, `apps/web`)
|
||||||
4. `"What is Mosaic?"` intro copy does not mention Pi SDK (the actual agent runtime behind Claude/Codex/OpenCode).
|
- **TUI** — the terminal-native interface for agent work (`packages/mosaic` wizard + Pi TUI)
|
||||||
5. CORS origin prompt is confusing — the user should be able to supply an FQDN/hostname and have the system derive the CORS value.
|
- **CLI** — `mosaic` command for scripted/headless workflows
|
||||||
6. Skill / additional feature install section is unusable in practice.
|
|
||||||
7. Quick-start asks far too many questions to be meaningfully "quick".
|
Federation is required NOW because it unblocks cross-host consolidation; it is necessary but not sufficient for MVP. Additional workstreams will be declared as their scope solidifies.
|
||||||
8. No drill-down main menu — everything is a linear interrogation.
|
|
||||||
9. Provider setup happens late and without intelligence. An OpenClaw-style provider-first flow would let the user describe what they want in natural language, have the agent expound on it, and have the agent choose its own name based on that intent.
|
## Prior Execution (March 13 → April 5)
|
||||||
|
|
||||||
|
This manifest was authored on 2026-04-19 to rollup work that began 2026-03-13. Before this date, MVP work was tracked via phase-based Gitea milestones and the scratchpad — there was no rollup manifest at the `docs/MISSION-MANIFEST.md` path (the slot was occupied by sub-mission manifests for `install-ux-hardening` and then `install-ux-v2`).
|
||||||
|
|
||||||
|
Prior execution outline (full detail in [scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md)):
|
||||||
|
|
||||||
|
- **Phases 0 → 7** (Gitea milestones `ms-157` → `ms-164`, issues #1–#59): foundation, core API, agent layer, web dashboard, memory, remote control, CLI/tools, polish/beta. Substantially shipped by Session 13.
|
||||||
|
- **Phase 8** (Gitea milestone `ms-165`, issues #160–#172): platform architecture extension — teams, workspaces, `/provider` OAuth, preferences, etc. Wave-based execution plan defined at Session 14.
|
||||||
|
- **Sub-missions** during the gap: `install-ux-hardening` (complete, `mosaic-v0.0.25`), `install-ux-v2` (complete on 2026-04-19, `0.0.27` → `0.0.29`). Both archived under `docs/archive/missions/`.
|
||||||
|
|
||||||
|
Going forward, MVP execution is tracked through the **Workstreams** table below. Phase-based issue numbering is preserved on Gitea but is no longer the primary control plane.
|
||||||
|
|
||||||
|
## Cross-Cutting MVP Requirements
|
||||||
|
|
||||||
|
These apply to every workstream and every milestone. A workstream cannot ship if it breaks any of them.
|
||||||
|
|
||||||
|
| # | Requirement |
|
||||||
|
| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| MVP-X1 | Three-surface parity: every user-facing capability is reachable via webUI **and** TUI **and** CLI (read paths at minimum; mutating paths where applicable to the surface). |
|
||||||
|
| MVP-X2 | Multi-tenant isolation is enforced at every boundary; no cross-user leakage under any circumstance. |
|
||||||
|
| MVP-X3 | Auth via BetterAuth (existing); SSO adapters per PRD; admin bootstrap remains a one-shot. |
|
||||||
|
| MVP-X4 | Three quality gates green before push: `pnpm typecheck`, `pnpm lint`, `pnpm format:check`. |
|
||||||
|
| MVP-X5 | Federated tier (PG + pgvector + Valkey) is the canonical MVP deployment topology; local/standalone tiers continue to work for non-federated installs but are not the MVP target. |
|
||||||
|
| MVP-X6 | OTEL tracing on every request path; `traceparent` propagated across the federation boundary in both directions. |
|
||||||
|
| MVP-X7 | Trunk merge strategy: branch from `main`, squash-merge via PR, never push to `main` directly. |
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
- [x] AC-1: Admin bootstrap completes successfully end-to-end on a fresh install (DTO value import, no forbidNonWhitelisted regression); covered by an integration or e2e test that exercises the real DTO binding. _(PR #440)_
|
The MVP is complete when ALL declared workstreams are complete AND every cross-cutting requirement is verifiable on a live two-host deployment (woltje.com ↔ uscllc.com).
|
||||||
- [x] AC-2: Wizard fails loudly (non-zero exit, clear error) when the bootstrap stage returns `completed: false`, in both interactive and headless modes. No more silent `✔ Wizard complete` after a 400. _(PR #440)_
|
|
||||||
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
|
||||||
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
|
||||||
- [x] AC-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path. _(tag: mosaic-v0.0.26, registry: 0.0.26 live)_
|
|
||||||
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
|
||||||
- [ ] AC-7: Skill / additional feature install section is reworked until it is actually usable end-to-end (worker defines the concrete failure modes during diagnosis).
|
|
||||||
- [ ] AC-8: First-run flow has a drill-down main menu with at least `Plugins` (Recommended / Custom), `Providers`, and the other top-level configuration groups. Linear interrogation is gone.
|
|
||||||
- [ ] AC-9: `Quick Start` path completes with a minimal, curated set of questions (target: under 90 seconds for a returning user; define the exact baseline during design).
|
|
||||||
- [ ] AC-10: Provider setup happens first, driven by a natural-language intake prompt. The agent expounds on the user's intent and chooses its own name based on that intent (OpenClaw-style). Naming is confirmable / overridable.
|
|
||||||
- [ ] AC-11: All milestones ship as merged PRs with green CI and closed issues.
|
|
||||||
|
|
||||||
## Milestones
|
- [ ] AC-MVP-1: All declared workstreams reach `complete` status with merged PRs and green CI
|
||||||
|
- [ ] AC-MVP-2: A user session on the home gateway can transparently query work-gateway data subject to scope, with no data persisted across the boundary
|
||||||
|
- [ ] AC-MVP-3: The same user-facing capability is reachable from webUI, TUI, and CLI (per MVP-X1)
|
||||||
|
- [ ] AC-MVP-4: Two-gateway production deployment (woltje.com ↔ uscllc.com) operational ≥7 days without incident
|
||||||
|
- [ ] AC-MVP-5: All cross-cutting requirements (MVP-X1 → MVP-X7) verified with evidence
|
||||||
|
- [ ] AC-MVP-6: PRD `docs/PRD.md` "In Scope (v0.1.0 Beta)" list mapped to evidence (each item: shipped / explicitly deferred with rationale)
|
||||||
|
|
||||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
## Workstreams
|
||||||
| --- | ------- | ------------------------------------------------------------ | ----------- | ---------------------- | ----- | ---------- | ---------- |
|
|
||||||
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | complete | fix/bootstrap-hotfix | #436 | 2026-04-05 | 2026-04-05 |
|
|
||||||
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | complete | feat/install-ux-polish | #437 | 2026-04-05 | 2026-04-05 |
|
|
||||||
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
|
|
||||||
|
|
||||||
## Subagent Delegation Plan
|
| # | ID | Name | Status | Manifest | Notes |
|
||||||
|
| --- | --- | ------------------------------------------- | ----------------- | ----------------------------------------------------------------------- | --------------------------------------------------- |
|
||||||
|
| W1 | FED | Federation v1 | planning-complete | [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) | 7 milestones, ~175K tokens, issues #460–#466 filed |
|
||||||
|
| W2+ | TBD | (additional workstreams declared as scoped) | — | — | Scope creep is expected and explicitly accommodated |
|
||||||
|
|
||||||
| Milestone | Recommended Tier | Rationale |
|
### Likely Additional Workstreams (Not Yet Declared)
|
||||||
| --------- | ---------------- | --------------------------------------------------------------------- |
|
|
||||||
| IUV-M01 | sonnet | Tight bug cluster with known fix sites + small release cycle |
|
These are anticipated based on the PRD `In Scope` list but are NOT counted toward MVP completion until they have their own manifest, milestones, and tracking issues. Listed here so the orchestrator knows what's likely coming.
|
||||||
| IUV-M02 | sonnet | UX rework, moderate surface, diagnostic-heavy for the skill installer |
|
|
||||||
| IUV-M03 | opus | Architectural redesign of first-run flow, state machine + LLM intake |
|
- Web dashboard parity with PRD scope (chat, tasks, projects, missions, agent status surfaces)
|
||||||
|
- Pi TUI integration for terminal-native agent work
|
||||||
|
- CLI completeness for headless / scripted workflows that mirror webUI capability
|
||||||
|
- Remote control plugins (Discord priority, then Telegram)
|
||||||
|
- Multi-user / SSO finishing (BetterAuth + Authentik/WorkOS/Keycloak adapters per PRD)
|
||||||
|
- LLM provider expansion (Anthropic, Codex, Z.ai, Ollama, LM Studio, llama.cpp) + routing matrix
|
||||||
|
- MCP server/client capability + skill import interface
|
||||||
|
- Brain (`@mosaicstack/brain`) as the structured data layer on PG + vector
|
||||||
|
|
||||||
|
When any of these solidify into a real workstream, add a row to the Workstreams table, create a workstream-level manifest under `docs/{workstream}/MISSION-MANIFEST.md`, and file tracking issues.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
|
|
||||||
- **Hotfix regression surface** — the `import type` → `import` fix on the DTO class is one character but needs an integration test that binds the real DTO, not just a controller unit test, to prevent the same class-erasure regression from sneaking back in.
|
- **Scope creep is the named risk.** Workstreams will be added; the rule is that each must have its own manifest + milestones + acceptance criteria before it consumes execution capacity.
|
||||||
- **LLM-driven intake latency / offline** — M03's provider-first intent flow assumes an available LLM call to expound on user input and choose a name. Offline installs need a deterministic fallback.
|
- **Federation urgency vs. surface parity** — federation is being built first because it unblocks the user, but webUI/TUI/CLI parity (MVP-X1) cannot slip indefinitely. Track surface coverage explicitly when each workstream lands.
|
||||||
- **Menu vs. linear back-compat** — M03 changes the top-level flow shape; existing `tools/install.sh --yes` + env-var headless path must continue to work.
|
- **Three-surface fan-out** — the same capability exposed three ways multiplies test surface and design effort. Default to a shared API/contract layer, then thin surface adapters; resist surface-specific business logic.
|
||||||
- **Scope creep in M03** — "redesign the wizard" can absorb arbitrary work. Keep it bounded with explicit non-goals.
|
- **Federated-tier dependency** — MVP requires PG + pgvector + Valkey; users on local/standalone tier cannot federate. This is intentional but must be communicated clearly in the wizard.
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope (MVP)
|
||||||
|
|
||||||
- Migrating the wizard to a GUI / web UI (still terminal-first)
|
- SaaS / multi-tenant revenue model — personal/family/team tool only
|
||||||
- Replacing the Gitea registry or the Woodpecker publish pipeline
|
- Mobile native apps — responsive web only
|
||||||
- Multi-tenant / multi-user onboarding (still single-admin bootstrap)
|
- Public npm registry publishing — Gitea registry only
|
||||||
- Reworking `mosaic uninstall` (M01 of the parent mission — stable)
|
- Voice / video agent interaction
|
||||||
|
- Full OpenClaw feature parity — inspiration only
|
||||||
|
- Calendar / GLPI / Woodpecker tooling integrations (deferred to post-MVP)
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
For sessions 1–14 (phase-based execution, 2026-03-13 → 2026-03-15), see [scratchpads/mvp-20260312.md](./scratchpads/mvp-20260312.md). Sessions below are tracked at the rollup level.
|
||||||
|
|
||||||
|
| Session | Date | Runtime | Outcome |
|
||||||
|
| ------- | ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| S15 | 2026-04-19 | claude | MVP rollup manifest authored. Install-ux-v2 archived (IUV-M03 retroactively closed — shipped via PR #446 + releases 0.0.27 → 0.0.29). Federation v1 planning landed via PR #468. W1 manifest reachable at `docs/federation/MISSION-MANIFEST.md`. Next: kickoff FED-M1. |
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
|
||||||
|
Begin W1 / FED-M1 — federated tier infrastructure. Task breakdown lives at [docs/federation/TASKS.md](./federation/TASKS.md).
|
||||||
|
|||||||
@@ -1,39 +1,40 @@
|
|||||||
# Tasks — Install UX v2
|
# Tasks — MVP (Top-Level Rollup)
|
||||||
|
|
||||||
> Single-writer: orchestrator only. Workers read but never modify.
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
>
|
>
|
||||||
> **Mission:** install-ux-v2-20260405
|
> **Mission:** mvp-20260312
|
||||||
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
> **Manifest:** [docs/MISSION-MANIFEST.md](./MISSION-MANIFEST.md)
|
||||||
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
>
|
||||||
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
> This file is a **rollup**. Per-workstream task breakdowns live in workstream task files
|
||||||
|
> (e.g. `docs/federation/TASKS.md`). Workers operating inside a workstream should treat
|
||||||
|
> the workstream file as their primary task source; this file exists for orchestrator-level
|
||||||
|
> visibility into MVP-wide state.
|
||||||
|
>
|
||||||
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed`
|
||||||
|
|
||||||
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
## Workstream Rollup
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
| id | status | workstream | progress | tasks file | notes |
|
||||||
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------- | ---------- | -------- | --------------------------------------------------------------------------------------- |
|
| --- | ----------------- | ------------------- | ---------------- | ------------------------------------------------- | --------------------------------------------------------------- |
|
||||||
| IUV-01-01 | done | Fix `apps/gateway/src/admin/bootstrap.controller.ts:16` — switch `import type { BootstrapSetupDto }` to a value import so Nest's `@Body()` binds the real class | #436 | sonnet | fix/bootstrap-hotfix | — | 3K | PR #440 merged `0ae932ab` |
|
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2–M7 deferred to mission planning |
|
||||||
| IUV-01-02 | done | Add integration / e2e test that POSTs `/api/bootstrap/setup` with `{name,email,password}` against a real Nest app instance and asserts 201 — NOT a mocked controller unit test | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-01 | 10K | `apps/gateway/src/admin/bootstrap.e2e.spec.ts` — 4 tests; unplugin-swc added for vitest |
|
|
||||||
| IUV-01-03 | done | `packages/mosaic/src/wizard.ts:147` — propagate `!bootstrapResult.completed` as a wizard failure in **interactive** mode too (not only headless); non-zero exit + no `✔ Wizard complete` line | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-02 | 5K | removed `&& headlessRun` guard |
|
|
||||||
| IUV-01-04 | done | Gateway port prompt prefills `14242` in the input buffer — investigate why `promptPort`'s `defaultValue` isn't reaching the user-visible input | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-03 | 5K | added `initialValue` through prompter interface → clack |
|
|
||||||
| IUV-01-05 | done | `"What is Mosaic?"` intro copy updated to mention Pi SDK as the underlying agent runtime (alongside Claude Code / Codex / OpenCode) | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-04 | 2K | `packages/mosaic/src/stages/welcome.ts` |
|
|
||||||
| IUV-01-06 | done | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | PRs #440/#441/#442 merged; tag `mosaic-v0.0.26`; registry latest=0.0.26 ✓ |
|
|
||||||
|
|
||||||
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
## Cross-Cutting Tracking
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
These are MVP-level checks that don't belong to any single workstream. Updated by the orchestrator at each session.
|
||||||
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
|
|
||||||
| IUV-02-01 | done | Replace CORS origin prompt with FQDN / hostname input; derive the CORS value internally; default to `localhost` with clear help text | #437 | sonnet | feat/install-ux-polish | — | 10K | `deriveCorsOrigin()` pure fn; MOSAIC_HOSTNAME headless var; PR #444 |
|
|
||||||
| IUV-02-02 | done | Diagnose and document the concrete failure modes of the current skill / additional feature install section end-to-end | #437 | sonnet | feat/install-ux-polish | IUV-02-01 | 8K | selection→install gap, silent catch{}, no whitelist concept |
|
|
||||||
| IUV-02-03 | done | Rework the skill installer so it is usable end-to-end (selection, install, verify, failure reporting) | #437 | sonnet | feat/install-ux-polish | IUV-02-02 | 20K | MOSAIC_INSTALL_SKILLS env var whitelist; SyncSkillsResult typed return |
|
|
||||||
| IUV-02-04 | done | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | 18 new tests (13 CORS + 5 skills); PR #444 merged `172bacb3` |
|
|
||||||
|
|
||||||
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
| id | status | description | notes |
|
||||||
|
| ------- | ----------- | -------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| MVP-T01 | done | Author MVP-level manifest at `docs/MISSION-MANIFEST.md` | This session (2026-04-19); PR pending |
|
||||||
|
| MVP-T02 | done | Archive install-ux-v2 mission state to `docs/archive/missions/install-ux-v2-20260405/` | IUV-M03 retroactively closed (shipped via PR #446 + releases 0.0.27→0.0.29) |
|
||||||
|
| MVP-T03 | done | Land federation v1 planning artifacts on `main` | PR #468 merged 2026-04-19 (commit `66512550`) |
|
||||||
|
| MVP-T04 | not-started | Sync `.mosaic/orchestrator/mission.json` MVP slot with this manifest (milestone enumeration, etc.) | Coord state file; consider whether to repopulate via `mosaic coord` or accept hand-edit |
|
||||||
|
| MVP-T05 | not-started | Kick off W1 / FED-M1 — federated tier infrastructure | First execution task in MVP |
|
||||||
|
| MVP-T06 | not-started | Declare additional workstreams (web dashboard, TUI/CLI parity, remote control, etc.) as scope solidifies | Track each new workstream by adding a row to the Workstream Rollup |
|
||||||
|
|
||||||
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
## Pointer to Active Workstream
|
||||||
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----- | ---------------------- | ---------- | -------- | ------------------------------------------------------------- |
|
|
||||||
| IUV-03-01 | not-started | Design doc: new first-run state machine — main menu (Plugins / Providers / …), Quick Start vs Custom paths, provider-first flow, intent intake + naming loop | #438 | opus | feat/install-ux-intent | — | 15K | scratchpad + explicit non-goals |
|
Active workstream is **W1 — Federation v1**. Workers should:
|
||||||
| IUV-03-02 | not-started | Implement drill-down main menu (Plugins: Recommended / Custom, Providers, …) as the top-level entry point of `mosaic wizard` | #438 | opus | feat/install-ux-intent | IUV-03-01 | 25K | |
|
|
||||||
| IUV-03-03 | not-started | Quick Start path: curated minimum question set — define the exact baseline, delete everything else from the fast path | #438 | opus | feat/install-ux-intent | IUV-03-02 | 15K | |
|
1. Read [docs/federation/MISSION-MANIFEST.md](./federation/MISSION-MANIFEST.md) for workstream scope
|
||||||
| IUV-03-04 | not-started | Provider-first natural-language intake: user describes intent → agent expounds → agent proposes a name (confirmable / overridable) — OpenClaw-style | #438 | opus | feat/install-ux-intent | IUV-03-03 | 25K | offline fallback required (deterministic default name + path) |
|
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
|
||||||
| IUV-03-05 | not-started | Preserve backward-compat: headless path (`MOSAIC_ASSUME_YES=1` + env vars) still works end-to-end; `tools/install.sh --yes` unchanged | #438 | opus | feat/install-ux-intent | IUV-03-04 | 10K | |
|
3. Follow per-task agent + tier guidance from the workstream manifest
|
||||||
| IUV-03-06 | not-started | Tests + code review + PR merge + `mosaic-v0.0.27` release | #438 | opus | feat/install-ux-intent | IUV-03-05 | 15K | |
|
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Mission Manifest — Install UX v2
|
||||||
|
|
||||||
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** install-ux-v2-20260405
|
||||||
|
**Statement:** The install-ux-hardening mission shipped the plumbing (uninstall, masked password, hooks consent, unified flow, headless path), but the first real end-to-end run surfaced a critical regression and a collection of UX failings that make the wizard feel neither quick nor intelligent. This mission closes the bootstrap regression as a hotfix, then rethinks the first-run experience around a provider-first, intent-driven flow with a drill-down main menu and a genuinely fast quick-start.
|
||||||
|
**Phase:** Closed
|
||||||
|
**Current Milestone:** —
|
||||||
|
**Progress:** 3 / 3 milestones
|
||||||
|
**Status:** complete
|
||||||
|
**Last Updated:** 2026-04-19 (archived during MVP manifest authoring; IUV-M03 substantively shipped via PR #446 — drill-down menu + provider-first flow + quick start; releases 0.0.27 → 0.0.29)
|
||||||
|
**Archived to:** `docs/archive/missions/install-ux-v2-20260405/`
|
||||||
|
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
|
||||||
|
|
||||||
|
1. **Critical:** admin bootstrap fails with HTTP 400 `property email should not exist` — `bootstrap.controller.ts` uses `import type { BootstrapSetupDto }`, erasing the class at runtime. Nest's `@Body()` falls back to plain `Object` metatype, and ValidationPipe with `forbidNonWhitelisted` rejects every property. One-character fix (drop the `type` keyword), but it blocks the happy path of the release that just shipped.
|
||||||
|
2. The wizard reports `✔ Wizard complete` and `✔ Done` _after_ the bootstrap 400 — failure only propagates in headless mode (`wizard.ts:147`).
|
||||||
|
3. The gateway port prompt does not prefill `14242` in the input buffer.
|
||||||
|
4. `"What is Mosaic?"` intro copy does not mention Pi SDK (the actual agent runtime behind Claude/Codex/OpenCode).
|
||||||
|
5. CORS origin prompt is confusing — the user should be able to supply an FQDN/hostname and have the system derive the CORS value.
|
||||||
|
6. Skill / additional feature install section is unusable in practice.
|
||||||
|
7. Quick-start asks far too many questions to be meaningfully "quick".
|
||||||
|
8. No drill-down main menu — everything is a linear interrogation.
|
||||||
|
9. Provider setup happens late and without intelligence. An OpenClaw-style provider-first flow would let the user describe what they want in natural language, have the agent expound on it, and have the agent choose its own name based on that intent.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [x] AC-1: Admin bootstrap completes successfully end-to-end on a fresh install (DTO value import, no forbidNonWhitelisted regression); covered by an integration or e2e test that exercises the real DTO binding. _(PR #440)_
|
||||||
|
- [x] AC-2: Wizard fails loudly (non-zero exit, clear error) when the bootstrap stage returns `completed: false`, in both interactive and headless modes. No more silent `✔ Wizard complete` after a 400. _(PR #440)_
|
||||||
|
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_
|
||||||
|
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_
|
||||||
|
- [x] AC-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path. _(tag: mosaic-v0.0.26, registry: 0.0.26 live)_
|
||||||
|
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that.
|
||||||
|
- [ ] AC-7: Skill / additional feature install section is reworked until it is actually usable end-to-end (worker defines the concrete failure modes during diagnosis).
|
||||||
|
- [ ] AC-8: First-run flow has a drill-down main menu with at least `Plugins` (Recommended / Custom), `Providers`, and the other top-level configuration groups. Linear interrogation is gone.
|
||||||
|
- [ ] AC-9: `Quick Start` path completes with a minimal, curated set of questions (target: under 90 seconds for a returning user; define the exact baseline during design).
|
||||||
|
- [ ] AC-10: Provider setup happens first, driven by a natural-language intake prompt. The agent expounds on the user's intent and chooses its own name based on that intent (OpenClaw-style). Naming is confirmable / overridable.
|
||||||
|
- [ ] AC-11: All milestones ship as merged PRs with green CI and closed issues.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
|
| --- | ------- | ------------------------------------------------------------ | -------- | ---------------------- | ----- | ---------- | ---------- |
|
||||||
|
| 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | complete | fix/bootstrap-hotfix | #436 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | complete | feat/install-ux-polish | #437 | 2026-04-05 | 2026-04-05 |
|
||||||
|
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | complete | feat/install-ux-intent | #438 | 2026-04-05 | 2026-04-19 |
|
||||||
|
|
||||||
|
## Subagent Delegation Plan
|
||||||
|
|
||||||
|
| Milestone | Recommended Tier | Rationale |
|
||||||
|
| --------- | ---------------- | --------------------------------------------------------------------- |
|
||||||
|
| IUV-M01 | sonnet | Tight bug cluster with known fix sites + small release cycle |
|
||||||
|
| IUV-M02 | sonnet | UX rework, moderate surface, diagnostic-heavy for the skill installer |
|
||||||
|
| IUV-M03 | opus | Architectural redesign of first-run flow, state machine + LLM intake |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Hotfix regression surface** — the `import type` → `import` fix on the DTO class is one character but needs an integration test that binds the real DTO, not just a controller unit test, to prevent the same class-erasure regression from sneaking back in.
|
||||||
|
- **LLM-driven intake latency / offline** — M03's provider-first intent flow assumes an available LLM call to expound on user input and choose a name. Offline installs need a deterministic fallback.
|
||||||
|
- **Menu vs. linear back-compat** — M03 changes the top-level flow shape; existing `tools/install.sh --yes` + env-var headless path must continue to work.
|
||||||
|
- **Scope creep in M03** — "redesign the wizard" can absorb arbitrary work. Keep it bounded with explicit non-goals.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Migrating the wizard to a GUI / web UI (still terminal-first)
|
||||||
|
- Replacing the Gitea registry or the Woodpecker publish pipeline
|
||||||
|
- Multi-tenant / multi-user onboarding (still single-admin bootstrap)
|
||||||
|
- Reworking `mosaic uninstall` (M01 of the parent mission — stable)
|
||||||
39
docs/archive/missions/install-ux-v2-20260405/TASKS.md
Normal file
39
docs/archive/missions/install-ux-v2-20260405/TASKS.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Tasks — Install UX v2
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **Mission:** install-ux-v2-20260405
|
||||||
|
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||||
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||||
|
> **Agent values:** `codex` | `sonnet` | `haiku` | `opus` | `—` (auto)
|
||||||
|
|
||||||
|
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------ | -------------------- | ---------- | -------- | --------------------------------------------------------------------------------------- |
|
||||||
|
| IUV-01-01 | done | Fix `apps/gateway/src/admin/bootstrap.controller.ts:16` — switch `import type { BootstrapSetupDto }` to a value import so Nest's `@Body()` binds the real class | #436 | sonnet | fix/bootstrap-hotfix | — | 3K | PR #440 merged `0ae932ab` |
|
||||||
|
| IUV-01-02 | done | Add integration / e2e test that POSTs `/api/bootstrap/setup` with `{name,email,password}` against a real Nest app instance and asserts 201 — NOT a mocked controller unit test | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-01 | 10K | `apps/gateway/src/admin/bootstrap.e2e.spec.ts` — 4 tests; unplugin-swc added for vitest |
|
||||||
|
| IUV-01-03 | done | `packages/mosaic/src/wizard.ts:147` — propagate `!bootstrapResult.completed` as a wizard failure in **interactive** mode too (not only headless); non-zero exit + no `✔ Wizard complete` line | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-02 | 5K | removed `&& headlessRun` guard |
|
||||||
|
| IUV-01-04 | done | Gateway port prompt prefills `14242` in the input buffer — investigate why `promptPort`'s `defaultValue` isn't reaching the user-visible input | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-03 | 5K | added `initialValue` through prompter interface → clack |
|
||||||
|
| IUV-01-05 | done | `"What is Mosaic?"` intro copy updated to mention Pi SDK as the underlying agent runtime (alongside Claude Code / Codex / OpenCode) | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-04 | 2K | `packages/mosaic/src/stages/welcome.ts` |
|
||||||
|
| IUV-01-06 | done | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | PRs #440/#441/#442 merged; tag `mosaic-v0.0.26`; registry latest=0.0.26 ✓ |
|
||||||
|
|
||||||
|
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ---------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
|
||||||
|
| IUV-02-01 | done | Replace CORS origin prompt with FQDN / hostname input; derive the CORS value internally; default to `localhost` with clear help text | #437 | sonnet | feat/install-ux-polish | — | 10K | `deriveCorsOrigin()` pure fn; MOSAIC_HOSTNAME headless var; PR #444 |
|
||||||
|
| IUV-02-02 | done | Diagnose and document the concrete failure modes of the current skill / additional feature install section end-to-end | #437 | sonnet | feat/install-ux-polish | IUV-02-01 | 8K | selection→install gap, silent catch{}, no whitelist concept |
|
||||||
|
| IUV-02-03 | done | Rework the skill installer so it is usable end-to-end (selection, install, verify, failure reporting) | #437 | sonnet | feat/install-ux-polish | IUV-02-02 | 20K | MOSAIC_INSTALL_SKILLS env var whitelist; SyncSkillsResult typed return |
|
||||||
|
| IUV-02-04 | done | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | 18 new tests (13 CORS + 5 skills); PR #444 merged `172bacb3` |
|
||||||
|
|
||||||
|
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ----- | ---------------------- | ---------- | -------- | ------------------------------------------------------------- |
|
||||||
|
| IUV-03-01 | not-started | Design doc: new first-run state machine — main menu (Plugins / Providers / …), Quick Start vs Custom paths, provider-first flow, intent intake + naming loop | #438 | opus | feat/install-ux-intent | — | 15K | scratchpad + explicit non-goals |
|
||||||
|
| IUV-03-02 | not-started | Implement drill-down main menu (Plugins: Recommended / Custom, Providers, …) as the top-level entry point of `mosaic wizard` | #438 | opus | feat/install-ux-intent | IUV-03-01 | 25K | |
|
||||||
|
| IUV-03-03 | not-started | Quick Start path: curated minimum question set — define the exact baseline, delete everything else from the fast path | #438 | opus | feat/install-ux-intent | IUV-03-02 | 15K | |
|
||||||
|
| IUV-03-04 | not-started | Provider-first natural-language intake: user describes intent → agent expounds → agent proposes a name (confirmable / overridable) — OpenClaw-style | #438 | opus | feat/install-ux-intent | IUV-03-03 | 25K | offline fallback required (deterministic default name + path) |
|
||||||
|
| IUV-03-05 | not-started | Preserve backward-compat: headless path (`MOSAIC_ASSUME_YES=1` + env vars) still works end-to-end; `tools/install.sh --yes` unchanged | #438 | opus | feat/install-ux-intent | IUV-03-04 | 10K | |
|
||||||
|
| IUV-03-06 | not-started | Tests + code review + PR merge + `mosaic-v0.0.27` release | #438 | opus | feat/install-ux-intent | IUV-03-05 | 15K | |
|
||||||
227
docs/archive/missions/install-ux-v2-20260405/iuv-m03-design.md
Normal file
227
docs/archive/missions/install-ux-v2-20260405/iuv-m03-design.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# IUV-M03 Design: Provider-first intelligent flow + drill-down main menu
|
||||||
|
|
||||||
|
**Issue:** #438
|
||||||
|
**Branch:** `feat/install-ux-intent`
|
||||||
|
**Date:** 2026-04-05
|
||||||
|
|
||||||
|
## 1. New first-run state machine
|
||||||
|
|
||||||
|
The linear 12-stage interrogation is replaced with a menu-driven architecture.
|
||||||
|
|
||||||
|
### Flow overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Welcome banner
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Detect existing install (auto)
|
||||||
|
|
|
||||||
|
v
|
||||||
|
Main Menu (loop)
|
||||||
|
|-- Quick Start -> provider key + admin creds -> finalize
|
||||||
|
|-- Providers -> LLM API key config
|
||||||
|
|-- Agent Identity -> intent intake + naming (deterministic)
|
||||||
|
|-- Skills -> recommended / custom selection
|
||||||
|
|-- Gateway -> port, storage tier, hostname, CORS
|
||||||
|
|-- Advanced -> SOUL.md, USER.md, TOOLS.md, runtimes, hooks
|
||||||
|
|-- Finish & Apply -> finalize + gateway bootstrap
|
||||||
|
v
|
||||||
|
Done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Menu navigation
|
||||||
|
|
||||||
|
- Main menu is a `select` prompt. Each option drills into a sub-flow.
|
||||||
|
- Completing a section returns to the main menu.
|
||||||
|
- Menu items show completion state: `[done]` hint after configuration.
|
||||||
|
- `Finish & Apply` is always last and requires at minimum a provider key (or explicit skip).
|
||||||
|
- The menu tracks configured sections in `WizardState.completedSections`.
|
||||||
|
|
||||||
|
### Headless bypass
|
||||||
|
|
||||||
|
When `MOSAIC_ASSUME_YES=1` or `!process.stdin.isTTY`, the entire menu is skipped.
|
||||||
|
The wizard runs: defaults + env var overrides -> finalize -> gateway config -> bootstrap.
|
||||||
|
This preserves full backward compatibility with `tools/install.sh --yes`.
|
||||||
|
|
||||||
|
## 2. Quick Start path
|
||||||
|
|
||||||
|
Target: 3-5 questions max. Under 90 seconds for a returning user.
|
||||||
|
|
||||||
|
### Questions asked
|
||||||
|
|
||||||
|
1. **Provider API key** (Anthropic/OpenAI) - `text` prompt with paste support
|
||||||
|
2. **Admin email** - `text` prompt
|
||||||
|
3. **Admin password** - masked + confirmed
|
||||||
|
|
||||||
|
### Questions skipped (with defaults)
|
||||||
|
|
||||||
|
| Setting | Default | Rationale |
|
||||||
|
| ---------------------------- | ------------------------------- | ---------------------- |
|
||||||
|
| Agent name | "Mosaic" | Generic but branded |
|
||||||
|
| Port | 14242 | Standard default |
|
||||||
|
| Storage tier | local | No external deps |
|
||||||
|
| Hostname | localhost | Dev-first |
|
||||||
|
| CORS origin | http://localhost:3000 | Standard web UI port |
|
||||||
|
| Skills | recommended set | Curated by maintainers |
|
||||||
|
| Runtimes | auto-detected | No user input needed |
|
||||||
|
| Communication style | direct | Most popular choice |
|
||||||
|
| SOUL.md / USER.md / TOOLS.md | template defaults | Can customize later |
|
||||||
|
| Hooks | auto-install if Claude detected | Safe default |
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Quick Start selected
|
||||||
|
-> "Paste your LLM API key (Anthropic recommended):"
|
||||||
|
-> [auto-detect provider from key prefix: sk-ant-* = Anthropic, sk-* = OpenAI]
|
||||||
|
-> Apply all defaults
|
||||||
|
-> Run finalize (sync framework, write configs, link assets, sync skills)
|
||||||
|
-> Run gateway config (headless-style with defaults + provided key)
|
||||||
|
-> "Admin email:"
|
||||||
|
-> "Admin password:" (masked + confirm)
|
||||||
|
-> Run gateway bootstrap
|
||||||
|
-> Done
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Provider-first flow
|
||||||
|
|
||||||
|
Provider configuration (currently buried in gateway-config stage as "ANTHROPIC_API_KEY")
|
||||||
|
moves to a dedicated top-level menu item and is the first question in Quick Start.
|
||||||
|
|
||||||
|
### Provider detection
|
||||||
|
|
||||||
|
The API key prefix determines the provider:
|
||||||
|
|
||||||
|
- `sk-ant-api03-*` -> Anthropic (Claude)
|
||||||
|
- `sk-*` -> OpenAI
|
||||||
|
- Empty/skipped -> no provider (gateway starts without LLM access)
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
The provider key is stored in the gateway `.env` as `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`.
|
||||||
|
For Quick Start, this replaces the old interactive prompt in `collectAndWriteConfig`.
|
||||||
|
|
||||||
|
### Menu section: "Providers"
|
||||||
|
|
||||||
|
In the drill-down menu, "Providers" lets users:
|
||||||
|
|
||||||
|
1. Enter/change their API key
|
||||||
|
2. See which provider was detected
|
||||||
|
3. Optionally configure a second provider
|
||||||
|
|
||||||
|
For v0.0.27, we support Anthropic and OpenAI keys only. The key is stored
|
||||||
|
in `WizardState` and written during finalize.
|
||||||
|
|
||||||
|
## 4. Intent intake + naming (deterministic fallback - Option B)
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
At install time, the LLM provider may not be configured yet (chicken-and-egg).
|
||||||
|
We use **Option B: deterministic advisor** for the install wizard.
|
||||||
|
|
||||||
|
### Flow (Agent Identity menu section)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. "What will this agent primarily help you with?"
|
||||||
|
-> Select from presets:
|
||||||
|
- General purpose assistant
|
||||||
|
- Software development
|
||||||
|
- DevOps & infrastructure
|
||||||
|
- Research & analysis
|
||||||
|
- Content & writing
|
||||||
|
- Custom (free text description)
|
||||||
|
|
||||||
|
2. System proposes a thematic name based on selection:
|
||||||
|
- General purpose -> "Mosaic"
|
||||||
|
- Software development -> "Forge"
|
||||||
|
- DevOps & infrastructure -> "Sentinel"
|
||||||
|
- Research & analysis -> "Atlas"
|
||||||
|
- Content & writing -> "Muse"
|
||||||
|
- Custom -> "Mosaic" (default)
|
||||||
|
|
||||||
|
3. "Your agent will be named 'Forge'. Press Enter to accept or type a new name:"
|
||||||
|
-> User confirms or overrides
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
- Agent name -> `WizardState.soul.agentName` -> written to SOUL.md
|
||||||
|
- Intent category -> `WizardState.agentIntent` (new field) -> written to `~/.config/mosaic/agent.json`
|
||||||
|
|
||||||
|
### Post-install LLM-powered intake (future)
|
||||||
|
|
||||||
|
A future `mosaic configure identity` command can use the configured LLM to:
|
||||||
|
|
||||||
|
- Accept free-text intent description
|
||||||
|
- Generate an expounded persona
|
||||||
|
- Propose a contextual name
|
||||||
|
|
||||||
|
This is explicitly out of scope for the install wizard.
|
||||||
|
|
||||||
|
## 5. Headless backward-compat
|
||||||
|
|
||||||
|
### Supported env vars (unchanged)
|
||||||
|
|
||||||
|
| Variable | Used by |
|
||||||
|
| -------------------------- | ---------------------------------------------- |
|
||||||
|
| `MOSAIC_ASSUME_YES=1` | Skip all prompts, use defaults + env overrides |
|
||||||
|
| `MOSAIC_ADMIN_NAME` | Gateway bootstrap |
|
||||||
|
| `MOSAIC_ADMIN_EMAIL` | Gateway bootstrap |
|
||||||
|
| `MOSAIC_ADMIN_PASSWORD` | Gateway bootstrap |
|
||||||
|
| `MOSAIC_GATEWAY_PORT` | Gateway config |
|
||||||
|
| `MOSAIC_HOSTNAME` | Gateway config (CORS derivation) |
|
||||||
|
| `MOSAIC_CORS_ORIGIN` | Gateway config (full override) |
|
||||||
|
| `MOSAIC_STORAGE_TIER` | Gateway config (local/team) |
|
||||||
|
| `MOSAIC_DATABASE_URL` | Gateway config (team tier) |
|
||||||
|
| `MOSAIC_VALKEY_URL` | Gateway config (team tier) |
|
||||||
|
| `MOSAIC_ANTHROPIC_API_KEY` | Provider config |
|
||||||
|
|
||||||
|
### New env vars
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
| --------------------- | ----------------------------------------- |
|
||||||
|
| `MOSAIC_AGENT_NAME` | Override agent name in headless mode |
|
||||||
|
| `MOSAIC_AGENT_INTENT` | Override intent category in headless mode |
|
||||||
|
|
||||||
|
### `tools/install.sh --yes`
|
||||||
|
|
||||||
|
The install script sets `MOSAIC_ASSUME_YES=1` and passes through env vars.
|
||||||
|
No changes needed to the script itself. The new wizard detects headless mode
|
||||||
|
at the top of `runWizard` and runs a linear path identical to the old flow.
|
||||||
|
|
||||||
|
## 6. Explicit non-goals
|
||||||
|
|
||||||
|
- **No GUI** — this is a terminal wizard only
|
||||||
|
- **No multi-user install** — single-user, single-machine
|
||||||
|
- **No registry changes** — npm publish flow is unchanged
|
||||||
|
- **No LLM calls during install** — deterministic fallback only
|
||||||
|
- **No new dependencies** — uses existing @clack/prompts and picocolors
|
||||||
|
- **No changes to gateway API** — only the wizard orchestration changes
|
||||||
|
- **No changes to tools/install.sh** — headless compat maintained via env vars
|
||||||
|
|
||||||
|
## 7. Implementation plan
|
||||||
|
|
||||||
|
### Files to modify
|
||||||
|
|
||||||
|
1. `packages/mosaic/src/types.ts` — add `MenuSection`, `AgentIntent`, `completedSections`, `agentIntent`, `providerKey`, `providerType` to WizardState
|
||||||
|
2. `packages/mosaic/src/wizard.ts` — replace linear flow with menu loop
|
||||||
|
3. `packages/mosaic/src/stages/mode-select.ts` — becomes the main menu
|
||||||
|
4. `packages/mosaic/src/stages/provider-setup.ts` — new: provider key collection
|
||||||
|
5. `packages/mosaic/src/stages/agent-intent.ts` — new: intent intake + naming
|
||||||
|
6. `packages/mosaic/src/stages/menu-gateway.ts` — new: gateway sub-menu wrapper
|
||||||
|
7. `packages/mosaic/src/stages/quick-start.ts` — new: quick start linear path
|
||||||
|
8. `packages/mosaic/src/constants.ts` — add intent presets and name mappings
|
||||||
|
9. `packages/mosaic/package.json` — version bump 0.0.26 -> 0.0.27
|
||||||
|
|
||||||
|
### Files to add (tests)
|
||||||
|
|
||||||
|
1. `packages/mosaic/src/stages/wizard-menu.spec.ts` — menu navigation tests
|
||||||
|
2. `packages/mosaic/src/stages/quick-start.spec.ts` — quick start path tests
|
||||||
|
3. `packages/mosaic/src/stages/agent-intent.spec.ts` — intent + naming tests
|
||||||
|
4. `packages/mosaic/src/stages/provider-setup.spec.ts` — provider detection tests
|
||||||
|
|
||||||
|
### Migration strategy
|
||||||
|
|
||||||
|
The existing stage functions remain intact. The menu system wraps them —
|
||||||
|
each menu item calls the appropriate stage function(s). The linear headless
|
||||||
|
path calls them in the same order as before.
|
||||||
368
docs/federation/MILESTONES.md
Normal file
368
docs/federation/MILESTONES.md
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
# Mosaic Stack — Federation Implementation Milestones
|
||||||
|
|
||||||
|
**Companion to:** `PRD.md`
|
||||||
|
**Approach:** Each milestone is a verifiable slice. A milestone is "done" only when its acceptance tests pass in CI against a real (not mocked) dependency stack.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone Dependency Graph
|
||||||
|
|
||||||
|
```
|
||||||
|
M1 (federated tier infra)
|
||||||
|
└── M2 (Step-CA + grant schema + CLI)
|
||||||
|
└── M3 (mTLS handshake + list/get + scope enforcement)
|
||||||
|
├── M4 (search + audit + rate limit)
|
||||||
|
│ └── M5 (cache + offline degradation + OTEL)
|
||||||
|
├── M6 (revocation + auto-renewal) ◄── can start after M3
|
||||||
|
└── M7 (multi-user hardening + e2e suite) ◄── depends on M4+M5+M6
|
||||||
|
```
|
||||||
|
|
||||||
|
M5 and M6 can run in parallel once M4 is merged.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Strategy (applies to all milestones)
|
||||||
|
|
||||||
|
Three layers, all required before a milestone ships:
|
||||||
|
|
||||||
|
| Layer | Scope | Runtime |
|
||||||
|
| ------------------ | --------------------------------------------- | ------------------------------------------------------------------------ |
|
||||||
|
| **Unit** | Per-module logic, pure functions, adapters | Vitest, no I/O |
|
||||||
|
| **Integration** | Single gateway against real PG/Valkey/Step-CA | Vitest + Docker Compose test profile |
|
||||||
|
| **Federation E2E** | Two gateways on a Docker network, real mTLS | Playwright/custom harness (`tools/federation-harness/`) introduced in M3 |
|
||||||
|
|
||||||
|
Every milestone adds tests to these layers. A milestone cannot be claimed complete if the federation E2E harness fails (applies from M3 onward).
|
||||||
|
|
||||||
|
**Quality gates per milestone** (same as stack-wide):
|
||||||
|
|
||||||
|
- `pnpm typecheck` green
|
||||||
|
- `pnpm lint` green
|
||||||
|
- `pnpm test` green (unit + integration)
|
||||||
|
- `pnpm test:federation` green (M3+)
|
||||||
|
- Independent code review passed
|
||||||
|
- Docs updated (`docs/federation/`)
|
||||||
|
- Merged PR on `main`, CI terminal green, linked issue closed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M1 — Federated Tier Infrastructure
|
||||||
|
|
||||||
|
**Goal:** A gateway can run in `federated` tier with containerized Postgres + Valkey + pgvector, with no federation logic active yet.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Add `"tier": "federated"` to `mosaic.config.json` schema and validators
|
||||||
|
- Docker Compose `federated` profile (`docker-compose.federated.yml`) adds: Postgres+pgvector (5433), Valkey (6380), dedicated volumes
|
||||||
|
- Tier detector in gateway bootstrap: reads config, asserts required services reachable, refuses to start otherwise
|
||||||
|
- `pgvector` extension installed + verified on startup
|
||||||
|
- Migration logic: safe upgrade path from `local`/`standalone` → `federated` (data export/import script, one-way)
|
||||||
|
- `mosaic doctor` reports tier + service health
|
||||||
|
- Gateway continues to serve as a normal standalone instance (no federation yet)
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- `mosaic.config.json` schema v2 (tier enum includes `federated`)
|
||||||
|
- `apps/gateway/src/bootstrap/tier-detector.ts`
|
||||||
|
- `docker-compose.federated.yml`
|
||||||
|
- `scripts/migrate-to-federated.ts`
|
||||||
|
- Updated `mosaic doctor` output
|
||||||
|
- Updated `packages/storage/src/adapters/postgres.ts` with pgvector support
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
| # | Test | Layer |
|
||||||
|
| - | ---------------------------------------------------------------------------------------- | ----------- |
|
||||||
|
| 1 | Gateway boots in `federated` tier with all services present | Integration |
|
||||||
|
| 2 | Gateway refuses to boot in `federated` tier when Postgres unreachable (fail-fast, clear) | Integration |
|
||||||
|
| 3 | `pgvector` extension available in target DB (`SELECT * FROM pg_extension WHERE extname='vector'`) | Integration |
|
||||||
|
| 4 | Migration script moves a populated `local` (PGlite) instance to `federated` (Postgres) with no data loss | Integration |
|
||||||
|
| 5 | `mosaic doctor` reports correct tier and all services green | Unit |
|
||||||
|
| 6 | Existing standalone behavior regression: agent session works end-to-end, no federation references | E2E (single-gateway) |
|
||||||
|
|
||||||
|
**Estimated budget:** ~20K tokens (infra + config + migration script)
|
||||||
|
**Risk notes:** Pgvector install on existing PG installs is occasionally finicky; test the migration path on a realistic DB snapshot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M2 — Step-CA + Grant Schema + Admin CLI
|
||||||
|
|
||||||
|
**Goal:** An admin can create a federation grant and its counterparty can enroll. No runtime traffic flows yet.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Embed Step-CA as a Docker Compose sidecar with a persistent CA volume
|
||||||
|
- Gateway exposes a short-lived enrollment endpoint (single-use token from the grant)
|
||||||
|
- DB schema: `federation_grants`, `federation_peers`, `federation_audit_log` (table only, not yet written to)
|
||||||
|
- Sealed storage for `client_key_pem` using the existing credential sealing key
|
||||||
|
- Admin CLI:
|
||||||
|
- `mosaic federation grant create --user <id> --peer <host> --scope <file>`
|
||||||
|
- `mosaic federation grant list`
|
||||||
|
- `mosaic federation grant show <id>`
|
||||||
|
- `mosaic federation peer add <enrollment-url>`
|
||||||
|
- `mosaic federation peer list`
|
||||||
|
- Step-CA signs the cert with SAN OIDs for `grantId` + `subjectUserId`
|
||||||
|
- Grant status transitions: `pending` → `active` on successful enrollment
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- `packages/db` migration: three federation tables + enum types
|
||||||
|
- `apps/gateway/src/federation/ca.service.ts` (Step-CA client)
|
||||||
|
- `apps/gateway/src/federation/grants.service.ts`
|
||||||
|
- `apps/gateway/src/federation/enrollment.controller.ts`
|
||||||
|
- `packages/mosaic/src/commands/federation/` (grant + peer subcommands)
|
||||||
|
- `docker-compose.federated.yml` adds Step-CA service
|
||||||
|
- Scope JSON schema + validator
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
| # | Test | Layer |
|
||||||
|
| - | ---------------------------------------------------------------------------------------- | ----------- |
|
||||||
|
| 1 | `grant create` writes a `pending` row with a scoped bundle | Integration |
|
||||||
|
| 2 | Enrollment endpoint signs a CSR and returns a cert with expected SAN OIDs | Integration |
|
||||||
|
| 3 | Enrollment token is single-use; second attempt returns 410 | Integration |
|
||||||
|
| 4 | Cert `subjectUserId` OID matches the grant's `subject_user_id` | Unit |
|
||||||
|
| 5 | `client_key_pem` is at-rest encrypted; raw DB read shows ciphertext, not PEM | Integration |
|
||||||
|
| 6 | `peer add <url>` on Server A yields an `active` peer record with a valid cert + key | E2E (two gateways, no traffic) |
|
||||||
|
| 7 | Scope JSON with unknown resource type rejected at `grant create` | Unit |
|
||||||
|
| 8 | `grant list` and `peer list` render active / pending / revoked accurately | Unit |
|
||||||
|
|
||||||
|
**Estimated budget:** ~30K tokens (schema + CA integration + CLI + sealing)
|
||||||
|
**Risk notes:** Step-CA's API surface is well-documented but the sealing integration with existing provider-credential encryption is a cross-module concern — walk that seam deliberately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M3 — mTLS Handshake + `list` + `get` with Scope Enforcement
|
||||||
|
|
||||||
|
**Goal:** Two federated gateways exchange real data over mTLS with scope intersecting native RBAC.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- `FederationClient` (outbound): picks cert from `federation_peers`, does mTLS call
|
||||||
|
- `FederationServer` (inbound): NestJS guard validates client cert, extracts `grantId` + `subjectUserId`, loads grant
|
||||||
|
- Scope enforcement pipeline:
|
||||||
|
1. Resource allowlist / excluded-list check
|
||||||
|
2. Native RBAC evaluation as the `subjectUserId`
|
||||||
|
3. Scope filter intersection (`include_teams`, `include_personal`)
|
||||||
|
4. `max_rows_per_query` cap
|
||||||
|
- Verbs: `list`, `get`, `capabilities`
|
||||||
|
- Gateway query layer accepts `source: "local" | "federated:<host>" | "all"`; fan-out for `"all"`
|
||||||
|
- **Federation E2E harness** (`tools/federation-harness/`): docker-compose.two-gateways.yml, seed script, assertion helpers — this is its own deliverable
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- `apps/gateway/src/federation/client/federation-client.service.ts`
|
||||||
|
- `apps/gateway/src/federation/server/federation-auth.guard.ts`
|
||||||
|
- `apps/gateway/src/federation/server/scope.service.ts`
|
||||||
|
- `apps/gateway/src/federation/server/verbs/{list,get,capabilities}.controller.ts`
|
||||||
|
- `apps/gateway/src/federation/client/query-source.service.ts` (fan-out/merge)
|
||||||
|
- `tools/federation-harness/` (compose + seed + test helpers)
|
||||||
|
- `packages/types` — federation request/response DTOs in `federation.dto.ts`
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
| # | Test | Layer |
|
||||||
|
| -- | -------------------------------------------------------------------------------------------------------- | ----- |
|
||||||
|
| 1 | A→B `list tasks` returns subjectUser's tasks intersected with scope | E2E |
|
||||||
|
| 2 | A→B `list tasks` with `include_teams: [T1]` excludes T2 tasks the user owns | E2E |
|
||||||
|
| 3 | A→B `get credential <id>` returns 403 when `credentials` is in `excluded_resources` | E2E |
|
||||||
|
| 4 | Client presenting cert for grant X cannot query subjectUser of grant Y (cross-user isolation) | E2E |
|
||||||
|
| 5 | Cert signed by untrusted CA rejected at TLS layer (no NestJS handler reached) | E2E |
|
||||||
|
| 6 | Malformed SAN OIDs → 401; cert valid but grant revoked in DB → 403 | Integration |
|
||||||
|
| 7 | `max_rows_per_query` caps response; request for more paginated | Integration |
|
||||||
|
| 8 | `source: "all"` fan-out merges local + federated results, each tagged with `_source` | Integration |
|
||||||
|
| 9 | Federation responses never persist: verify DB row count unchanged after `list` round-trip | E2E |
|
||||||
|
| 10 | Scope cannot grant more than native RBAC: user without access to team T still gets [] even if scope allows T | E2E |
|
||||||
|
|
||||||
|
**Estimated budget:** ~40K tokens (largest milestone — core federation logic + harness)
|
||||||
|
**Risk notes:** This is the critical trust boundary. Code review should focus on scope enforcement bypass and cert-SAN-spoofing paths. Every 403/401 path needs a test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M4 — `search` Verb + Audit Log + Rate Limit
|
||||||
|
|
||||||
|
**Goal:** Keyword search over allowed resources with full audit and per-grant rate limiting.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- `search` verb across `resources` allowlist (intersection of scope + native RBAC)
|
||||||
|
- Keyword search (reuse existing `packages/memory/src/adapters/keyword.ts`); pgvector search stays out of v1 search verb
|
||||||
|
- Every federated request (all verbs) writes to `federation_audit_log`: `grant_id`, `verb`, `resource`, `query_hash`, `outcome`, `bytes_out`, `latency_ms`
|
||||||
|
- No request body captured; `query_hash` is SHA-256 of normalized query params
|
||||||
|
- Token-bucket rate limit per grant (default 60/min, override per grant)
|
||||||
|
- 429 response with `Retry-After` header and structured body
|
||||||
|
- 90-day hot retention for audit log; cold-tier rollover deferred to M7
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- `apps/gateway/src/federation/server/verbs/search.controller.ts`
|
||||||
|
- `apps/gateway/src/federation/server/audit.service.ts` (async write, no blocking)
|
||||||
|
- `apps/gateway/src/federation/server/rate-limit.guard.ts`
|
||||||
|
- Tests in harness
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
| # | Test | Layer |
|
||||||
|
| - | ------------------------------------------------------------------------------------------------- | ----------- |
|
||||||
|
| 1 | `search` returns ranked hits only from allowed resources | E2E |
|
||||||
|
| 2 | `search` excluding `credentials` does not return a match even when keyword matches a credential name | E2E |
|
||||||
|
| 3 | Every successful request appears in `federation_audit_log` within 1s | Integration |
|
||||||
|
| 4 | Denied request (403) is also audited with `outcome='denied'` | Integration |
|
||||||
|
| 5 | Audit row stores query hash but NOT query body | Unit |
|
||||||
|
| 6 | 61st request in 60s window returns 429 with `Retry-After` | E2E |
|
||||||
|
| 7 | Per-grant override (e.g., 600/min) takes effect without restart | Integration |
|
||||||
|
| 8 | Audit writes are async: request latency unchanged when audit write slow (simulated) | Integration |
|
||||||
|
|
||||||
|
**Estimated budget:** ~20K tokens
|
||||||
|
**Risk notes:** Ensure audit writes can't block or error-out the request path; use a bounded queue and drop-with-counter pattern rather than in-line writes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M5 — Cache + Offline Degradation + Observability
|
||||||
|
|
||||||
|
**Goal:** Sessions feel fast and stay useful when the peer is slow or down.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- In-memory response cache keyed by `(grant_id, verb, resource, query_hash)`, TTL 30s default
|
||||||
|
- Cache NOT used for `search`; only `list` and `get`
|
||||||
|
- Cache flushed on cert rotation and grant revocation
|
||||||
|
- Circuit breaker per peer: after N failures, fast-fail for cooldown window
|
||||||
|
- `_source` tagging extended with `_cached: true` when served from cache
|
||||||
|
- Agent-visible "federation offline for `<peer>`" signal emitted once per session per peer
|
||||||
|
- OTEL spans: `federation.request` with attrs `grant_id`, `peer`, `verb`, `resource`, `outcome`, `latency_ms`, `cached`
|
||||||
|
- W3C `traceparent` propagated across the mTLS boundary (both directions)
|
||||||
|
- `mosaic federation status` CLI subcommand
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- `apps/gateway/src/federation/client/response-cache.service.ts`
|
||||||
|
- `apps/gateway/src/federation/client/circuit-breaker.service.ts`
|
||||||
|
- `apps/gateway/src/federation/observability/` (span helpers)
|
||||||
|
- `packages/mosaic/src/commands/federation/status.ts`
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
| # | Test | Layer |
|
||||||
|
| - | --------------------------------------------------------------------------------------------- | ----- |
|
||||||
|
| 1 | Two identical `list` calls within 30s: second served from cache, flagged `_cached` | Integration |
|
||||||
|
| 2 | `search` is never cached: two identical searches both hit the peer | Integration |
|
||||||
|
| 3 | After grant revocation, peer's cache is flushed immediately | Integration |
|
||||||
|
| 4 | After N consecutive failures, circuit opens; subsequent requests fail-fast without network call | E2E |
|
||||||
|
| 5 | Circuit closes after cooldown and next success | E2E |
|
||||||
|
| 6 | With peer offline, session completes using local data, one "federation offline" signal surfaced | E2E |
|
||||||
|
| 7 | OTEL traces show spans on both gateways correlated by `traceparent` | E2E |
|
||||||
|
| 8 | `mosaic federation status` prints peer state, cert expiry, last success/failure, circuit state | Unit |
|
||||||
|
|
||||||
|
**Estimated budget:** ~20K tokens
|
||||||
|
**Risk notes:** Caching correctness under revocation must be provable — write tests that intentionally race revocation against cached hits.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M6 — Revocation, Auto-Renewal, CRL
|
||||||
|
|
||||||
|
**Goal:** Grant lifecycle works end-to-end: admin revoke, revoke-on-delete, automatic cert renewal, CRL distribution.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- `mosaic federation grant revoke <id>` → status `revoked`, CRL updated, audit entry
|
||||||
|
- DB hook: deleting a user cascades `revoke-on-delete` on all grants where that user is subject
|
||||||
|
- Step-CA CRL endpoint exposed; serving gateway enforces CRL check on every handshake (cached CRL, refresh interval 60s)
|
||||||
|
- Client-side cert renewal job: at T-7 days, submit renewal CSR; rotate cert atomically; flush cache
|
||||||
|
- On renewal failure, peer marked `degraded` and admin-visible alert emitted
|
||||||
|
- Server A detects revocation on next request (TLS handshake fails with specific error) → peer marked `revoked`, user notified
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- `apps/gateway/src/federation/server/crl.service.ts` + endpoint
|
||||||
|
- `apps/gateway/src/federation/server/revocation.service.ts`
|
||||||
|
- DB cascade trigger or ORM hook for user deletion → grant revocation
|
||||||
|
- `apps/gateway/src/federation/client/renewal.job.ts` (scheduled)
|
||||||
|
- `packages/mosaic/src/commands/federation/grant.ts` gains `revoke` subcommand
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
| # | Test | Layer |
|
||||||
|
| - | ----------------------------------------------------------------------------------------- | ----- |
|
||||||
|
| 1 | Admin `grant revoke` → A's next request fails with TLS-level error | E2E |
|
||||||
|
| 2 | Deleting subject user on B auto-revokes all grants where that user was the subject | Integration |
|
||||||
|
| 3 | CRL endpoint serves correct list; revoked cert present | Integration |
|
||||||
|
| 4 | Server rejects cert listed in CRL even if cert itself is still time-valid | E2E |
|
||||||
|
| 5 | Cert at T-7 days triggers renewal job; new cert issued and installed without dropped requests | E2E |
|
||||||
|
| 6 | Renewal failure marks peer `degraded` and surfaces alert | Integration |
|
||||||
|
| 7 | A marks peer `revoked` after a revocation-caused handshake failure (not on transient network errors) | E2E |
|
||||||
|
|
||||||
|
**Estimated budget:** ~20K tokens
|
||||||
|
**Risk notes:** The atomic cert swap during renewal is the sharpest edge here — any in-flight request mid-swap must either complete on old or retry on new, never fail mid-call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## M7 — Multi-User RBAC Hardening + Team-Scoped Grants + Acceptance Suite
|
||||||
|
|
||||||
|
**Goal:** The full multi-tenant scenario from §4 user stories works end-to-end, with no cross-user leakage under any circumstance.
|
||||||
|
|
||||||
|
**Scope:**
|
||||||
|
|
||||||
|
- Three-user scenario on Server B (E1, E2, E3) each with their own Server A
|
||||||
|
- Team-scoped grants exercised: each employee's team-data visible on their own A, but E1's personal data never visible on E2's A
|
||||||
|
- User-facing UI surfaces on both gateways for: peer list, grant list, audit log viewer, scope editor
|
||||||
|
- Negative-path test matrix (every denial path from PRD §8)
|
||||||
|
- All PRD §15 acceptance criteria mapped to automated tests in the harness
|
||||||
|
- Security review: cert-spoofing, scope-bypass, audit-bypass paths explicitly tested
|
||||||
|
- Cold-storage rollover for audit log >90 days
|
||||||
|
- Docs: operator runbook, onboarding guide, troubleshooting guide
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
|
||||||
|
- Full federation acceptance suite in `tools/federation-harness/acceptance/`
|
||||||
|
- `apps/web` surfaces for peer/grant/audit management
|
||||||
|
- `docs/federation/RUNBOOK.md`, `docs/federation/ONBOARDING.md`, `docs/federation/TROUBLESHOOTING.md`
|
||||||
|
- Audit cold-tier job (daily cron, moves rows >90d to separate table or object storage)
|
||||||
|
|
||||||
|
**Acceptance tests:**
|
||||||
|
Every PRD §15 criterion must be automated and green. Additionally:
|
||||||
|
|
||||||
|
| # | Test | Layer |
|
||||||
|
| --- | ----------------------------------------------------------------------------------------------------- | ---------------- |
|
||||||
|
| 1 | 3-employee scenario: each A sees only its user's data from B | E2E |
|
||||||
|
| 2 | Grant with team scope returns team data; same grant denied access to another employee's personal data | E2E |
|
||||||
|
| 3 | Concurrent sessions from E1's and E2's Server A to B interleave without any leakage | E2E |
|
||||||
|
| 4 | Audit log across 3-user test shows per-grant trails with no mis-attributed rows | E2E |
|
||||||
|
| 5 | Scope editor UI round-trip: edit → save → next request uses new scope | E2E |
|
||||||
|
| 6 | Attempt to use a revoked grant's cert against a different grant's endpoint: rejected | E2E |
|
||||||
|
| 7 | 90-day-old audit rows moved to cold tier; queryable via explicit historical query | Integration |
|
||||||
|
| 8 | Runbook steps validated: an operator following the runbook can onboard, rotate, and revoke | Manual checklist |
|
||||||
|
|
||||||
|
**Estimated budget:** ~25K tokens
|
||||||
|
**Risk notes:** This is the security-critical milestone. Budget review time here is non-negotiable — plan for two independent code reviews (internal + security-focused) before merge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Total Budget & Timeline Sketch
|
||||||
|
|
||||||
|
| Milestone | Tokens (est.) | Can parallelize? |
|
||||||
|
| --------- | ------------- | ---------------------- |
|
||||||
|
| M1 | 20K | No (foundation) |
|
||||||
|
| M2 | 30K | No (needs M1) |
|
||||||
|
| M3 | 40K | No (needs M2) |
|
||||||
|
| M4 | 20K | No (needs M3) |
|
||||||
|
| M5 | 20K | Yes (with M6 after M4) |
|
||||||
|
| M6 | 20K | Yes (with M5 after M3) |
|
||||||
|
| M7 | 25K | No (needs all) |
|
||||||
|
| **Total** | **~175K** | |
|
||||||
|
|
||||||
|
Parallelization of M5 and M6 after M4 saves one milestone's worth of serial time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Exit Criteria (federation feature complete)
|
||||||
|
|
||||||
|
All of the following must be green on `main`:
|
||||||
|
|
||||||
|
- Every PRD §15 acceptance criterion automated and passing
|
||||||
|
- Every milestone's acceptance table green
|
||||||
|
- Security review sign-off on M7
|
||||||
|
- Runbook walk-through completed by operator (not author)
|
||||||
|
- `mosaic doctor` recognizes federated tier and reports peer health accurately
|
||||||
|
- Two-gateway production deployment (woltje.com ↔ uscllc.com) operational for ≥7 days without incident
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next Step After This Doc Is Approved
|
||||||
|
|
||||||
|
1. File tracking issues on `git.mosaicstack.dev/mosaicstack/stack` — one per milestone, labeled `epic:federation`
|
||||||
|
2. Populate `docs/TASKS.md` with M1's task breakdown (per-task agent assignment, budget, dependencies)
|
||||||
|
3. Begin M1 implementation
|
||||||
85
docs/federation/MISSION-MANIFEST.md
Normal file
85
docs/federation/MISSION-MANIFEST.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Mission Manifest — Federation v1
|
||||||
|
|
||||||
|
> Persistent document tracking full mission scope, status, and session history.
|
||||||
|
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
**ID:** federation-v1-20260419
|
||||||
|
**Statement:** Jarvis operates across 3–4 workstations in two physical locations (home, USC). The user currently reaches back to a single jarvis-brain checkout from every session; a prior OpenBrain attempt caused cache, latency, and opacity pain. This mission builds asymmetric federation between Mosaic Stack gateways so that a session on a user's home gateway can query their work gateway in real time without data ever persisting across the boundary, with full multi-tenant isolation and standard-PKI (X.509 / Step-CA) trust management.
|
||||||
|
**Phase:** Planning complete — M1 implementation not started
|
||||||
|
**Current Milestone:** FED-M1
|
||||||
|
**Progress:** 0 / 7 milestones
|
||||||
|
**Status:** active
|
||||||
|
**Last Updated:** 2026-04-19 (PRD + MILESTONES + tracking issues filed)
|
||||||
|
**Parent Mission:** None — new mission
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Federation is the solution to what originally drove OpenBrain. The prior attempt coupled every agent session to a remote service, introduced cache/latency/opacity pain, and created a hard dependency that punished offline use. This redesign:
|
||||||
|
|
||||||
|
1. Makes federation **gateway-to-gateway**, not agent-to-service
|
||||||
|
2. Keeps each user's home instance as source of truth for their data
|
||||||
|
3. Exposes scoped, read-only data on demand without persisting across the boundary
|
||||||
|
4. Uses X.509 mTLS via Step-CA so rotation/revocation/CRL/OCSP are standard
|
||||||
|
5. Supports multi-tenant serving sides (employees on uscllc.com each federating back to their own home gateway) with no cross-user leakage
|
||||||
|
6. Requires federation-tier instances on both sides (PG + pgvector + Valkey) — local/standalone tiers cannot federate
|
||||||
|
7. Works over public HTTPS (no VPN required); Tailscale is an optional overlay
|
||||||
|
|
||||||
|
Key design references:
|
||||||
|
|
||||||
|
- `docs/federation/PRD.md` — 16-section product requirements
|
||||||
|
- `docs/federation/MILESTONES.md` — 7-milestone decomposition with per-milestone acceptance tests
|
||||||
|
- `docs/federation/TASKS.md` — per-task breakdown (M1 populated; M2-M7 deferred to mission planning)
|
||||||
|
- `docs/research/mempalace-evaluation/` (in jarvis-brain) — why we didn't adopt MemPalace
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- [ ] AC-1: Two Mosaic Stack gateways on different hosts can establish a federation grant via CLI-driven onboarding
|
||||||
|
- [ ] AC-2: Server A can query Server B for `tasks`, `notes`, `memory` respecting scope filters
|
||||||
|
- [ ] AC-3: User on B with no grant cannot be queried by A, even if A has a valid grant for another user (cross-user isolation)
|
||||||
|
- [ ] AC-4: Revoking a grant on B causes A's next request to fail with a clear error within one request cycle
|
||||||
|
- [ ] AC-5: Cert rotation happens automatically at T-7 days; in-progress session survives rotation without user action
|
||||||
|
- [ ] AC-6: Rate-limit enforcement returns 429 with `Retry-After`; client backs off
|
||||||
|
- [ ] AC-7: With B unreachable, a session on A completes using local data and surfaces "federation offline for `<peer>`" once per session
|
||||||
|
- [ ] AC-8: Every federated request appears in B's `federation_audit_log` within 1 second
|
||||||
|
- [ ] AC-9: Scope excluding `credentials` means credentials are never returned — even via `search` with matching keywords
|
||||||
|
- [ ] AC-10: `mosaic federation status` shows cert expiry, grant status, last success/failure per peer
|
||||||
|
- [ ] AC-11: Full 3-employee multi-tenant scenario passes with no cross-user leakage
|
||||||
|
- [ ] AC-12: Two-gateway production deployment (woltje.com ↔ uscllc.com) operational ≥7 days without incident
|
||||||
|
- [ ] AC-13: All 7 milestones ship as merged PRs with green CI and closed issues
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
|
||||||
|
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||||
|
| --- | ------ | --------------------------------------------- | ----------- | ------ | ----- | ------- | --------- |
|
||||||
|
| 1 | FED-M1 | Federated tier infrastructure | not-started | — | #460 | — | — |
|
||||||
|
| 2 | FED-M2 | Step-CA + grant schema + admin CLI | not-started | — | #461 | — | — |
|
||||||
|
| 3 | FED-M3 | mTLS handshake + list/get + scope enforcement | not-started | — | #462 | — | — |
|
||||||
|
| 4 | FED-M4 | search verb + audit log + rate limit | not-started | — | #463 | — | — |
|
||||||
|
| 5 | FED-M5 | Cache + offline degradation + OTEL | not-started | — | #464 | — | — |
|
||||||
|
| 6 | FED-M6 | Revocation + auto-renewal + CRL | not-started | — | #465 | — | — |
|
||||||
|
| 7 | FED-M7 | Multi-user RBAC hardening + acceptance suite | not-started | — | #466 | — | — |
|
||||||
|
|
||||||
|
## Budget
|
||||||
|
|
||||||
|
| Milestone | Est. tokens | Parallelizable? |
|
||||||
|
| --------- | ----------- | ---------------------- |
|
||||||
|
| FED-M1 | 20K | No (foundation) |
|
||||||
|
| FED-M2 | 30K | No (needs M1) |
|
||||||
|
| FED-M3 | 40K | No (needs M2) |
|
||||||
|
| FED-M4 | 20K | No (needs M3) |
|
||||||
|
| FED-M5 | 20K | Yes (with M6 after M4) |
|
||||||
|
| FED-M6 | 20K | Yes (with M5 after M3) |
|
||||||
|
| FED-M7 | 25K | No (needs all) |
|
||||||
|
| **Total** | **~175K** | |
|
||||||
|
|
||||||
|
## Session History
|
||||||
|
|
||||||
|
| Session | Date | Runtime | Outcome |
|
||||||
|
| ------- | ---------- | ------- | --------------------------------------------------- |
|
||||||
|
| S1 | 2026-04-19 | claude | PRD authored, MILESTONES decomposed, 7 issues filed |
|
||||||
|
|
||||||
|
## Next Step
|
||||||
|
|
||||||
|
Begin FED-M1 implementation: federated tier infrastructure. Breakdown in `docs/federation/TASKS.md`.
|
||||||
330
docs/federation/PRD.md
Normal file
330
docs/federation/PRD.md
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
# Mosaic Stack — Federation PRD
|
||||||
|
|
||||||
|
**Status:** Draft v1 (locked for implementation)
|
||||||
|
**Owner:** Jason
|
||||||
|
**Date:** 2026-04-19
|
||||||
|
**Scope:** Enables cross-instance data federation between Mosaic Stack gateways with asymmetric trust, multi-tenant scoping, and no cross-boundary data persistence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
Jarvis operates across 3–4 workstations in two physical locations (home, USC). The user currently reaches back to a single jarvis-brain checkout from every session, and has tried OpenBrain to solve cross-session state — with poor results (cache invalidation, latency, opacity, hard dependency on a remote service).
|
||||||
|
|
||||||
|
The goal is a federation model where each user's **home instance** remains the source of truth for their personal data, and **work/shared instances** expose scoped data to that user's home instance on demand — without persisting anything across the boundary.
|
||||||
|
|
||||||
|
## 2. Goals
|
||||||
|
|
||||||
|
1. A user logged into their **home gateway** (Server A) can query their **work gateway** (Server B) in real time during a session.
|
||||||
|
2. Data returned from Server B is used in-session only; never written to Server A storage.
|
||||||
|
3. Server B has multiple users, each with their own Server A. No user's data leaks to another user.
|
||||||
|
4. Federation works over public HTTPS (no VPN required). Tailscale is a supported optional overlay.
|
||||||
|
5. Sync latency target: seconds, or at the next data need of the agent.
|
||||||
|
6. Graceful degradation: if the remote instance is unreachable, the local session continues with local data and a clear "federation offline" signal.
|
||||||
|
7. Teams exist on both sides. A federation grant can share **team-owned** data without exposing other team members' personal data.
|
||||||
|
8. Auth and revocation use standard PKI (X.509) so that certificate tooling (Step-CA, rotation, OCSP, CRL) is available out of the box.
|
||||||
|
|
||||||
|
## 3. Non-Goals (v1)
|
||||||
|
|
||||||
|
- Mesh federation (N-to-N). v1 is strictly A↔B pairs.
|
||||||
|
- Cross-instance writes. All federation is **read-only** on the remote side.
|
||||||
|
- Shared agent sessions across instances. Sessions live on one instance; federation is data-plane only.
|
||||||
|
- Cross-instance SSO. Each instance owns its own BetterAuth identity store; federation is service-to-service, not user-to-user.
|
||||||
|
- Realtime push from B→A. v1 is pull-only (A pulls from B during a session).
|
||||||
|
- Global search index. Federation is query-by-query, not index replication.
|
||||||
|
|
||||||
|
## 4. User Stories
|
||||||
|
|
||||||
|
- **US-1 (Solo user at home):** As the sole user on Server A, I want my agent session on workstation-1 to see the same data it saw on workstation-2, without running OpenBrain.
|
||||||
|
- **US-2 (Cross-location):** As a user with a home server and a work server, I want a session on my home laptop to transparently pull my USC-owned tasks/notes when I ask for them.
|
||||||
|
- **US-3 (Work admin):** As the admin of mosaic.uscllc.com, I want to grant each employee's home gateway scoped read access to only their own data plus explicitly-shared team data.
|
||||||
|
- **US-4 (Privacy boundary):** As employee A on mosaic.uscllc.com, my data must never appear in a session on employee B's home gateway — even if both are federated with uscllc.com.
|
||||||
|
- **US-5 (Revocation):** As a work admin, when I delete an employee, their home gateway loses access within one request cycle.
|
||||||
|
- **US-6 (Offline):** As a user in a hotel with flaky wifi, my local session keeps working; federation calls fail fast and are reported as "offline," not hung.
|
||||||
|
|
||||||
|
## 5. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐ mTLS / X.509 ┌─────────────────────────────────────┐
|
||||||
|
│ Server A — mosaic.woltje.com │ ───────────────────────► │ Server B — mosaic.uscllc.com │
|
||||||
|
│ (home, master for Jason) │ ◄── JSON over HTTPS │ (work, multi-tenant) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Gateway │ │ Postgres │ │ │ │ Gateway │ │ Postgres │ │
|
||||||
|
│ │ (NestJS) │──│ (local SSOT)│ │ │ │ (NestJS) │──│ (tenant SSOT)│ │
|
||||||
|
│ └──────┬───────┘ └──────────────┘ │ │ └──────┬───────┘ └──────────────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ FederationClient │ │ │ FederationServer │
|
||||||
|
│ │ (outbound, scoped query) │ │ │ (inbound, RBAC-gated) │
|
||||||
|
│ └───────────────────────────┼──────────────────────────┼────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ Step-CA (issues A's client cert) │ │ Step-CA (issues B's server cert, │
|
||||||
|
│ │ │ trusts A's CA root on grant)│
|
||||||
|
└─────────────────────────────────────┘ └──────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Federation is a **transport-layer** concern between two gateways, implemented as a new internal module on each gateway.
|
||||||
|
- Both sides run the same code. Direction (client vs. server role) is per-request.
|
||||||
|
- Nothing in the agent runtime changes — agents query the gateway; the gateway decides local vs. remote.
|
||||||
|
|
||||||
|
## 6. Transport & Authentication
|
||||||
|
|
||||||
|
**Transport:** HTTPS with mutual TLS (mTLS).
|
||||||
|
|
||||||
|
**Identity:** X.509 client certificates issued by Step-CA. Each federation grant materializes as a client cert on the requesting side and a trust-anchor entry (CA root or explicit cert) on the serving side.
|
||||||
|
|
||||||
|
**Why mTLS over HMAC bearer tokens:**
|
||||||
|
|
||||||
|
- Standard rotation/revocation semantics (renew, CRL, OCSP).
|
||||||
|
- The cert subject carries identity claims (user, grant_id) that don't need a separate DB lookup to verify authenticity.
|
||||||
|
- Client certs never transit request bodies, so they can't be logged by accident.
|
||||||
|
- Transport is pinned at the TLS layer, not re-validated per-handler.
|
||||||
|
|
||||||
|
**Cert contents (SAN + subject):**
|
||||||
|
|
||||||
|
- `CN=grant-<uuid>`
|
||||||
|
- `O=<requesting-server-hostname>` (e.g., `mosaic.woltje.com`)
|
||||||
|
- Custom OIDs embedded in SAN otherName:
|
||||||
|
- `mosaic.federation.grantId` (UUID)
|
||||||
|
- `mosaic.federation.subjectUserId` (user on the **serving** side that this grant acts-as)
|
||||||
|
- Default lifetime: **30 days**, with auto-renewal at T-7 days if the grant is still active.
|
||||||
|
|
||||||
|
**Step-CA topology (v1):** Each server runs its own Step-CA instance. During onboarding, the serving side imports the requesting side's CA root. A central/shared Step-CA is out of scope for v1.
|
||||||
|
|
||||||
|
**Handshake:**
|
||||||
|
|
||||||
|
1. Client (A) opens HTTPS to B with its grant cert.
|
||||||
|
2. B validates cert chain against trusted CA roots for that grant.
|
||||||
|
3. B extracts `grantId` and `subjectUserId` from the cert.
|
||||||
|
4. B loads the grant record, checks it is `active`, not revoked, and not expired.
|
||||||
|
5. B enforces scope and rate-limit for this grant.
|
||||||
|
6. Request proceeds; response returned.
|
||||||
|
|
||||||
|
## 7. Data Model
|
||||||
|
|
||||||
|
All tables live on **each instance's own Postgres**. Federation grants are bilateral — each side has a record of the grant.
|
||||||
|
|
||||||
|
### 7.1 `federation_grants` (on serving side, Server B)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --------------------------- | ----------- | ------------------------------------------------- |
|
||||||
|
| `id` | uuid PK | |
|
||||||
|
| `subject_user_id` | uuid FK | Which local user this grant acts-as |
|
||||||
|
| `requesting_server` | text | Hostname of requesting gateway (e.g., woltje.com) |
|
||||||
|
| `requesting_ca_fingerprint` | text | SHA-256 of trusted CA root |
|
||||||
|
| `active_cert_fingerprint` | text | SHA-256 of currently valid client cert |
|
||||||
|
| `scope` | jsonb | See §8 |
|
||||||
|
| `rate_limit_rpm` | int | Default 60 |
|
||||||
|
| `status` | enum | `pending`, `active`, `suspended`, `revoked` |
|
||||||
|
| `created_at` | timestamptz | |
|
||||||
|
| `activated_at` | timestamptz | |
|
||||||
|
| `revoked_at` | timestamptz | |
|
||||||
|
| `last_used_at` | timestamptz | |
|
||||||
|
| `notes` | text | Admin-visible description |
|
||||||
|
|
||||||
|
### 7.2 `federation_peers` (on requesting side, Server A)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| --------------------- | ----------- | ------------------------------------------------ |
|
||||||
|
| `id` | uuid PK | |
|
||||||
|
| `peer_hostname` | text | e.g., `mosaic.uscllc.com` |
|
||||||
|
| `peer_ca_fingerprint` | text | SHA-256 of peer's CA root |
|
||||||
|
| `grant_id` | uuid | The grant ID assigned by the peer |
|
||||||
|
| `local_user_id` | uuid FK | Who on Server A this federation belongs to |
|
||||||
|
| `client_cert_pem` | text (enc) | Current client cert (PEM); rotated automatically |
|
||||||
|
| `client_key_pem` | text (enc) | Private key (encrypted at rest) |
|
||||||
|
| `cert_expires_at` | timestamptz | |
|
||||||
|
| `status` | enum | `pending`, `active`, `degraded`, `revoked` |
|
||||||
|
| `last_success_at` | timestamptz | |
|
||||||
|
| `last_failure_at` | timestamptz | |
|
||||||
|
| `notes` | text | |
|
||||||
|
|
||||||
|
### 7.3 `federation_audit_log` (on serving side, Server B)
|
||||||
|
|
||||||
|
| Field | Type | Notes |
|
||||||
|
| ------------- | ----------- | ------------------------------------------------ |
|
||||||
|
| `id` | uuid PK | |
|
||||||
|
| `grant_id` | uuid FK | |
|
||||||
|
| `occurred_at` | timestamptz | indexed |
|
||||||
|
| `verb` | text | `query`, `handshake`, `rejected`, `rate_limited` |
|
||||||
|
| `resource` | text | e.g., `tasks`, `notes`, `credentials` |
|
||||||
|
| `query_hash` | text | SHA-256 of normalized query (no payload stored) |
|
||||||
|
| `outcome` | text | `ok`, `denied`, `error` |
|
||||||
|
| `bytes_out` | int | |
|
||||||
|
| `latency_ms` | int | |
|
||||||
|
|
||||||
|
**Audit policy:** Every federation request is logged on the serving side. Read-only requests only — no body capture. Retention: 90 days hot, then roll to cold storage.
|
||||||
|
|
||||||
|
## 8. RBAC & Scope
|
||||||
|
|
||||||
|
Every federation grant has a scope object that answers three questions for every inbound request:
|
||||||
|
|
||||||
|
1. **Who is acting?** — `subject_user_id` from the cert.
|
||||||
|
2. **What resources?** — an allowlist of resource types (`tasks`, `notes`, `credentials`, `memory`, `teams/:id/tasks`, …).
|
||||||
|
3. **Filter expression** — predicates applied on top of the subject's normal RBAC (see below).
|
||||||
|
|
||||||
|
### 8.1 Scope schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"resources": ["tasks", "notes", "memory"],
|
||||||
|
"filters": {
|
||||||
|
"tasks": { "include_teams": ["team_uuid_1", "team_uuid_2"], "include_personal": true },
|
||||||
|
"notes": { "include_personal": true, "include_teams": [] },
|
||||||
|
"memory": { "include_personal": true }
|
||||||
|
},
|
||||||
|
"excluded_resources": ["credentials", "api_keys"],
|
||||||
|
"max_rows_per_query": 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 Access rule (enforced on serving side)
|
||||||
|
|
||||||
|
For every inbound federated query on resource R:
|
||||||
|
|
||||||
|
1. Resolve effective identity → `subject_user_id`.
|
||||||
|
2. Check R is in `scope.resources` and NOT in `scope.excluded_resources`. Otherwise 403.
|
||||||
|
3. Evaluate the user's **normal RBAC** (what would they see if they logged into Server B directly)?
|
||||||
|
4. Intersect with the scope filter (e.g., only team X, only personal).
|
||||||
|
5. Apply `max_rows_per_query`.
|
||||||
|
6. Return; log to audit.
|
||||||
|
|
||||||
|
### 8.3 Team boundary guarantees
|
||||||
|
|
||||||
|
- Scope filters are additive, never subtractive of the native RBAC. A grant cannot grant access the user would not have had themselves.
|
||||||
|
- `include_teams` means "only these teams," not "these teams in addition to all teams."
|
||||||
|
- `include_personal: false` hides the user's personal data entirely from federation, even if they own it — useful for work-only accounts.
|
||||||
|
|
||||||
|
### 8.4 No cross-user leakage
|
||||||
|
|
||||||
|
When Server B has multiple users (employees) all federating back to their own Server A:
|
||||||
|
|
||||||
|
- Each employee has their own grant with their own `subject_user_id`.
|
||||||
|
- The cert is bound to a specific grant; there is no mechanism by which one grant's cert can be used to impersonate another.
|
||||||
|
- Audit log is per-grant.
|
||||||
|
|
||||||
|
## 9. Query Model
|
||||||
|
|
||||||
|
Federation exposes a **narrow read API**, not arbitrary SQL.
|
||||||
|
|
||||||
|
### 9.1 Supported verbs (v1)
|
||||||
|
|
||||||
|
| Verb | Purpose | Returns |
|
||||||
|
| -------------- | ------------------------------------------ | ------------------------------- |
|
||||||
|
| `list` | Paginated list of a resource type | Array of resources |
|
||||||
|
| `get` | Fetch a single resource by id | One resource or 404 |
|
||||||
|
| `search` | Keyword search within allowed resources | Ranked list of hits |
|
||||||
|
| `capabilities` | What this grant is allowed to do right now | Scope object + rate-limit state |
|
||||||
|
|
||||||
|
### 9.2 Not in v1
|
||||||
|
|
||||||
|
- Write verbs.
|
||||||
|
- Aggregations / analytics.
|
||||||
|
- Streaming / subscriptions (future: see §13).
|
||||||
|
|
||||||
|
### 9.3 Agent-facing integration
|
||||||
|
|
||||||
|
Agents never call federation directly. Instead:
|
||||||
|
|
||||||
|
- The gateway query layer accepts `source: "local" | "federated:<peer_hostname>" | "all"`.
|
||||||
|
- `"all"` fans out in parallel, merges results, tags each with `_source`.
|
||||||
|
- Federation results are in-memory only; the gateway does not persist them.
|
||||||
|
|
||||||
|
## 10. Caching
|
||||||
|
|
||||||
|
- **In-memory response cache** with short TTL (default 30s) for `list` and `get`. `search` is not cached.
|
||||||
|
- Cache is keyed by `(grant_id, verb, resource, query_hash)`.
|
||||||
|
- Cache is flushed on cert rotation and on grant revocation.
|
||||||
|
- No disk cache. No cross-session cache.
|
||||||
|
|
||||||
|
## 11. Bootstrap & Onboarding
|
||||||
|
|
||||||
|
### 11.1 Instance capability tiers
|
||||||
|
|
||||||
|
| Tier | Storage | Queue | Memory | Can federate? |
|
||||||
|
| ------------ | -------- | ------- | -------- | --------------------- |
|
||||||
|
| `local` | PGlite | in-proc | keyword | No |
|
||||||
|
| `standalone` | Postgres | Valkey | keyword | No (can be client) |
|
||||||
|
| `federated` | Postgres | Valkey | pgvector | Yes (server + client) |
|
||||||
|
|
||||||
|
Federation requires `federated` tier on **both** sides.
|
||||||
|
|
||||||
|
### 11.2 Onboarding flow (admin-driven)
|
||||||
|
|
||||||
|
1. Admin on Server B runs `mosaic federation grant create --user <user-id> --peer <peer-hostname> --scope-file scope.json`.
|
||||||
|
2. Server B generates a `grant_id`, prints a one-time enrollment URL containing the grant ID + B's CA root fingerprint.
|
||||||
|
3. Admin on Server A (or the user themselves, if allowed) runs `mosaic federation peer add <enrollment-url>`.
|
||||||
|
4. Server A's Step-CA generates a CSR for the new grant. A submits the CSR to B over a short-lived enrollment endpoint (single-use token in the enrollment URL).
|
||||||
|
5. B's Step-CA signs the cert (with grant ID embedded in SAN OIDs), returns it.
|
||||||
|
6. A stores the signed cert + private key (encrypted) in `federation_peers`.
|
||||||
|
7. Grant status flips from `pending` to `active` on both sides.
|
||||||
|
8. Cert auto-renews at T-7 days using the standard Step-CA renewal flow as long as the grant remains active.
|
||||||
|
|
||||||
|
### 11.3 Revocation
|
||||||
|
|
||||||
|
- **Admin-initiated:** `mosaic federation grant revoke <grant-id>` on B flips status to `revoked`, adds the cert to B's CRL, and writes an audit entry.
|
||||||
|
- **Revoke-on-delete:** Deleting a user on B automatically revokes all grants where that user is the subject.
|
||||||
|
- Server A learns of revocation on the next request (TLS handshake fails) and flips the peer to `revoked`.
|
||||||
|
|
||||||
|
### 11.4 Rate limit
|
||||||
|
|
||||||
|
Default `60 req/min` per grant. Configurable per grant. Enforced at the serving side. A rate-limited request returns `429` with `Retry-After`.
|
||||||
|
|
||||||
|
## 12. Operational Concerns
|
||||||
|
|
||||||
|
- **Observability:** Each federation request emits an OTEL span with `grant_id`, `peer`, `verb`, `resource`, `outcome`, `latency_ms`. Traces correlate across both servers via W3C traceparent.
|
||||||
|
- **Health check:** `mosaic federation status` on each side shows active grants, last-success times, cert expirations, and any CRL mismatches.
|
||||||
|
- **Backpressure:** If the serving side is overloaded, it returns `503` with a structured body; the client marks the peer `degraded` and falls back to local-only until the next successful handshake.
|
||||||
|
- **Secrets:** `client_key_pem` in `federation_peers` is encrypted with the gateway's key (sealed with the instance's master key — same mechanism as `provider_credentials`).
|
||||||
|
- **Credentials never cross:** The `credentials` resource type is in the default excluded list. It must be explicitly added to scope (admin action, logged) and even then is per-grant and per-user.
|
||||||
|
|
||||||
|
## 13. Future (post-v1)
|
||||||
|
|
||||||
|
- B→A push (e.g., "notify A when a task assigned to subject changes") via Socket.IO over mTLS.
|
||||||
|
- Mesh (N-to-N) federation.
|
||||||
|
- Write verbs with conflict resolution.
|
||||||
|
- Shared Step-CA (a "root of roots") so that onboarding doesn't require exchanging CA roots.
|
||||||
|
- Federated memory search over vector indexes with homomorphic filtering.
|
||||||
|
|
||||||
|
## 14. Locked Decisions (was "Open Questions")
|
||||||
|
|
||||||
|
| # | Question | Decision |
|
||||||
|
| --- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 1 | What happens to a grant when its subject user is deleted? | **Revoke-on-delete.** All grants where the user is subject are auto-revoked and CRL'd. |
|
||||||
|
| 2 | Do we audit read-only requests? | **Yes.** All federated reads are audited on the serving side. Bodies are not captured; query hash + metadata only. |
|
||||||
|
| 3 | Default rate limit? | **60 requests per minute per grant,** override-able per grant. |
|
||||||
|
| 4 | How do we verify the requesting-server's identity beyond the grant token? | **X.509 client cert tied to the user,** issued by Step-CA (per-server) or locally generated. Cert subject carries `grantId` + `subjectUserId`. |
|
||||||
|
|
||||||
|
### M1 decisions
|
||||||
|
|
||||||
|
- **Postgres deployment:** **Containerized** alongside the gateway in M1 (Docker Compose profile). Moving to a dedicated host is a M5+ operational concern, not a v1 feature.
|
||||||
|
- **Instance signing key:** **Separate** from the Step-CA key. Step-CA signs federation certs; the instance master key seals at-rest secrets (client keys, provider credentials). Different blast-radius, different rotation cadences.
|
||||||
|
|
||||||
|
## 15. Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Two Mosaic Stack gateways on different hosts can establish a federation grant via the CLI-driven onboarding flow.
|
||||||
|
- [ ] Server A can query Server B for `tasks`, `notes`, `memory` respecting scope filters.
|
||||||
|
- [ ] A user on B with no grant cannot be queried by A, even if A has a valid grant for another user.
|
||||||
|
- [ ] Revoking a grant on B causes A's next request to fail with a clear error within one request cycle.
|
||||||
|
- [ ] Cert rotation happens automatically at T-7 days; an in-progress session survives rotation without user action.
|
||||||
|
- [ ] Rate-limit enforcement returns 429 with `Retry-After`; client backs off.
|
||||||
|
- [ ] With B unreachable, a session on A completes using local data and surfaces a "federation offline for `<peer>`" signal once.
|
||||||
|
- [ ] Every federated request appears in B's `federation_audit_log` within 1 second.
|
||||||
|
- [ ] A scope excluding `credentials` means credentials are not returnable even via `search` with matching keywords.
|
||||||
|
- [ ] `mosaic federation status` shows cert expiry, grant status, and last success/failure per peer.
|
||||||
|
|
||||||
|
## 16. Implementation Milestones (reference)
|
||||||
|
|
||||||
|
Milestones live in `docs/federation/MILESTONES.md` (to be authored next). High-level:
|
||||||
|
|
||||||
|
- **M1:** Server A runs `federated` tier standalone (Postgres + Valkey + pgvector, containerized). No peer yet.
|
||||||
|
- **M2:** Step-CA embedded; `federation_grants` / `federation_peers` schema + admin CLI.
|
||||||
|
- **M3:** Handshake + `list`/`get` verbs with scope enforcement.
|
||||||
|
- **M4:** `search` verb, audit log, rate limits.
|
||||||
|
- **M5:** Cache layer, offline-degradation UX, observability surfaces.
|
||||||
|
- **M6:** Revocation flows (admin + revoke-on-delete), cert auto-renewal.
|
||||||
|
- **M7:** Multi-user RBAC hardening on B, team-scoped grants end-to-end, acceptance suite green.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next step after PRD sign-off:** author `docs/federation/MILESTONES.md` with per-milestone acceptance tests and estimated token budget, then file tracking issues on `git.mosaicstack.dev/mosaicstack/stack`.
|
||||||
76
docs/federation/TASKS.md
Normal file
76
docs/federation/TASKS.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# Tasks — Federation v1
|
||||||
|
|
||||||
|
> Single-writer: orchestrator only. Workers read but never modify.
|
||||||
|
>
|
||||||
|
> **Mission:** federation-v1-20260419
|
||||||
|
> **Schema:** `| id | status | description | issue | agent | branch | depends_on | estimate | notes |`
|
||||||
|
> **Status values:** `not-started` | `in-progress` | `done` | `blocked` | `failed` | `needs-qa`
|
||||||
|
> **Agent values:** `codex` | `glm-5.1` | `haiku` | `sonnet` | `opus` | `—` (auto)
|
||||||
|
>
|
||||||
|
> **Scope of this file:** M1 is fully decomposed below. M2–M7 are placeholders pending each milestone's entry into active planning — the orchestrator expands them one milestone at a time to avoid speculative decomposition of work whose shape will depend on what M1 surfaces.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 1 — Federated tier infrastructure (FED-M1)
|
||||||
|
|
||||||
|
Goal: Gateway runs in `federated` tier with containerized PG+pgvector+Valkey. No federation logic yet. Existing standalone behavior does not regress.
|
||||||
|
|
||||||
|
| id | status | description | issue | agent | branch | depends_on | estimate | notes |
|
||||||
|
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------- | ---------- | -------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| FED-M1-01 | not-started | Extend `mosaic.config.json` schema: add `"federated"` to `tier` enum in validator + TS types. Keep `local` and `standalone` working. Update schema docs/README where referenced. | #460 | codex | feat/federation-m1-tier-config | — | 4K | Schema lives in `packages/types`; validator in gateway bootstrap. No behavior change yet — enum only. |
|
||||||
|
| FED-M1-02 | not-started | Author `docker-compose.federated.yml` as an overlay profile: Postgres 16 + pgvector extension (port 5433), Valkey (6380), named volumes, healthchecks. Compose-up should boot cleanly on a clean machine. | #460 | codex | feat/federation-m1-compose | FED-M1-01 | 5K | Overlay on existing `docker-compose.yml`; no changes to base file. Add `profile: federated` gating. |
|
||||||
|
| FED-M1-03 | not-started | Add pgvector support to `packages/storage/src/adapters/postgres.ts`: create extension on init (idempotent), expose vector column type in schema helpers. No adapter changes for non-federated tiers. | #460 | codex | feat/federation-m1-pgvector | FED-M1-02 | 8K | Extension create is idempotent `CREATE EXTENSION IF NOT EXISTS vector`. Gate on tier = federated. |
|
||||||
|
| FED-M1-04 | not-started | Implement `apps/gateway/src/bootstrap/tier-detector.ts`: reads config, asserts PG/Valkey/pgvector reachable for `federated`, fail-fast with actionable error message on failure. Unit tests for each failure mode. | #460 | codex | feat/federation-m1-detector | FED-M1-03 | 8K | Structured error type with remediation hints. Logs which service failed, with host:port attempted. |
|
||||||
|
| FED-M1-05 | not-started | Write `scripts/migrate-to-federated.ts`: one-way migration from `local` (PGlite) / `standalone` (PG without pgvector) → `federated`. Dumps, transforms, loads; dry-run + confirm UX. Idempotent on re-run. | #460 | codex | feat/federation-m1-migrate | FED-M1-04 | 10K | Do NOT run automatically. CLI subcommand `mosaic migrate tier --to federated --dry-run`. Safety rails. |
|
||||||
|
| FED-M1-06 | not-started | Update `mosaic doctor`: report current tier, required services, actual health per service, pgvector presence, overall green/yellow/red. Machine-readable JSON output flag for CI use. | #460 | sonnet | feat/federation-m1-doctor | FED-M1-04 | 6K | Existing doctor output evolves; add `--json` flag. Green/yellow/red + remediation suggestions per issue. |
|
||||||
|
| FED-M1-07 | not-started | Integration test: gateway boots in `federated` tier with docker-compose `federated` profile; refuses to boot when PG unreachable (asserts fail-fast); pgvector extension query succeeds. | #460 | sonnet | feat/federation-m1-integration | FED-M1-04 | 8K | Vitest + docker-compose test profile. One test file per assertion; real services, no mocks. |
|
||||||
|
| FED-M1-08 | not-started | Integration test for migration script: seed a local PGlite with representative data (tasks, notes, users, teams), run migration, assert row counts + key samples equal on federated PG. | #460 | sonnet | feat/federation-m1-migrate-test | FED-M1-05 | 6K | Runs against docker-compose federated profile; uses temp PGlite file; deterministic seed. |
|
||||||
|
| FED-M1-09 | not-started | Standalone regression: full agent-session E2E on existing `standalone` tier with a gateway built from this branch. Must pass without referencing any federation module. | #460 | haiku | feat/federation-m1-regression | FED-M1-07 | 4K | Reuse existing e2e harness; just re-point at the federation branch build. Canary that we didn't break it. |
|
||||||
|
| FED-M1-10 | not-started | Code review pass: security-focused on the migration script (data-at-rest during migration) + tier detector (error-message sensitivity leakage). Independent reviewer, not authors of tasks 01-09. | #460 | sonnet | — | FED-M1-09 | 8K | Use `feature-dev:code-reviewer` agent. Specifically: no secrets in error messages; no partial-migration footguns. |
|
||||||
|
| FED-M1-11 | not-started | Docs update: `docs/federation/` operator notes for tier setup; README blurb on federated tier; `docs/guides/` entry for migration. Do NOT touch runbook yet (deferred to FED-M7). | #460 | haiku | feat/federation-m1-docs | FED-M1-10 | 4K | Short, actionable. Link from MISSION-MANIFEST. No decisions captured here — those belong in PRD. |
|
||||||
|
| FED-M1-12 | not-started | PR, CI green, merge to main, close #460. | #460 | — | (aggregate) | FED-M1-11 | 3K | Queue-guard before push; wait for green; merge squashed; tea `issue-close` #460. |
|
||||||
|
|
||||||
|
**M1 total estimate:** ~74K tokens (over-budget vs 20K PRD estimate — explanation below)
|
||||||
|
|
||||||
|
**Why over-budget:** PRD's 20K estimate reflected implementation complexity only. The per-task breakdown includes tests, review, and docs as separate tasks per the delivery cycle, which catches the real cost. The final per-milestone budgets in MISSION-MANIFEST will be updated after M1 completes with actuals.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Milestone 2 — Step-CA + grant schema + admin CLI (FED-M2)
|
||||||
|
|
||||||
|
_Deferred to mission planning when M1 is complete. Issue #461 tracks scope._
|
||||||
|
|
||||||
|
## Milestone 3 — mTLS handshake + list/get + scope enforcement (FED-M3)
|
||||||
|
|
||||||
|
_Deferred. Issue #462._
|
||||||
|
|
||||||
|
## Milestone 4 — search + audit + rate limit (FED-M4)
|
||||||
|
|
||||||
|
_Deferred. Issue #463._
|
||||||
|
|
||||||
|
## Milestone 5 — cache + offline + OTEL (FED-M5)
|
||||||
|
|
||||||
|
_Deferred. Issue #464._
|
||||||
|
|
||||||
|
## Milestone 6 — revocation + auto-renewal + CRL (FED-M6)
|
||||||
|
|
||||||
|
_Deferred. Issue #465._
|
||||||
|
|
||||||
|
## Milestone 7 — multi-user hardening + acceptance suite (FED-M7)
|
||||||
|
|
||||||
|
_Deferred. Issue #466._
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Notes
|
||||||
|
|
||||||
|
**Agent assignment rationale:**
|
||||||
|
|
||||||
|
- `codex` for most implementation tasks (OpenAI credit pool preferred for feature code)
|
||||||
|
- `sonnet` for tests (pattern-based, moderate complexity), `doctor` work (cross-cutting), and independent code review
|
||||||
|
- `haiku` for docs and the standalone regression canary (cheapest tier for mechanical/verification work)
|
||||||
|
- No `opus` in M1 — save for cross-cutting architecture decisions if they surface later
|
||||||
|
|
||||||
|
**Branch strategy:** Each task gets its own feature branch off `main`. Tasks within a milestone merge in dependency order. Final aggregate PR (FED-M1-12) isn't a branch of its own — it's the merge of the last upstream task that closes the issue.
|
||||||
|
|
||||||
|
**Queue guard:** Every push and every merge in this mission must run `~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge` per Mosaic hard gate #6.
|
||||||
@@ -165,7 +165,13 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
|
|||||||
Install via the Mosaic installer:
|
Install via the Mosaic installer:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
The installer places the `mosaic` binary at `~/.npm-global/bin/mosaic`. Flags for
|
||||||
|
|||||||
@@ -266,3 +266,42 @@ Issues closed: #52, #55, #57, #58, #120-#134
|
|||||||
**P8-018 closed:** Spin-off stubs created (gatekeeper-service.md, task-queue-unification.md, chroot-sandboxing.md)
|
**P8-018 closed:** Spin-off stubs created (gatekeeper-service.md, task-queue-unification.md, chroot-sandboxing.md)
|
||||||
|
|
||||||
**Next:** Begin execution at Wave 1 — P8-007 (DB migrations) + P8-008 (Types) in parallel.
|
**Next:** Begin execution at Wave 1 — P8-007 (DB migrations) + P8-008 (Types) in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Session 15 — 2026-04-19 — MVP Rollup Manifest Authored
|
||||||
|
|
||||||
|
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||||
|
| ------- | ---------- | -------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| 15 | 2026-04-19 | (rollup-level) | MVP-T01 (manifest), MVP-T02 (archive iuv-v2), MVP-T03 (land FED planning) | Authored MVP rollup manifest at `docs/MISSION-MANIFEST.md`. Federation v1 planning merged to `main` (PR #468 / commit `66512550`). Install-ux-v2 archived as complete. |
|
||||||
|
|
||||||
|
**Gap context:** The MVP scratchpad was last updated at Session 14 (2026-03-15). In the intervening month, two sub-missions ran outside the MVP framework: `install-ux-hardening` (complete, `mosaic-v0.0.25`) and `install-ux-v2` (complete on 2026-04-19, `0.0.27` → `0.0.29`). Both archived under `docs/archive/missions/`. The phase-based execution from Sessions 1–14 (Phases 0–8, issues #1–#172) substantially shipped during this window via those sub-missions and standalone PRs — the MVP mission was nominally active but had no rollup manifest tracking it.
|
||||||
|
|
||||||
|
**User reframe (this session):**
|
||||||
|
|
||||||
|
> There will be more in the MVP. This will inevitably become scope creep. I need a solution that works via webUI, TUI, CLI, and just works for MVP. Federation is required because I need it to work NOW, so my disparate jarvis-brain usage can be consolidated properly.
|
||||||
|
|
||||||
|
**Decisions:**
|
||||||
|
|
||||||
|
1. **MVP is the rollup mission**, not a single-purpose mission. Federation v1 is one workstream of MVP, not MVP itself. Phase 0–8 work is preserved as historical context but is no longer the primary control plane.
|
||||||
|
2. **Three-surface parity (webUI / TUI / CLI) is a cross-cutting MVP requirement** (MVP-X1), not a workstream. Encoded explicitly so it can't be silently dropped.
|
||||||
|
3. **Scope creep is named and accommodated.** Manifest has explicit "Likely Additional Workstreams" section listing PRD-derived candidates without committing execution capacity to them.
|
||||||
|
4. **Workstream isolation** — each workstream gets its own manifest under `docs/{workstream}/MISSION-MANIFEST.md`. MVP manifest is rollup only.
|
||||||
|
5. **Archive-don't-delete** — install-ux-v2 manifest moved to `docs/archive/missions/install-ux-v2-20260405/` with status corrected to `complete` (IUV-M03 closeout note added pointing at PR #446 + releases 0.0.27 → 0.0.29).
|
||||||
|
6. **Federation planning landed first** — PR #468 merged before MVP manifest authored, so the manifest references real on-`main` artifacts.
|
||||||
|
|
||||||
|
**Open items:**
|
||||||
|
|
||||||
|
- `.mosaic/orchestrator/mission.json` MVP slot remains empty (zero milestones). Tracked as MVP-T04. Defer until next session — does not block W1 kickoff. Open question: hand-edit vs. `mosaic coord init` reinit.
|
||||||
|
- Additional workstreams (web dashboard parity, TUI/CLI completion, remote control, multi-user/SSO, LLM provider expansion, MCP, brain) anticipated per PRD but not declared. Pre-staged in manifest's "Likely Additional Workstreams" list.
|
||||||
|
|
||||||
|
**Artifacts this session:**
|
||||||
|
|
||||||
|
| Artifact | Status |
|
||||||
|
| -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
|
| PR #468 (`docs(federation): PRD, milestones, mission manifest, and M1 task breakdown`) | merged 2026-04-19 → `main` (commit `66512550`) |
|
||||||
|
| `docs/MISSION-MANIFEST.md` (MVP rollup, replaces install-ux-v2 manifest) | authored on `docs/mvp-mission-manifest` branch |
|
||||||
|
| `docs/TASKS.md` (MVP rollup, points at workstream task files) | authored |
|
||||||
|
| Install-ux-v2 manifest + tasks + scratchpad + iuv-m03-design | moved to `docs/archive/missions/install-ux-v2-20260405/` with status corrected to complete |
|
||||||
|
|
||||||
|
**Next:** PR `docs/mvp-mission-manifest` → merge to `main` → next session begins W1 / FED-M1 from clean state.
|
||||||
|
|||||||
110
docs/scratchpads/tools-md-seeding-20260411.md
Normal file
110
docs/scratchpads/tools-md-seeding-20260411.md
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
# Hotfix Scratchpad — `install.sh` does not seed `TOOLS.md`
|
||||||
|
|
||||||
|
- **Issue:** mosaicstack/stack#457
|
||||||
|
- **Branch:** `fix/tools-md-seeding`
|
||||||
|
- **Type:** Out-of-mission hotfix (not part of Install UX v2 mission)
|
||||||
|
- **Started:** 2026-04-11
|
||||||
|
- **Ships in:** `@mosaicstack/mosaic` 0.0.30
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Ensure `~/.config/mosaic/TOOLS.md` is created on every supported install path so the mandatory AGENTS.md load order actually resolves. The load order lists `TOOLS.md` at position 5 but the bash installer never seeds it.
|
||||||
|
|
||||||
|
## Root cause
|
||||||
|
|
||||||
|
`packages/mosaic/framework/install.sh:228-236` — the post-sync "Seed defaults" loop explicitly lists `AGENTS.md STANDARDS.md`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DEFAULTS_DIR="$TARGET_DIR/defaults"
|
||||||
|
if [[ -d "$DEFAULTS_DIR" ]]; then
|
||||||
|
for default_file in AGENTS.md STANDARDS.md; do # ← missing TOOLS.md
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
`TOOLS.md` is listed in `PRESERVE_PATHS` (line 24) but never created in the first place. A fresh bootstrap install via `tools/install.sh → framework/install.sh` leaves `~/.config/mosaic/TOOLS.md` absent, and the agent load order then points at a missing file.
|
||||||
|
|
||||||
|
### Secondary: TypeScript `syncFramework` is too greedy
|
||||||
|
|
||||||
|
`packages/mosaic/src/config/file-adapter.ts:133-160` — `FileConfigAdapter.syncFramework` correctly seeds TOOLS.md, but it does so by iterating _every_ file in `framework/defaults/`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
for (const entry of readdirSync(defaultsDir)) {
|
||||||
|
const dest = join(this.mosaicHome, entry);
|
||||||
|
if (!existsSync(dest)) {
|
||||||
|
copyFileSync(join(defaultsDir, entry), dest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`framework/defaults/` contains:
|
||||||
|
|
||||||
|
```
|
||||||
|
AGENTS.md
|
||||||
|
AUDIT-2026-02-17-framework-consistency.md
|
||||||
|
README.md
|
||||||
|
SOUL.md ← hardcoded "Jarvis"
|
||||||
|
STANDARDS.md
|
||||||
|
TOOLS.md
|
||||||
|
USER.md
|
||||||
|
```
|
||||||
|
|
||||||
|
So on a fresh install the TS wizard would silently copy the `Jarvis`-flavored `SOUL.md` + placeholder `USER.md` + internal `AUDIT-*.md` and `README.md` into the user's mosaic home before `mosaic init` ever prompts them. That's a latent identity bug as well as a root-clutter bug — the wizard's own stages are responsible for generating `SOUL.md`/`USER.md` via templates.
|
||||||
|
|
||||||
|
### Tertiary: stale `TOOLS.md.template`
|
||||||
|
|
||||||
|
`packages/mosaic/framework/templates/TOOLS.md.template` still references `~/.config/mosaic/rails/git/…` and `~/.config/mosaic/rails/codex/…`. The `rails/` tree was renamed to `tools/` in the v1→v2 migration (see `run_migrations` in `install.sh`, which removes the old `rails/` symlink). Any user who does run `mosaic init` ends up with a `TOOLS.md` that points to paths that no longer exist.
|
||||||
|
|
||||||
|
## Scope of this fix
|
||||||
|
|
||||||
|
1. **`packages/mosaic/framework/install.sh`** — extend the explicit seed list to include `TOOLS.md`.
|
||||||
|
2. **`packages/mosaic/src/config/file-adapter.ts`** — restrict `syncFramework` defaults-seeding to an explicit whitelist (`AGENTS.md`, `STANDARDS.md`, `TOOLS.md`) so the TS wizard never accidentally seeds `SOUL.md`/`USER.md`/`README.md`/`AUDIT-*.md` into the mosaic home.
|
||||||
|
3. **`packages/mosaic/framework/templates/TOOLS.md.template`** — replace `rails/` with `tools/` in the wrapper-path examples (minimal surgical fix; full template modernization is out of scope for a 0.0.30 hotfix).
|
||||||
|
4. **Regression test** — unit test around `FileConfigAdapter.syncFramework` that runs against a tmpdir fixture asserting:
|
||||||
|
- `TOOLS.md` is seeded when absent
|
||||||
|
- `AGENTS.md` / `STANDARDS.md` are still seeded when absent
|
||||||
|
- `SOUL.md` / `USER.md` are **not** seeded from `defaults/` (the wizard stages own those)
|
||||||
|
- Existing root files are not clobbered.
|
||||||
|
|
||||||
|
Out of scope (tracked separately / future work):
|
||||||
|
|
||||||
|
- Regenerating `defaults/SOUL.md` and `defaults/USER.md` so they no longer contain Jarvis-specific content.
|
||||||
|
- Fully modernizing `TOOLS.md.template` to match the rich canonical `defaults/TOOLS.md` reference.
|
||||||
|
- `issue-create.sh` / `pr-create.sh` `eval` bugs (already captured to OpenBrain from the prior hotfix).
|
||||||
|
|
||||||
|
## Plan / checklist
|
||||||
|
|
||||||
|
- [ ] Branch `fix/tools-md-seeding` from `main` (at `b2cbf89`)
|
||||||
|
- [ ] File Gitea issue (direct API; wrappers broken for bodies with backticks)
|
||||||
|
- [ ] Scratchpad created (this file)
|
||||||
|
- [ ] `install.sh` seed loop extended to `AGENTS.md STANDARDS.md TOOLS.md`
|
||||||
|
- [ ] `file-adapter.ts` seeding restricted to explicit whitelist
|
||||||
|
- [ ] `TOOLS.md.template` `rails/` → `tools/`
|
||||||
|
- [ ] Regression test added (`file-adapter.test.ts`) — failing first, then green
|
||||||
|
- [ ] `pnpm --filter @mosaicstack/mosaic run typecheck` green
|
||||||
|
- [ ] `pnpm --filter @mosaicstack/mosaic run lint` green
|
||||||
|
- [ ] `pnpm --filter @mosaicstack/mosaic exec vitest run` — new test green, no new failures beyond the known pre-existing `uninstall.spec.ts:138`
|
||||||
|
- [ ] Repo baselines: `pnpm typecheck` / `pnpm lint` / `pnpm format:check`
|
||||||
|
- [ ] Independent code review (`feature-dev:code-reviewer`, sonnet tier)
|
||||||
|
- [ ] Commit + push
|
||||||
|
- [ ] PR opened via Gitea API
|
||||||
|
- [ ] CI queue guard cleared (bypass local `ci-queue-wait.sh` if stale origin URL breaks it; query Gitea API directly)
|
||||||
|
- [ ] CI green on PR
|
||||||
|
- [ ] PR merged (squash)
|
||||||
|
- [ ] CI green on main
|
||||||
|
- [ ] Issue closed with link to merge commit
|
||||||
|
- [ ] `chore/release-mosaic-0.0.30` branch bumps `packages/mosaic/package.json` 0.0.29 → 0.0.30
|
||||||
|
- [ ] Release PR opened + merged
|
||||||
|
- [ ] `.woodpecker/publish.yml` auto-publishes to Gitea npm registry
|
||||||
|
- [ ] Publish verified (`npm view @mosaicstack/mosaic version` or registry check)
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- `ci-queue-wait.sh` wrapper may still crash on stale `origin` URL (captured in OpenBrain from prior hotfix). Workaround: query Gitea API directly for running/queued pipelines.
|
||||||
|
- `issue-create.sh` / `pr-create.sh` `eval` bugs. Workaround: Gitea API direct call.
|
||||||
|
- `uninstall.spec.ts:138` is a pre-existing failure on main; not this change's problem.
|
||||||
|
- Publish flow is fire-and-forget on main push — if `publish.yml` fails, rollback means republishing a follow-up patch, not reverting the version bump.
|
||||||
114
docs/scratchpads/yolo-runtime-initial-arg-20260411.md
Normal file
114
docs/scratchpads/yolo-runtime-initial-arg-20260411.md
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
# Hotfix Scratchpad — `mosaic yolo <runtime>` passes runtime name as initial user message
|
||||||
|
|
||||||
|
- **Issue:** mosaicstack/stack#454
|
||||||
|
- **Branch:** `fix/yolo-runtime-initial-arg`
|
||||||
|
- **Type:** Out-of-mission hotfix (not part of Install UX v2 mission)
|
||||||
|
- **Started:** 2026-04-11
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Stop `mosaic yolo <runtime>` from passing the runtime name (`claude`, `codex`, etc.) as the initial user message to the underlying CLI. Restore the mission-auto-prompt path for yolo launches.
|
||||||
|
|
||||||
|
## Root cause (confirmed)
|
||||||
|
|
||||||
|
`packages/mosaic/src/commands/launch.ts:779` — the `yolo <runtime>` action handler:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
.action((runtime: string, _opts: unknown, cmd: Command) => {
|
||||||
|
// ... validate runtime ...
|
||||||
|
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Commander.js includes declared positional arguments in `cmd.args`. For `mosaic yolo claude`:
|
||||||
|
|
||||||
|
- `runtime` (destructured) = `"claude"`
|
||||||
|
- `cmd.args` = `["claude"]` — the same value
|
||||||
|
|
||||||
|
`launchRuntime` treats `["claude"]` as excess positional args, and for the `claude` case that becomes the initial user message. As a secondary consequence, `hasMissionNoArgs` evaluates false, so the mission-auto-prompt path is bypassed too.
|
||||||
|
|
||||||
|
## Live reproduction (intercepted claude binary)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ PATH=/tmp/fake-claude-bin:$PATH mosaic yolo claude
|
||||||
|
[mosaic] Launching Claude Code in YOLO mode...
|
||||||
|
argv[1]: --dangerously-skip-permissions
|
||||||
|
argv[2]: --append-system-prompt
|
||||||
|
argv[3] (len=25601): # ACTIVE MISSION — HARD GATE ...
|
||||||
|
argv[4]: claude ← the bug
|
||||||
|
```
|
||||||
|
|
||||||
|
Non-yolo variant `mosaic claude` is clean:
|
||||||
|
|
||||||
|
```
|
||||||
|
argv[1]: --append-system-prompt
|
||||||
|
argv[2]: <prompt>
|
||||||
|
argv[3]: Active mission detected: MVP. Read the mission state files and report status.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Refactor `launch.ts`: extract `registerRuntimeLaunchers(program, handler)` with an injectable handler so commander wiring is testable without spawning subprocesses. `registerLaunchCommands` delegates to it with `launchRuntime` as the handler.
|
||||||
|
2. Fix: in the `yolo <runtime>` action, pass `cmd.args.slice(1)` instead of `cmd.args`.
|
||||||
|
3. Add `packages/mosaic/src/commands/launch.spec.ts`:
|
||||||
|
- Failing-first reproducer: parse `['node','x','yolo','claude']` and assert handler receives `extraArgs=[]` and `yolo=true`.
|
||||||
|
- Regression test: parse `['node','x','claude']` asserts handler receives `extraArgs=[]` and `yolo=false`.
|
||||||
|
- Excess args: parse `['node','x','yolo','claude','--print','hi']` asserts handler receives `extraArgs=['--print','hi']` (with `--print` kept because `allowUnknownOption` is true).
|
||||||
|
- Excess args non-yolo: parse `['node','x','claude','--print','hi']` asserts `extraArgs=['--print','hi']`.
|
||||||
|
- Reject unknown runtime under yolo.
|
||||||
|
4. Run typecheck, lint, format:check, vitest for `@mosaicstack/mosaic`.
|
||||||
|
5. Independent code review (feature-dev:code-reviewer subagent, sonnet tier).
|
||||||
|
6. Commit → push → PR via wrappers → merge → CI green → close issue #454.
|
||||||
|
7. Release decision (`mosaic-v0.0.30`) deferred to Jason after merge.
|
||||||
|
|
||||||
|
## Framework compliance sub-findings (out-of-scope; to capture in OpenBrain after)
|
||||||
|
|
||||||
|
- `~/.config/mosaic/tools/git/issue-create.sh` uses `eval` on `$BODY`; arbitrary bodies with backticks, `$`, or parens break catastrophically.
|
||||||
|
- `gitea_issue_create_api` fallback uses `curl -fsS` without `-L`; after the `mosaicstack/mosaic-stack → mosaicstack/stack` rename, the API redirect is not followed and the fallback silently fails.
|
||||||
|
- Local repo `origin` remote still points at old `mosaic/mosaic-stack.git` slug. Not touched here per git-config safety rule.
|
||||||
|
- `~/.config/mosaic/TOOLS.md` referenced by the global load order but does not exist on disk.
|
||||||
|
|
||||||
|
These will be captured to OpenBrain after the hotfix merges so they don't get lost, and filed as separate tracking items.
|
||||||
|
|
||||||
|
## Progress checkpoints
|
||||||
|
|
||||||
|
- [x] Branch created (`fix/yolo-runtime-initial-arg`)
|
||||||
|
- [x] Issue #454 opened
|
||||||
|
- [x] Scratchpad scaffolded
|
||||||
|
- [x] Failing test added (red)
|
||||||
|
- [x] Refactor + fix applied
|
||||||
|
- [x] Tests green (launch.spec.ts 11/11)
|
||||||
|
- [x] Baselines green (typecheck, lint, format:check, vitest — pre-existing `uninstall.spec.ts:138` failure on branch main acknowledged, not caused by this change)
|
||||||
|
- [x] Code review pass (feature-dev:code-reviewer, sonnet — no blockers)
|
||||||
|
- [x] Commit + push (commit 1dd4f59)
|
||||||
|
- [x] PR opened (mosaicstack/stack#455)
|
||||||
|
- [x] CI queue guard cleared (no pending pipelines pre-push or pre-merge)
|
||||||
|
- [x] PR merged (squash merge commit b2cec8c6bac29336a6cdcdb4f19806f7b5fa0054)
|
||||||
|
- [x] CI green on main (`ci/woodpecker/push/ci` + `ci/woodpecker/push/publish` both success on merge commit)
|
||||||
|
- [x] Issue #454 closed
|
||||||
|
- [x] Scratchpad final evidence entry
|
||||||
|
|
||||||
|
## Tests run
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic run typecheck` → green
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic run lint` → green
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec prettier --check "src/**/*.ts"` → green
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/launch.spec.ts` → 11/11 pass
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run` → 270/271 pass (1 pre-existing `uninstall.spec.ts:138` EACCES failure, confirmed on the branch before this change)
|
||||||
|
- `pnpm typecheck` (repo) → green
|
||||||
|
- `pnpm lint` (repo) → green
|
||||||
|
- `pnpm format:check` (repo) → green (after prettier-writing the scratchpad)
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
None expected. Refactor is small and the Commander API is stable. Test needs `exitOverride()` to prevent `process.exit` on invalid runtime.
|
||||||
|
|
||||||
|
## Final verification evidence
|
||||||
|
|
||||||
|
- PR: mosaicstack/stack#455 — state `closed`, merged.
|
||||||
|
- Merge commit: `b2cec8c6bac29336a6cdcdb4f19806f7b5fa0054` (squash to `main`).
|
||||||
|
- Post-merge CI (main @ b2cec8c6): `ci/woodpecker/push/ci` = success, `ci/woodpecker/push/publish` = success. (`ci/woodpecker/tag/publish` was last observed as a pre-existing failure on the prior release tag and is unrelated to this change.)
|
||||||
|
- Issue mosaicstack/stack#454 closed with a comment linking the merge commit.
|
||||||
|
- Launch regression suite: `launch.spec.ts` 11/11 pass on main.
|
||||||
|
- Baselines on main after merge are inherited from the PR CI run.
|
||||||
|
- Release decision (`mosaicstack/mosaic` 0.0.30) intentionally deferred to the user — the fix is now sitting on main awaiting a release cut.
|
||||||
@@ -73,6 +73,27 @@ Spawn a worker instead. No exceptions. No "quick fixes."
|
|||||||
- Wait for at least one worker to complete before spawning more
|
- Wait for at least one worker to complete before spawning more
|
||||||
- This optimizes token usage and reduces context pressure
|
- This optimizes token usage and reduces context pressure
|
||||||
|
|
||||||
|
## File Ownership & Partitioning (Hard Rule for Parallel Workers)
|
||||||
|
|
||||||
|
When dispatching parallel workers, the orchestrator MUST assign **non-overlapping file scopes** to each worker. File collisions between parallel workers cause merge conflicts, lost edits, and wasted tokens.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. **Exclusive file ownership.** Each file may be assigned to at most one active worker. The orchestrator records ownership in the worker dispatch (prompt or task definition).
|
||||||
|
2. **Partition by directory or module.** Prefer assigning entire directories/modules to one worker rather than splitting files within a directory across workers.
|
||||||
|
3. **Shared files are serialized.** If two tasks must modify the same file (e.g., a shared types file, a barrel export), they MUST run sequentially — never in parallel. Mark the second task with `depends_on` pointing to the first.
|
||||||
|
4. **Test files follow source ownership.** If Worker A owns `src/auth/login.ts`, Worker A also owns `src/auth/__tests__/login.test.ts`. Do not split source and test across workers.
|
||||||
|
5. **Config files are orchestrator-reserved.** Files like `package.json`, `tsconfig.json`, and CI config are owned by the orchestrator and modified only between worker cycles, never during parallel execution.
|
||||||
|
6. **Document ownership in dispatch.** When spawning a worker, include an explicit `Files:` section listing owned paths/globs. Example:
|
||||||
|
|
||||||
|
```
|
||||||
|
Files (exclusive — do not touch files outside this scope):
|
||||||
|
- apps/web/src/components/auth/**
|
||||||
|
- apps/web/src/lib/auth.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Conflict recovery.** If a worker edits a file outside its scope, the orchestrator MUST flag the violation, assess the diff, and either revert the out-of-scope change or re-run the affected worker with the corrected file.
|
||||||
|
|
||||||
## Delegation Mode Selection
|
## Delegation Mode Selection
|
||||||
|
|
||||||
Choose one delegation mode at session start:
|
Choose one delegation mode at session start:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/agent"
|
"directory": "packages/agent"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/auth"
|
"directory": "packages/auth"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/brain"
|
"directory": "packages/brain"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/config"
|
"directory": "packages/config"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/coord"
|
"directory": "packages/coord"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/db"
|
"directory": "packages/db"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/design-tokens"
|
"directory": "packages/design-tokens"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/forge"
|
"directory": "packages/forge"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/log"
|
"directory": "packages/log"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/macp"
|
"directory": "packages/macp"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/memory"
|
"directory": "packages/memory"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { runWizard } from '../../src/wizard.js';
|
|||||||
describe('Full Wizard (headless)', () => {
|
describe('Full Wizard (headless)', () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
const repoRoot = join(import.meta.dirname, '..', '..');
|
const repoRoot = join(import.meta.dirname, '..', '..');
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
tmpDir = mkdtempSync(join(tmpdir(), 'mosaic-wizard-test-'));
|
||||||
@@ -32,12 +33,16 @@ describe('Full Wizard (headless)', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
rmSync(tmpDir, { recursive: true, force: true });
|
rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
process.env = { ...originalEnv };
|
||||||
});
|
});
|
||||||
|
|
||||||
it('quick start produces valid SOUL.md', async () => {
|
it('quick start produces valid SOUL.md', async () => {
|
||||||
|
// The headless path reads agent name from MOSAIC_AGENT_NAME env var
|
||||||
|
// (via agentIntentStage) rather than prompting interactively.
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
'Communication style': 'direct',
|
||||||
'Your name': 'Tester',
|
'Your name': 'Tester',
|
||||||
'Your pronouns': 'They/Them',
|
'Your pronouns': 'They/Them',
|
||||||
@@ -62,9 +67,10 @@ describe('Full Wizard (headless)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('quick start produces valid USER.md', async () => {
|
it('quick start produces valid USER.md', async () => {
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'TestBot';
|
||||||
|
|
||||||
const prompter = new HeadlessPrompter({
|
const prompter = new HeadlessPrompter({
|
||||||
'Installation mode': 'quick',
|
'Installation mode': 'quick',
|
||||||
'What name should agents use?': 'TestBot',
|
|
||||||
'Communication style': 'direct',
|
'Communication style': 'direct',
|
||||||
'Your name': 'Tester',
|
'Your name': 'Tester',
|
||||||
'Your pronouns': 'He/Him',
|
'Your pronouns': 'He/Him',
|
||||||
|
|||||||
@@ -151,11 +151,68 @@ When delegating work to subagents, you MUST select the cheapest model capable of
|
|||||||
|
|
||||||
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
|
**Runtime-specific syntax**: See the runtime reference for how to specify model tier when spawning subagents (e.g., Claude Code Task tool `model` parameter).
|
||||||
|
|
||||||
|
## Superpowers Enforcement (Hard Rule)
|
||||||
|
|
||||||
|
Mosaic provides capabilities beyond basic code editing: **skills**, **hooks**, **MCP tools**, and **plugins**. These are not optional extras — they are force multipliers that agents MUST actively use when applicable. Under-utilization of superpowers is a framework violation.
|
||||||
|
|
||||||
|
### Skills
|
||||||
|
|
||||||
|
Skills are domain-specific instruction sets in `~/.config/mosaic/skills/` that encode best practices, patterns, and guardrails. They are loaded into agents via the runtime's skill mechanism (e.g., Claude Code slash commands, Pi `--skill` flag).
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. Before starting implementation, scan available skills (`ls ~/.config/mosaic/skills/`) and load any that match the task domain.
|
||||||
|
2. When a skill exists for the technology being used (e.g., `nestjs-best-practices` for NestJS work), you MUST load it.
|
||||||
|
3. When spawning workers, include skill loading in the kickstart prompt.
|
||||||
|
4. If you complete a task without loading a relevant available skill, that is a quality gap.
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
Hooks provide automated quality gates (lint, format, typecheck) that fire on file edits. They are configured in the runtime settings and run automatically.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. Do NOT bypass or suppress hook output. If a hook reports errors, fix them before proceeding.
|
||||||
|
2. Hook failures are immediate feedback — treat them like failing tests.
|
||||||
|
3. If a hook is consistently failing on valid code, report it as a framework issue rather than working around it.
|
||||||
|
|
||||||
|
### MCP Tools
|
||||||
|
|
||||||
|
MCP servers extend agent capabilities with external integrations (sequential-thinking, web search, memory, browser automation, etc.). Available MCP tools are listed at session start.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. **sequential-thinking** is REQUIRED for planning, architecture, and multi-step reasoning. Use it — do not skip structured thinking for complex decisions.
|
||||||
|
2. **OpenBrain** (`capture`, `search`, `recent`) is the cross-agent memory layer. Capture discoveries and search for prior context at session start.
|
||||||
|
3. When a task involves web research, browser testing, or external data, use the available MCP tools (web-search, chrome-devtools, web-reader) rather than asking the user to look things up.
|
||||||
|
4. Check available MCP tools at session start and use them proactively throughout the session.
|
||||||
|
|
||||||
|
### Plugins (Runtime-Specific)
|
||||||
|
|
||||||
|
Runtime plugins (e.g., Claude Code's `feature-dev`, `pr-review-toolkit`, `code-review`) provide specialized agent capabilities like code review, architecture analysis, and test coverage analysis.
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
|
||||||
|
1. After completing a significant code change, use code review plugins proactively — do not wait for the user to ask.
|
||||||
|
2. Before creating a PR, use PR review plugins to catch issues early.
|
||||||
|
3. When designing architecture, use planning/architecture plugins for structured analysis.
|
||||||
|
|
||||||
|
### Self-Evolution
|
||||||
|
|
||||||
|
The Mosaic framework should improve over time based on usage patterns:
|
||||||
|
|
||||||
|
1. When you discover a recurring pattern that should be codified, capture it to OpenBrain with `type: "framework-improvement"`.
|
||||||
|
2. When a hook, skill, or tool is missing for a common task, capture the gap to OpenBrain with `type: "tooling-gap"`.
|
||||||
|
3. When a framework rule causes friction without adding value, capture the observation to OpenBrain with `type: "framework-friction"`.
|
||||||
|
|
||||||
|
These captures feed the framework's continuous improvement cycle.
|
||||||
|
|
||||||
## Skills Policy
|
## Skills Policy
|
||||||
|
|
||||||
- Use only the minimum required skills for the active task.
|
- Load skills that match the active task domain before starting implementation.
|
||||||
- Do not load unrelated skills.
|
- Do not load unrelated skills.
|
||||||
- Follow skill trigger rules from the active runtime instruction layer.
|
- Follow skill trigger rules from the active runtime instruction layer.
|
||||||
|
- Actively check `~/.config/mosaic/skills/` for applicable skills rather than passively waiting for them to be mentioned.
|
||||||
|
|
||||||
## Session Closure Requirement
|
## Session Closure Requirement
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,20 @@ Universal agent standards layer for Claude Code, Codex, OpenCode, and Pi.
|
|||||||
|
|
||||||
One config, every runtime, same standards.
|
One config, every runtime, same standards.
|
||||||
|
|
||||||
> **This is the framework component of [mosaic-stack](https://git.mosaicstack.dev/mosaic/mosaic-stack).** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
|
> **This is the framework component of [mosaic-stack](https://git.mosaicstack.dev/mosaicstack/stack).** No personal data, credentials, user-specific preferences, or machine-specific paths should be committed. All personalization happens at install time via `mosaic init` or by editing files in `~/.config/mosaic/` after installation.
|
||||||
|
|
||||||
## Quick Install
|
## Quick Install
|
||||||
|
|
||||||
### Mac / Linux
|
### Mac / Linux
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Windows (PowerShell)
|
### Windows (PowerShell)
|
||||||
@@ -23,8 +29,8 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/mai
|
|||||||
### From Source (any platform)
|
### From Source (any platform)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git ~/src/mosaic-stack
|
git clone git@git.mosaicstack.dev:mosaicstack/stack.git ~/src/stack
|
||||||
cd ~/src/mosaic-stack && bash tools/install.sh
|
cd ~/src/stack && bash tools/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer:
|
The installer:
|
||||||
@@ -145,13 +151,19 @@ mosaic upgrade check # Check upgrade status (no changes)
|
|||||||
Run the installer again — it handles upgrades automatically:
|
Run the installer again — it handles upgrades automatically:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
|
curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the direct URL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
Or from a local checkout:
|
Or from a local checkout:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/src/mosaic-stack && git pull && bash tools/install.sh
|
cd ~/src/stack && git pull && bash tools/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by default.
|
The installer preserves local `SOUL.md`, `USER.md`, `TOOLS.md`, and `memory/` by default.
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||||||
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
|
||||||
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
|
||||||
|
|
||||||
# Files preserved across upgrades (never overwritten)
|
# Files/dirs preserved across upgrades (never overwritten).
|
||||||
PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources")
|
# User-created content in these paths survives rsync --delete.
|
||||||
|
PRESERVE_PATHS=("AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials")
|
||||||
|
|
||||||
# Current framework schema version — bump this when the layout changes.
|
# Current framework schema version — bump this when the layout changes.
|
||||||
# The migration system uses this to run upgrade steps.
|
# The migration system uses this to run upgrade steps.
|
||||||
@@ -217,8 +218,27 @@ fi
|
|||||||
|
|
||||||
sync_framework
|
sync_framework
|
||||||
|
|
||||||
# Ensure memory directory exists
|
# Ensure persistent directories exist
|
||||||
mkdir -p "$TARGET_DIR/memory"
|
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.
|
||||||
|
#
|
||||||
|
# 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 AGENTS.md STANDARDS.md TOOLS.md; do
|
||||||
|
if [[ -f "$DEFAULTS_DIR/$default_file" ]] && [[ ! -f "$TARGET_DIR/$default_file" ]]; then
|
||||||
|
cp "$DEFAULTS_DIR/$default_file" "$TARGET_DIR/$default_file"
|
||||||
|
ok "Seeded $default_file from defaults"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
# Ensure tool scripts are executable
|
# Ensure tool scripts are executable
|
||||||
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
|
||||||
|
|||||||
@@ -102,3 +102,30 @@ claude mcp add --scope user <name> -- npx -y <package>
|
|||||||
`--scope local` = default, local-only (not committed).
|
`--scope local` = default, local-only (not committed).
|
||||||
|
|
||||||
Do NOT add `mcpServers` to `~/.claude/settings.json` — that key is ignored for MCP loading.
|
Do NOT add `mcpServers` to `~/.claude/settings.json` — that key is ignored for MCP loading.
|
||||||
|
|
||||||
|
## Required Claude Code Settings (Enforced by Launcher)
|
||||||
|
|
||||||
|
The `mosaic claude` launcher validates that `~/.claude/settings.json` contains the required Mosaic configuration. Missing or outdated settings trigger a warning at launch.
|
||||||
|
|
||||||
|
**Required hooks:**
|
||||||
|
|
||||||
|
| Event | Matcher | Script | Purpose |
|
||||||
|
| ----------- | ------------------------ | ------------------------- | ---------------------------------------------- |
|
||||||
|
| PreToolUse | `Write\|Edit\|MultiEdit` | `prevent-memory-write.sh` | Block writes to `~/.claude/projects/*/memory/` |
|
||||||
|
| PostToolUse | `Edit\|MultiEdit\|Write` | `qa-hook-stdin.sh` | QA report generation after code edits |
|
||||||
|
| PostToolUse | `Edit\|MultiEdit\|Write` | `typecheck-hook.sh` | Inline TypeScript type checking |
|
||||||
|
|
||||||
|
**Required plugins:**
|
||||||
|
|
||||||
|
| Plugin | Purpose |
|
||||||
|
| ------------------- | -------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `feature-dev` | Subagent architecture: code-reviewer, code-architect, code-explorer |
|
||||||
|
| `pr-review-toolkit` | PR review: code-simplifier, comment-analyzer, test-analyzer, silent-failure-hunter, type-design-analyzer |
|
||||||
|
| `code-review` | Standalone code review capabilities |
|
||||||
|
|
||||||
|
**Required settings:**
|
||||||
|
|
||||||
|
- `enableAllMcpTools: true` — Allow all configured MCP tools without per-tool approval
|
||||||
|
- `model: "opus"` — Default to opus for orchestrator-level sessions (workers use tiered models via Task tool)
|
||||||
|
|
||||||
|
If `mosaic claude` detects missing hooks or plugins, it will print a warning with the exact settings to add. The session will still launch — enforcement is advisory, not blocking — but agents operating without these settings are running degraded.
|
||||||
|
|||||||
@@ -23,6 +23,16 @@
|
|||||||
"timeout": 60
|
"timeout": 60
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matcher": "Edit|MultiEdit|Write",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
|
||||||
|
"timeout": 30
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,32 +5,32 @@ Project-specific tooling belongs in the project's `AGENTS.md`, not here.
|
|||||||
|
|
||||||
## Mosaic Git Wrappers (Use First)
|
## Mosaic Git Wrappers (Use First)
|
||||||
|
|
||||||
Mosaic wrappers at `~/.config/mosaic/rails/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
Mosaic wrappers at `~/.config/mosaic/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Issues
|
# Issues
|
||||||
~/.config/mosaic/rails/git/issue-create.sh
|
~/.config/mosaic/tools/git/issue-create.sh
|
||||||
~/.config/mosaic/rails/git/issue-close.sh
|
~/.config/mosaic/tools/git/issue-close.sh
|
||||||
|
|
||||||
# PRs
|
# PRs
|
||||||
~/.config/mosaic/rails/git/pr-create.sh
|
~/.config/mosaic/tools/git/pr-create.sh
|
||||||
~/.config/mosaic/rails/git/pr-merge.sh
|
~/.config/mosaic/tools/git/pr-merge.sh
|
||||||
|
|
||||||
# Milestones
|
# Milestones
|
||||||
~/.config/mosaic/rails/git/milestone-create.sh
|
~/.config/mosaic/tools/git/milestone-create.sh
|
||||||
|
|
||||||
# CI queue guard (required before push/merge)
|
# CI queue guard (required before push/merge)
|
||||||
~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge
|
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge
|
||||||
```
|
```
|
||||||
|
|
||||||
## Code Review (Codex)
|
## Code Review (Codex)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Code quality review
|
# Code quality review
|
||||||
~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
|
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted
|
||||||
|
|
||||||
# Security review
|
# Security review
|
||||||
~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
|
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted
|
||||||
```
|
```
|
||||||
|
|
||||||
## Git Providers
|
## Git Providers
|
||||||
|
|||||||
63
packages/mosaic/framework/tools/qa/typecheck-hook.sh
Executable file
63
packages/mosaic/framework/tools/qa/typecheck-hook.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Lightweight PostToolUse typecheck hook for TypeScript files.
|
||||||
|
# Runs tsc --noEmit on the nearest tsconfig after TS/TSX edits.
|
||||||
|
# Returns non-zero with diagnostic output so the agent sees type errors immediately.
|
||||||
|
# Location: ~/.config/mosaic/tools/qa/typecheck-hook.sh
|
||||||
|
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
# Read JSON from stdin (Claude Code PostToolUse payload)
|
||||||
|
JSON_INPUT=$(cat)
|
||||||
|
|
||||||
|
# Extract file path
|
||||||
|
if command -v jq &>/dev/null; then
|
||||||
|
FILE_PATH=$(echo "$JSON_INPUT" | jq -r '.tool_input.file_path // .tool_response.filePath // .file_path // empty' 2>/dev/null || echo "")
|
||||||
|
else
|
||||||
|
FILE_PATH=$(echo "$JSON_INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only check TypeScript files
|
||||||
|
if ! [[ "$FILE_PATH" =~ \.(ts|tsx)$ ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Must be a real file
|
||||||
|
if [ ! -f "$FILE_PATH" ]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find nearest tsconfig.json by walking up from the file
|
||||||
|
DIR=$(dirname "$FILE_PATH")
|
||||||
|
TSCONFIG=""
|
||||||
|
while [ "$DIR" != "/" ] && [ "$DIR" != "." ]; do
|
||||||
|
if [ -f "$DIR/tsconfig.json" ]; then
|
||||||
|
TSCONFIG="$DIR/tsconfig.json"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
DIR=$(dirname "$DIR")
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$TSCONFIG" ]; then
|
||||||
|
# No tsconfig found — skip silently
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run tsc --noEmit from the tsconfig directory
|
||||||
|
# Use --pretty for readable output, limit to 10 errors to keep output short
|
||||||
|
TSCONFIG_DIR=$(dirname "$TSCONFIG")
|
||||||
|
cd "$TSCONFIG_DIR"
|
||||||
|
|
||||||
|
# Run typecheck — capture output and exit code
|
||||||
|
OUTPUT=$(npx tsc --noEmit --pretty --maxNodeModuleJsDepth 0 2>&1) || STATUS=$?
|
||||||
|
|
||||||
|
if [ "${STATUS:-0}" -ne 0 ]; then
|
||||||
|
# Filter output to only show errors related to the edited file (if possible)
|
||||||
|
BASENAME=$(basename "$FILE_PATH")
|
||||||
|
RELEVANT=$(echo "$OUTPUT" | grep -A2 "$BASENAME" 2>/dev/null || echo "$OUTPUT" | head -20)
|
||||||
|
|
||||||
|
echo "TypeScript type errors detected after editing $FILE_PATH:"
|
||||||
|
echo "$RELEVANT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaicstack/mosaic",
|
"name": "@mosaicstack/mosaic",
|
||||||
"version": "0.0.26",
|
"version": "0.0.30",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/mosaic"
|
"directory": "packages/mosaic"
|
||||||
},
|
},
|
||||||
"description": "Mosaic agent framework — installation wizard and meta package",
|
"description": "Mosaic agent framework — installation wizard and meta package",
|
||||||
|
|||||||
@@ -135,15 +135,11 @@ program
|
|||||||
|
|
||||||
// No valid session — prompt for credentials
|
// No valid session — prompt for credentials
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const readline = await import('node:readline');
|
const { promptLine, promptSecret } = await import('./commands/gateway/login.js');
|
||||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
||||||
const ask = (q: string): Promise<string> =>
|
|
||||||
new Promise((resolve) => rl.question(q, resolve));
|
|
||||||
|
|
||||||
console.log(`Sign in to ${opts.gateway}`);
|
console.log(`Sign in to ${opts.gateway}`);
|
||||||
const email = await ask('Email: ');
|
const email = await promptLine('Email: ');
|
||||||
const password = await ask('Password: ');
|
const password = await promptSecret('Password: ');
|
||||||
rl.close();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const auth = await signIn(opts.gateway, email, password);
|
const auth = await signIn(opts.gateway, email, password);
|
||||||
|
|||||||
111
packages/mosaic/src/commands/launch.spec.ts
Normal file
111
packages/mosaic/src/commands/launch.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest';
|
||||||
|
import { Command } from 'commander';
|
||||||
|
import { registerRuntimeLaunchers, type RuntimeLaunchHandler } from './launch.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the commander wiring between `mosaic <runtime>` / `mosaic yolo <runtime>`
|
||||||
|
* subcommands and the internal `launchRuntime` dispatcher.
|
||||||
|
*
|
||||||
|
* Regression target: see mosaicstack/stack#454 — before the fix, `mosaic yolo claude`
|
||||||
|
* passed the literal string "claude" as an excess positional argument to the
|
||||||
|
* underlying CLI, which Claude Code then interpreted as the first user message.
|
||||||
|
*
|
||||||
|
* The bug existed because Commander.js includes declared positional arguments
|
||||||
|
* (here `<runtime>`) in `cmd.args` alongside any true excess args. The action
|
||||||
|
* handler must slice them off before forwarding.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function buildProgram(handler: RuntimeLaunchHandler): Command {
|
||||||
|
const program = new Command();
|
||||||
|
program.exitOverride(); // prevent process.exit on parse errors
|
||||||
|
registerRuntimeLaunchers(program, handler);
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `process.exit` returns `never`, so vi.spyOn demands a replacement with the
|
||||||
|
// same signature. We throw from the mock to short-circuit into test-land.
|
||||||
|
const exitThrows = (): never => {
|
||||||
|
throw new Error('process.exit called');
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('registerRuntimeLaunchers — non-yolo subcommands', () => {
|
||||||
|
let mockExit: MockInstance<typeof process.exit>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// process.exit is called when the yolo action rejects an invalid runtime.
|
||||||
|
// Stub it so the assertion catches the rejection instead of terminating
|
||||||
|
// the test runner.
|
||||||
|
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockExit.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'forwards %s with empty extraArgs and yolo=false',
|
||||||
|
(runtime) => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const program = buildProgram(handler);
|
||||||
|
program.parse(['node', 'mosaic', runtime]);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handler).toHaveBeenCalledWith(runtime, [], false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('forwards excess args after a non-yolo runtime subcommand', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const program = buildProgram(handler);
|
||||||
|
program.parse(['node', 'mosaic', 'claude', '--print', 'hello']);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith('claude', ['--print', 'hello'], false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('registerRuntimeLaunchers — yolo <runtime>', () => {
|
||||||
|
let mockExit: MockInstance<typeof process.exit>;
|
||||||
|
let mockError: MockInstance<typeof console.error>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockExit = vi.spyOn(process, 'exit').mockImplementation(exitThrows);
|
||||||
|
mockError = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockExit.mockRestore();
|
||||||
|
mockError.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['claude', 'codex', 'opencode', 'pi'] as const)(
|
||||||
|
'does NOT pass the runtime name as an extra arg (regression #454) for yolo %s',
|
||||||
|
(runtime) => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const program = buildProgram(handler);
|
||||||
|
program.parse(['node', 'mosaic', 'yolo', runtime]);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
// The critical assertion: extraArgs must be empty, not [runtime].
|
||||||
|
// Before the fix, cmd.args was [runtime] and the runtime name leaked
|
||||||
|
// through to the underlying CLI as an initial positional argument.
|
||||||
|
expect(handler).toHaveBeenCalledWith(runtime, [], true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('forwards true excess args after a yolo runtime', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const program = buildProgram(handler);
|
||||||
|
program.parse(['node', 'mosaic', 'yolo', 'claude', '--print', 'hi']);
|
||||||
|
|
||||||
|
expect(handler).toHaveBeenCalledWith('claude', ['--print', 'hi'], true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unknown runtime under yolo without invoking the handler', () => {
|
||||||
|
const handler = vi.fn();
|
||||||
|
const program = buildProgram(handler);
|
||||||
|
|
||||||
|
expect(() => program.parse(['node', 'mosaic', 'yolo', 'bogus'])).toThrow('process.exit called');
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
expect(mockExit).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -78,6 +78,82 @@ function checkSoul(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Claude settings validation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SettingsAudit {
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditClaudeSettings(): SettingsAudit {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
||||||
|
const settings = readJson(settingsPath);
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
warnings.push('~/.claude/settings.json not found — hooks and plugins will be missing');
|
||||||
|
return { warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required hooks
|
||||||
|
const hooks = settings['hooks'] as Record<string, unknown[]> | undefined;
|
||||||
|
|
||||||
|
const requiredPreToolUse = ['prevent-memory-write.sh'];
|
||||||
|
const requiredPostToolUse = ['qa-hook-stdin.sh', 'typecheck-hook.sh'];
|
||||||
|
|
||||||
|
const preHooks = (hooks?.['PreToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||||
|
const postHooks = (hooks?.['PostToolUse'] ?? []) as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const preCommands = preHooks.flatMap((h) => {
|
||||||
|
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
|
||||||
|
return inner.map((ih) => String(ih['command'] ?? ''));
|
||||||
|
});
|
||||||
|
const postCommands = postHooks.flatMap((h) => {
|
||||||
|
const inner = (h['hooks'] ?? []) as Array<Record<string, unknown>>;
|
||||||
|
return inner.map((ih) => String(ih['command'] ?? ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const script of requiredPreToolUse) {
|
||||||
|
if (!preCommands.some((c) => c.includes(script))) {
|
||||||
|
warnings.push(`Missing PreToolUse hook: ${script}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const script of requiredPostToolUse) {
|
||||||
|
if (!postCommands.some((c) => c.includes(script))) {
|
||||||
|
warnings.push(`Missing PostToolUse hook: ${script}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required plugins
|
||||||
|
const plugins = (settings['enabledPlugins'] ?? {}) as Record<string, boolean>;
|
||||||
|
const requiredPlugins = ['feature-dev', 'pr-review-toolkit', 'code-review'];
|
||||||
|
|
||||||
|
for (const plugin of requiredPlugins) {
|
||||||
|
const found = Object.keys(plugins).some((k) => k.startsWith(plugin) && plugins[k]);
|
||||||
|
if (!found) {
|
||||||
|
warnings.push(`Missing plugin: ${plugin}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check enableAllMcpTools
|
||||||
|
if (!settings['enableAllMcpTools']) {
|
||||||
|
warnings.push('enableAllMcpTools is not true — MCP tools may require per-tool approval');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSettingsWarnings(audit: SettingsAudit): void {
|
||||||
|
if (audit.warnings.length === 0) return;
|
||||||
|
|
||||||
|
console.log('\n[mosaic] Claude Code settings audit:');
|
||||||
|
for (const w of audit.warnings) {
|
||||||
|
console.log(` ⚠ ${w}`);
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
'[mosaic] Run: mosaic doctor — or see ~/.config/mosaic/runtime/claude/RUNTIME.md for required settings.\n',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function checkSequentialThinking(runtime: string): void {
|
function checkSequentialThinking(runtime: string): void {
|
||||||
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
const checker = fwScript('mosaic-ensure-sequential-thinking');
|
||||||
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
if (!existsSync(checker)) return; // Skip if checker doesn't exist
|
||||||
@@ -407,6 +483,10 @@ function launchRuntime(runtime: RuntimeName, args: string[], yolo: boolean): nev
|
|||||||
|
|
||||||
switch (runtime) {
|
switch (runtime) {
|
||||||
case 'claude': {
|
case 'claude': {
|
||||||
|
// Audit Claude Code settings and warn about missing hooks/plugins
|
||||||
|
const settingsAudit = auditClaudeSettings();
|
||||||
|
printSettingsWarnings(settingsAudit);
|
||||||
|
|
||||||
const prompt = buildRuntimePrompt('claude');
|
const prompt = buildRuntimePrompt('claude');
|
||||||
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
const cliArgs = yolo ? ['--dangerously-skip-permissions'] : [];
|
||||||
cliArgs.push('--append-system-prompt', prompt);
|
cliArgs.push('--append-system-prompt', prompt);
|
||||||
@@ -677,8 +757,23 @@ function runUpgrade(args: string[]): never {
|
|||||||
|
|
||||||
// ─── Commander registration ─────────────────────────────────────────────────
|
// ─── Commander registration ─────────────────────────────────────────────────
|
||||||
|
|
||||||
export function registerLaunchCommands(program: Command): void {
|
/**
|
||||||
// Runtime launchers
|
* Handler invoked when a runtime subcommand (`<runtime>` or `yolo <runtime>`)
|
||||||
|
* is parsed. Exposed so tests can exercise the commander wiring without
|
||||||
|
* spawning subprocesses.
|
||||||
|
*/
|
||||||
|
export type RuntimeLaunchHandler = (
|
||||||
|
runtime: RuntimeName,
|
||||||
|
extraArgs: string[],
|
||||||
|
yolo: boolean,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire `<runtime>` and `yolo <runtime>` subcommands onto `program` using a
|
||||||
|
* pluggable launch handler. Separated from `registerLaunchCommands` so tests
|
||||||
|
* can inject a spy and verify argument forwarding.
|
||||||
|
*/
|
||||||
|
export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunchHandler): void {
|
||||||
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
for (const runtime of ['claude', 'codex', 'opencode', 'pi'] as const) {
|
||||||
program
|
program
|
||||||
.command(runtime)
|
.command(runtime)
|
||||||
@@ -686,11 +781,10 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
.allowUnknownOption(true)
|
.allowUnknownOption(true)
|
||||||
.allowExcessArguments(true)
|
.allowExcessArguments(true)
|
||||||
.action((_opts: unknown, cmd: Command) => {
|
.action((_opts: unknown, cmd: Command) => {
|
||||||
launchRuntime(runtime, cmd.args, false);
|
handler(runtime, cmd.args, false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yolo mode
|
|
||||||
program
|
program
|
||||||
.command('yolo <runtime>')
|
.command('yolo <runtime>')
|
||||||
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
.description('Launch a runtime in dangerous-permissions mode (claude|codex|opencode|pi)')
|
||||||
@@ -704,8 +798,21 @@ export function registerLaunchCommands(program: Command): void {
|
|||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
launchRuntime(runtime as RuntimeName, cmd.args, true);
|
// Commander includes declared positional arguments (`<runtime>`) in
|
||||||
|
// `cmd.args` alongside any trailing excess args. Slice off the first
|
||||||
|
// element so we forward only true excess args — otherwise the runtime
|
||||||
|
// name leaks into the underlying CLI as an initial positional arg,
|
||||||
|
// which Claude Code interprets as the first user message.
|
||||||
|
// Regression test: launch.spec.ts, issue mosaicstack/stack#454.
|
||||||
|
handler(runtime as RuntimeName, cmd.args.slice(1), true);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerLaunchCommands(program: Command): void {
|
||||||
|
// Runtime launchers + yolo mode wired to the real process-replacing launcher.
|
||||||
|
registerRuntimeLaunchers(program, (runtime, extraArgs, yolo) => {
|
||||||
|
launchRuntime(runtime, extraArgs, yolo);
|
||||||
|
});
|
||||||
|
|
||||||
// Coord (mission orchestrator)
|
// Coord (mission orchestrator)
|
||||||
program
|
program
|
||||||
|
|||||||
134
packages/mosaic/src/config/file-adapter.test.ts
Normal file
134
packages/mosaic/src/config/file-adapter.test.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { FileConfigAdapter, DEFAULT_SEED_FILES } from './file-adapter.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for the `FileConfigAdapter.syncFramework` seed behavior.
|
||||||
|
*
|
||||||
|
* Background: the bash installer (`framework/install.sh`) and this TS wizard
|
||||||
|
* path both seed framework-contract files from `framework/defaults/` into the
|
||||||
|
* user's mosaic home on first install. Before this fix:
|
||||||
|
*
|
||||||
|
* - The bash installer only seeded `AGENTS.md` and `STANDARDS.md`, leaving
|
||||||
|
* `TOOLS.md` missing despite it being listed as mandatory in the
|
||||||
|
* AGENTS.md load order (position 5).
|
||||||
|
* - The TS wizard iterated every file in `defaults/` and copied it to the
|
||||||
|
* mosaic home root — including `defaults/SOUL.md` (hardcoded "Jarvis"),
|
||||||
|
* `defaults/USER.md` (placeholder), and internal framework files like
|
||||||
|
* `README.md` and `AUDIT-*.md`. That clobbered the identity flow on
|
||||||
|
* fresh installs and leaked framework-internal clutter into the user's
|
||||||
|
* home directory.
|
||||||
|
*
|
||||||
|
* This suite pins the whitelist and the preservation semantics so both
|
||||||
|
* regressions stay fixed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function makeFixture(): { sourceDir: string; mosaicHome: string; defaultsDir: string } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'mosaic-file-adapter-'));
|
||||||
|
const sourceDir = join(root, 'source');
|
||||||
|
const mosaicHome = join(root, 'mosaic-home');
|
||||||
|
const defaultsDir = join(sourceDir, 'defaults');
|
||||||
|
|
||||||
|
mkdirSync(defaultsDir, { recursive: true });
|
||||||
|
mkdirSync(mosaicHome, { recursive: true });
|
||||||
|
|
||||||
|
// Framework-contract defaults we expect the wizard to seed.
|
||||||
|
writeFileSync(join(defaultsDir, 'AGENTS.md'), '# AGENTS default\n');
|
||||||
|
writeFileSync(join(defaultsDir, 'STANDARDS.md'), '# STANDARDS default\n');
|
||||||
|
writeFileSync(join(defaultsDir, 'TOOLS.md'), '# TOOLS default\n');
|
||||||
|
|
||||||
|
// Non-contract files we must NOT seed on first install.
|
||||||
|
writeFileSync(join(defaultsDir, 'SOUL.md'), '# SOUL default (should not be seeded)\n');
|
||||||
|
writeFileSync(join(defaultsDir, 'USER.md'), '# USER default (should not be seeded)\n');
|
||||||
|
writeFileSync(join(defaultsDir, 'README.md'), '# README (framework-internal)\n');
|
||||||
|
writeFileSync(
|
||||||
|
join(defaultsDir, 'AUDIT-2026-02-17-framework-consistency.md'),
|
||||||
|
'# Audit snapshot\n',
|
||||||
|
);
|
||||||
|
|
||||||
|
return { sourceDir, mosaicHome, defaultsDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('FileConfigAdapter.syncFramework — defaults seeding', () => {
|
||||||
|
let fixture: ReturnType<typeof makeFixture>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = makeFixture();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(join(fixture.sourceDir, '..'), { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the three framework-contract files on a fresh mosaic home', async () => {
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
|
||||||
|
await adapter.syncFramework('fresh');
|
||||||
|
|
||||||
|
for (const name of DEFAULT_SEED_FILES) {
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, name))).toBe(true);
|
||||||
|
}
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toContain(
|
||||||
|
'# TOOLS default',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT seed SOUL.md or USER.md from defaults/ (wizard stages own those)', async () => {
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
|
||||||
|
await adapter.syncFramework('fresh');
|
||||||
|
|
||||||
|
// SOUL.md and USER.md live in defaults/ for historical reasons, but they
|
||||||
|
// are template-rendered per-user by the wizard stages. Seeding them here
|
||||||
|
// would clobber the identity flow and leak placeholder content.
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'SOUL.md'))).toBe(false);
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'USER.md'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT seed README.md or AUDIT-*.md from defaults/', async () => {
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
|
||||||
|
await adapter.syncFramework('fresh');
|
||||||
|
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'README.md'))).toBe(false);
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'AUDIT-2026-02-17-framework-consistency.md'))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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.
|
||||||
|
writeFileSync(join(fixture.sourceDir, 'AGENTS.md'), '# shipped AGENTS from source root\n');
|
||||||
|
|
||||||
|
writeFileSync(join(fixture.mosaicHome, 'TOOLS.md'), '# user-customized TOOLS\n');
|
||||||
|
writeFileSync(join(fixture.mosaicHome, 'AGENTS.md'), '# user-customized AGENTS\n');
|
||||||
|
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
await adapter.syncFramework('keep');
|
||||||
|
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'TOOLS.md'), 'utf-8')).toBe(
|
||||||
|
'# user-customized TOOLS\n',
|
||||||
|
);
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'AGENTS.md'), 'utf-8')).toBe(
|
||||||
|
'# user-customized AGENTS\n',
|
||||||
|
);
|
||||||
|
// And the missing contract file still gets seeded.
|
||||||
|
expect(readFileSync(join(fixture.mosaicHome, 'STANDARDS.md'), 'utf-8')).toContain(
|
||||||
|
'# STANDARDS default',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for seeding when defaults/ dir does not exist', async () => {
|
||||||
|
rmSync(fixture.defaultsDir, { recursive: true });
|
||||||
|
|
||||||
|
const adapter = new FileConfigAdapter(fixture.mosaicHome, fixture.sourceDir);
|
||||||
|
await expect(adapter.syncFramework('fresh')).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(existsSync(join(fixture.mosaicHome, 'TOOLS.md'))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { readFileSync, existsSync, readdirSync, statSync, copyFileSync } from 'node:fs';
|
import { readFileSync, existsSync, statSync, copyFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Framework-contract files that `syncFramework` seeds from `framework/defaults/`
|
||||||
|
* into the mosaic home root on first install. These are the only files the
|
||||||
|
* wizard is allowed to touch as a one-time seed — SOUL.md and USER.md are
|
||||||
|
* generated from templates by their respective wizard stages with
|
||||||
|
* user-supplied values, and anything else under `defaults/` (README.md,
|
||||||
|
* audit snapshots, etc.) is framework-internal and must not leak into the
|
||||||
|
* user's mosaic home.
|
||||||
|
*
|
||||||
|
* This list must match the explicit seed loop in
|
||||||
|
* packages/mosaic/framework/install.sh.
|
||||||
|
*/
|
||||||
|
export const DEFAULT_SEED_FILES = ['AGENTS.md', 'STANDARDS.md', 'TOOLS.md'] as const;
|
||||||
import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
|
import type { ConfigService, ConfigSection, ResolvedConfig } from './config-service.js';
|
||||||
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
import type { SoulConfig, UserConfig, ToolsConfig, InstallAction } from '../types.js';
|
||||||
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
import { soulSchema, userSchema, toolsSchema } from './schemas.js';
|
||||||
@@ -131,9 +145,24 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async syncFramework(action: InstallAction): Promise<void> {
|
async syncFramework(action: InstallAction): Promise<void> {
|
||||||
|
// Must match PRESERVE_PATHS in packages/mosaic/framework/install.sh so
|
||||||
|
// the bash and TS install paths have the same upgrade-preservation
|
||||||
|
// semantics. Contract files (AGENTS.md, STANDARDS.md, TOOLS.md) are
|
||||||
|
// seeded from defaults/ on first install and preserved thereafter;
|
||||||
|
// identity files (SOUL.md, USER.md) are generated by wizard stages and
|
||||||
|
// must never be touched by the framework sync.
|
||||||
const preservePaths =
|
const preservePaths =
|
||||||
action === 'keep' || action === 'reconfigure'
|
action === 'keep' || action === 'reconfigure'
|
||||||
? ['SOUL.md', 'USER.md', 'TOOLS.md', 'memory']
|
? [
|
||||||
|
'AGENTS.md',
|
||||||
|
'SOUL.md',
|
||||||
|
'USER.md',
|
||||||
|
'TOOLS.md',
|
||||||
|
'STANDARDS.md',
|
||||||
|
'memory',
|
||||||
|
'sources',
|
||||||
|
'credentials',
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
syncDirectory(this.sourceDir, this.mosaicHome, {
|
syncDirectory(this.sourceDir, this.mosaicHome, {
|
||||||
@@ -141,20 +170,23 @@ export class FileConfigAdapter implements ConfigService {
|
|||||||
excludeGit: true,
|
excludeGit: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Copy default root-level .md files (AGENTS.md, STANDARDS.md, etc.)
|
// Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md)
|
||||||
// from framework/defaults/ into mosaicHome root if they don't exist yet.
|
// from framework/defaults/ into the mosaic home root if they don't
|
||||||
// These are framework contracts — only written on first install, never
|
// exist yet. These are written on first install only and are never
|
||||||
// overwritten (user may have customized them).
|
// overwritten afterwards — the user may have customized them.
|
||||||
|
//
|
||||||
|
// SOUL.md and USER.md are deliberately NOT seeded here. They are
|
||||||
|
// generated from templates by the soul/user wizard stages with
|
||||||
|
// user-supplied values; seeding them from defaults would clobber the
|
||||||
|
// identity flow and leak placeholder content into the mosaic home.
|
||||||
const defaultsDir = join(this.sourceDir, 'defaults');
|
const defaultsDir = join(this.sourceDir, 'defaults');
|
||||||
if (existsSync(defaultsDir)) {
|
if (existsSync(defaultsDir)) {
|
||||||
for (const entry of readdirSync(defaultsDir)) {
|
for (const entry of DEFAULT_SEED_FILES) {
|
||||||
|
const src = join(defaultsDir, entry);
|
||||||
const dest = join(this.mosaicHome, entry);
|
const dest = join(this.mosaicHome, entry);
|
||||||
if (!existsSync(dest)) {
|
if (existsSync(dest)) continue;
|
||||||
const src = join(defaultsDir, entry);
|
if (!existsSync(src) || !statSync(src).isFile()) continue;
|
||||||
if (statSync(src).isFile()) {
|
copyFileSync(src, dest);
|
||||||
copyFileSync(src, dest);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,53 @@ export const DEFAULTS = {
|
|||||||
| (add your git providers here) | | | |`,
|
| (add your git providers here) | | | |`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Preset intent categories with display labels and suggested agent names. */
|
||||||
|
export const INTENT_PRESETS: Record<
|
||||||
|
string,
|
||||||
|
{ label: string; hint: string; suggestedName: string }
|
||||||
|
> = {
|
||||||
|
general: {
|
||||||
|
label: 'General purpose assistant',
|
||||||
|
hint: 'Versatile helper for any task',
|
||||||
|
suggestedName: 'Mosaic',
|
||||||
|
},
|
||||||
|
'software-dev': {
|
||||||
|
label: 'Software development',
|
||||||
|
hint: 'Coding, debugging, architecture',
|
||||||
|
suggestedName: 'Forge',
|
||||||
|
},
|
||||||
|
devops: {
|
||||||
|
label: 'DevOps & infrastructure',
|
||||||
|
hint: 'CI/CD, containers, monitoring',
|
||||||
|
suggestedName: 'Sentinel',
|
||||||
|
},
|
||||||
|
research: {
|
||||||
|
label: 'Research & analysis',
|
||||||
|
hint: 'Data analysis, literature review',
|
||||||
|
suggestedName: 'Atlas',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
label: 'Content & writing',
|
||||||
|
hint: 'Documentation, copywriting, editing',
|
||||||
|
suggestedName: 'Muse',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
label: 'Custom',
|
||||||
|
hint: 'Describe your own use case',
|
||||||
|
suggestedName: 'Mosaic',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detect LLM provider type from an API key prefix.
|
||||||
|
*/
|
||||||
|
export function detectProviderType(key: string): 'anthropic' | 'openai' | 'none' {
|
||||||
|
if (!key) return 'none';
|
||||||
|
if (key.startsWith('sk-ant-')) return 'anthropic';
|
||||||
|
if (key.startsWith('sk-')) return 'openai';
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
export const RECOMMENDED_SKILLS = new Set([
|
export const RECOMMENDED_SKILLS = new Set([
|
||||||
'brainstorming',
|
'brainstorming',
|
||||||
'code-review-excellence',
|
'code-review-excellence',
|
||||||
|
|||||||
129
packages/mosaic/src/stages/agent-intent.spec.ts
Normal file
129
packages/mosaic/src/stages/agent-intent.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import type { WizardState } from '../types.js';
|
||||||
|
import { agentIntentStage } from './agent-intent.js';
|
||||||
|
|
||||||
|
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
intro: vi.fn(),
|
||||||
|
outro: vi.fn(),
|
||||||
|
note: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
text: vi.fn().mockResolvedValue('Mosaic'),
|
||||||
|
confirm: vi.fn().mockResolvedValue(false),
|
||||||
|
select: vi.fn().mockResolvedValue('general'),
|
||||||
|
multiselect: vi.fn(),
|
||||||
|
groupMultiselect: vi.fn(),
|
||||||
|
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||||
|
separator: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeState(): WizardState {
|
||||||
|
return {
|
||||||
|
mosaicHome: '/tmp/mosaic',
|
||||||
|
sourceDir: '/tmp/mosaic',
|
||||||
|
mode: 'quick',
|
||||||
|
installAction: 'fresh',
|
||||||
|
soul: {},
|
||||||
|
user: {},
|
||||||
|
tools: {},
|
||||||
|
runtimes: { detected: [], mcpConfigured: false },
|
||||||
|
selectedSkills: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('agentIntentStage', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses default intent and name in headless mode', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
delete process.env['MOSAIC_AGENT_INTENT'];
|
||||||
|
delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await agentIntentStage(p, state);
|
||||||
|
|
||||||
|
expect(state.agentIntent).toBe('general');
|
||||||
|
expect(state.soul.agentName).toBe('Mosaic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads intent from MOSAIC_AGENT_INTENT env var', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
process.env['MOSAIC_AGENT_INTENT'] = 'software-dev';
|
||||||
|
delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await agentIntentStage(p, state);
|
||||||
|
|
||||||
|
expect(state.agentIntent).toBe('software-dev');
|
||||||
|
expect(state.soul.agentName).toBe('Forge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors MOSAIC_AGENT_NAME env var override', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
process.env['MOSAIC_AGENT_INTENT'] = 'devops';
|
||||||
|
process.env['MOSAIC_AGENT_NAME'] = 'MyBot';
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await agentIntentStage(p, state);
|
||||||
|
|
||||||
|
expect(state.agentIntent).toBe('devops');
|
||||||
|
expect(state.soul.agentName).toBe('MyBot');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to general for unknown intent values', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
process.env['MOSAIC_AGENT_INTENT'] = 'nonexistent';
|
||||||
|
delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await agentIntentStage(p, state);
|
||||||
|
|
||||||
|
expect(state.agentIntent).toBe('general');
|
||||||
|
expect(state.soul.agentName).toBe('Mosaic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts for intent and name in interactive mode', async () => {
|
||||||
|
delete process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
const origIsTTY = process.stdin.isTTY;
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
|
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter({
|
||||||
|
select: vi.fn().mockResolvedValue('research'),
|
||||||
|
text: vi.fn().mockResolvedValue('Atlas'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await agentIntentStage(p, state);
|
||||||
|
|
||||||
|
expect(state.agentIntent).toBe('research');
|
||||||
|
expect(state.soul.agentName).toBe('Atlas');
|
||||||
|
expect(p.select).toHaveBeenCalled();
|
||||||
|
expect(p.text).toHaveBeenCalled();
|
||||||
|
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps content intent to Muse suggested name', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
process.env['MOSAIC_AGENT_INTENT'] = 'content';
|
||||||
|
delete process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await agentIntentStage(p, state);
|
||||||
|
|
||||||
|
expect(state.agentIntent).toBe('content');
|
||||||
|
expect(state.soul.agentName).toBe('Muse');
|
||||||
|
});
|
||||||
|
});
|
||||||
64
packages/mosaic/src/stages/agent-intent.ts
Normal file
64
packages/mosaic/src/stages/agent-intent.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import type { WizardPrompter } from '../prompter/interface.js';
|
||||||
|
import type { AgentIntent, WizardState } from '../types.js';
|
||||||
|
import { INTENT_PRESETS } from '../constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent intent + naming stage — deterministic (no LLM required).
|
||||||
|
*
|
||||||
|
* The user picks an intent category from presets, the system proposes a
|
||||||
|
* thematic name, and the user confirms or overrides it.
|
||||||
|
*
|
||||||
|
* In headless mode, reads from `MOSAIC_AGENT_INTENT` and `MOSAIC_AGENT_NAME`.
|
||||||
|
*/
|
||||||
|
export async function agentIntentStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||||
|
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
|
|
||||||
|
if (isHeadless) {
|
||||||
|
const intentEnv = process.env['MOSAIC_AGENT_INTENT'] ?? 'general';
|
||||||
|
const nameEnv = process.env['MOSAIC_AGENT_NAME'];
|
||||||
|
const preset = INTENT_PRESETS[intentEnv] ?? INTENT_PRESETS['general']!;
|
||||||
|
state.agentIntent ??= (intentEnv in INTENT_PRESETS ? intentEnv : 'general') as AgentIntent;
|
||||||
|
// Respect existing agentName (e.g. from CLI overrides) — only set from
|
||||||
|
// env/preset if not already populated.
|
||||||
|
state.soul.agentName ??= nameEnv ?? preset.suggestedName;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.separator();
|
||||||
|
p.note(
|
||||||
|
'Tell us what this agent will primarily help you with.\n' +
|
||||||
|
"We'll suggest a name based on your choice — you can always change it.",
|
||||||
|
'Agent Identity',
|
||||||
|
);
|
||||||
|
|
||||||
|
const intentOptions = Object.entries(INTENT_PRESETS).map(([value, info]) => ({
|
||||||
|
value: value as AgentIntent,
|
||||||
|
label: info.label,
|
||||||
|
hint: info.hint,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const intent = await p.select<AgentIntent>({
|
||||||
|
message: 'What will this agent primarily help you with?',
|
||||||
|
options: intentOptions,
|
||||||
|
initialValue: 'general' as AgentIntent,
|
||||||
|
});
|
||||||
|
|
||||||
|
state.agentIntent = intent;
|
||||||
|
|
||||||
|
const preset = INTENT_PRESETS[intent];
|
||||||
|
const suggestedName = preset?.suggestedName ?? 'Mosaic';
|
||||||
|
|
||||||
|
const name = await p.text({
|
||||||
|
message: `Your agent will be named "${suggestedName}". Press Enter to accept or type a new name`,
|
||||||
|
initialValue: suggestedName,
|
||||||
|
defaultValue: suggestedName,
|
||||||
|
validate: (v) => {
|
||||||
|
if (v.length === 0) return 'Name cannot be empty';
|
||||||
|
if (v.length > 50) return 'Name must be under 50 characters';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
state.soul.agentName = name;
|
||||||
|
p.log(`Agent name set to: ${name}`);
|
||||||
|
}
|
||||||
@@ -126,6 +126,14 @@ export interface GatewayConfigStageOptions {
|
|||||||
portOverride?: number;
|
portOverride?: number;
|
||||||
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
/** Skip the `npm install -g @mosaicstack/gateway` step (local build / tests). */
|
||||||
skipInstall?: boolean;
|
skipInstall?: boolean;
|
||||||
|
/**
|
||||||
|
* Pre-collected provider API key (from the provider-setup stage or Quick
|
||||||
|
* Start path). When set, the gateway-config stage will skip the interactive
|
||||||
|
* API key prompt and use this value directly.
|
||||||
|
*/
|
||||||
|
providerKey?: string;
|
||||||
|
/** Provider type detected from the key prefix. */
|
||||||
|
providerType?: 'anthropic' | 'openai' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayConfigStageResult {
|
export interface GatewayConfigStageResult {
|
||||||
@@ -314,6 +322,8 @@ export async function gatewayConfigStage(
|
|||||||
envFile: ENV_FILE,
|
envFile: ENV_FILE,
|
||||||
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
mosaicConfigFile: MOSAIC_CONFIG_FILE,
|
||||||
gatewayHome: GATEWAY_HOME,
|
gatewayHome: GATEWAY_HOME,
|
||||||
|
providerKey: opts.providerKey,
|
||||||
|
providerType: opts.providerType,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof GatewayConfigValidationError) {
|
if (err instanceof GatewayConfigValidationError) {
|
||||||
@@ -389,6 +399,10 @@ interface CollectOptions {
|
|||||||
envFile: string;
|
envFile: string;
|
||||||
mosaicConfigFile: string;
|
mosaicConfigFile: string;
|
||||||
gatewayHome: string;
|
gatewayHome: string;
|
||||||
|
/** Pre-collected API key — skips the interactive prompt when set. */
|
||||||
|
providerKey?: string;
|
||||||
|
/** Provider type — determines the env var name for the key. */
|
||||||
|
providerType?: 'anthropic' | 'openai' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Raised by the config stage when headless env validation fails. */
|
/** Raised by the config stage when headless env validation fails. */
|
||||||
@@ -466,10 +480,15 @@ async function collectAndWriteConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
anthropicKey = await p.text({
|
if (opts.providerKey) {
|
||||||
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
anthropicKey = opts.providerKey;
|
||||||
defaultValue: '',
|
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`);
|
||||||
});
|
} else {
|
||||||
|
anthropicKey = await p.text({
|
||||||
|
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
hostname = await p.text({
|
hostname = await p.text({
|
||||||
message: 'Web UI hostname (for browser access)',
|
message: 'Web UI hostname (for browser access)',
|
||||||
@@ -508,7 +527,11 @@ async function collectAndWriteConfig(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (anthropicKey) {
|
if (anthropicKey) {
|
||||||
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
if (opts.providerType === 'openai') {
|
||||||
|
envLines.push(`OPENAI_API_KEY=${anthropicKey}`);
|
||||||
|
} else {
|
||||||
|
envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
writeFileSync(opts.envFile, envLines.join('\n') + '\n', { mode: 0o600 });
|
||||||
|
|||||||
118
packages/mosaic/src/stages/provider-setup.spec.ts
Normal file
118
packages/mosaic/src/stages/provider-setup.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
import type { WizardState } from '../types.js';
|
||||||
|
import { providerSetupStage } from './provider-setup.js';
|
||||||
|
|
||||||
|
function buildPrompter(overrides: Partial<Record<string, unknown>> = {}) {
|
||||||
|
return {
|
||||||
|
intro: vi.fn(),
|
||||||
|
outro: vi.fn(),
|
||||||
|
note: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
text: vi.fn().mockResolvedValue(''),
|
||||||
|
confirm: vi.fn().mockResolvedValue(false),
|
||||||
|
select: vi.fn().mockResolvedValue('general'),
|
||||||
|
multiselect: vi.fn(),
|
||||||
|
groupMultiselect: vi.fn(),
|
||||||
|
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
|
||||||
|
separator: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeState(): WizardState {
|
||||||
|
return {
|
||||||
|
mosaicHome: '/tmp/mosaic',
|
||||||
|
sourceDir: '/tmp/mosaic',
|
||||||
|
mode: 'quick',
|
||||||
|
installAction: 'fresh',
|
||||||
|
soul: {},
|
||||||
|
user: {},
|
||||||
|
tools: {},
|
||||||
|
runtimes: { detected: [], mcpConfigured: false },
|
||||||
|
selectedSkills: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('providerSetupStage', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects Anthropic key from prefix in headless mode', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
process.env['MOSAIC_ANTHROPIC_API_KEY'] = 'sk-ant-api03-test123';
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await providerSetupStage(p, state);
|
||||||
|
|
||||||
|
expect(state.providerKey).toBe('sk-ant-api03-test123');
|
||||||
|
expect(state.providerType).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects OpenAI key from prefix in headless mode', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
process.env['MOSAIC_OPENAI_API_KEY'] = 'sk-proj-test123';
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await providerSetupStage(p, state);
|
||||||
|
|
||||||
|
expect(state.providerKey).toBe('sk-proj-test123');
|
||||||
|
expect(state.providerType).toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets provider type to none when no key is provided in headless mode', async () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
delete process.env['MOSAIC_ANTHROPIC_API_KEY'];
|
||||||
|
delete process.env['MOSAIC_OPENAI_API_KEY'];
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter();
|
||||||
|
|
||||||
|
await providerSetupStage(p, state);
|
||||||
|
|
||||||
|
expect(state.providerKey).toBeUndefined();
|
||||||
|
expect(state.providerType).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prompts for key in interactive mode', async () => {
|
||||||
|
delete process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
// Simulate a TTY
|
||||||
|
const origIsTTY = process.stdin.isTTY;
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
|
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter({
|
||||||
|
text: vi.fn().mockResolvedValue('sk-ant-api03-interactive'),
|
||||||
|
});
|
||||||
|
|
||||||
|
await providerSetupStage(p, state);
|
||||||
|
|
||||||
|
expect(p.text).toHaveBeenCalled();
|
||||||
|
expect(state.providerKey).toBe('sk-ant-api03-interactive');
|
||||||
|
expect(state.providerType).toBe('anthropic');
|
||||||
|
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty key in interactive mode', async () => {
|
||||||
|
delete process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
const origIsTTY = process.stdin.isTTY;
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
|
|
||||||
|
const state = makeState();
|
||||||
|
const p = buildPrompter({
|
||||||
|
text: vi.fn().mockResolvedValue(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
await providerSetupStage(p, state);
|
||||||
|
|
||||||
|
expect(state.providerType).toBe('none');
|
||||||
|
expect(state.providerKey).toBeUndefined();
|
||||||
|
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: origIsTTY, configurable: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
54
packages/mosaic/src/stages/provider-setup.ts
Normal file
54
packages/mosaic/src/stages/provider-setup.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { WizardPrompter } from '../prompter/interface.js';
|
||||||
|
import type { WizardState } from '../types.js';
|
||||||
|
import { detectProviderType } from '../constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider setup stage — collects the user's LLM API key and detects the
|
||||||
|
* provider type from the key prefix.
|
||||||
|
*
|
||||||
|
* In headless mode, reads from `MOSAIC_ANTHROPIC_API_KEY` or `MOSAIC_OPENAI_API_KEY`.
|
||||||
|
*/
|
||||||
|
export async function providerSetupStage(p: WizardPrompter, state: WizardState): Promise<void> {
|
||||||
|
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
|
|
||||||
|
if (isHeadless) {
|
||||||
|
const anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
|
||||||
|
const openaiKey = process.env['MOSAIC_OPENAI_API_KEY'] ?? '';
|
||||||
|
const key = anthropicKey || openaiKey;
|
||||||
|
state.providerKey = key || undefined;
|
||||||
|
state.providerType = detectProviderType(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
p.separator();
|
||||||
|
p.note(
|
||||||
|
'Configure your LLM provider so the agent has a brain.\n' +
|
||||||
|
'Anthropic (Claude) and OpenAI are supported.\n' +
|
||||||
|
'You can skip this and add a key later via `mosaic configure`.',
|
||||||
|
'LLM Provider',
|
||||||
|
);
|
||||||
|
|
||||||
|
const key = await p.text({
|
||||||
|
message: 'API key (paste your Anthropic or OpenAI key, or press Enter to skip)',
|
||||||
|
defaultValue: '',
|
||||||
|
placeholder: 'sk-ant-api03-... or sk-...',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (key) {
|
||||||
|
const provider = detectProviderType(key);
|
||||||
|
state.providerKey = key;
|
||||||
|
state.providerType = provider;
|
||||||
|
|
||||||
|
if (provider === 'anthropic') {
|
||||||
|
p.log('Detected provider: Anthropic (Claude)');
|
||||||
|
} else if (provider === 'openai') {
|
||||||
|
p.log('Detected provider: OpenAI');
|
||||||
|
} else {
|
||||||
|
p.log('Provider auto-detection failed. Key will be stored as ANTHROPIC_API_KEY.');
|
||||||
|
state.providerType = 'anthropic';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.providerType = 'none';
|
||||||
|
p.log('No API key provided. You can add one later with `mosaic configure`.');
|
||||||
|
}
|
||||||
|
}
|
||||||
98
packages/mosaic/src/stages/quick-start.ts
Normal file
98
packages/mosaic/src/stages/quick-start.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { WizardPrompter } from '../prompter/interface.js';
|
||||||
|
import type { ConfigService } from '../config/config-service.js';
|
||||||
|
import type { WizardState } from '../types.js';
|
||||||
|
import { DEFAULTS } from '../constants.js';
|
||||||
|
import { providerSetupStage } from './provider-setup.js';
|
||||||
|
import { runtimeSetupStage } from './runtime-setup.js';
|
||||||
|
import { hooksPreviewStage } from './hooks-preview.js';
|
||||||
|
import { skillsSelectStage } from './skills-select.js';
|
||||||
|
import { finalizeStage } from './finalize.js';
|
||||||
|
import { gatewayConfigStage } from './gateway-config.js';
|
||||||
|
import { gatewayBootstrapStage } from './gateway-bootstrap.js';
|
||||||
|
|
||||||
|
export interface QuickStartOptions {
|
||||||
|
skipGateway?: boolean;
|
||||||
|
gatewayHost?: string;
|
||||||
|
gatewayPort?: number;
|
||||||
|
gatewayPortOverride?: number;
|
||||||
|
skipGatewayNpmInstall?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick Start path — minimal questions to get a working agent.
|
||||||
|
*
|
||||||
|
* 1. Provider API key
|
||||||
|
* 2. Admin email + password (via gateway bootstrap)
|
||||||
|
* 3. Everything else uses defaults.
|
||||||
|
*
|
||||||
|
* Target: under 90 seconds for a returning user.
|
||||||
|
*/
|
||||||
|
export async function quickStartPath(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
state: WizardState,
|
||||||
|
configService: ConfigService,
|
||||||
|
options: QuickStartOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
state.mode = 'quick';
|
||||||
|
|
||||||
|
// 1. Provider setup (first question)
|
||||||
|
await providerSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// Apply sensible defaults for everything else
|
||||||
|
state.soul.agentName ??= 'Mosaic';
|
||||||
|
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||||
|
state.soul.communicationStyle ??= 'direct';
|
||||||
|
state.user.background = DEFAULTS.background;
|
||||||
|
state.user.accessibilitySection = DEFAULTS.accessibilitySection;
|
||||||
|
state.user.personalBoundaries = DEFAULTS.personalBoundaries;
|
||||||
|
state.tools.gitProviders = [];
|
||||||
|
state.tools.credentialsLocation = DEFAULTS.credentialsLocation;
|
||||||
|
state.tools.customToolsSection = DEFAULTS.customToolsSection;
|
||||||
|
|
||||||
|
// Runtime detection (auto, no user input in quick mode)
|
||||||
|
await runtimeSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// Hooks (auto-accept in quick mode for Claude)
|
||||||
|
await hooksPreviewStage(prompter, state);
|
||||||
|
|
||||||
|
// Skills (recommended set, no user input in quick mode)
|
||||||
|
await skillsSelectStage(prompter, state);
|
||||||
|
|
||||||
|
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||||
|
await finalizeStage(prompter, state, configService);
|
||||||
|
|
||||||
|
// Gateway config + bootstrap
|
||||||
|
if (!options.skipGateway) {
|
||||||
|
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
|
host: options.gatewayHost ?? 'localhost',
|
||||||
|
defaultPort: options.gatewayPort ?? 14242,
|
||||||
|
portOverride: options.gatewayPortOverride,
|
||||||
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
|
providerKey: state.providerKey,
|
||||||
|
providerType: state.providerType ?? 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
|
if (headlessRun) {
|
||||||
|
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
|
host: configResult.host,
|
||||||
|
port: configResult.port,
|
||||||
|
});
|
||||||
|
if (!bootstrapResult.completed) {
|
||||||
|
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
packages/mosaic/src/stages/wizard-menu.spec.ts
Normal file
118
packages/mosaic/src/stages/wizard-menu.spec.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import type { MenuSection } from '../types.js';
|
||||||
|
import { detectProviderType, INTENT_PRESETS } from '../constants.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for the drill-down menu system and its supporting utilities.
|
||||||
|
*
|
||||||
|
* The menu loop itself is in wizard.ts and is hard to unit test in isolation
|
||||||
|
* because it orchestrates many async stages. These tests verify the building
|
||||||
|
* blocks: provider detection, intent presets, and the WizardState shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe('detectProviderType', () => {
|
||||||
|
it('detects Anthropic from sk-ant- prefix', () => {
|
||||||
|
expect(detectProviderType('sk-ant-api03-abc123')).toBe('anthropic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects OpenAI from sk- prefix', () => {
|
||||||
|
expect(detectProviderType('sk-proj-abc123')).toBe('openai');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns none for empty string', () => {
|
||||||
|
expect(detectProviderType('')).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns none for unrecognized prefix', () => {
|
||||||
|
expect(detectProviderType('gsk_abc123')).toBe('none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('INTENT_PRESETS', () => {
|
||||||
|
it('has all expected intent categories', () => {
|
||||||
|
expect(Object.keys(INTENT_PRESETS)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'general',
|
||||||
|
'software-dev',
|
||||||
|
'devops',
|
||||||
|
'research',
|
||||||
|
'content',
|
||||||
|
'custom',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each preset has label, hint, and suggestedName', () => {
|
||||||
|
for (const [key, preset] of Object.entries(INTENT_PRESETS)) {
|
||||||
|
expect(preset.label, `${key}.label`).toBeTruthy();
|
||||||
|
expect(preset.hint, `${key}.hint`).toBeTruthy();
|
||||||
|
expect(preset.suggestedName, `${key}.suggestedName`).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps software-dev to Forge', () => {
|
||||||
|
expect(INTENT_PRESETS['software-dev']?.suggestedName).toBe('Forge');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps devops to Sentinel', () => {
|
||||||
|
expect(INTENT_PRESETS['devops']?.suggestedName).toBe('Sentinel');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('WizardState completedSections', () => {
|
||||||
|
it('tracks completed sections as a Set', () => {
|
||||||
|
const completed = new Set<MenuSection>();
|
||||||
|
completed.add('providers');
|
||||||
|
completed.add('identity');
|
||||||
|
|
||||||
|
expect(completed.has('providers')).toBe(true);
|
||||||
|
expect(completed.has('identity')).toBe(true);
|
||||||
|
expect(completed.has('skills')).toBe(false);
|
||||||
|
expect(completed.size).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('headless backward compat', () => {
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MOSAIC_ASSUME_YES=1 triggers headless path', () => {
|
||||||
|
process.env['MOSAIC_ASSUME_YES'] = '1';
|
||||||
|
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
|
expect(isHeadless).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('non-TTY triggers headless path', () => {
|
||||||
|
delete process.env['MOSAIC_ASSUME_YES'];
|
||||||
|
// In test environments, process.stdin.isTTY is typically undefined (falsy)
|
||||||
|
const isHeadless = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
|
expect(isHeadless).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all headless env vars are recognized', () => {
|
||||||
|
// This test documents the expected env vars for headless installs.
|
||||||
|
const headlessVars = [
|
||||||
|
'MOSAIC_ASSUME_YES',
|
||||||
|
'MOSAIC_ADMIN_NAME',
|
||||||
|
'MOSAIC_ADMIN_EMAIL',
|
||||||
|
'MOSAIC_ADMIN_PASSWORD',
|
||||||
|
'MOSAIC_GATEWAY_PORT',
|
||||||
|
'MOSAIC_HOSTNAME',
|
||||||
|
'MOSAIC_CORS_ORIGIN',
|
||||||
|
'MOSAIC_STORAGE_TIER',
|
||||||
|
'MOSAIC_DATABASE_URL',
|
||||||
|
'MOSAIC_VALKEY_URL',
|
||||||
|
'MOSAIC_ANTHROPIC_API_KEY',
|
||||||
|
'MOSAIC_AGENT_NAME',
|
||||||
|
'MOSAIC_AGENT_INTENT',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Just verify none of them throw when accessed
|
||||||
|
for (const v of headlessVars) {
|
||||||
|
expect(() => process.env[v]).not.toThrow();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,19 @@ export type InstallAction = 'fresh' | 'keep' | 'reconfigure' | 'reset';
|
|||||||
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
export type CommunicationStyle = 'direct' | 'friendly' | 'formal';
|
||||||
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
export type RuntimeName = 'claude' | 'codex' | 'opencode' | 'pi';
|
||||||
|
|
||||||
|
export type MenuSection =
|
||||||
|
| 'quick-start'
|
||||||
|
| 'providers'
|
||||||
|
| 'identity'
|
||||||
|
| 'skills'
|
||||||
|
| 'gateway'
|
||||||
|
| 'advanced'
|
||||||
|
| 'finish';
|
||||||
|
|
||||||
|
export type AgentIntent = 'general' | 'software-dev' | 'devops' | 'research' | 'content' | 'custom';
|
||||||
|
|
||||||
|
export type ProviderType = 'anthropic' | 'openai' | 'none';
|
||||||
|
|
||||||
export interface SoulConfig {
|
export interface SoulConfig {
|
||||||
agentName?: string;
|
agentName?: string;
|
||||||
roleDescription?: string;
|
roleDescription?: string;
|
||||||
@@ -86,4 +99,12 @@ export interface WizardState {
|
|||||||
selectedSkills: string[];
|
selectedSkills: string[];
|
||||||
hooks?: HooksState;
|
hooks?: HooksState;
|
||||||
gateway?: GatewayState;
|
gateway?: GatewayState;
|
||||||
|
/** Tracks which menu sections have been completed in drill-down mode. */
|
||||||
|
completedSections?: Set<MenuSection>;
|
||||||
|
/** The user's chosen agent intent category. */
|
||||||
|
agentIntent?: AgentIntent;
|
||||||
|
/** The LLM provider API key entered during setup. */
|
||||||
|
providerKey?: string;
|
||||||
|
/** Detected provider type based on API key prefix. */
|
||||||
|
providerType?: ProviderType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { WizardPrompter } from './prompter/interface.js';
|
import type { WizardPrompter } from './prompter/interface.js';
|
||||||
import type { ConfigService } from './config/config-service.js';
|
import type { ConfigService } from './config/config-service.js';
|
||||||
import type { WizardState } from './types.js';
|
import type { MenuSection, WizardState } from './types.js';
|
||||||
import { welcomeStage } from './stages/welcome.js';
|
import { welcomeStage } from './stages/welcome.js';
|
||||||
import { detectInstallStage } from './stages/detect-install.js';
|
import { detectInstallStage } from './stages/detect-install.js';
|
||||||
import { modeSelectStage } from './stages/mode-select.js';
|
|
||||||
import { soulSetupStage } from './stages/soul-setup.js';
|
import { soulSetupStage } from './stages/soul-setup.js';
|
||||||
import { userSetupStage } from './stages/user-setup.js';
|
import { userSetupStage } from './stages/user-setup.js';
|
||||||
import { toolsSetupStage } from './stages/tools-setup.js';
|
import { toolsSetupStage } from './stages/tools-setup.js';
|
||||||
@@ -13,6 +12,10 @@ import { skillsSelectStage } from './stages/skills-select.js';
|
|||||||
import { finalizeStage } from './stages/finalize.js';
|
import { finalizeStage } from './stages/finalize.js';
|
||||||
import { gatewayConfigStage } from './stages/gateway-config.js';
|
import { gatewayConfigStage } from './stages/gateway-config.js';
|
||||||
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
|
import { gatewayBootstrapStage } from './stages/gateway-bootstrap.js';
|
||||||
|
import { providerSetupStage } from './stages/provider-setup.js';
|
||||||
|
import { agentIntentStage } from './stages/agent-intent.js';
|
||||||
|
import { quickStartPath } from './stages/quick-start.js';
|
||||||
|
import { DEFAULTS } from './constants.js';
|
||||||
|
|
||||||
export interface WizardOptions {
|
export interface WizardOptions {
|
||||||
mosaicHome: string;
|
mosaicHome: string;
|
||||||
@@ -54,6 +57,7 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
|||||||
tools: {},
|
tools: {},
|
||||||
runtimes: { detected: [], mcpConfigured: false },
|
runtimes: { detected: [], mcpConfigured: false },
|
||||||
selectedSkills: [],
|
selectedSkills: [],
|
||||||
|
completedSections: new Set<MenuSection>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply CLI overrides (strip undefined values)
|
// Apply CLI overrides (strip undefined values)
|
||||||
@@ -90,55 +94,304 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
|||||||
// Stage 2: Existing Install Detection
|
// Stage 2: Existing Install Detection
|
||||||
await detectInstallStage(prompter, state, configService);
|
await detectInstallStage(prompter, state, configService);
|
||||||
|
|
||||||
// Stage 3: Quick Start vs Advanced (skip if keeping existing)
|
// ── Headless bypass ────────────────────────────────────────────────────────
|
||||||
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
// When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path.
|
||||||
await modeSelectStage(prompter, state);
|
// This preserves full backward compatibility with tools/install.sh --yes.
|
||||||
} else if (state.installAction === 'reconfigure') {
|
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
||||||
state.mode = 'advanced';
|
if (headlessRun) {
|
||||||
|
await runHeadlessPath(prompter, state, configService, options);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stage 4: SOUL.md
|
// ── Interactive: Main Menu ─────────────────────────────────────────────────
|
||||||
|
if (state.installAction === 'fresh' || state.installAction === 'reset') {
|
||||||
|
await runMenuLoop(prompter, state, configService, options);
|
||||||
|
} else if (state.installAction === 'reconfigure') {
|
||||||
|
state.mode = 'advanced';
|
||||||
|
await runMenuLoop(prompter, state, configService, options);
|
||||||
|
} else {
|
||||||
|
// 'keep' — skip identity setup, go straight to finalize + gateway
|
||||||
|
await runKeepPath(prompter, state, configService, options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Menu-driven interactive flow ────────────────────────────────────────────
|
||||||
|
|
||||||
|
type MenuChoice =
|
||||||
|
| 'quick-start'
|
||||||
|
| 'providers'
|
||||||
|
| 'identity'
|
||||||
|
| 'skills'
|
||||||
|
| 'gateway-config'
|
||||||
|
| 'advanced'
|
||||||
|
| 'finish';
|
||||||
|
|
||||||
|
function menuLabel(section: MenuChoice, completed: Set<MenuSection>): string {
|
||||||
|
const labels: Record<MenuChoice, string> = {
|
||||||
|
'quick-start': 'Quick Start',
|
||||||
|
providers: 'Providers',
|
||||||
|
identity: 'Agent Identity',
|
||||||
|
skills: 'Skills',
|
||||||
|
'gateway-config': 'Gateway',
|
||||||
|
advanced: 'Advanced',
|
||||||
|
finish: 'Finish & Apply',
|
||||||
|
};
|
||||||
|
const base = labels[section];
|
||||||
|
const sectionKey: MenuSection =
|
||||||
|
section === 'gateway-config' ? 'gateway' : (section as MenuSection);
|
||||||
|
if (completed.has(sectionKey)) {
|
||||||
|
return `${base} [done]`;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runMenuLoop(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
state: WizardState,
|
||||||
|
configService: ConfigService,
|
||||||
|
options: WizardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const completed = state.completedSections!;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const choice = await prompter.select<MenuChoice>({
|
||||||
|
message: 'What would you like to configure?',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
value: 'quick-start',
|
||||||
|
label: menuLabel('quick-start', completed),
|
||||||
|
hint: 'Recommended defaults, minimal questions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'providers',
|
||||||
|
label: menuLabel('providers', completed),
|
||||||
|
hint: 'LLM API keys (Anthropic, OpenAI)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'identity',
|
||||||
|
label: menuLabel('identity', completed),
|
||||||
|
hint: 'Agent name, intent, persona',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'skills',
|
||||||
|
label: menuLabel('skills', completed),
|
||||||
|
hint: 'Install agent skills',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'gateway-config',
|
||||||
|
label: menuLabel('gateway-config', completed),
|
||||||
|
hint: 'Port, storage, database',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'advanced',
|
||||||
|
label: menuLabel('advanced', completed),
|
||||||
|
hint: 'SOUL.md, USER.md, TOOLS.md, runtimes, hooks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'finish',
|
||||||
|
label: menuLabel('finish', completed),
|
||||||
|
hint: 'Write configs and start gateway',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case 'quick-start':
|
||||||
|
await quickStartPath(prompter, state, configService, options);
|
||||||
|
return; // Quick start is a complete flow — exit menu
|
||||||
|
|
||||||
|
case 'providers':
|
||||||
|
await providerSetupStage(prompter, state);
|
||||||
|
completed.add('providers');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'identity':
|
||||||
|
await agentIntentStage(prompter, state);
|
||||||
|
completed.add('identity');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'skills':
|
||||||
|
await skillsSelectStage(prompter, state);
|
||||||
|
completed.add('skills');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'gateway-config':
|
||||||
|
// Gateway config is handled during Finish — mark as "configured"
|
||||||
|
// after user reviews settings.
|
||||||
|
await runGatewaySubMenu(prompter, state, options);
|
||||||
|
completed.add('gateway');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'advanced':
|
||||||
|
await runAdvancedSubMenu(prompter, state);
|
||||||
|
completed.add('advanced');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'finish':
|
||||||
|
await runFinishPath(prompter, state, configService, options);
|
||||||
|
return; // Done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Gateway sub-menu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runGatewaySubMenu(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
state: WizardState,
|
||||||
|
_options: WizardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
prompter.note(
|
||||||
|
'Gateway settings will be applied when you select "Finish & Apply".\n' +
|
||||||
|
'Configure the settings you want to customize here.',
|
||||||
|
'Gateway Configuration',
|
||||||
|
);
|
||||||
|
|
||||||
|
// For now, just let them know defaults will be used and they can
|
||||||
|
// override during finish. The actual gateway config stage runs
|
||||||
|
// during Finish & Apply. This menu item exists so users know
|
||||||
|
// the gateway is part of the wizard.
|
||||||
|
const port = await prompter.text({
|
||||||
|
message: 'Gateway port',
|
||||||
|
initialValue: (_options.gatewayPort ?? 14242).toString(),
|
||||||
|
defaultValue: (_options.gatewayPort ?? 14242).toString(),
|
||||||
|
validate: (v) => {
|
||||||
|
const n = parseInt(v, 10);
|
||||||
|
if (Number.isNaN(n) || n < 1 || n > 65535) return 'Port must be 1-65535';
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store for later use in the gateway config stage
|
||||||
|
_options.gatewayPort = parseInt(port, 10);
|
||||||
|
prompter.log(`Gateway port set to ${port}. Will be applied during Finish & Apply.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Advanced sub-menu ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function runAdvancedSubMenu(prompter: WizardPrompter, state: WizardState): Promise<void> {
|
||||||
|
state.mode = 'advanced';
|
||||||
|
|
||||||
|
// Run the detailed setup stages
|
||||||
await soulSetupStage(prompter, state);
|
await soulSetupStage(prompter, state);
|
||||||
|
|
||||||
// Stage 5: USER.md
|
|
||||||
await userSetupStage(prompter, state);
|
await userSetupStage(prompter, state);
|
||||||
|
|
||||||
// Stage 6: TOOLS.md
|
|
||||||
await toolsSetupStage(prompter, state);
|
await toolsSetupStage(prompter, state);
|
||||||
|
|
||||||
// Stage 7: Runtime Detection & Installation
|
|
||||||
await runtimeSetupStage(prompter, state);
|
await runtimeSetupStage(prompter, state);
|
||||||
|
|
||||||
// Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
|
|
||||||
await hooksPreviewStage(prompter, state);
|
await hooksPreviewStage(prompter, state);
|
||||||
|
}
|
||||||
|
|
||||||
// Stage 9: Skills Selection
|
// ── Finish & Apply ──────────────────────────────────────────────────────────
|
||||||
await skillsSelectStage(prompter, state);
|
|
||||||
|
|
||||||
// Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
|
async function runFinishPath(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
state: WizardState,
|
||||||
|
configService: ConfigService,
|
||||||
|
options: WizardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
// Apply defaults for anything not explicitly configured
|
||||||
|
state.soul.agentName ??= 'Mosaic';
|
||||||
|
state.soul.roleDescription ??= DEFAULTS.roleDescription;
|
||||||
|
state.soul.communicationStyle ??= 'direct';
|
||||||
|
state.user.background ??= DEFAULTS.background;
|
||||||
|
state.user.accessibilitySection ??= DEFAULTS.accessibilitySection;
|
||||||
|
state.user.personalBoundaries ??= DEFAULTS.personalBoundaries;
|
||||||
|
state.tools.gitProviders ??= [];
|
||||||
|
state.tools.credentialsLocation ??= DEFAULTS.credentialsLocation;
|
||||||
|
state.tools.customToolsSection ??= DEFAULTS.customToolsSection;
|
||||||
|
|
||||||
|
// Runtime detection if not already done
|
||||||
|
if (state.runtimes.detected.length === 0 && !state.completedSections?.has('advanced')) {
|
||||||
|
await runtimeSetupStage(prompter, state);
|
||||||
|
await hooksPreviewStage(prompter, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills defaults if not already configured
|
||||||
|
if (!state.completedSections?.has('skills')) {
|
||||||
|
await skillsSelectStage(prompter, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize (writes configs, links runtime assets, syncs skills)
|
||||||
await finalizeStage(prompter, state, configService);
|
await finalizeStage(prompter, state, configService);
|
||||||
|
|
||||||
// Stages 11 & 12: Gateway config + admin bootstrap.
|
// Gateway stages
|
||||||
// The unified first-run flow runs these as terminal stages so the user
|
|
||||||
// goes from "welcome" through "admin user created" in a single cohesive
|
|
||||||
// experience. Callers that only want the framework portion pass
|
|
||||||
// `skipGateway: true`.
|
|
||||||
if (!options.skipGateway) {
|
if (!options.skipGateway) {
|
||||||
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const configResult = await gatewayConfigStage(prompter, state, {
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
host: options.gatewayHost ?? 'localhost',
|
host: options.gatewayHost ?? 'localhost',
|
||||||
defaultPort: options.gatewayPort ?? 14242,
|
defaultPort: options.gatewayPort ?? 14242,
|
||||||
portOverride: options.gatewayPortOverride,
|
portOverride: options.gatewayPortOverride,
|
||||||
skipInstall: options.skipGatewayNpmInstall,
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
|
providerKey: state.providerKey,
|
||||||
|
providerType: state.providerType ?? 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configResult.ready && configResult.host && configResult.port) {
|
||||||
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
|
host: configResult.host,
|
||||||
|
port: configResult.port,
|
||||||
|
});
|
||||||
|
if (!bootstrapResult.completed) {
|
||||||
|
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Headless linear path (backward compat) ──────────────────────────────────
|
||||||
|
|
||||||
|
async function runHeadlessPath(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
state: WizardState,
|
||||||
|
configService: ConfigService,
|
||||||
|
options: WizardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
// Provider setup from env vars
|
||||||
|
await providerSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// Agent intent from env vars
|
||||||
|
await agentIntentStage(prompter, state);
|
||||||
|
|
||||||
|
// SOUL.md
|
||||||
|
await soulSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// USER.md
|
||||||
|
await userSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// TOOLS.md
|
||||||
|
await toolsSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// Runtime Detection
|
||||||
|
await runtimeSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
await hooksPreviewStage(prompter, state);
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
await skillsSelectStage(prompter, state);
|
||||||
|
|
||||||
|
// Finalize
|
||||||
|
await finalizeStage(prompter, state, configService);
|
||||||
|
|
||||||
|
// Gateway stages
|
||||||
|
if (!options.skipGateway) {
|
||||||
|
try {
|
||||||
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
|
host: options.gatewayHost ?? 'localhost',
|
||||||
|
defaultPort: options.gatewayPort ?? 14242,
|
||||||
|
portOverride: options.gatewayPortOverride,
|
||||||
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
|
providerKey: state.providerKey,
|
||||||
|
providerType: state.providerType ?? 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!configResult.ready || !configResult.host || !configResult.port) {
|
if (!configResult.ready || !configResult.host || !configResult.port) {
|
||||||
if (headlessRun) {
|
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
||||||
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
|
process.exit(1);
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
host: configResult.host,
|
host: configResult.host,
|
||||||
@@ -150,12 +403,53 @@ export async function runWizard(options: WizardOptions): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Stages normally return structured `ready: false` results for
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
// expected failures. Anything that reaches here is an unexpected
|
throw err;
|
||||||
// runtime error — render a concise warning for UX AND re-throw so
|
}
|
||||||
// the CLI (and `tools/install.sh` auto-launch) sees a non-zero exit.
|
}
|
||||||
// Swallowing here would let headless installs report success even
|
}
|
||||||
// when the gateway stage crashed.
|
|
||||||
|
// ── Keep path (preserve existing identity) ──────────────────────────────────
|
||||||
|
|
||||||
|
async function runKeepPath(
|
||||||
|
prompter: WizardPrompter,
|
||||||
|
state: WizardState,
|
||||||
|
configService: ConfigService,
|
||||||
|
options: WizardOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
// Runtime detection
|
||||||
|
await runtimeSetupStage(prompter, state);
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
await hooksPreviewStage(prompter, state);
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
await skillsSelectStage(prompter, state);
|
||||||
|
|
||||||
|
// Finalize
|
||||||
|
await finalizeStage(prompter, state, configService);
|
||||||
|
|
||||||
|
// Gateway stages
|
||||||
|
if (!options.skipGateway) {
|
||||||
|
try {
|
||||||
|
const configResult = await gatewayConfigStage(prompter, state, {
|
||||||
|
host: options.gatewayHost ?? 'localhost',
|
||||||
|
defaultPort: options.gatewayPort ?? 14242,
|
||||||
|
portOverride: options.gatewayPortOverride,
|
||||||
|
skipInstall: options.skipGatewayNpmInstall,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configResult.ready && configResult.host && configResult.port) {
|
||||||
|
const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
|
||||||
|
host: configResult.host,
|
||||||
|
port: configResult.port,
|
||||||
|
});
|
||||||
|
if (!bootstrapResult.completed) {
|
||||||
|
prompter.warn('Admin bootstrap failed — aborting wizard.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/prdy"
|
"directory": "packages/prdy"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.3",
|
"version": "0.0.3",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/quality-rails"
|
"directory": "packages/quality-rails"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/queue"
|
"directory": "packages/queue"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.4",
|
"version": "0.0.4",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/storage"
|
"directory": "packages/storage"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "packages/types"
|
"directory": "packages/types"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "plugins/discord"
|
"directory": "plugins/discord"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "plugins/macp"
|
"directory": "plugins/macp"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "plugins/mosaic-framework"
|
"directory": "plugins/mosaic-framework"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
|
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git",
|
||||||
"directory": "plugins/telegram"
|
"directory": "plugins/telegram"
|
||||||
},
|
},
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
# 1. Mosaic framework → ~/.config/mosaic/ (bash launcher, guides, runtime configs, tools)
|
||||||
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
# 2. @mosaicstack/mosaic (npm) → ~/.npm-global/ (CLI, TUI, gateway client, wizard)
|
||||||
#
|
#
|
||||||
# Remote install (recommended):
|
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash
|
||||||
# bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
|
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh)
|
||||||
#
|
#
|
||||||
# Remote install (alternative — use -s -- to pass flags):
|
# Remote install (alternative — use -s -- to pass flags):
|
||||||
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh | bash -s --
|
# curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh | bash -s --
|
||||||
#
|
#
|
||||||
# Flags:
|
# Flags:
|
||||||
# --check Version check only, no install
|
# --check Version check only, no install
|
||||||
@@ -69,7 +69,7 @@ REGISTRY="${MOSAIC_REGISTRY:-https://git.mosaicstack.dev/api/packages/mosaicstac
|
|||||||
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
SCOPE="${MOSAIC_SCOPE:-@mosaicstack}"
|
||||||
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
PREFIX="${MOSAIC_PREFIX:-$HOME/.npm-global}"
|
||||||
CLI_PKG="${SCOPE}/mosaic"
|
CLI_PKG="${SCOPE}/mosaic"
|
||||||
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack"
|
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
|
||||||
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
|
||||||
|
|
||||||
# ─── uninstall path ───────────────────────────────────────────────────────────
|
# ─── uninstall path ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user