Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
eeb39cfc0a fix: add vitest.config.ts to eslint allowDefaultProject, revert tsconfig include
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
vitest.config.ts is not under apps/gateway/src (rootDir), so including
it in tsconfig.json broke the build. Instead, add it to the
eslint allowDefaultProject list so the typescript-eslint parser can
lint it without being covered by the project tsconfig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:57:05 -05:00
62 changed files with 173 additions and 2670 deletions

View File

@@ -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/stack/gateway:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-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/stack/gateway:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/gateway:latest"
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/gateway:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-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/stack/web:sha-${CI_COMMIT_SHA:0:7}" DESTINATIONS="--destination git.mosaicstack.dev/mosaicstack/mosaic-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/stack/web:latest" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-stack/web:latest"
fi fi
if [ -n "$CI_COMMIT_TAG" ]; then if [ -n "$CI_COMMIT_TAG" ]; then
DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/stack/web:$CI_COMMIT_TAG" DESTINATIONS="$DESTINATIONS --destination git.mosaicstack.dev/mosaicstack/mosaic-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:

View File

@@ -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.1` | Cost-sensitive coding where Codex is unavailable | Z.ai credits | | `glm-5` | 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.

View File

@@ -7,13 +7,7 @@ Mosaic gives you a unified launcher for Claude Code, Codex, OpenCode, and Pi —
## Quick Install ## Quick Install
```bash ```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
```
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:
@@ -185,8 +179,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/stack.git git clone git@git.mosaicstack.dev:mosaicstack/mosaic-stack.git
cd stack cd mosaic-stack
# Start infrastructure (Postgres, Valkey, Jaeger) # Start infrastructure (Postgres, Valkey, Jaeger)
docker compose up -d docker compose up -d
@@ -235,7 +229,7 @@ npm packages are published to the Gitea package registry on main merges.
## Architecture ## Architecture
``` ```
stack/ mosaic-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)
@@ -308,13 +302,7 @@ 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
curl -fsSL https://mosaicstack.dev/install.sh | bash bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
```
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:

View File

@@ -3,7 +3,7 @@
"version": "0.0.6", "version": "0.0.6",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "apps/gateway" "directory": "apps/gateway"
}, },
"type": "module", "type": "module",

View File

@@ -7,11 +7,11 @@
**ID:** install-ux-v2-20260405 **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. **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:** Execution **Phase:** Planning
**Current Milestone:** IUV-M03 **Current Milestone:** IUV-M01
**Progress:** 2 / 3 milestones **Progress:** 0 / 3 milestones
**Status:** active **Status:** active
**Last Updated:** 2026-04-05 (IUV-M02 complete — CORS/FQDN + skill installer rework) **Last Updated:** 2026-04-05
**Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`) **Parent Mission:** [install-ux-hardening-20260405](./archive/missions/install-ux-hardening-20260405/MISSION-MANIFEST.md) (complete — `mosaic-v0.0.25`)
## Context ## Context
@@ -30,11 +30,11 @@ Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
## 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)_ - [ ] 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.
- [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)_ - [ ] 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.
- [x] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept). _(PR #440)_ - [ ] AC-3: Gateway port prompt prefills `14242` in the input field (user can press Enter to accept).
- [x] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime. _(PR #440)_ - [ ] AC-4: `"What is Mosaic?"` intro copy mentions Pi SDK as the underlying agent runtime.
- [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-5: Release `mosaic-v0.0.26` tagged and published to the Gitea npm registry, unblocking the 0.0.25 happy path.
- [ ] AC-6: CORS origin prompt replaced with FQDN/hostname input; CORS string is derived from that. - [ ] 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-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-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.
@@ -44,11 +44,11 @@ Real-run testing of `@mosaicstack/mosaic@0.0.25` uncovered:
## Milestones ## Milestones
| # | ID | Name | Status | Branch | Issue | Started | Completed | | # | 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 | | 1 | IUV-M01 | Hotfix: bootstrap DTO + wizard failure + port prefill + copy | in-progress | fix/bootstrap-hotfix | #436 | 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 | | 2 | IUV-M02 | UX polish: CORS/FQDN, skill installer rework | not-started | feat/install-ux-polish | #437 | — | — |
| 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — | | 3 | IUV-M03 | Provider-first intelligent flow + drill-down main menu | not-started | feat/install-ux-intent | #438 | — | — |
## Subagent Delegation Plan ## Subagent Delegation Plan

View File

@@ -9,29 +9,29 @@
## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01) ## Milestone 1 — Hotfix: bootstrap DTO + wizard failure + port prefill + copy (IUV-M01)
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | 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-01 | not-started | 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 | one-character fix; repro is real-run `mosaic wizard` against `0.0.25` |
| 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-02 | not-started | 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 | must fail before the fix and pass after; guards against the class-erasure regression recurring |
| 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-03 | not-started | `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 | |
| 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-04 | not-started | 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 | likely WizardPrompter adapter or @clack/prompts `initialValue` vs `defaultValue` mismatch |
| 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-05 | not-started | `"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 | |
| 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 ✓ | | IUV-01-06 | not-started | Tests + code review + PR merge + tag `mosaic-v0.0.26` + Gitea release + npm registry republish | #436 | sonnet | fix/bootstrap-hotfix | IUV-01-05 | 10K | bump `packages/mosaic/package.json` to 0.0.25 → 0.0.26 |
## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02) ## Milestone 2 — UX polish: CORS/FQDN, skill installer rework (IUV-M02)
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | 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-01 | not-started | 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 | IUV-01-06 | 10K | |
| 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-02 | not-started | 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 | needs real-run reproduction |
| 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-03 | not-started | 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 | |
| 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` | | IUV-02-04 | not-started | Tests + code review + PR merge | #437 | sonnet | feat/install-ux-polish | IUV-02-03 | 10K | |
## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03) ## Milestone 3 — Provider-first intelligent flow + drill-down main menu (IUV-M03)
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | 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-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 | IUV-02-04 | 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-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-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-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) |

View File

@@ -165,13 +165,7 @@ The `mosaic` CLI provides a terminal interface to the same gateway API.
Install via the Mosaic installer: Install via the Mosaic installer:
```bash ```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-stack/raw/branch/main/tools/install.sh)
```
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

View File

@@ -107,67 +107,3 @@ Sequencing: strict. M01 ships first as a hotfix release (mosaic-v0.0.26). M02 is
1. Create Gitea issues for M01, M02, M03 1. Create Gitea issues for M01, M02, M03
2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430) 2. Open the mission-scaffold docs PR (same pattern as parent mission's PR #430)
3. After merge, delegate IUV-M01 to a sonnet subagent in an isolated worktree with the concrete fix-site pointers above 3. After merge, delegate IUV-M01 to a sonnet subagent in an isolated worktree with the concrete fix-site pointers above
## Session 2 — 2026-04-05 (IUV-M01 delivery + close-out)
### Outcome
IUV-M01 shipped. `mosaic-v0.0.26` released and registry latest confirmed `0.0.26`.
### PRs merged
| PR | Title | Merge |
| ---- | ------------------------------------------------------------------------ | -------- |
| #440 | fix: bootstrap hotfix — DTO erasure, wizard failure, port prefill, copy | 0ae932ab |
| #441 | fix: add vitest.config.ts to eslint allowDefaultProject (#440 build fix) | c08aa6fa |
| #442 | docs: mark IUV-M01 complete — mosaic-v0.0.26 released | 78388437 |
### Bugs fixed (all 4 in worker's PR #440)
1. **DTO class erasure**`apps/gateway/src/admin/bootstrap.controller.ts:16` — dropped `type` from `import { BootstrapSetupDto }`. Guarded by new e2e test `bootstrap.e2e.spec.ts` (4 cases) that binds through a real Nest app with `ValidationPipe { whitelist, forbidNonWhitelisted }`. Test suite needed `unplugin-swc` in `apps/gateway/vitest.config.ts` to emit `decoratorMetadata` (tsx/esbuild can't).
2. **Wizard silent failure**`packages/mosaic/src/wizard.ts` — removed the `&& headlessRun` guard so `!bootstrapResult.completed` now aborts in both modes.
3. **Port prefill** — root cause was clack's `defaultValue` vs `initialValue` semantics (`defaultValue` only fills on empty submit, `initialValue` prefills the buffer). Added an `initialValue` field to `WizardPrompter.text()` interface, threaded through clack and headless prompters, switched `gateway-config.ts` port/url prompts to use it.
4. **Pi SDK copy**`packages/mosaic/src/stages/welcome.ts` — intro copy now lists Pi SDK.
### Mid-delivery hiccup — tsconfig/eslint cross-contamination
Worker's initial approach added `vitest.config.ts` to `apps/gateway/tsconfig.json`'s `include` to appease the eslint parser. That broke `pnpm --filter @mosaicstack/gateway build` with TS6059 (`vitest.config.ts` outside `rootDir: "src"`). The publish pipeline on the `#440` merge commit failed.
**Correct fix** (worker's PR #441): leave `tsconfig.json` clean (`include: ["src/**/*"]`) and instead add the file to `allowDefaultProject` in the root `eslint.config.mjs`. This keeps the tsc program strict while letting eslint resolve a parser project for the standalone config file.
**Pattern to remember**: when adding root-level `.ts` config files (vitest, build scripts) to a package with `rootDir: "src"`, the eslint parser project conflict is solved with `allowDefaultProject`, NEVER by widening tsconfig include. I had independently arrived at the same fix on a branch before the worker shipped #441 — deleted the duplicate.
### Residual follow-ups carried forward
1. Headless prompter fallback order: worker set `initialValue > defaultValue` in the headless path. Correct semantic, but any future headless test that explicitly depends on `defaultValue` precedence will need review.
2. Vitest + SWC decorator metadata pattern is now the blessed approach for NestJS e2e tests in this monorepo. Any other package that adds NestJS e2e tests should mirror `apps/gateway/vitest.config.ts`.
### Next action
- Close out orchestrator doc sync (this commit): mark M01 subtasks done in `TASKS.md`, update manifest phase to Execution, commit scratchpad session 2, PR to main.
- After merge, delegate IUV-M02 (sonnet, isolated worktree). Dependencies: IUV-02-01 (CORS→FQDN) starts unblocked since M01 is released; first real task for the M02 worker is diagnosing the skill installer failure modes (IUV-02-02) against the fresh 0.0.26 install.
## Session 3 — 2026-04-05 (IUV-M02 delivery + close-out)
### Outcome
IUV-M02 shipped. PR #444 merged (`172bacb3`), issue #437 closed. 18 new tests (13 CORS derivation, 5 skill sync).
### Changes
**CORS → FQDN (IUV-02-01):**
- `packages/mosaic/src/stages/gateway-config.ts` — replaced raw "CORS origin" text prompt with "Web UI hostname" (default: `localhost`). Added HTTPS follow-up for remote hosts. Pure `deriveCorsOrigin(hostname, port, useHttps?)` function exported for testability.
- Headless: `MOSAIC_HOSTNAME` env var as friendly alternative; `MOSAIC_CORS_ORIGIN` still works as full override.
- `packages/mosaic/src/types.ts` — added `hostname?: string` to `GatewayState`.
**Skill installer rework (IUV-02-02 + IUV-02-03):**
- Root cause confirmed: `syncSkills()` in `finalize.ts` ignored `state.selectedSkills` entirely. The multiselect UI was a no-op.
- `packages/mosaic/src/stages/finalize.ts``syncSkills()` rewritten to accept `selectedSkills[]`, returns typed `SyncSkillsResult`, passes `MOSAIC_INSTALL_SKILLS` (colon-separated) as env var to the bash script.
- `packages/mosaic/framework/tools/_scripts/mosaic-sync-skills` — added bash associative array whitelist filter keyed on `MOSAIC_INSTALL_SKILLS`. When set, only whitelisted skills are linked. Empty/unset = all skills (legacy behavior preserved for `mosaic sync` outside wizard).
- Failure surfaces: silent `catch {}` replaced with typed error reporting through `p.warn()`.
### Next action
- Delegate IUV-M03 (opus, isolated worktree) — the architectural milestone: provider-first intelligent flow, drill-down main menu, Quick Start fast path, agent self-naming. This is the biggest piece of the mission.

View File

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

View File

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

View File

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

View File

@@ -73,27 +73,6 @@ 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:

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/agent" "directory": "packages/agent"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/auth" "directory": "packages/auth"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/brain" "directory": "packages/brain"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/config" "directory": "packages/config"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/coord" "directory": "packages/coord"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/db" "directory": "packages/db"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/design-tokens" "directory": "packages/design-tokens"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/forge" "directory": "packages/forge"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/log" "directory": "packages/log"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/macp" "directory": "packages/macp"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.4", "version": "0.0.4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/memory" "directory": "packages/memory"
}, },
"type": "module", "type": "module",

View File

@@ -17,7 +17,6 @@ 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-'));
@@ -33,16 +32,12 @@ 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',
@@ -67,10 +62,9 @@ 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',

View File

@@ -151,68 +151,11 @@ 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
- Load skills that match the active task domain before starting implementation. - Use only the minimum required skills for the active task.
- 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

View File

@@ -4,20 +4,14 @@ 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/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. > **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.
## Quick Install ## Quick Install
### Mac / Linux ### Mac / Linux
```bash ```bash
curl -fsSL https://mosaicstack.dev/install.sh | bash bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
```
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)
@@ -29,8 +23,8 @@ bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/
### From Source (any platform) ### From Source (any platform)
```bash ```bash
git clone git@git.mosaicstack.dev:mosaicstack/stack.git ~/src/stack git clone git@git.mosaicstack.dev:mosaic/mosaic-stack.git ~/src/mosaic-stack
cd ~/src/stack && bash tools/install.sh cd ~/src/mosaic-stack && bash tools/install.sh
``` ```
The installer: The installer:
@@ -151,19 +145,13 @@ 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
curl -fsSL https://mosaicstack.dev/install.sh | bash bash <(curl -fsSL https://git.mosaicstack.dev/mosaic/mosaic-stack/raw/branch/main/tools/install.sh)
```
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/stack && git pull && bash tools/install.sh cd ~/src/mosaic-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.

View File

@@ -19,9 +19,8 @@ SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# Files/dirs preserved across upgrades (never overwritten). # Files preserved across upgrades (never overwritten)
# User-created content in these paths survives rsync --delete. PRESERVE_PATHS=("SOUL.md" "USER.md" "TOOLS.md" "memory" "sources")
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.
@@ -218,27 +217,8 @@ fi
sync_framework sync_framework
# Ensure persistent directories exist # Ensure memory directory exists
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

View File

@@ -102,30 +102,3 @@ 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.

View File

@@ -23,16 +23,6 @@
"timeout": 60 "timeout": 60
} }
] ]
},
{
"matcher": "Edit|MultiEdit|Write",
"hooks": [
{
"type": "command",
"command": "~/.config/mosaic/tools/qa/typecheck-hook.sh",
"timeout": 30
}
]
} }
] ]
}, },

View File

@@ -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/tools/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands. Mosaic wrappers at `~/.config/mosaic/rails/git/*.sh` handle platform detection and edge cases. Always use these before raw CLI commands.
```bash ```bash
# Issues # Issues
~/.config/mosaic/tools/git/issue-create.sh ~/.config/mosaic/rails/git/issue-create.sh
~/.config/mosaic/tools/git/issue-close.sh ~/.config/mosaic/rails/git/issue-close.sh
# PRs # PRs
~/.config/mosaic/tools/git/pr-create.sh ~/.config/mosaic/rails/git/pr-create.sh
~/.config/mosaic/tools/git/pr-merge.sh ~/.config/mosaic/rails/git/pr-merge.sh
# Milestones # Milestones
~/.config/mosaic/tools/git/milestone-create.sh ~/.config/mosaic/rails/git/milestone-create.sh
# CI queue guard (required before push/merge) # CI queue guard (required before push/merge)
~/.config/mosaic/tools/git/ci-queue-wait.sh --purpose push|merge ~/.config/mosaic/rails/git/ci-queue-wait.sh --purpose push|merge
``` ```
## Code Review (Codex) ## Code Review (Codex)
```bash ```bash
# Code quality review # Code quality review
~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted ~/.config/mosaic/rails/codex/codex-code-review.sh --uncommitted
# Security review # Security review
~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted ~/.config/mosaic/rails/codex/codex-security-review.sh --uncommitted
``` ```
## Git Providers ## Git Providers

View File

@@ -7,11 +7,6 @@ SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}"
MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills" MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills"
MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local" MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local"
# Colon-separated list of skill names to install. When set, only these skills
# are linked into runtime skill directories. Empty/unset = link all skills
# (the legacy "mosaic sync" full-catalog behavior).
MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}"
fetch=1 fetch=1
link_only=0 link_only=0
@@ -30,7 +25,6 @@ Env:
MOSAIC_HOME Default: ~/.config/mosaic MOSAIC_HOME Default: ~/.config/mosaic
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
USAGE USAGE
} }
@@ -162,27 +156,6 @@ link_targets=(
canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")" canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")"
# Build an associative array from the colon-separated whitelist for O(1) lookup.
# When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed.
declare -A _skill_whitelist=()
_whitelist_active=0
if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then
_whitelist_active=1
IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS"
for _item in "${_wl_items[@]}"; do
[[ -n "$_item" ]] && _skill_whitelist["$_item"]=1
done
fi
is_skill_selected() {
local name="$1"
if [[ $_whitelist_active -eq 0 ]]; then
return 0
fi
[[ -n "${_skill_whitelist[$name]:-}" ]] && return 0
return 1
}
link_skill_into_target() { link_skill_into_target() {
local skill_path="$1" local skill_path="$1"
local target_dir="$2" local target_dir="$2"
@@ -195,11 +168,6 @@ link_skill_into_target() {
return return
fi fi
# Respect the install whitelist (set during first-run wizard).
if ! is_skill_selected "$name"; then
return
fi
link_path="$target_dir/$name" link_path="$target_dir/$name"
if [[ -L "$link_path" ]]; then if [[ -L "$link_path" ]]; then

View File

@@ -1,63 +0,0 @@
#!/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

View File

@@ -1,9 +1,9 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.30", "version": "0.0.26",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-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",

View File

@@ -135,11 +135,15 @@ program
// No valid session — prompt for credentials // No valid session — prompt for credentials
if (!session) { if (!session) {
const { promptLine, promptSecret } = await import('./commands/gateway/login.js'); const readline = await import('node:readline');
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 promptLine('Email: '); const email = await ask('Email: ');
const password = await promptSecret('Password: '); const password = await ask('Password: ');
rl.close();
try { try {
const auth = await signIn(opts.gateway, email, password); const auth = await signIn(opts.gateway, email, password);

View File

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

View File

@@ -78,82 +78,6 @@ 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
@@ -483,10 +407,6 @@ 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);
@@ -757,23 +677,8 @@ function runUpgrade(args: string[]): never {
// ─── Commander registration ───────────────────────────────────────────────── // ─── Commander registration ─────────────────────────────────────────────────
/** export function registerLaunchCommands(program: Command): void {
* Handler invoked when a runtime subcommand (`<runtime>` or `yolo <runtime>`) // Runtime launchers
* 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)
@@ -781,10 +686,11 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
.allowUnknownOption(true) .allowUnknownOption(true)
.allowExcessArguments(true) .allowExcessArguments(true)
.action((_opts: unknown, cmd: Command) => { .action((_opts: unknown, cmd: Command) => {
handler(runtime, cmd.args, false); launchRuntime(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)')
@@ -798,21 +704,8 @@ export function registerRuntimeLaunchers(program: Command, handler: RuntimeLaunc
); );
process.exit(1); process.exit(1);
} }
// Commander includes declared positional arguments (`<runtime>`) in launchRuntime(runtime as RuntimeName, cmd.args, true);
// `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

View File

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

View File

@@ -1,19 +1,5 @@
import { readFileSync, existsSync, statSync, copyFileSync } from 'node:fs'; import { readFileSync, existsSync, readdirSync, 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';
@@ -145,24 +131,9 @@ 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, {
@@ -170,23 +141,20 @@ export class FileConfigAdapter implements ConfigService {
excludeGit: true, excludeGit: true,
}); });
// Copy framework-contract files (AGENTS.md, STANDARDS.md, TOOLS.md) // Copy default root-level .md files (AGENTS.md, STANDARDS.md, etc.)
// from framework/defaults/ into the mosaic home root if they don't // from framework/defaults/ into mosaicHome root if they don't exist yet.
// exist yet. These are written on first install only and are never // These are framework contracts — only written on first install, never
// overwritten afterwards — the user may have customized them. // overwritten (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 DEFAULT_SEED_FILES) { for (const entry of readdirSync(defaultsDir)) {
const src = join(defaultsDir, entry);
const dest = join(this.mosaicHome, entry); const dest = join(this.mosaicHome, entry);
if (existsSync(dest)) continue; if (!existsSync(dest)) {
if (!existsSync(src) || !statSync(src).isFile()) continue; const src = join(defaultsDir, entry);
copyFileSync(src, dest); if (statSync(src).isFile()) {
copyFileSync(src, dest);
}
}
} }
} }
} }

View File

@@ -26,53 +26,6 @@ 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',

View File

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

View File

@@ -1,64 +0,0 @@
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}`);
}

View File

@@ -1,186 +0,0 @@
/**
* Tests for the skill installer rework (IUV-02-03).
*
* We mock `node:child_process` to verify that:
* 1. syncSkills passes MOSAIC_INSTALL_SKILLS with the exact selected subset
* 2. When the script exits non-zero, the failure is surfaced to the user
* 3. When the script is missing, a clear error is shown (not a silent no-op)
* 4. An empty selection is a no-op (script never called)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import type { WizardState } from '../types.js';
import type { ConfigService } from '../config/config-service.js';
// ── spawnSync mock ─────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spawnSyncMock = vi.fn<any>();
vi.mock('node:child_process', () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spawnSync: (...args: any[]) => spawnSyncMock(...args),
}));
// ── platform stub ──────────────────────────────────────────────────────────────
vi.mock('../platform/detect.js', () => ({
getShellProfilePath: () => null,
}));
import { finalizeStage } from './finalize.js';
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeState(mosaicHome: string, selectedSkills: string[] = []): WizardState {
return {
mosaicHome,
sourceDir: mosaicHome,
mode: 'quick',
installAction: 'fresh',
soul: { agentName: 'TestBot', communicationStyle: 'direct' },
user: {},
tools: {},
runtimes: { detected: [], mcpConfigured: false },
selectedSkills,
};
}
function buildPrompter() {
return {
intro: vi.fn(),
outro: vi.fn(),
note: vi.fn(),
log: vi.fn(),
warn: vi.fn(),
text: vi.fn(),
confirm: vi.fn(),
select: vi.fn(),
multiselect: vi.fn(),
groupMultiselect: vi.fn(),
spinner: vi.fn().mockReturnValue({ update: vi.fn(), stop: vi.fn() }),
separator: vi.fn(),
};
}
function makeConfigService(): ConfigService {
return {
readSoul: vi.fn().mockResolvedValue({}),
readUser: vi.fn().mockResolvedValue({}),
readTools: vi.fn().mockResolvedValue({}),
writeSoul: vi.fn().mockResolvedValue(undefined),
writeUser: vi.fn().mockResolvedValue(undefined),
writeTools: vi.fn().mockResolvedValue(undefined),
syncFramework: vi.fn().mockResolvedValue(undefined),
get: vi.fn(),
set: vi.fn(),
getSection: vi.fn(),
} as unknown as ConfigService;
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe('finalizeStage — skill installer', () => {
let tmp: string;
let binDir: string;
let syncScript: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'mosaic-finalize-'));
binDir = join(tmp, 'bin');
mkdirSync(binDir, { recursive: true });
syncScript = join(binDir, 'mosaic-sync-skills');
// Default: script exists and succeeds
writeFileSync(syncScript, '#!/usr/bin/env bash\necho ok\n', { mode: 0o755 });
spawnSyncMock.mockReturnValue({ status: 0, stdout: 'ok', stderr: '' });
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
vi.clearAllMocks();
});
function findSkillsSyncCall() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (spawnSyncMock.mock.calls as any[][]).find(
(args) =>
Array.isArray(args[1]) &&
(args[1] as string[]).some((a) => a.includes('mosaic-sync-skills')),
);
}
it('passes MOSAIC_INSTALL_SKILLS with the selected skill list', async () => {
const state = makeState(tmp, ['brainstorming', 'lint', 'systematic-debugging']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const call = findSkillsSyncCall();
expect(call).toBeDefined();
const opts = call![2] as { env?: Record<string, string> };
expect(opts.env?.['MOSAIC_INSTALL_SKILLS']).toBe('brainstorming:lint:systematic-debugging');
});
it('skips the sync script entirely when no skills are selected', async () => {
const state = makeState(tmp, []);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
expect(findSkillsSyncCall()).toBeUndefined();
});
it('warns the user when the sync script exits non-zero', async () => {
spawnSyncMock.mockReturnValue({
status: 1,
stdout: '',
stderr: 'git clone failed: connection refused',
});
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('git clone failed'));
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('mosaic sync'));
});
it('warns the user when the sync script is missing', async () => {
// Remove the script to simulate a missing installation
rmSync(syncScript);
const state = makeState(tmp, ['brainstorming']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
// spawnSync should NOT have been called for the skills script
expect(findSkillsSyncCall()).toBeUndefined();
expect(p.warn).toHaveBeenCalledWith(expect.stringContaining('not found'));
});
it('includes skills count in the summary when install succeeds', async () => {
const state = makeState(tmp, ['brainstorming', 'lint']);
const p = buildPrompter();
const config = makeConfigService();
await finalizeStage(p, state, config);
const noteMock = p.note as ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const summaryCall = (noteMock.mock.calls as any[][]).find(
([, title]) => title === 'Installation Summary',
);
expect(summaryCall).toBeDefined();
expect(summaryCall![0] as string).toContain('2 installed');
});
});

View File

@@ -25,68 +25,14 @@ function linkRuntimeAssets(mosaicHome: string, skipClaudeHooks: boolean): void {
} }
} }
interface SyncSkillsResult { function syncSkills(mosaicHome: string): void {
success: boolean;
installedCount: number;
failureReason?: string;
}
/**
* Sync skills from the catalog and link only the user-selected subset.
*
* When `selectedSkills` is non-empty the script receives the list via
* `MOSAIC_INSTALL_SKILLS` (colon-separated) so it can skip unlisted skills
* during the linking phase. An empty selection is a no-op.
*
* Failure modes surfaced here:
* - Script not found → tells the user explicitly
* - Script exits non-zero → stderr is captured and reported
* - Catalog directory missing → detected before exec, reported clearly
*/
function syncSkills(mosaicHome: string, selectedSkills: string[]): SyncSkillsResult {
if (selectedSkills.length === 0) {
return { success: true, installedCount: 0 };
}
const script = join(mosaicHome, 'bin', 'mosaic-sync-skills'); const script = join(mosaicHome, 'bin', 'mosaic-sync-skills');
if (!existsSync(script)) { if (existsSync(script)) {
return { try {
success: false, spawnSync('bash', [script], { timeout: 60000, stdio: 'pipe' });
installedCount: 0, } catch {
failureReason: `Skills sync script not found at ${script} — run 'mosaic sync' after installation.`, // Non-fatal
};
}
try {
const result = spawnSync('bash', [script], {
timeout: 60000,
stdio: 'pipe',
encoding: 'utf-8',
env: {
...process.env,
MOSAIC_HOME: mosaicHome,
MOSAIC_INSTALL_SKILLS: selectedSkills.join(':'),
},
});
if (result.status !== 0) {
const stderr = (result.stderr ?? '').trim();
return {
success: false,
installedCount: 0,
failureReason: stderr
? `Skills sync failed: ${stderr}`
: `Skills sync script exited with code ${(result.status ?? 'unknown').toString()}`,
};
} }
return { success: true, installedCount: selectedSkills.length };
} catch (err) {
return {
success: false,
installedCount: 0,
failureReason: `Skills sync threw: ${err instanceof Error ? err.message : String(err)}`,
};
} }
} }
@@ -178,11 +124,10 @@ export async function finalizeStage(
const skipClaudeHooks = state.hooks?.accepted === false; const skipClaudeHooks = state.hooks?.accepted === false;
linkRuntimeAssets(state.mosaicHome, skipClaudeHooks); linkRuntimeAssets(state.mosaicHome, skipClaudeHooks);
// 4. Sync skills (only installs the user-selected subset) // 4. Sync skills
let skillsResult: SyncSkillsResult = { success: true, installedCount: 0 };
if (state.selectedSkills.length > 0) { if (state.selectedSkills.length > 0) {
spin.update(`Installing ${state.selectedSkills.length.toString()} selected skill(s)...`); spin.update('Syncing skills...');
skillsResult = syncSkills(state.mosaicHome, state.selectedSkills); syncSkills(state.mosaicHome);
} }
// 5. Run doctor // 5. Run doctor
@@ -191,27 +136,15 @@ export async function finalizeStage(
spin.stop('Installation complete'); spin.stop('Installation complete');
// Report skill install failure clearly (non-fatal but user should know)
if (!skillsResult.success && skillsResult.failureReason) {
p.warn(skillsResult.failureReason);
p.warn("Run 'mosaic sync' manually after installation to install skills.");
}
// 6. PATH setup // 6. PATH setup
const pathAction = setupPath(state.mosaicHome, p); const pathAction = setupPath(state.mosaicHome, p);
// 7. Summary // 7. Summary
const skillsSummary = skillsResult.success
? skillsResult.installedCount > 0
? `${skillsResult.installedCount.toString()} installed`
: 'none selected'
: `install failed — ${skillsResult.failureReason ?? 'unknown error'}`;
const summary: string[] = [ const summary: string[] = [
`Agent: ${state.soul.agentName ?? 'Assistant'}`, `Agent: ${state.soul.agentName ?? 'Assistant'}`,
`Style: ${state.soul.communicationStyle ?? 'direct'}`, `Style: ${state.soul.communicationStyle ?? 'direct'}`,
`Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`, `Runtimes: ${state.runtimes.detected.join(', ') || 'none detected'}`,
`Skills: ${skillsSummary}`, `Skills: ${state.selectedSkills.length.toString()} selected`,
`Config: ${state.mosaicHome}`, `Config: ${state.mosaicHome}`,
]; ];

View File

@@ -158,7 +158,7 @@ export async function gatewayBootstrapStage(
host, host,
port, port,
tier: 'local', tier: 'local',
corsOrigin: `http://${host}:3000`, corsOrigin: 'http://localhost:3000',
}), }),
admin: { name, email, password }, admin: { name, email, password },
}; };

View File

@@ -1,69 +0,0 @@
import { describe, it, expect } from 'vitest';
import { deriveCorsOrigin } from './gateway-config.js';
describe('deriveCorsOrigin', () => {
describe('localhost / loopback — always http', () => {
it('localhost port 3000 → http://localhost:3000', () => {
expect(deriveCorsOrigin('localhost', 3000)).toBe('http://localhost:3000');
});
it('127.0.0.1 port 3000 → http://127.0.0.1:3000', () => {
expect(deriveCorsOrigin('127.0.0.1', 3000)).toBe('http://127.0.0.1:3000');
});
it('localhost port 80 omits port suffix', () => {
expect(deriveCorsOrigin('localhost', 80)).toBe('http://localhost');
});
it('localhost port 443 still uses http (loopback overrides), includes port', () => {
// 443 is the https default port, but since localhost forces http, the port
// is NOT the default for http (80), so it must be included.
expect(deriveCorsOrigin('localhost', 443)).toBe('http://localhost:443');
});
it('useHttps=false on localhost keeps http', () => {
expect(deriveCorsOrigin('localhost', 3000, false)).toBe('http://localhost:3000');
});
it('useHttps=true on localhost still uses http (loopback wins)', () => {
// Passing useHttps=true for localhost is unusual but the function honours
// the explicit override — loopback detection only applies when useHttps is
// undefined (auto-detect path).
expect(deriveCorsOrigin('localhost', 3000, true)).toBe('https://localhost:3000');
});
});
describe('remote hostname — defaults to https', () => {
it('example.com port 3000 → https://example.com:3000', () => {
expect(deriveCorsOrigin('example.com', 3000)).toBe('https://example.com:3000');
});
it('example.com port 443 omits port suffix', () => {
expect(deriveCorsOrigin('example.com', 443)).toBe('https://example.com');
});
it('example.com port 80 → https://example.com:80 (non-default port for https)', () => {
expect(deriveCorsOrigin('example.com', 80)).toBe('https://example.com:80');
});
it('useHttps=false on remote host uses http', () => {
expect(deriveCorsOrigin('example.com', 3000, false)).toBe('http://example.com:3000');
});
it('useHttps=false on remote host, port 80 omits suffix', () => {
expect(deriveCorsOrigin('example.com', 80, false)).toBe('http://example.com');
});
});
describe('subdomain and non-standard hostnames', () => {
it('sub.domain.example.com defaults to https', () => {
expect(deriveCorsOrigin('sub.domain.example.com', 3000)).toBe(
'https://sub.domain.example.com:3000',
);
});
it('myserver.local defaults to https (not loopback)', () => {
expect(deriveCorsOrigin('myserver.local', 8080)).toBe('https://myserver.local:8080');
});
});
});

View File

@@ -26,25 +26,6 @@ function isHeadless(): boolean {
return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY; return process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
} }
// ── CORS derivation ───────────────────────────────────────────────────────────
/**
* Derive a full CORS origin URL from a user-provided hostname + web UI port.
*
* Rules:
* - "localhost" and "127.0.0.1" always use http (never https)
* - Everything else uses https by default; pass useHttps=false to override
* - Standard ports (80 for http, 443 for https) are omitted from the origin
*/
export function deriveCorsOrigin(hostname: string, webUiPort: number, useHttps?: boolean): string {
const isLocalhost = hostname === 'localhost' || hostname === '127.0.0.1';
const proto =
useHttps !== undefined ? (useHttps ? 'https' : 'http') : isLocalhost ? 'http' : 'https';
const defaultPort = proto === 'https' ? 443 : 80;
const portSuffix = webUiPort === defaultPort ? '' : `:${webUiPort.toString()}`;
return `${proto}://${hostname}${portSuffix}`;
}
// ── .env helpers ────────────────────────────────────────────────────────────── // ── .env helpers ──────────────────────────────────────────────────────────────
function readEnvVarFromFile(envFile: string, key: string): string | null { function readEnvVarFromFile(envFile: string, key: string): string | null {
@@ -126,14 +107,6 @@ 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 {
@@ -255,9 +228,7 @@ export async function gatewayConfigStage(
host: existing.host, host: existing.host,
port: existing.port, port: existing.port,
tier: 'local', tier: 'local',
corsOrigin: corsOrigin: 'http://localhost:3000',
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ??
deriveCorsOrigin('localhost', 3000),
regeneratedConfig: false, regeneratedConfig: false,
}; };
return { ready: true, host: existing.host, port: existing.port }; return { ready: true, host: existing.host, port: existing.port };
@@ -310,8 +281,7 @@ export async function gatewayConfigStage(
host, host,
port, port,
tier: 'local', tier: 'local',
corsOrigin: corsOrigin: 'http://localhost:3000',
readEnvVarFromFile(ENV_FILE, 'GATEWAY_CORS_ORIGIN') ?? deriveCorsOrigin('localhost', 3000),
regeneratedConfig: false, regeneratedConfig: false,
}; };
} else { } else {
@@ -322,8 +292,6 @@ 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) {
@@ -399,10 +367,6 @@ 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. */
@@ -431,7 +395,6 @@ async function collectAndWriteConfig(
let valkeyUrl: string | undefined; let valkeyUrl: string | undefined;
let anthropicKey: string; let anthropicKey: string;
let corsOrigin: string; let corsOrigin: string;
let hostname: string;
if (isHeadless()) { if (isHeadless()) {
p.log('Headless mode detected — reading configuration from environment variables.'); p.log('Headless mode detected — reading configuration from environment variables.');
@@ -445,13 +408,7 @@ async function collectAndWriteConfig(
databaseUrl = process.env['MOSAIC_DATABASE_URL']; databaseUrl = process.env['MOSAIC_DATABASE_URL'];
valkeyUrl = process.env['MOSAIC_VALKEY_URL']; valkeyUrl = process.env['MOSAIC_VALKEY_URL'];
anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? ''; anthropicKey = process.env['MOSAIC_ANTHROPIC_API_KEY'] ?? '';
corsOrigin = process.env['MOSAIC_CORS_ORIGIN'] ?? 'http://localhost:3000';
// MOSAIC_CORS_ORIGIN is the full override (e.g. from CI).
// MOSAIC_HOSTNAME is the user-friendly alternative — derive from it.
const corsOverride = process.env['MOSAIC_CORS_ORIGIN'];
const hostnameEnv = process.env['MOSAIC_HOSTNAME'] ?? 'localhost';
hostname = hostnameEnv;
corsOrigin = corsOverride ?? deriveCorsOrigin(hostnameEnv, 3000);
if (tier === 'team') { if (tier === 'team') {
const missing: string[] = []; const missing: string[] = [];
@@ -480,34 +437,16 @@ async function collectAndWriteConfig(
}); });
} }
if (opts.providerKey) { anthropicKey = await p.text({
anthropicKey = opts.providerKey; message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
p.log(`Using API key from provider setup (${opts.providerType ?? 'unknown'}).`); defaultValue: '',
} else {
anthropicKey = await p.text({
message: 'ANTHROPIC_API_KEY (optional, press Enter to skip)',
defaultValue: '',
});
}
hostname = await p.text({
message: 'Web UI hostname (for browser access)',
initialValue: 'localhost',
defaultValue: 'localhost',
placeholder: 'e.g. localhost or myserver.example.com',
}); });
// For non-localhost, ask if HTTPS is in use (defaults to yes for remote hosts) corsOrigin = await p.text({
let useHttps: boolean | undefined; message: 'CORS origin',
if (hostname !== 'localhost' && hostname !== '127.0.0.1') { initialValue: 'http://localhost:3000',
useHttps = await p.confirm({ defaultValue: 'http://localhost:3000',
message: 'Is HTTPS enabled for the web UI?', });
initialValue: true,
});
}
corsOrigin = deriveCorsOrigin(hostname, 3000, useHttps);
p.log(`CORS origin set to: ${corsOrigin}`);
} }
const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex'); const authSecret = preservedAuthSecret ?? randomBytes(32).toString('hex');
@@ -527,11 +466,7 @@ async function collectAndWriteConfig(
} }
if (anthropicKey) { if (anthropicKey) {
if (opts.providerType === 'openai') { envLines.push(`ANTHROPIC_API_KEY=${anthropicKey}`);
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 });
@@ -565,7 +500,6 @@ async function collectAndWriteConfig(
valkeyUrl, valkeyUrl,
anthropicKey: anthropicKey || undefined, anthropicKey: anthropicKey || undefined,
corsOrigin, corsOrigin,
hostname,
regeneratedConfig: true, regeneratedConfig: true,
}; };
} }

View File

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

View File

@@ -1,54 +0,0 @@
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`.');
}
}

View File

@@ -1,98 +0,0 @@
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;
}
}
}

View File

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

View File

@@ -3,19 +3,6 @@ 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;
@@ -75,11 +62,6 @@ export interface GatewayState {
valkeyUrl?: string; valkeyUrl?: string;
anthropicKey?: string; anthropicKey?: string;
corsOrigin: string; corsOrigin: string;
/**
* Raw hostname the user entered (e.g. "localhost", "myserver.example.com").
* The full CORS origin (`corsOrigin`) is derived from this + protocol + webUiPort.
*/
hostname?: string;
/** True when .env + mosaic.config.json were (re)generated in this run. */ /** True when .env + mosaic.config.json were (re)generated in this run. */
regeneratedConfig?: boolean; regeneratedConfig?: boolean;
admin?: GatewayAdminState; admin?: GatewayAdminState;
@@ -99,12 +81,4 @@ 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;
} }

View File

@@ -1,8 +1,9 @@
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 { MenuSection, WizardState } from './types.js'; import type { 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';
@@ -12,10 +13,6 @@ 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;
@@ -57,7 +54,6 @@ 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)
@@ -94,304 +90,55 @@ 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);
// ── Headless bypass ──────────────────────────────────────────────────────── // Stage 3: Quick Start vs Advanced (skip if keeping existing)
// When MOSAIC_ASSUME_YES=1 or no TTY, run the linear headless path.
// This preserves full backward compatibility with tools/install.sh --yes.
const headlessRun = process.env['MOSAIC_ASSUME_YES'] === '1' || !process.stdin.isTTY;
if (headlessRun) {
await runHeadlessPath(prompter, state, configService, options);
return;
}
// ── Interactive: Main Menu ─────────────────────────────────────────────────
if (state.installAction === 'fresh' || state.installAction === 'reset') { if (state.installAction === 'fresh' || state.installAction === 'reset') {
await runMenuLoop(prompter, state, configService, options); await modeSelectStage(prompter, state);
} else if (state.installAction === 'reconfigure') { } else if (state.installAction === 'reconfigure') {
state.mode = 'advanced'; 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 userSetupStage(prompter, state);
await toolsSetupStage(prompter, state);
await runtimeSetupStage(prompter, state);
await hooksPreviewStage(prompter, state);
}
// ── Finish & Apply ──────────────────────────────────────────────────────────
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 // Stage 4: SOUL.md
if (!state.completedSections?.has('skills')) {
await skillsSelectStage(prompter, state);
}
// Finalize (writes configs, links runtime assets, syncs skills)
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) {
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); await soulSetupStage(prompter, state);
// USER.md // Stage 5: USER.md
await userSetupStage(prompter, state); await userSetupStage(prompter, state);
// TOOLS.md // Stage 6: TOOLS.md
await toolsSetupStage(prompter, state); await toolsSetupStage(prompter, state);
// Runtime Detection // Stage 7: Runtime Detection & Installation
await runtimeSetupStage(prompter, state); await runtimeSetupStage(prompter, state);
// Hooks // Stage 8: Hooks preview (Claude only — skipped if Claude not detected)
await hooksPreviewStage(prompter, state); await hooksPreviewStage(prompter, state);
// Skills // Stage 9: Skills Selection
await skillsSelectStage(prompter, state); await skillsSelectStage(prompter, state);
// Finalize // Stage 10: Finalize (writes configs, links runtime assets, runs doctor)
await finalizeStage(prompter, state, configService); await finalizeStage(prompter, state, configService);
// Gateway stages // Stages 11 & 12: Gateway config + admin bootstrap.
// 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) { if (!configResult.ready || !configResult.host || !configResult.port) {
prompter.warn('Gateway configuration failed in headless mode — aborting wizard.'); if (headlessRun) {
process.exit(1); prompter.warn('Gateway configuration failed in headless mode — aborting wizard.');
process.exit(1);
}
} else { } else {
const bootstrapResult = await gatewayBootstrapStage(prompter, state, { const bootstrapResult = await gatewayBootstrapStage(prompter, state, {
host: configResult.host, host: configResult.host,
@@ -403,53 +150,12 @@ async function runHeadlessPath(
} }
} }
} catch (err) { } catch (err) {
prompter.warn(`Gateway setup failed: ${err instanceof Error ? err.message : String(err)}`); // Stages normally return structured `ready: false` results for
throw err; // expected failures. Anything that reaches here is an unexpected
} // 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;
} }

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/prdy" "directory": "packages/prdy"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.3", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/quality-rails" "directory": "packages/quality-rails"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.4", "version": "0.0.4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/queue" "directory": "packages/queue"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.4", "version": "0.0.4",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/storage" "directory": "packages/storage"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "packages/types" "directory": "packages/types"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "plugins/discord" "directory": "plugins/discord"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "plugins/macp" "directory": "plugins/macp"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "plugins/mosaic-framework" "directory": "plugins/mosaic-framework"
}, },
"type": "module", "type": "module",

View File

@@ -3,7 +3,7 @@
"version": "0.0.2", "version": "0.0.2",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/mosaic-stack.git",
"directory": "plugins/telegram" "directory": "plugins/telegram"
}, },
"main": "dist/index.js", "main": "dist/index.js",

View File

@@ -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)
# #
# Quick: curl -fsSL https://mosaicstack.dev/install.sh | bash # Remote install (recommended):
# Direct: bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/stack/raw/branch/main/tools/install.sh) # bash <(curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-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/stack/raw/branch/main/tools/install.sh | bash -s -- # curl -fsSL https://git.mosaicstack.dev/mosaicstack/mosaic-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/stack" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/mosaic-stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# ─── uninstall path ─────────────────────────────────────────────────────────── # ─── uninstall path ───────────────────────────────────────────────────────────