Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
fb61b26818 feat(fleet): system-type profiles — declarative roster+topology mapping (H2)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add declarative system-type profiles: framework/fleet/profiles/*.yaml map a
system type to a persona roster + org topology (reports_to, multiplicity).
Profiles are DATA, seeded like roles, so an operator declares a system type and
gets the matching roster from the baseline library with no code change
(NS-9 / AC-NS-6).

- 5 baseline profiles: software-delivery, personal-assistant, research,
  business (company-in-a-box), marketing.
- fleet-profiles.ts: loadProfiles/loadProfile/parseProfile/validateProfile +
  listPersonaClasses (extracts valid classes from the role library by unioning
  inline `class:` markers, LIBRARY.md rows, and role filenames so marker-less
  personas like planner/decomposition resolve).
- CLI: `mosaic fleet profile list|show [--json]`; invalid profiles exit non-zero.
- Spec covers parse/validate, the library-drift guard (every referenced class
  resolves against the real role library), and unknown-class/reports_to rejection.
- install.sh: profiles seed via the existing rsync (comment clarified; the
  preserved top-level `fleet/*.yaml` glob does not shadow fleet/profiles/*.yaml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:41:37 -05:00
35 changed files with 126 additions and 2908 deletions

View File

@@ -1,255 +0,0 @@
import 'reflect-metadata';
import { describe, expect, it, vi } from 'vitest';
import type { Db } from '@mosaicstack/db';
import type { FederationListResponse } from '@mosaicstack/types';
import {
FederationClientError,
type FederationClientService,
} from '../federation-client.service.js';
import { type QuerySourceError, QuerySourceService } from '../query-source.service.js';
interface TestRow {
id: string;
title: string;
}
interface PeerRow {
id: string;
commonName: string;
endpointUrl: string | null;
clientKeyPem: string | null;
state: 'active' | 'pending' | 'suspended' | 'revoked';
}
const LOCAL_ROWS: TestRow[] = [
{ id: 'local-1', title: 'Local One' },
{ id: 'local-2', title: 'Local Two' },
];
const PEER_A: PeerRow = {
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
commonName: 'peer-a',
endpointUrl: 'https://peer-a.example.com',
clientKeyPem: 'sealed-key-a',
state: 'active',
};
const PEER_B: PeerRow = {
id: 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
commonName: 'peer-b',
endpointUrl: 'https://peer-b.example.com',
clientKeyPem: 'sealed-key-b',
state: 'active',
};
const PEER_LOCALHOST: PeerRow = {
id: 'cccccccc-cccc-cccc-cccc-cccccccccccc',
commonName: 'peer-localhost',
endpointUrl: 'https://localhost:3001',
clientKeyPem: 'sealed-key-c',
state: 'active',
};
function makeDb(activePeers: PeerRow[]): Db {
const orderBy = vi.fn().mockResolvedValue(activePeers);
const where = vi.fn().mockReturnValue({ orderBy });
const from = vi.fn().mockReturnValue({ where });
const select = vi.fn().mockReturnValue({ from });
return {
select,
insert: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
transaction: vi.fn(),
} as unknown as Db;
}
function makeFederationClient(
list: (
peerId: string,
resource: string,
request: Record<string, unknown>,
) => Promise<FederationListResponse<TestRow>>,
): FederationClientService {
return {
list: list as unknown as FederationClientService['list'],
} as FederationClientService;
}
function makeLocalResponse(rows: TestRow[] = LOCAL_ROWS): Promise<FederationListResponse<TestRow>> {
return Promise.resolve({ items: rows });
}
describe('QuerySourceService', () => {
it('routes source="local" to the local executor and tags rows as local', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'local',
resource: 'tasks',
request: { cursor: 'ignored-for-local-test' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'local-2', title: 'Local Two', _source: 'local' },
],
});
expect(list).not.toHaveBeenCalled();
});
it('routes source="federated:<host>" to the matching active peer and tags rows with peer commonName', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-1', title: 'Remote One' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:peer-b.example.com',
resource: 'tasks',
request: { status: 'open' },
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-1', title: 'Remote One', _source: 'peer-b' }],
});
expect(list).toHaveBeenCalledWith(PEER_B.id, 'tasks', { status: 'open' });
});
it('matches federated hosts by endpoint host including non-default port', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-port', title: 'Remote Port' }],
}),
);
const service = new QuerySourceService(makeDb([PEER_LOCALHOST]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'federated:localhost:3001',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
});
expect(result).toEqual({
items: [{ id: 'remote-port', title: 'Remote Port', _source: 'peer-localhost' }],
});
expect(list).toHaveBeenCalledWith(PEER_LOCALHOST.id, 'tasks', {});
});
it('fans out source="all" to local plus every active outbound peer in parallel and merges tagged rows', async () => {
const callOrder: string[] = [];
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
callOrder.push(`remote-start:${peerId}`);
await Promise.resolve();
return {
items: [{ id: `remote-${peerId.slice(0, 1)}`, title: `Remote ${peerId.slice(0, 1)}` }],
};
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: { limit: 25 },
local: async () => {
callOrder.push('local-start');
await Promise.resolve();
return { items: [{ id: 'local-1', title: 'Local One' }] };
},
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote a', _source: 'peer-a' },
{ id: 'remote-b', title: 'Remote b', _source: 'peer-b' },
],
});
expect(list).toHaveBeenCalledTimes(2);
expect(callOrder).toEqual([
'local-start',
`remote-start:${PEER_A.id}`,
`remote-start:${PEER_B.id}`,
]);
});
it('marks source="all" as partial and truncated when any subquery returns a cursor', async () => {
const list = vi.fn(
async (): Promise<FederationListResponse<TestRow>> => ({
items: [{ id: 'remote-a', title: 'Remote A' }],
nextCursor: 'remote-next',
}),
);
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
_truncated: true,
});
});
it('returns _partial=true for source="all" when one peer fails without dropping successful sources', async () => {
const list = vi.fn(async (peerId: string): Promise<FederationListResponse<TestRow>> => {
if (peerId === PEER_B.id) {
throw new FederationClientError({
code: 'NETWORK',
message: 'peer unavailable',
peerId,
});
}
return { items: [{ id: 'remote-a', title: 'Remote A' }] };
});
const service = new QuerySourceService(makeDb([PEER_A, PEER_B]), makeFederationClient(list));
const result = await service.list<TestRow>({
source: 'all',
resource: 'tasks',
request: {},
local: () => makeLocalResponse([{ id: 'local-1', title: 'Local One' }]),
});
expect(result).toEqual({
items: [
{ id: 'local-1', title: 'Local One', _source: 'local' },
{ id: 'remote-a', title: 'Remote A', _source: 'peer-a' },
],
_partial: true,
});
});
it('throws QuerySourceError when a federated host does not match an active outbound peer', async () => {
const list = vi.fn(async (): Promise<FederationListResponse<TestRow>> => ({ items: [] }));
const service = new QuerySourceService(makeDb([PEER_A]), makeFederationClient(list));
await expect(
service.list<TestRow>({
source: 'federated:missing.example.com',
resource: 'tasks',
request: {},
local: () => makeLocalResponse(),
}),
).rejects.toMatchObject({
name: 'QuerySourceError',
code: 'PEER_NOT_FOUND',
} satisfies Partial<QuerySourceError>);
});
});

View File

@@ -11,13 +11,3 @@ export {
type FederationClientErrorCode, type FederationClientErrorCode,
type FederationClientErrorOptions, type FederationClientErrorOptions,
} from './federation-client.service.js'; } from './federation-client.service.js';
export {
QuerySourceService,
QuerySourceError,
type QuerySource,
type QuerySourceErrorCode,
type QuerySourceErrorOptions,
type QuerySourceListOptions,
type QuerySourceListResponse,
type LocalListExecutor,
} from './query-source.service.js';

View File

@@ -1,261 +0,0 @@
/**
* QuerySourceService — gateway query source router (FED-M3-09).
*
* Accepts the federation query-layer `source` selector and routes list-style
* reads to local storage, one federated peer, or all active outbound peers.
*
* `source: "all"` is intentionally tolerant of per-peer failures: local data
* and successful peer responses are returned, and the envelope is marked
* `_partial: true`. Local failures still reject because there is no safe local
* fallback and the gateway's own storage is expected to be authoritative.
*/
import { Inject, Injectable, Logger } from '@nestjs/common';
import { and, eq, federationPeers, isNotNull, type Db } from '@mosaicstack/db';
import {
SOURCE_LOCAL,
tagWithSource,
type FederationListResponse,
type SourceTag,
} from '@mosaicstack/types';
import { DB } from '../../database/database.module.js';
import { FederationClientService } from './federation-client.service.js';
export type QuerySource = 'local' | 'all' | `federated:${string}`;
export type QuerySourceErrorCode = 'INVALID_SOURCE' | 'PEER_NOT_FOUND';
export interface QuerySourceErrorOptions {
code: QuerySourceErrorCode;
message: string;
source: string;
}
export class QuerySourceError extends Error {
readonly code: QuerySourceErrorCode;
readonly source: string;
constructor(opts: QuerySourceErrorOptions) {
super(opts.message);
this.name = 'QuerySourceError';
this.code = opts.code;
this.source = opts.source;
}
}
export type LocalListExecutor<T extends object> = () => Promise<FederationListResponse<T> | T[]>;
export interface QuerySourceListOptions<T extends object> {
source: QuerySource;
resource: string;
request?: Record<string, unknown>;
local: LocalListExecutor<T>;
}
export type QuerySourceListResponse<T extends object> = FederationListResponse<T & SourceTag>;
interface OutboundPeer {
id: string;
commonName: string;
endpointUrl: string;
}
interface TaggedList<T extends object> {
items: Array<T & SourceTag>;
partial: boolean;
truncated: boolean;
nextCursor?: string;
}
@Injectable()
export class QuerySourceService {
private readonly logger = new Logger(QuerySourceService.name);
constructor(
@Inject(DB) private readonly db: Db,
@Inject(FederationClientService) private readonly federationClient: FederationClientService,
) {}
async list<T extends object>(
options: QuerySourceListOptions<T>,
): Promise<QuerySourceListResponse<T>> {
const request = options.request ?? {};
if (options.source === 'local') {
const local = await this.runLocal(options.local);
return this.toResponse(this.tagList(local, SOURCE_LOCAL));
}
if (options.source === 'all') {
return this.listAll(options.resource, request, options.local);
}
if (options.source.startsWith('federated:')) {
const host = options.source.slice('federated:'.length).trim();
if (!host) {
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: 'Federated source must include a host after federated:',
source: options.source,
});
}
const peer = await this.findPeerByHost(host, options.source);
const remote = await this.federationClient.list<T>(peer.id, options.resource, request);
return this.toResponse(this.tagList(remote, peer.commonName));
}
throw new QuerySourceError({
code: 'INVALID_SOURCE',
message: `Unsupported query source: ${options.source}`,
source: options.source,
});
}
private async listAll<T extends object>(
resource: string,
request: Record<string, unknown>,
local: LocalListExecutor<T>,
): Promise<QuerySourceListResponse<T>> {
const peers = await this.listActiveOutboundPeers();
const localPromise = this.runLocal(local).then((response) =>
this.tagList(response, SOURCE_LOCAL),
);
const remotePromises = peers.map(async (peer: OutboundPeer): Promise<TaggedList<T> | null> => {
try {
const response = await this.federationClient.list<T>(peer.id, resource, request);
return this.tagList(response, peer.commonName);
} catch (error: unknown) {
this.logger.warn(
`Federated query to peer ${peer.commonName} (${peer.id}) failed; returning partial all-source response: ${
error instanceof Error ? error.message : String(error)
}`,
);
return null;
}
});
const [localResult, ...remoteResults] = await Promise.all([localPromise, ...remotePromises]);
const successfulRemoteResults = remoteResults.filter(
(result: TaggedList<T> | null): result is TaggedList<T> => result !== null,
);
const allResults = [localResult, ...successfulRemoteResults];
const peerFailure = successfulRemoteResults.length !== peers.length;
return this.mergeTaggedLists(allResults, peerFailure);
}
private async runLocal<T extends object>(
local: LocalListExecutor<T>,
): Promise<FederationListResponse<T>> {
const response = await local();
if (Array.isArray(response)) {
return { items: response };
}
return response;
}
private tagList<T extends object>(
response: FederationListResponse<T>,
source: string,
): TaggedList<T> {
return {
items: tagWithSource(response.items, source),
partial: response._partial === true,
truncated: response._truncated === true || response.nextCursor !== undefined,
nextCursor: response.nextCursor,
};
}
private mergeTaggedLists<T extends object>(
lists: Array<TaggedList<T>>,
peerFailure: boolean,
): QuerySourceListResponse<T> {
const items = lists.flatMap((list: TaggedList<T>) => list.items);
const partial =
peerFailure ||
lists.some((list: TaggedList<T>) => list.partial || list.nextCursor !== undefined);
const truncated = lists.some((list: TaggedList<T>) => list.truncated);
const response: QuerySourceListResponse<T> = { items };
if (partial) {
response._partial = true;
}
if (truncated) {
response._truncated = true;
}
return response;
}
private toResponse<T extends object>(tagged: TaggedList<T>): QuerySourceListResponse<T> {
const response: QuerySourceListResponse<T> = {
items: tagged.items,
};
if (tagged.nextCursor !== undefined) {
response.nextCursor = tagged.nextCursor;
}
if (tagged.partial) {
response._partial = true;
}
if (tagged.truncated) {
response._truncated = true;
}
return response;
}
private async findPeerByHost(sourceHost: string, source: string): Promise<OutboundPeer> {
const host = normalizeHost(sourceHost);
const peers = await this.listActiveOutboundPeers();
const peer = peers.find((candidate: OutboundPeer) => {
const commonName = normalizeHost(candidate.commonName);
const endpointHosts = endpointHostKeys(candidate.endpointUrl).map((endpointHost: string) =>
normalizeHost(endpointHost),
);
return commonName === host || endpointHosts.includes(host);
});
if (!peer) {
throw new QuerySourceError({
code: 'PEER_NOT_FOUND',
message: `No active outbound federation peer matches source ${source}`,
source,
});
}
return peer;
}
private async listActiveOutboundPeers(): Promise<OutboundPeer[]> {
const rows = await this.db
.select({
id: federationPeers.id,
commonName: federationPeers.commonName,
endpointUrl: federationPeers.endpointUrl,
})
.from(federationPeers)
.where(
and(
eq(federationPeers.state, 'active'),
isNotNull(federationPeers.endpointUrl),
isNotNull(federationPeers.clientKeyPem),
),
)
.orderBy(federationPeers.commonName);
return rows.filter((row): row is OutboundPeer => typeof row.endpointUrl === 'string');
}
}
function normalizeHost(host: string): string {
return host.trim().toLowerCase();
}
function endpointHostKeys(endpointUrl: string): string[] {
try {
const url = new URL(endpointUrl);
return Array.from(new Set([url.host, url.hostname].filter((host: string) => host.length > 0)));
} catch {
return [];
}
}

View File

@@ -4,20 +4,18 @@ import { CaService } from './ca.service.js';
import { EnrollmentController } from './enrollment.controller.js'; import { EnrollmentController } from './enrollment.controller.js';
import { EnrollmentService } from './enrollment.service.js'; import { EnrollmentService } from './enrollment.service.js';
import { FederationController } from './federation.controller.js'; import { FederationController } from './federation.controller.js';
import { CapabilitiesController } from './server/verbs/capabilities.controller.js';
import { GrantsService } from './grants.service.js'; import { GrantsService } from './grants.service.js';
import { FederationClientService, QuerySourceService } from './client/index.js'; import { FederationClientService } from './client/index.js';
import { FederationAuthGuard } from './server/index.js'; import { FederationAuthGuard } from './server/index.js';
@Module({ @Module({
controllers: [EnrollmentController, FederationController, CapabilitiesController], controllers: [EnrollmentController, FederationController],
providers: [ providers: [
AdminGuard, AdminGuard,
CaService, CaService,
EnrollmentService, EnrollmentService,
GrantsService, GrantsService,
FederationClientService, FederationClientService,
QuerySourceService,
FederationAuthGuard, FederationAuthGuard,
], ],
exports: [ exports: [
@@ -25,7 +23,6 @@ import { FederationAuthGuard } from './server/index.js';
EnrollmentService, EnrollmentService,
GrantsService, GrantsService,
FederationClientService, FederationClientService,
QuerySourceService,
FederationAuthGuard, FederationAuthGuard,
], ],
}) })

View File

@@ -1,88 +0,0 @@
import 'reflect-metadata';
import { RequestMethod } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import type { FastifyRequest } from 'fastify';
import { FederationCapabilitiesResponseSchema, FEDERATION_VERBS } from '@mosaicstack/types';
import { FederationScopeError } from '../../../scope-schema.js';
import { FederationAuthGuard } from '../../federation-auth.guard.js';
import { CapabilitiesController } from '../capabilities.controller.js';
const VALID_SCOPE = {
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
} as const;
const DEFAULTED_SCOPE = {
resources: ['memory'],
max_rows_per_query: 10,
} as const;
function makeRequest(scope: Record<string, unknown>): FastifyRequest {
return {
federationContext: {
grantId: 'grant-1',
peerId: 'peer-1',
subjectUserId: 'user-1',
scope,
},
} as FastifyRequest;
}
describe('CapabilitiesController', () => {
it('declares GET /api/federation/v1/capabilities', () => {
expect(Reflect.getMetadata('path', CapabilitiesController)).toBe(
'api/federation/v1/capabilities',
);
expect(Reflect.getMetadata('path', CapabilitiesController.prototype.getCapabilities)).toBe('/');
expect(Reflect.getMetadata('method', CapabilitiesController.prototype.getCapabilities)).toBe(
RequestMethod.GET,
);
});
it('is protected only by FederationAuthGuard', () => {
const guards = Reflect.getMetadata('__guards__', CapabilitiesController) as unknown[];
expect(guards).toEqual([FederationAuthGuard]);
});
it('returns resources, excluded resources, max rows, and M3 supported verbs from the active grant scope', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(VALID_SCOPE));
expect(response).toEqual({
resources: ['tasks', 'notes'],
excluded_resources: ['credentials'],
max_rows_per_query: 250,
supported_verbs: [...FEDERATION_VERBS],
});
expect(FederationCapabilitiesResponseSchema.safeParse(response).success).toBe(true);
});
it('applies scope defaults without RBAC or resource filtering', () => {
const controller = new CapabilitiesController();
const response = controller.getCapabilities(makeRequest(DEFAULTED_SCOPE));
expect(response).toEqual({
resources: ['memory'],
excluded_resources: [],
max_rows_per_query: 10,
supported_verbs: ['list', 'get', 'capabilities'],
});
});
it('rejects invalid scope state instead of returning an invalid capabilities contract', () => {
const controller = new CapabilitiesController();
expect(() =>
controller.getCapabilities(
makeRequest({
resources: [],
max_rows_per_query: 0,
}),
),
).toThrow(FederationScopeError);
});
});

View File

@@ -1,38 +0,0 @@
/**
* Federation capabilities verb (FED-M3-07).
*
* Returns the read-only capability envelope for the active grant attached by
* FederationAuthGuard. This endpoint intentionally does not invoke native RBAC
* or ScopeService: an active grant is enough to ask what the grant allows.
*/
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import {
FEDERATION_VERBS,
type FederationCapabilitiesResponse,
type FederationVerb,
} from '@mosaicstack/types';
import { parseFederationScope } from '../../scope-schema.js';
import { FederationAuthGuard } from '../federation-auth.guard.js';
import '../federation-context.js';
@Controller('api/federation/v1/capabilities')
@UseGuards(FederationAuthGuard)
export class CapabilitiesController {
@Get()
getCapabilities(@Req() request: FastifyRequest): FederationCapabilitiesResponse {
if (!request.federationContext) {
throw new Error('Federation context missing after auth guard');
}
const scope = parseFederationScope(request.federationContext.scope);
return {
resources: [...scope.resources],
excluded_resources: [...scope.excluded_resources],
max_rows_per_query: scope.max_rows_per_query,
supported_verbs: [...FEDERATION_VERBS] satisfies FederationVerb[],
};
}
}

View File

@@ -91,22 +91,22 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
> >
> **Tracking issue:** #462. > **Tracking issue:** #462.
| id | status | description | issue | agent | branch | depends_on | estimate | notes | | id | status | description | issue | agent | branch | depends_on | estimate | notes |
| --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | --------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- | ------ | ------------------------------------ | ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| FED-M3-01 | done | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. | | FED-M3-01 | not-started | `packages/types/src/federation/` — request/response DTOs for `list`, `get`, `capabilities` verbs. Wire-format zod schemas + inferred TS types. Includes `FederationRequest`, `FederationListResponse<T>`, `FederationGetResponse<T>`, `FederationCapabilitiesResponse`, error envelope, `_source` tag. | #462 | sonnet | feat/federation-m3-types | — | 4K | Reusable from gateway server + client + harness. Pure types — no I/O, no NestJS. |
| FED-M3-02 | done | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. | | FED-M3-02 | not-started | `tools/federation-harness/` scaffold: `docker-compose.two-gateways.yml` (Server A + Server B + step-CA), `seed.ts` (provisions grants, peers, sample tasks/notes/credentials per scope variant), `harness.ts` helper (boots stack, returns typed clients). README documents harness use. | #462 | sonnet | feat/federation-m3-harness | DEPLOY-04 (soft) | 8K | Falls back to local docker-compose if `mos-test-1/-2` not yet redeployed (DEPLOY chain blocked on IMG-FIX). Permanent test infra used by M3+. |
| FED-M3-03 | done | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. | | FED-M3-03 | not-started | `apps/gateway/src/federation/server/federation-auth.guard.ts` (NestJS guard). Validates inbound client cert from Fastify TLS context, extracts `grantId` + `subjectUserId` from custom OIDs, loads grant from DB, asserts `status='active'`, attaches `FederationContext` to request. | #462 | sonnet | feat/federation-m3-auth-guard | M3-01 | 8K | Reuses OID parsing logic mirrored from `ca.service.ts` post-issuance verification. 401 on malformed/missing OIDs; 403 on revoked/expired/missing grant. |
| FED-M3-04 | in-progress | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. | | FED-M3-04 | not-started | `apps/gateway/src/federation/server/scope.service.ts`. Pipeline: (1) resource allowlist + excluded check, (2) native RBAC eval as `subjectUserId`, (3) scope filter intersection (`include_teams`, `include_personal`), (4) `max_rows_per_query` cap. Pure service — DB calls injected. | #462 | sonnet | feat/federation-m3-scope-service | M3-01 | 10K | Hardest correctness target in M3. Reuses `parseFederationScope` (M2-03). Returns either `{ allowed: true, filter }` or structured deny reason for audit. |
| FED-M3-05 | in-progress | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. | | FED-M3-05 | not-started | `apps/gateway/src/federation/server/verbs/list.controller.ts`. Wires AuthGuard → ScopeService → tasks/notes/memory query layer; applies row cap; tags rows with `_source`. Resource selector via path param. | #462 | sonnet | feat/federation-m3-verb-list | M3-03, M3-04 | 6K | Routes: `POST /api/federation/v1/list/:resource`. No body persistence. Audit write deferred to M4. |
| FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. | | FED-M3-06 | not-started | `apps/gateway/src/federation/server/verbs/get.controller.ts`. Single-resource fetch by id; same pipeline as list. 404 on not-found, 403 on RBAC/scope deny — both audited the same way. | #462 | sonnet | feat/federation-m3-verb-get | M3-03, M3-04 | 6K | `POST /api/federation/v1/get/:resource/:id`. Mirrors list controller patterns. |
| FED-M3-07 | done | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. | | FED-M3-07 | not-started | `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`. Read-only enumeration: returns `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope. Always allowed for an active grant — no RBAC eval. | #462 | sonnet | feat/federation-m3-verb-capabilities | M3-03 | 4K | `GET /api/federation/v1/capabilities`. Smallest verb; useful sanity check that mTLS + auth guard work end-to-end. |
| FED-M3-08 | done | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. | | FED-M3-08 | not-started | `apps/gateway/src/federation/client/federation-client.service.ts`. Outbound mTLS dialer: picks `(certPem, sealed clientKey)` from `federation_peers`, unwraps key, builds undici Agent with mTLS, calls peer verb, parses typed response, wraps non-2xx into `FederationClientError`. | #462 | sonnet | feat/federation-m3-client | M3-01 | 8K | Independent of server stream — can land in parallel with M3-03/04. Cert/key cached per-peer; flushed by future M5/M6 logic. |
| FED-M3-09 | done | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. | | FED-M3-09 | not-started | `apps/gateway/src/federation/client/query-source.service.ts`. Accepts `source: "local" \| "federated:<host>" \| "all"` from gateway query layer; for `"all"` fans out to local + each peer in parallel; merges results; tags every row with `_source`. | #462 | sonnet | feat/federation-m3-query-source | M3-08 | 8K | Per-peer failure surfaces as `_partial: true` in response, not hard failure (sets up M5 offline UX). M5 adds caching + circuit breaker on top. |
| FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. | | FED-M3-10 | not-started | Integration tests for MILESTONES.md M3 acceptance #6 (malformed OIDs → 401; valid cert + revoked grant → 403) and #7 (`max_rows_per_query` cap). Real PG, mocked TLS context (Fastify req shim). | #462 | sonnet | feat/federation-m3-integration | M3-05, M3-06 | 8K | Vitest profile gated by `FEDERATED_INTEGRATION=1`. Single-gateway suite; no harness required. |
| FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-04, M3-05, M3-06, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. | | FED-M3-11 | not-started | E2E tests for MILESTONES.md M3 acceptance #1, #2, #3, #4, #5, #8, #9, #10 (8 cases). Uses harness from M3-02; two real gateways, real Step-CA, real mTLS. Each test asserts both happy-path response and audit/no-persist invariants. | #462 | sonnet | feat/federation-m3-e2e | M3-02, M3-09 | 12K | Largest single task. Each acceptance gets its own `it(...)` for clear failure attribution. |
| FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. | | FED-M3-12 | not-started | Independent security review (sonnet, not author of M3-03/04/05/06/07/08/09): focus on cert-SAN spoofing, OID extraction edge cases, scope-bypass via filter manipulation, RBAC-bypass via subjectUser swap, response leakage when scope deny. | #462 | sonnet | feat/federation-m3-security-review | M3-11 | 10K | Two review rounds budgeted. PRD requires explicit test for every 401/403 path — review verifies coverage. |
| FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. | | FED-M3-13 | not-started | Docs update: `docs/federation/SETUP.md` mTLS handshake section, new `docs/federation/HARNESS.md` for federation-harness usage, OID reference table in SETUP.md, scope enforcement pipeline diagram. Runbook still M7-deferred. | #462 | haiku | feat/federation-m3-docs | M3-12 | 5K | One ASCII diagram for the auth-guard → scope → RBAC pipeline; helps future reviewers reason about denial paths. |
| FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. | | FED-M3-14 | not-started | PR aggregate close, CI green, merge to main, close #462. Release tag `fed-v0.3.0-m3`. Update mission manifest M3 row → done; M4 row → in-progress when work begins. | #462 | sonnet | chore/federation-m3-close | M3-13 | 3K | Same close pattern as M1-12 / M2-13. |
**M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission. **M3 estimate:** ~100K tokens (vs MILESTONES.md 40K — same per-task breakdown pattern as M1/M2: tests, review, and docs split out from implementation cost). Largest milestone in the federation mission.
@@ -118,10 +118,6 @@ Goal: Two federated gateways exchange real data over mTLS. Inbound requests pass
**Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12). **Test bed fallback:** If `mos-test-1.woltje.com` / `mos-test-2.woltje.com` are still blocked on `FED-M2-DEPLOY-IMG-FIX` when M3-11 is ready to run, the harness's local `docker-compose.two-gateways.yml` is a sufficient stand-in. Production-host validation moves to M7 acceptance suite (PRD AC-12).
**Backlog sync — 2026-06-24 (orchestrator):** Status reconciled against `origin/main` (release 0.0.48). Landed on main: **FED-M3-01** (DTOs, PR #506), **FED-M3-02** (harness scaffold, PR #505), **FED-M3-03** (mTLS auth-guard, PR #509 — CRIT-1/2 + HIGH-1..4 remediated in-PR), **FED-M3-08** (outbound mTLS client, PR #508). With M3-01/03/08 merged, three cards became dependency-clear and were dispatched to the idle coder lane: **FED-M3-04** scope.service → coder0 (`feat/federation-m3-scope-service`); **FED-M3-09** query-source + **FED-M3-07** capabilities verb → coder1 (`feat/federation-m3-query-source` first). Reviewer warmed for the M3 trust-boundary PRs. Remaining blocked-by-DAG: M3-05/06 (await M3-04), M3-10 (await M3-05/06), M3-11 (await M3-09), M3-12→14 (tail). Deploy chain (DEPLOY-IMG-FIX → 03/04) still independent of M3 code — harness local docker-compose fallback covers M3-11.
**Backlog sync #2 — 2026-06-24 (orchestrator):** **FED-M3-09** (query-source) merged via PR #673 and **FED-M3-07** (capabilities) merged via PR #674 — both squash-merged on independent agent review-of-record + green CI (formal Gitea approve unavailable under the shared service account; merge is not gated by the self-approve guard). **FED-M3-05** (list verb) dispatched to coder1 (based on the M3-04 branch, rebase onto main once #672 lands). **FED-M3-04** (scope.service, PR #672) is in review-changes (one include_personal no-leak test outstanding). **DAG fix:** corrected `FED-M3-11` depends_on from `M3-02, M3-09``M3-02, M3-04, M3-05, M3-06, M3-09` — the E2E acceptance cases (#1#5, #8#10) exercise list/get over mTLS, so the server verbs + scope service are hard prerequisites; the original edge set omitted them and caused a premature M3-11 dispatch. Note: M3 read-path invariant for M3-11 is **no-persist + existing enrollment audit only** — read-verb audit-log writes are deferred to M4 (see M3-05/06 notes), so M3-11 must not assert read-audit-log entries.
## Milestone 4 — search + audit + rate limit (FED-M4) ## Milestone 4 — search + audit + rate limit (FED-M4)
_Deferred. Issue #463._ _Deferred. Issue #463._

View File

@@ -353,25 +353,6 @@ re-evaluate if isolation or write-volume demands it.
- **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained. - **Docs as projections:** `docs/TASKS.md` and `MISSION-MANIFEST.md` become generated exports of the DB, not hand-maintained.
- **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it. - **Sub-decision pending:** dedicated schema in existing PG instance (recommended) vs. dedicated PG instance. Revisit if isolation or write-volume demands it.
## Decisions of record (2026-06-24, with Jason)
- **Per-agent model switch (operator-configurable, NOT a global lock):** model selection is
**per-agent**, never a host-global pin. Claude sessions MUST NOT be locked to a single model in
`~/.claude/settings.json`; each agent chooses its model independently. The plumbing already exists —
roster `model_hint``MOSAIC_AGENT_MODEL``start-agent-session.sh` appends `--model <hint>` to that
agent's harness (claude or pi); settable today via `mosaic fleet add|edit <agent> --model <hint>`.
**North-star target:** surface this as a **per-agent model switch in the webUI** (with CLI/TUI parity
per MVP-X1) — read the roster, expose a per-agent model dropdown, write `model_hint` back, and restart
that one agent to apply. Unset = inherit the harness default. This **composes with** the budget
downgrade ladder (opus → sonnet → haiku, then Claude → Codex): the operator sets the per-agent model
_intent/ceiling_; budget pacing may downgrade within policy. Tracked as a Fleet `TASKS.md` entry under
the Phase-5 webUI surface.
- **Orchestrator runtime (confirmed live):** the **orchestrator and enhancer run Claude Opus 4.8 in the
Claude Code harness**; only workers (coder/reviewer) run pi/gpt-5.5. Consistent with the 2026-06-20
"Claude reserved for Claude Code only" decision (the orchestrator runs _in_ Claude Code, not an
alternate Claude harness). Pi/gpt-5.5 as the orchestrator is permitted **only if proven** at least as
satisfactory; absent that proof, the orchestrator stays on Claude Opus 4.8.
## Future enhancements (north-star, post-MVP — not on the MVP track) ## Future enhancements (north-star, post-MVP — not on the MVP track)
- **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly - **Mosaic Claude Discord Plugin** — a first-party Mosaic Discord connector that properly

View File

@@ -1,25 +0,0 @@
# Scratchpad — fleet-personas spec timeout
## Objective
Raise the `@mosaicstack/mosaic` Vitest timeout to 30s at config level so filesystem-backed fleet drift-guard specs (`fleet-personas`, `fleet-profiles`, and siblings) stop false-reding under contended CI.
## Plan
1. Move timeout policy into `packages/mosaic/vitest.config.ts` with `testTimeout: 30_000`.
2. Remove the narrower `fleet-personas.spec.ts` local override so PR #677 fixes the suite class, not one file.
3. Run targeted fleet specs plus typecheck/lint/format gates.
4. Commit, queue guard, push, PR update.
## Evidence
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts` — pass (8 tests; initial narrow fix).
- `pnpm typecheck` — pass (41 tasks; initial narrow fix).
- `pnpm lint` — pass (23 tasks; initial narrow fix).
- `pnpm format:check` — pass after formatting this scratchpad (initial narrow fix).
- Package-wide timeout follow-up:
- `pnpm --filter @mosaicstack/mosaic test -- src/commands/fleet-personas.spec.ts src/commands/fleet-profiles.spec.ts` — pass (24 tests).
- `pnpm --filter @mosaicstack/mosaic test` — pass (44 files / 618 tests).
- `pnpm typecheck` — pass (41 tasks).
- `pnpm lint` — pass (23 tasks).
- `pnpm format:check` — pass.

View File

@@ -1,65 +0,0 @@
# FED-M3-07 — Capabilities Verb Scratchpad
## Objective
Implement `GET /api/federation/v1/capabilities` in `apps/gateway/src/federation/server/verbs/capabilities.controller.ts`.
## Scope
- Add read-only capabilities controller under federation server verbs.
- Use `FederationAuthGuard` only; active grant is sufficient and no native RBAC/scope-service eval runs.
- Response shape: `{ resources, excluded_resources, max_rows_per_query, supported_verbs }` derived from grant scope.
- Register controller in `FederationModule`.
- Unit-test happy path, defaults, no-context guard seam, and invalid scope handling.
## Constraints / assumptions
- Issue: #462.
- Branch: `feat/federation-m3-verb-capabilities` from `origin/main` (`3eeed04e`).
- Depends on M3-03 auth guard; guard attaches `request.federationContext.scope` after active-grant validation.
- ASSUMPTION: `supported_verbs` is the M3 verb set from `@mosaicstack/types` (`list`, `get`, `capabilities`).
- ASSUMPTION: `filters`/`rate_limit` are intentionally omitted for FED-M3-07 because the cards response shape lists only the four required fields.
- Budget: no explicit hard cap from orchestrator; working cap ~4K-8K tokens for card implementation + tests + PR cycle.
## Plan
1. Write controller unit tests first.
2. Implement controller and module registration.
3. Run scoped tests + typecheck/lint/format.
4. Run Codex code/security review and remediate.
5. Commit, queue guard, push, PR via wrapper.
## Progress
- 2026-06-24: Intake complete; fresh worktree created from origin/main.
- 2026-06-24: Added `CapabilitiesController`, registered it in `FederationModule`, and added 5 unit tests.
- 2026-06-24: Code/security reviews passed with no findings.
## Tests run
- `pnpm --filter @mosaicstack/gateway test -- capabilities.controller.spec.ts` — PASS (5 tests).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit PostgreSQL connection/schema state for the `messages` table. Changed capabilities tests passed; failure is outside FED-M3-07 surface. No `fleet-personas.spec` flake encountered.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
## Risks / blockers
- Full repo `pnpm test` may hit known `fleet-personas.spec` flake per orchestrator; ignore that specific flake if encountered.
- Previous card saw local DB schema issue in `cross-user-isolation.test.ts`; scoped capabilities tests should be authoritative for this surface.
## Acceptance evidence mapping
| Acceptance criterion | Evidence |
| -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| GET `/api/federation/v1/capabilities` exists | Route metadata test in `capabilities.controller.spec.ts`; scoped test PASS |
| Uses active-grant auth guard and no RBAC eval | Guard metadata test confirms only `FederationAuthGuard`; controller has no service injections/RBAC calls; scoped test PASS |
| Response enumerates resources/excluded/max rows/supported verbs from scope | Happy-path/default scope tests + response schema parse; scoped test PASS |
| Read-only/no persistence side effects | Controller only parses request `federationContext.scope` and returns a DTO; no DB/service dependency; code review PASS |

View File

@@ -1,67 +0,0 @@
# FED-M3-09 — Query Source Service Scratchpad
## Objective
Implement `apps/gateway/src/federation/client/query-source.service.ts` for `source: "local" | "federated:<host>" | "all"` routing.
## Scope
- Add QuerySourceService in gateway federation client layer.
- Unit-test local-only, single federated peer, all-source fan-out/merge, and per-peer partial failures.
- Keep `docs/federation/TASKS.md` read-only per project agent guidance.
## Constraints / assumptions
- Issue: #462.
- Branch: `feat/federation-m3-query-source` from `origin/main` (`e0e7be70`).
- ASSUMPTION: `federated:<host>` should match active outbound peers by `commonName` first and by `endpointUrl` host/hostname as compatibility fallback; source tags use `peer.commonName` per `@mosaicstack/types` source-tag docs.
- ASSUMPTION: QuerySourceService provides list/fan-out behavior; get/source routing can be layered later because card acceptance says merge rows.
- ASSUMPTION: `source: "all"` cannot safely return a single continuation cursor for multiple sub-sources; any subquery cursor marks the merged response `_partial: true` + `_truncated: true` while omitting `nextCursor`.
- Budget: no explicit hard cap from orchestrator; working cap ~8K-12K tokens for card 1 implementation + tests + PR cycle.
- OpenBrain unavailable: credential loader failed with missing `/home/jarvis/.config/mosaic/credentials.json`; not blocking code delivery.
## Plan
1. Review federation client/types/db patterns.
2. Write unit tests for source behavior.
3. Implement QuerySourceService and export/register it in FederationModule.
4. Run scoped tests, typecheck, lint, format.
5. Run codex uncommitted review and remediate.
6. Commit, queue guard, push, PR via wrapper.
## Progress
- 2026-06-24: Intake complete; using isolated worktree to avoid dirty orchestrator files in original checkout.
- 2026-06-24: Added QuerySourceService, module export, barrel export, and 7 unit tests.
- 2026-06-24: First Codex review found pagination and port-host matching issues; both remediated with tests.
## Tests run
- `pnpm --filter @mosaicstack/gateway test -- query-source.service.spec.ts` — PASS (7 tests).
- `pnpm --filter @mosaicstack/gateway typecheck` — PASS.
- `pnpm --filter @mosaicstack/gateway lint` — PASS.
- `pnpm format:check` — PASS.
- `pnpm typecheck` — PASS (41/41 turbo tasks).
- `pnpm lint` — PASS (23/23 turbo tasks).
- `pnpm test` — FAIL in pre-existing/live-DB integration suite: `apps/gateway/src/__tests__/cross-user-isolation.test.ts` cleanup hit `relation "messages" does not exist` against local PostgreSQL. Changed QuerySource unit tests passed; failure is outside FED-M3-09 surface and appears tied to local DB schema state.
## Review evidence
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — first pass request-changes, 2 should-fix findings (all-source cursor handling; endpoint port host matching).
- Remediation: `_partial` + `_truncated` when any all-source subquery has `nextCursor`; endpoint match accepts URL `host` and `hostname`; added tests for both.
- `~/.config/mosaic/tools/codex/codex-code-review.sh --uncommitted` — PASS/approve, no findings.
- `~/.config/mosaic/tools/codex/codex-security-review.sh --uncommitted` — PASS, risk level none, no findings.
## Risks / blockers
- Federation query layer is not yet wired; service API needs to be stable and easy to compose.
- Must avoid hard-failing `source: all` on remote peer failures.
## Acceptance evidence mapping
| Acceptance criterion | Evidence |
| ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| local source returns local rows tagged `_source: local` | `query-source.service.spec.ts` local test; scoped test PASS |
| `federated:<host>` queries selected peer and tags rows with peer source | `query-source.service.spec.ts` commonName/endpoint-host tests; scoped test PASS |
| `all` fans out local + active outbound peers in parallel and merges tagged rows | `query-source.service.spec.ts` all-source call-order/merge test; scoped test PASS |
| per-peer failure on `all` returns `_partial: true`, not throw | `query-source.service.spec.ts` peer failure test; scoped test PASS |

View File

@@ -30,7 +30,6 @@ export default tseslint.config(
'apps/gateway/vitest.config.ts', 'apps/gateway/vitest.config.ts',
'packages/db/vitest.config.ts', 'packages/db/vitest.config.ts',
'packages/storage/vitest.config.ts', 'packages/storage/vitest.config.ts',
'packages/mosaic/vitest.config.ts',
'packages/mosaic/__tests__/*.ts', 'packages/mosaic/__tests__/*.ts',
'tools/federation-harness/*.ts', 'tools/federation-harness/*.ts',
], ],

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/db", "name": "@mosaicstack/db",
"version": "0.0.4", "version": "0.0.3",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -27,49 +27,45 @@ id: software-delivery
title: Software Delivery title: Software Delivery
description: >- description: >-
The engineering fleet that turns ratified objectives into shipped, reviewed, The engineering fleet that turns ratified objectives into shipped, reviewed,
merged code. The lead (orchestrator) runs the supervisor loop and dispatches merged code. The lead (planner — the orchestrator seat) plans phased FRs into a
ready work; it hands goal-decomposition to the planner, which plans phased FRs depends_on DAG, decomposition splits them into one-PR-each cards, coders execute
into a depends_on DAG, decomposition splits them into one-PR-each cards, coders to green CI, and review / security-review / site-tester / merge-gate guard the
execute to green CI, and review / security-review / site-tester / merge-gate merge. This mirrors today's coding fleet.
guard the merge. This mirrors today's coding fleet. # NOTE: the canonical lead seat is the "orchestrator". In the persona library the
# NOTE: the lead seat is the dedicated "orchestrator" — the always-on coordinator # orchestrator IS the `planner` class (see roles/planner.md: "the planner role IS
# that runs the supervisor tick, dispatches ready work, and routes PRs to the # the existing orchestrator class") — so the lead/floor reference `planner`, the
# merge-gate while holding only lean coordination state. The planner is now a # only class that actually resolves to a role contract.
# distinct seat (heavy goal-decomposition context) that reports to the lead: planner
# orchestrator. The two-agent floor is orchestrator + enhancer.
lead: orchestrator
floor: floor:
- orchestrator - planner
- enhancer - enhancer
roster: roster:
- class: orchestrator
- class: board - class: board
reports_to: orchestrator reports_to: planner
- class: planner - class: planner
reports_to: orchestrator
- class: decomposition - class: decomposition
reports_to: planner reports_to: planner
- class: code - class: code
reports_to: decomposition reports_to: decomposition
multiplicity: 2 multiplicity: 2
- class: review - class: review
reports_to: orchestrator reports_to: planner
- class: security-review - class: security-review
reports_to: review reports_to: review
- class: site-tester - class: site-tester
reports_to: review reports_to: review
- class: documentation - class: documentation
reports_to: orchestrator reports_to: planner
- class: merge-gate - class: merge-gate
reports_to: orchestrator reports_to: planner
- class: rebase - class: rebase
reports_to: merge-gate reports_to: merge-gate
- class: operator - class: operator
reports_to: orchestrator reports_to: planner
- class: session-review - class: session-review
reports_to: orchestrator reports_to: planner
- class: enhancer - class: enhancer
reports_to: orchestrator reports_to: planner
notes: >- notes: >-
Two-agent floor (orchestrator + enhancer) is always staffed; every other seat is Two-agent floor (orchestrator/planner + enhancer) is always staffed; every other
added on demand. seat is added on demand.

View File

@@ -19,7 +19,6 @@ their intro so tooling can group them.
| Persona | Purpose | | Persona | Purpose |
| --------------- | ------------------------------------------------------------------------------ | | --------------- | ------------------------------------------------------------------------------ |
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution | | board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG | | planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges | | decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |

View File

@@ -1,46 +0,0 @@
# Orchestrator — fleet role definition
The **orchestrator** is one half of the fleet's two-agent floor: every fleet runs,
at minimum, an **orchestrator** and an **enhancer**. The orchestrator is the
fleet's **always-on coordinator and dispatcher** (`class: orchestrator`,
`persistent_persona: true`) — it owns fleet _movement_, not the work itself.
It is a **core, always-on** agent, not an ephemeral per-lane worker.
## Mandate
1. **Run the supervisor tick** — perform the readiness scan each loop and keep the
two-agent floor (orchestrator + enhancer) healthy, restoring it the moment it
drops below the floor.
2. **Dispatch ready work** — pick up cards whose `depends_on` edges are satisfied
and assign them via the backlog/claim, so no idle agent sits while ready work
exists.
3. **Delegate decomposition, don't do it** — hand goal-decomposition work to the
**planner**, which it coordinates; the orchestrator tracks the resulting plan
but does not author the DAG itself.
4. **Route PRs to the merge-gate** — push reviewed, ready-to-land PRs at the
**merge-gate** (the only merge path); it never approves or merges itself.
5. **Interface with the operator/user** — be the fleet's coordination surface,
relaying status and accepting direction, while holding only coordination state.
6. **Keep the loop turning** — re-dispatch on completion or failure so the fleet
keeps moving rather than stalling.
## Boundaries
- **Does NOT decompose goals into the DAG/cards** — that is the **planner**'s lane,
which the orchestrator dispatches to.
- **Does NOT write product/source code** (coders), **review** (review), or
**approve merges itself** (merge-gate).
- **Does NOT carry deep per-task context** — it delegates and tracks, keeping its
own context lean so the coordination loop stays fast.
The orchestrator moves work; it never holds the heavy planning or execution
context that the seats it dispatches to carry.
## Persona
A lean, decisive coordinator. It thinks in readiness and throughput, dispatches the
next ready card the instant a dependency clears, and never lets an idle agent sit
while ready work exists — keeping its own context minimal so the loop never slows.
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).

View File

@@ -3,11 +3,11 @@
The **planner** turns ratified objectives into an executable **plan** — phased The **planner** turns ratified objectives into an executable **plan** — phased
functional requirements (FRs) wired into a `depends_on` DAG. functional requirements (FRs) wired into a `depends_on` DAG.
> **Reports to the orchestrator.** The planner is the goal-decomposition seat that > **Alias:** the planner role IS the existing **orchestrator** class. The
> the **orchestrator** dispatches planning work to; it carries the heavy > orchestrator _plays_ planner; this file documents the planning contract, it does
> goal-decomposition context, while the orchestrator holds only the lean > **not** introduce a competing class. The two-agent floor (orchestrator +
> coordination state. The two-agent floor is **orchestrator + enhancer** — the > enhancer) is preserved — do not split planner into a separate persistent agent
> planner is added on demand, not part of the floor. > that would break it.
It is a **front-office** role. It is a **front-office** role.
@@ -19,8 +19,8 @@ It is a **front-office** role.
between FRs so downstream decomposition can parallelize safely. between FRs so downstream decomposition can parallelize safely.
3. **Emit a plan, not tasks** — the planner's output is the phased FR/DAG 3. **Emit a plan, not tasks** — the planner's output is the phased FR/DAG
document. Splitting FRs into one-PR-each cards is the **decomposition** role's job. document. Splitting FRs into one-PR-each cards is the **decomposition** role's job.
4. **Re-plan on failure** — when execution diverges, the planner re-sequences the 4. **Re-plan on failure** — when execution diverges, the planner (orchestrator)
DAG rather than letting agents improvise. re-sequences the DAG rather than letting agents improvise.
## Boundaries ## Boundaries
@@ -35,7 +35,6 @@ merge path.
## Persona ## Persona
The architect of the mission's shape. It thinks in phases and dependencies, hands The architect of the mission's shape. It thinks in phases and dependencies, hands
a clean DAG to decomposition, and reports its plan back to the orchestrator that a clean DAG to decomposition, and keeps the orchestrator/enhancer floor intact.
dispatched it.
> Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library). > Doctrine: `docs/fleet/north-star.md` (two-agent floor + role library).

View File

@@ -37,14 +37,7 @@ INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"
# packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update # packages/mosaic/src/commands/fleet-backlog.ts). Without these, an update
# wipes the operator's fleet AND their backlog. Glob entries are honored by # wipes the operator's fleet AND their backlog. Glob entries are honored by
# both the rsync path (`--exclude`) and the glob-aware cp fallback below. # both the rsync path (`--exclude`) and the glob-aware cp fallback below.
# PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog")
# fleet/roles.local — the persona OVERRIDE layer (H4). Baseline personas in
# fleet/roles/ are reseeded normally on every update (delivering new baseline
# personas), so any local edit there would be clobbered. User customizations
# and user-ADDED personas instead live in fleet/roles.local/ and MUST survive
# `mosaic update` — they win over the baseline on merge (AC-NS-7; see
# packages/mosaic/src/commands/fleet-personas.ts).
PRESERVE_PATHS=("CONSTITUTION.md" "AGENTS.md" "SOUL.md" "USER.md" "TOOLS.md" "STANDARDS.md" "memory" "sources" "credentials" "fleet/*.yaml" "fleet/agents" "fleet/run" "fleet/backlog" "fleet/roles.local")
# Framework-owned contract files: re-copied from defaults/ on every upgrade (the # Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite). # user must not edit them; a divergent copy is backed up once before overwrite).

View File

@@ -114,21 +114,10 @@ MOSAIC_RUNTIME_BIN_PREFIX=$(_build_runtime_bin_prefix)
# safe single bash token regardless of the name's characters. # safe single bash token regardless of the name's characters.
AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME") AGENT_NAME_Q=$(printf '%q' "$AGENT_NAME")
# MOSAIC_AGENT_CLASS must ALSO be exported INTO the pane, for the same reason as
# MOSAIC_AGENT_NAME above: the pane inherits the tmux SERVER environment (not this
# script's env, and not the systemd unit's EnvironmentFile), so the per-agent class
# written to agents/<name>.env would otherwise be invisible in-pane. The launcher
# composes the persona contract from process.env.MOSAIC_AGENT_CLASS at launch
# (compose-contract -> readPersonaContractBlock); without this export it sees an
# undefined class and silently injects NO persona contract. %q-quote it so it is a
# safe single bash token; an empty/unset class %q-quotes to '' and is a harmless
# no-op downstream (readPersonaContractBlock returns '' for an empty class).
AGENT_CLASS_Q=$(printf '%q' "${MOSAIC_AGENT_CLASS:-}")
if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then if [ -n "$MOSAIC_RUNTIME_BIN_PREFIX" ]; then
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}" PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export PATH=\"${MOSAIC_RUNTIME_BIN_PREFIX}:\${PATH}\"; exec ${MOSAIC_AGENT_COMMAND}"
else else
PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; export MOSAIC_AGENT_CLASS=${AGENT_CLASS_Q}; exec ${MOSAIC_AGENT_COMMAND}" PANE_SHELL_SNIPPET="export MOSAIC_AGENT_NAME=${AGENT_NAME_Q}; exec ${MOSAIC_AGENT_COMMAND}"
fi fi
mkdir -p "$MOSAIC_AGENT_WORKDIR" mkdir -p "$MOSAIC_AGENT_WORKDIR"

View File

@@ -104,7 +104,6 @@ PATH="$FAKE_BIN:$PATH" \
MOSAIC_TMUX_SOCKET="$SOCKET3" \ MOSAIC_TMUX_SOCKET="$SOCKET3" \
MOSAIC_AGENT_WORKDIR="$WORKDIR3" \ MOSAIC_AGENT_WORKDIR="$WORKDIR3" \
MOSAIC_AGENT_RUNTIME="pi" \ MOSAIC_AGENT_RUNTIME="pi" \
MOSAIC_AGENT_CLASS="code" \
MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \ MOSAIC_RUNTIME_BIN="$FAKE_RUNTIME_BIN" \
MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \ MOSAIC_AGENT_COMMAND="mosaic yolo pi --model openai-codex/gpt-5.5:high" \
MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \ MOSAIC_HEARTBEAT_RUN_DIR="$HB_RUN_DIR3" \
@@ -128,18 +127,6 @@ echo "$all_args" | grep -qF "exec " || fail "pane command does not use exec"
echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \ echo "$all_args" | grep -qF "mosaic yolo pi --model openai-codex/gpt-5.5:high" || \
fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact" fail "pane command does not forward MOSAIC_AGENT_COMMAND with flags intact"
# d) MOSAIC_AGENT_NAME and the per-agent MOSAIC_AGENT_CLASS must BOTH be exported
# INTO the pane. The pane inherits the tmux SERVER environment (not this
# script's env, nor the systemd unit's EnvironmentFile), so any per-agent var
# the launcher needs in-pane must be re-exported in the snippet. CLASS is
# load-bearing: the launcher composes the persona contract from
# process.env.MOSAIC_AGENT_CLASS, so a missing export silently drops the
# persona (regression guard for the A3a pane-propagation gap).
echo "$all_args" | grep -qF "export MOSAIC_AGENT_NAME=" || \
fail "pane command does not export MOSAIC_AGENT_NAME into the pane"
echo "$all_args" | grep -qF "export MOSAIC_AGENT_CLASS=code" || \
fail "pane command does not export MOSAIC_AGENT_CLASS into the pane (persona would silently drop)"
# ── Test 4: when no extra runtime-bin dirs exist, exec still appears ─────────── # ── Test 4: when no extra runtime-bin dirs exist, exec still appears ───────────
TMUX_ARGS_FILE2=$(mktemp) TMUX_ARGS_FILE2=$(mktemp)
FAKE_BIN2=$(mktemp -d) FAKE_BIN2=$(mktemp -d)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaicstack/mosaic", "name": "@mosaicstack/mosaic",
"version": "0.0.48", "version": "0.0.45",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.mosaicstack.dev/mosaicstack/stack.git", "url": "https://git.mosaicstack.dev/mosaicstack/stack.git",

View File

@@ -164,89 +164,4 @@ describe('composeContract — overlay composer', () => {
expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract'); expect(composeContract('pi', fixture.home)).toContain('# pi runtime contract');
expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract'); expect(composeContract('codex', fixture.home)).not.toContain('# pi runtime contract');
}); });
// ── Persona contract injection (A3b) ──────────────────────────────────────
// composeContract reads MOSAIC_AGENT_CLASS and injects the resolved persona
// (override-aware). Save/restore the env so these tests don't leak state.
describe('persona contract (A3b)', () => {
let prevClass: string | undefined;
beforeEach(() => {
prevClass = process.env['MOSAIC_AGENT_CLASS'];
});
afterEach(() => {
if (prevClass === undefined) delete process.env['MOSAIC_AGENT_CLASS'];
else process.env['MOSAIC_AGENT_CLASS'] = prevClass;
});
const seedBaseline = (klass: string, body: string): void => {
mkdirSync(join(fixture.home, 'fleet', 'roles'), { recursive: true });
writeFileSync(join(fixture.home, 'fleet', 'roles', `${klass}.md`), body);
};
const seedOverride = (klass: string, body: string): void => {
mkdirSync(join(fixture.home, 'fleet', 'roles.local'), { recursive: true });
writeFileSync(join(fixture.home, 'fleet', 'roles.local', `${klass}.md`), body);
};
it('injects the baseline persona when MOSAIC_AGENT_CLASS is set and a role file exists', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE: ship the lane.\n');
process.env['MOSAIC_AGENT_CLASS'] = 'coder';
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Persona Contract (coder)');
expect(out).toContain('BASELINE-MANDATE');
});
it('OVERRIDE WINS at launch: roles.local persona is injected over baseline (AC-NS-7)', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE.\n');
seedOverride('coder', '# Coder (override)\n\n(`class: coder`)\n\nOVERRIDE-MANDATE.\n');
process.env['MOSAIC_AGENT_CLASS'] = 'coder';
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Persona Contract (coder)');
expect(out).toContain('OVERRIDE-MANDATE');
expect(out).not.toContain('BASELINE-MANDATE');
});
it('does NOT inject a persona when MOSAIC_AGENT_CLASS is unset', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE.\n');
delete process.env['MOSAIC_AGENT_CLASS'];
const out = composeContract('claude', fixture.home);
expect(out).not.toContain('# Persona Contract');
});
it('does NOT inject (no throw) when MOSAIC_AGENT_CLASS names an unknown class', () => {
seedBaseline('coder', '# Coder\n\n(`class: coder`)\n\nBASELINE-MANDATE.\n');
process.env['MOSAIC_AGENT_CLASS'] = 'nonexistent';
expect(() => composeContract('claude', fixture.home)).not.toThrow();
expect(composeContract('claude', fixture.home)).not.toContain('# Persona Contract');
});
it('places the persona contract BEFORE the fleet comms block (identity, then peers)', () => {
seedBaseline('enhancer', '# Enhancer\n\n(`class: enhancer`)\n\nIMPROVE.\n');
mkdirSync(join(fixture.home, 'fleet'), { recursive: true });
writeFileSync(
join(fixture.home, 'fleet', 'roster.yaml'),
[
'agents:',
' - name: orchestrator',
' class: orchestrator',
' - name: enhancer',
' class: enhancer',
'',
].join('\n'),
);
const prevName = process.env['MOSAIC_AGENT_NAME'];
try {
process.env['MOSAIC_AGENT_CLASS'] = 'enhancer';
process.env['MOSAIC_AGENT_NAME'] = 'enhancer';
const out = composeContract('claude', fixture.home);
expect(out).toContain('# Persona Contract (enhancer)');
expect(out).toContain('# Fleet Comms');
expect(out.indexOf('# Persona Contract')).toBeLessThan(out.indexOf('# Fleet Comms'));
} finally {
if (prevName === undefined) delete process.env['MOSAIC_AGENT_NAME'];
else process.env['MOSAIC_AGENT_NAME'] = prevName;
}
});
});
}); });

View File

@@ -1,210 +0,0 @@
import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
extractClassesFromDir,
listPersonaClasses,
personaStatus,
resolvePersona,
} from './fleet-personas.js';
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
// The real, committed library: packages/mosaic/src/commands -> framework/fleet.
const frameworkFleet = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'framework',
'fleet',
);
const realRolesDir = join(frameworkFleet, 'roles');
let tmp: string;
let rolesDir: string;
let overrideDir: string;
// A minimal baseline persona file with an inline `class:` + `domain:` marker.
function baselinePersona(klass: string, domain: string, marker = 'BASELINE'): string {
return `# ${klass} — fleet role definition
The **${klass}** is the ${marker} definition (\`class: ${klass}\`, \`domain: ${domain}\`).
`;
}
function overridePersona(klass: string, domain: string, marker = 'OVERRIDE'): string {
return `# ${klass} — fleet role definition (override)
The **${klass}** is the ${marker} definition (\`class: ${klass}\`, \`domain: ${domain}\`).
`;
}
beforeEach(async () => {
tmp = await mkdtemp(join(tmpdir(), 'h4-personas-'));
rolesDir = join(tmp, 'roles');
overrideDir = join(tmp, 'roles.local');
await mkdir(rolesDir, { recursive: true });
// Seed two baseline personas. (No override dir yet — created per test.)
await writeFile(join(rolesDir, 'ceo.md'), baselinePersona('ceo', 'executive'), 'utf8');
await writeFile(join(rolesDir, 'code.md'), baselinePersona('code', 'engineering'), 'utf8');
});
afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('extractClassesFromDir (shared extraction)', () => {
it('records class + domain from inline markers and degrades on missing dir', async () => {
const base = await extractClassesFromDir(rolesDir);
expect(base.classes.has('ceo')).toBe(true);
expect(base.byClass.get('ceo')?.domain).toBe('executive');
const missing = await extractClassesFromDir(join(tmp, 'nope'));
expect(missing.classes.size).toBe(0);
});
});
describe('resolvePersona — override wins', () => {
it('resolves to the override when a class exists in BOTH layers', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8');
const resolved = await resolvePersona('ceo', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('override');
expect(resolved?.content).toContain('OVERRIDE');
expect(resolved?.file).toBe(join(overrideDir, 'ceo.md'));
});
it('resolves to the baseline when no override exists', async () => {
const resolved = await resolvePersona('code', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('baseline');
expect(resolved?.content).toContain('BASELINE');
});
it('returns null for an unknown class', async () => {
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
});
});
describe('custom add — override-only class', () => {
it('a class present only in roles.local/ appears in listPersonaClasses and resolves', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'mascot.md'),
overridePersona('mascot', 'fun', 'CUSTOM'),
'utf8',
);
const classes = await listPersonaClasses({ rolesDir, overrideDir });
expect(classes.has('mascot')).toBe(true);
// Baseline classes are still present (union).
expect(classes.has('ceo')).toBe(true);
const resolved = await resolvePersona('mascot', { rolesDir, overrideDir });
expect(resolved?.layer).toBe('override');
expect(resolved?.content).toContain('CUSTOM');
});
});
describe('personaStatus classification', () => {
it('classifies baseline / overridden / custom correctly', async () => {
await mkdir(overrideDir, { recursive: true });
// ceo: overridden (both). code: baseline (only base). mascot: custom (only override).
await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8');
await writeFile(join(overrideDir, 'mascot.md'), overridePersona('mascot', 'fun'), 'utf8');
const status = await personaStatus({ rolesDir, overrideDir });
const byClass = new Map(status.map((s) => [s.klass, s]));
expect(byClass.get('ceo')?.status).toBe('overridden');
expect(byClass.get('code')?.status).toBe('baseline');
expect(byClass.get('mascot')?.status).toBe('custom');
// Domain surfaced.
expect(byClass.get('ceo')?.domain).toBe('executive');
});
});
describe('AC-NS-7 — update-survival simulation', () => {
it('override and custom-added class survive a baseline reseed', async () => {
// 1. User customizes ceo and adds a brand-new persona in the override layer.
await mkdir(overrideDir, { recursive: true });
await writeFile(join(overrideDir, 'ceo.md'), overridePersona('ceo', 'executive'), 'utf8');
await writeFile(
join(overrideDir, 'mascot.md'),
overridePersona('mascot', 'fun', 'CUSTOM'),
'utf8',
);
// 2. Simulate `mosaic update`: REPLACE the baseline roles/ entirely (as the
// framework reseed/rsync does), leaving roles.local/ untouched. The reseed
// even ships a NEW baseline ceo and adds a brand-new baseline persona.
await rm(rolesDir, { recursive: true, force: true });
await mkdir(rolesDir, { recursive: true });
await writeFile(
join(rolesDir, 'ceo.md'),
baselinePersona('ceo', 'executive', 'RESEEDED-BASELINE'),
'utf8',
);
await writeFile(join(rolesDir, 'code.md'), baselinePersona('code', 'engineering'), 'utf8');
await writeFile(join(rolesDir, 'new-role.md'), baselinePersona('new-role', 'ops'), 'utf8');
// 3. The override STILL wins (was not clobbered by the reseed).
const ceo = await resolvePersona('ceo', { rolesDir, overrideDir });
expect(ceo?.layer).toBe('override');
expect(ceo?.content).toContain('OVERRIDE');
expect(ceo?.content).not.toContain('RESEEDED-BASELINE');
// 4. The custom-added class still exists and resolves.
const mascot = await resolvePersona('mascot', { rolesDir, overrideDir });
expect(mascot?.layer).toBe('override');
expect(mascot?.content).toContain('CUSTOM');
// 5. New baseline personas from the reseed are now visible too.
const classes = await listPersonaClasses({ rolesDir, overrideDir });
expect(classes.has('new-role')).toBe(true);
expect(classes.has('mascot')).toBe(true);
});
});
describe('fleet-profiles validation accepts a custom (override-only) persona', () => {
it('a profile referencing an override-only class validates', async () => {
// Build a profiles dir + roles using the REAL library plus a custom persona.
const profilesDir = join(tmp, 'profiles');
const customRolesDir = join(tmp, 'real-roles');
const customOverrideDir = join(tmp, 'real-roles.local');
await mkdir(profilesDir, { recursive: true });
await cp(realRolesDir, customRolesDir, { recursive: true });
await mkdir(customOverrideDir, { recursive: true });
await writeFile(join(customOverrideDir, 'mascot.md'), overridePersona('mascot', 'fun'), 'utf8');
// A profile whose roster references the custom (override-only) persona.
const profileYaml = [
'id: custom-team',
'title: Custom Team',
'description: A team that uses a user-added persona.',
'lead: ceo',
'floor:',
' - ceo',
'roster:',
' - class: ceo',
' - class: mascot',
' reports_to: ceo',
].join('\n');
await writeFile(join(profilesDir, 'custom-team.yaml'), profileYaml, 'utf8');
// Override-aware loadProfiles must accept it (would throw if mascot unknown).
const profiles = await loadProfiles({
profilesDir,
rolesDir: customRolesDir,
overrideDir: customOverrideDir,
});
const team = profiles.find((p: FleetProfile) => p.id === 'custom-team');
expect(team).toBeDefined();
// And direct validation against the union confirms zero problems.
const validClasses = await listPersonaClasses({
rolesDir: customRolesDir,
overrideDir: customOverrideDir,
});
expect(validateProfile(team as FleetProfile, validClasses)).toEqual([]);
});
});

View File

@@ -1,512 +0,0 @@
/**
* Persona override layer + resolver (North Star H4).
*
* Baseline personas are markdown role contracts seeded by the framework into
* <mosaicHome>/fleet/roles/*.md
* They are RESEEDED on every `mosaic update` (so new baseline personas ship to
* existing installs). That reseed is exactly what would clobber any local edit,
* so user customizations must NOT live in roles/.
*
* The override layer is a sibling directory:
* <mosaicHome>/fleet/roles.local/*.md
* It is PRESERVE-protected in install.sh (see PRESERVE_PATHS "fleet/roles.local"),
* so `mosaic update` never deletes it while roles/ keeps reseeding. An override
* file WINS over the baseline of the same class, and an override file may ADD an
* entirely new class that has no baseline at all. This delivers AC-NS-7: a
* user-customized persona survives `mosaic update`.
*
* Class identity is encoded INLINE in the role prose, not as YAML frontmatter:
* (`class: ceo`, `domain: executive`)
* The marker value may wrap across a newline. A few engineering personas carry
* no marker at all and are identified by filename (e.g. planner -> orchestrator).
*
* The class-extraction logic here is the SINGLE SOURCE OF TRUTH for "what
* persona classes exist"; fleet-profiles.ts imports it (DRY) so a profile roster
* can reference a customized or user-added persona.
*/
import { readFileSync, readdirSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, join } from 'node:path';
import type { Command } from 'commander';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
/** Baseline persona role contracts (reseeded on update). */
export function defaultRolesDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles');
}
/** PRESERVE-protected override layer (survives update; wins on merge). */
export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string {
return join(mosaicHome, 'fleet', 'roles.local');
}
/**
* Match a `class: X` marker even when the value wrapped onto the next line.
* Allow surrounding backtick(s); the value is a single kebab-case token.
* Shared by every caller so the definition of "a class marker" lives once.
*/
const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
/** LIBRARY.md persona rows: the first table cell is the persona name. */
const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
/** Where a resolved persona's definition came from. */
export type PersonaLayer = 'baseline' | 'override';
/** One discovered persona file (a single role contract on disk). */
export interface PersonaFile {
klass: string;
/** The markdown file the class was found in. */
file: string;
domain?: string;
}
/** The set of persona classes a directory of role contracts defines. */
export interface DirClasses {
/** Every class name the dir contributes (markers + filenames + LIBRARY rows). */
classes: Set<string>;
/** For classes whose file carries a marker, the file + domain that defined it. */
byClass: Map<string, PersonaFile>;
}
/**
* Scan one directory of role contracts and extract the persona classes it
* defines. THIS is the shared extraction both fleet-personas and fleet-profiles
* rely on. Sources, unioned (each needed — see module doc):
* 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline),
* 2. persona-name cells from LIBRARY.md index tables,
* 3. the role filename stem (covers marker-less alias docs like planner).
*
* Missing dir / unreadable files degrade gracefully to whatever was found.
* `byClass` records the defining file+domain for marker-bearing classes so the
* resolver can map a class back to its file; filename-only and LIBRARY-only
* classes still appear in `classes` for membership checks.
*/
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
let entries: string[];
try {
entries = await readdir(dir);
} catch {
return acc;
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
let text: string;
try {
text = await readFile(join(dir, entry), 'utf8');
} catch {
continue;
}
accumulateEntry(acc, dir, entry, text);
}
return acc;
}
/**
* Synchronous twin of {@link extractClassesFromDir}. Identical extraction
* semantics (same markers, same union of marker/filename/LIBRARY sources) on
* sync fs, for the synchronous launch-time prompt path (composeContract) which
* cannot await. Missing dir / unreadable files degrade gracefully.
*/
export function extractClassesFromDirSync(dir: string): DirClasses {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return acc;
}
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
let text: string;
try {
text = readFileSync(join(dir, entry), 'utf8');
} catch {
continue;
}
accumulateEntry(acc, dir, entry, text);
}
return acc;
}
/**
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure
* over already-read content, so the async and sync directory scanners share a
* single definition of "what classes a file contributes" (DRY — no semantic
* drift between the launch-time and command-time paths).
*/
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
const { classes, byClass } = acc;
if (entry === 'LIBRARY.md') {
for (const m of text.matchAll(LIBRARY_ROW)) {
const name = m[1];
if (name && name !== 'persona') classes.add(name);
}
return;
}
// The filename stem is itself a valid class (covers marker-less alias docs).
const stem = basename(entry, '.md');
classes.add(stem);
const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
let markedClassForFile: string | undefined;
for (const m of text.matchAll(CLASS_MARKER)) {
const klass = m[1];
if (!klass) continue;
classes.add(klass);
// Record the FIRST marker as the file's defining class (the prose names
// the persona's own class up top; later mentions reference siblings).
if (!markedClassForFile) {
markedClassForFile = klass;
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
}
}
// A marker-less file still maps its stem to itself (no domain known).
if (!markedClassForFile && !byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
}
}
export interface PersonaDirs {
/** Baseline roles dir. Defaults to <mosaicHome>/fleet/roles. */
rolesDir?: string;
/** Override dir. Defaults to <mosaicHome>/fleet/roles.local. */
overrideDir?: string;
mosaicHome?: string;
}
function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string } {
const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
return {
rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
};
}
/**
* UNION of baseline classes and override classes. Overrides may ADD entirely new
* classes not present in the baseline, so callers (e.g. profile roster
* validation) treat a user-added persona as a real class.
*/
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const union = new Set<string>(base.classes);
for (const c of over.classes) union.add(c);
return union;
}
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
export interface PersonaResolution {
klass: string;
layer: PersonaLayer;
/** The file the resolved persona was read from (override wins). */
file: string;
content: string;
domain?: string;
}
/**
* Resolve a persona class to its winning definition: the override file if
* roles.local/ defines that class, else the baseline. Match by inline `class:`
* marker first, then by filename stem (roles.local/<klass>.md) as a fallback.
* Returns null if neither layer defines the class.
*/
export async function resolvePersona(
klass: string,
opts: PersonaDirs = {},
): Promise<PersonaResolution | null> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
return resolvePersonaFrom(klass, { rolesDir, overrideDir, base, over });
}
/**
* Resolve a single class against ALREADY-EXTRACTED layer maps. Callers that
* resolve many classes against the same two directories (e.g. provisioning a
* full roster) should {@link extractClassesFromDir} each dir ONCE and reuse the
* result here, rather than paying a full directory re-scan per class. Precedence
* is identical to {@link resolvePersona}: override layer wins, then baseline.
*/
export async function resolvePersonaFrom(
klass: string,
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
): Promise<PersonaResolution | null> {
const { rolesDir, overrideDir, base, over } = layers;
const fromLayer = async (
dir: string,
extracted: DirClasses,
layer: PersonaLayer,
): Promise<PersonaResolution | null> => {
// Prefer the marker-defined file; fall back to the filename stem.
let pf = extracted.byClass.get(klass);
if (!pf) {
const byName = join(dir, `${klass}.md`);
if (!extracted.classes.has(klass)) return null;
// Class known only via filename/LIBRARY: read the stem file if present.
try {
const content = await readFile(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
} catch {
return null;
}
}
try {
const content = await readFile(pf.file, 'utf8');
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
} catch {
return null;
}
};
return (
(await fromLayer(overrideDir, over, 'override')) ??
(await fromLayer(rolesDir, base, 'baseline'))
);
}
/**
* Synchronous twin of {@link resolvePersona} — same override-wins precedence
* (roles.local/ beats roles/, by marker first then filename stem), returning
* null if neither layer defines the class. Exists for the synchronous launch
* prompt path (composeContract → readPersonaContractBlock) which cannot await.
* Keeping it here, beside the async resolver, keeps the resolution semantics in
* one module so the launch-time and command-time resolutions never diverge.
*/
export function resolvePersonaSync(
klass: string,
opts: PersonaDirs = {},
): PersonaResolution | null {
const { rolesDir, overrideDir } = resolveDirs(opts);
const base = extractClassesFromDirSync(rolesDir);
const over = extractClassesFromDirSync(overrideDir);
const fromLayer = (
dir: string,
extracted: DirClasses,
layer: PersonaLayer,
): PersonaResolution | null => {
// Prefer the marker-defined file; fall back to the filename stem.
const pf = extracted.byClass.get(klass);
if (!pf) {
if (!extracted.classes.has(klass)) return null;
const byName = join(dir, `${klass}.md`);
try {
const content = readFileSync(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
} catch {
return null;
}
}
try {
const content = readFileSync(pf.file, 'utf8');
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
} catch {
return null;
}
};
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
}
export interface PersonaStatusEntry {
klass: string;
status: PersonaStatus;
domain?: string;
}
/**
* Classify every known class:
* - baseline — present only in roles/
* - overridden — present in BOTH roles/ and roles.local/ (override wins)
* - custom — present only in roles.local/ (user-added)
* Domain is taken from the WINNING layer (override domain wins if present).
*/
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const all = new Set<string>([...base.classes, ...over.classes]);
const domainOf = (extracted: DirClasses, klass: string): string | undefined =>
extracted.byClass.get(klass)?.domain;
const entries: PersonaStatusEntry[] = [];
for (const klass of all) {
const inBase = base.classes.has(klass);
const inOver = over.classes.has(klass);
const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
entries.push({ klass, status, ...(domain ? { domain } : {}) });
}
entries.sort((a, b) => a.klass.localeCompare(b.klass));
return entries;
}
// ─── CLI: `mosaic fleet persona <list|show|customize>` ───────────────────────
function printPersonaList(entries: PersonaStatusEntry[]): void {
if (entries.length === 0) {
console.log('(no personas)');
return;
}
for (const e of entries) {
console.log(`${e.klass}\t[${e.status}]\tdomain=${e.domain ?? '-'}`);
}
}
/** Minimal override scaffold for a brand-new (no-baseline) class. */
function scaffoldOverride(klass: string): string {
return `# ${klass} — fleet role definition (override)
The **${klass}** persona (\`class: ${klass}\`) is a user-defined override that
lives in the PRESERVE-protected \`fleet/roles.local/\` layer and survives
\`mosaic update\`. Edit this file to define the persona's mandate and boundaries.
## Mandate
1. (describe what this persona owns)
## Boundaries
- (describe what this persona does NOT do)
`;
}
/**
* Register `persona` under an existing `fleet` command. `mosaicHomeFor` resolves
* the active --mosaic-home (parent flag) at call time, mirroring the backlog and
* profile subcommand wiring.
*/
export function registerFleetPersonaCommand(
fleetCmd: Command,
mosaicHomeFor: () => string,
): Command {
const personaCmd = fleetCmd
.command('persona')
.description('Update-surviving persona overrides: baseline ⊕ roles.local layer (H4)');
personaCmd
.command('list')
.description('List every persona class with its status (baseline/overridden/custom) and domain')
.option('--json', 'Print JSON')
.action(async (opts: { json?: boolean }) => {
try {
const entries = await personaStatus({ mosaicHome: mosaicHomeFor() });
if (opts.json) {
console.log(JSON.stringify(entries));
return;
}
printPersonaList(entries);
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
personaCmd
.command('show <class>')
.description('Show the RESOLVED persona (override wins) and which layer it came from')
.option('--json', 'Print JSON')
.action(async (klass: string, opts: { json?: boolean }) => {
try {
const resolved = await resolvePersona(klass, { mosaicHome: mosaicHomeFor() });
if (!resolved) {
process.stderr.write(`Unknown persona class "${klass}"\n`);
process.exitCode = 1;
return;
}
if (opts.json) {
console.log(JSON.stringify(resolved));
return;
}
console.log(`# class: ${resolved.klass}`);
console.log(`# layer: ${resolved.layer}`);
console.log(`# domain: ${resolved.domain ?? '-'}`);
console.log(`# file: ${resolved.file}`);
console.log('');
console.log(resolved.content);
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
personaCmd
.command('customize <class>')
.description(
'Copy the baseline persona into fleet/roles.local/ to edit (override layer). ' +
'--new scaffolds a brand-new persona with no baseline.',
)
.option('--new', 'Scaffold a minimal override for a brand-new class (no baseline required)')
.action(async (klass: string, opts: { new?: boolean }) => {
try {
const { mkdir, writeFile, copyFile, access } = await import('node:fs/promises');
const { constants } = await import('node:fs');
const mosaicHome = mosaicHomeFor();
const rolesDir = defaultRolesDir(mosaicHome);
const overrideDir = defaultOverrideDir(mosaicHome);
const target = join(overrideDir, `${klass}.md`);
await mkdir(overrideDir, { recursive: true });
// Do not clobber an existing override.
try {
await access(target, constants.F_OK);
console.log(`Override already exists, not clobbering: ${target}`);
return;
} catch {
// not present — proceed
}
if (opts.new) {
await writeFile(target, scaffoldOverride(klass), 'utf8');
console.log(`Scaffolded new persona override: ${target}`);
return;
}
// Copy the baseline. Prefer the marker-defining file; fall back to stem.
const base = await extractClassesFromDir(rolesDir);
const pf = base.byClass.get(klass);
const source = pf?.file ?? join(rolesDir, `${klass}.md`);
try {
await access(source, constants.F_OK);
} catch {
process.stderr.write(
`No baseline persona "${klass}" to copy. Use --new to scaffold one.\n`,
);
process.exitCode = 1;
return;
}
await copyFile(source, target);
console.log(`Copied baseline persona to override layer: ${target}`);
console.log('Edit it there; it wins over the baseline and survives `mosaic update`.');
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
return personaCmd;
}

View File

@@ -46,12 +46,10 @@ describe('listPersonaClasses (real role library)', () => {
it('covers marker-less engineering personas via filename + LIBRARY index', async () => { it('covers marker-less engineering personas via filename + LIBRARY index', async () => {
const classes = await listPersonaClasses(rolesDir); const classes = await listPersonaClasses(rolesDir);
// planner/decomposition have a role file but no inline marker — they resolve // planner/decomposition have a role file but no inline marker (planner aliases
// from the filename + LIBRARY.md row. // the orchestrator class) — they resolve from the filename + LIBRARY.md row.
expect(classes.has('planner')).toBe(true); expect(classes.has('planner')).toBe(true);
expect(classes.has('decomposition')).toBe(true); expect(classes.has('decomposition')).toBe(true);
// The dedicated orchestrator persona resolves (inline marker + filename + row).
expect(classes.has('orchestrator')).toBe(true);
}); });
it('returns an empty set for a missing roles dir (graceful)', async () => { it('returns an empty set for a missing roles dir (graceful)', async () => {
@@ -77,17 +75,11 @@ describe('baseline profiles (real library)', () => {
it('software-delivery has the expected lead, floor, and roster shape', async () => { it('software-delivery has the expected lead, floor, and roster shape', async () => {
const profile = await loadProfile('software-delivery', realLib); const profile = await loadProfile('software-delivery', realLib);
expect(profile.lead).toBe('orchestrator'); expect(profile.lead).toBe('planner');
expect(profile.floor).toEqual(['orchestrator', 'enhancer']); expect(profile.floor).toEqual(['planner', 'enhancer']);
const code = profile.roster.find((r) => r.class === 'code'); const code = profile.roster.find((r) => r.class === 'code');
expect(code?.multiplicity).toBe(2); expect(code?.multiplicity).toBe(2);
expect(code?.reportsTo).toBe('decomposition'); expect(code?.reportsTo).toBe('decomposition');
// The dedicated orchestrator is the lead seat (no reports_to); the planner is
// now a distinct seat that reports to it.
const orchestrator = profile.roster.find((r) => r.class === 'orchestrator');
expect(orchestrator?.reportsTo).toBeUndefined();
const planner = profile.roster.find((r) => r.class === 'planner');
expect(planner?.reportsTo).toBe('orchestrator');
}); });
it('loadProfile throws on an unknown id', async () => { it('loadProfile throws on an unknown id', async () => {

View File

@@ -25,11 +25,6 @@ import { homedir } from 'node:os';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import {
defaultOverrideDir,
extractClassesFromDir,
listPersonaClasses as listOverrideAwarePersonaClasses,
} from './fleet-personas.js';
function defaultMosaicHome(): string { function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -62,29 +57,57 @@ export interface FleetProfile {
} }
/** /**
* Extract the set of valid persona classes from a single baseline role dir. * Extract the set of valid persona classes from the role library.
* *
* Thin wrapper over the shared {@link extractClassesFromDir} in fleet-personas.ts * Sources (unioned — see module doc for why each is needed):
* — the single source of truth for "what classes exist" (DRY). Kept as a * 1. inline `` `class: X` `` markers in every roles/*.md (the primary signal;
* baseline-only, positional-`rolesDir` helper for backward compatibility; the * a marker may wrap across a newline, e.g. `` `class:\n support-agent` ``).
* override-aware union (baseline ⊕ roles.local) used by roster validation is * 2. persona-name cells from the LIBRARY.md index tables.
* {@link listPersonaClassesWithOverrides} below. * 3. the role filename stems (roles/<class>.md), covering personas whose file
* documents an alias instead of carrying its own marker (planner ->
* orchestrator, decomposition).
*
* Returns a Set so membership checks in the validator are O(1). Missing dir or
* unreadable files degrade gracefully to whatever was found (an empty set makes
* the validator reject every class, which surfaces a clear error).
*/ */
export async function listPersonaClasses(rolesDir = defaultRolesDir()): Promise<Set<string>> { export async function listPersonaClasses(rolesDir = defaultRolesDir()): Promise<Set<string>> {
return (await extractClassesFromDir(rolesDir)).classes; const classes = new Set<string>();
} let entries: string[];
try {
entries = await readdir(rolesDir);
} catch {
return classes;
}
// Match `class: X` even when the value wrapped onto the next line. Allow
// surrounding backtick(s); the value is a single kebab-case token.
const inlineMarker = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g;
// LIBRARY.md persona rows: first table cell is the persona name.
const libraryRow = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
/** for (const entry of entries) {
* Override-aware valid-class set: baseline roles/ ⊕ override roles.local/. A if (!entry.endsWith('.md')) continue;
* profile may legitimately reference a user-customized OR user-ADDED persona, so let text: string;
* roster validation resolves against this union (H4). Delegates to the shared try {
* fleet-personas resolver. text = await readFile(join(rolesDir, entry), 'utf8');
*/ } catch {
export async function listPersonaClassesWithOverrides( continue;
rolesDir: string, }
overrideDir: string, if (entry === 'LIBRARY.md') {
): Promise<Set<string>> { for (const m of text.matchAll(libraryRow)) {
return listOverrideAwarePersonaClasses({ rolesDir, overrideDir }); const name = m[1];
// Skip the markdown table divider / header artifacts.
if (name && name !== 'persona') classes.add(name);
}
continue;
}
// Role contract: the filename stem is itself a valid class (covers alias docs).
classes.add(basename(entry, '.md'));
for (const m of text.matchAll(inlineMarker)) {
if (m[1]) classes.add(m[1]);
}
}
return classes;
} }
function asString(value: unknown, ctx: string): string { function asString(value: unknown, ctx: string): string {
@@ -204,21 +227,14 @@ export interface LoadProfilesOptions {
profilesDir?: string; profilesDir?: string;
/** Override the roles dir (tests). Defaults to <mosaicHome>/fleet/roles. */ /** Override the roles dir (tests). Defaults to <mosaicHome>/fleet/roles. */
rolesDir?: string; rolesDir?: string;
/** Persona override dir (tests). Defaults to <mosaicHome>/fleet/roles.local. */
overrideDir?: string;
mosaicHome?: string; mosaicHome?: string;
} }
function resolveDirs(opts: LoadProfilesOptions): { function resolveDirs(opts: LoadProfilesOptions): { profilesDir: string; rolesDir: string } {
profilesDir: string;
rolesDir: string;
overrideDir: string;
} {
const mosaicHome = opts.mosaicHome ?? defaultMosaicHome(); const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
return { return {
profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome), profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome),
rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome), rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
}; };
} }
@@ -228,7 +244,7 @@ function resolveDirs(opts: LoadProfilesOptions): {
* Profiles are returned sorted by id for deterministic output. * Profiles are returned sorted by id for deterministic output.
*/ */
export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<FleetProfile[]> { export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<FleetProfile[]> {
const { profilesDir, rolesDir, overrideDir } = resolveDirs(opts); const { profilesDir, rolesDir } = resolveDirs(opts);
let files: string[]; let files: string[];
try { try {
files = (await readdir(profilesDir)).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')); files = (await readdir(profilesDir)).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
@@ -237,10 +253,7 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
} }
files.sort(); files.sort();
// Override-aware: a profile may reference a user-customized or user-ADDED const validClasses = await listPersonaClasses(rolesDir);
// persona living in the roles.local/ layer (H4), so validate against the
// baseline ⊕ override union, not the baseline alone.
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
const profiles: FleetProfile[] = []; const profiles: FleetProfile[] = [];
const seen = new Map<string, string>(); const seen = new Map<string, string>();

View File

@@ -1,270 +0,0 @@
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadFleetRoster } from './fleet.js';
import { generateRoster, runProvision } from './fleet-provision.js';
import { loadProfile } from './fleet-profiles.js';
// These are INTEGRATION tests: each exercises real filesystem I/O — scanning the
// committed framework/fleet persona library, rendering YAML, writing to a temp
// mosaicHome, and round-tripping through the real roster parser. On a heavily
// contended CI runner (the whole monorepo's suites run in parallel) that genuine
// I/O can exceed vitest's 5s default even though it completes in ~400ms locally.
// Give the legitimately I/O-bound work generous headroom so CI is deterministic.
vi.setConfig({ testTimeout: 30_000 });
// The real, committed library: packages/mosaic/src/commands -> framework/fleet.
const frameworkFleet = resolve(
dirname(fileURLToPath(import.meta.url)),
'..',
'..',
'framework',
'fleet',
);
const rolesDir = join(frameworkFleet, 'roles');
const profilesDir = join(frameworkFleet, 'profiles');
/** A fresh temp mosaicHome whose fleet/ dir is empty (for write-path tests). */
async function freshMosaicHome(): Promise<string> {
const home = await mkdtemp(join(tmpdir(), 'mosaic-provision-'));
await mkdir(join(home, 'fleet'), { recursive: true });
return home;
}
async function fileExists(path: string): Promise<boolean> {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
describe('provision software-delivery (floor, default)', () => {
it('materializes only the floor seats with correct flags + valid scaffold', async () => {
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
const { seats, yaml } = await generateRoster(profile, { profilesDir, rolesDir });
// Floor is orchestrator + enhancer.
expect(seats.map((s) => s.name).sort()).toEqual(['enhancer', 'orchestrator']);
const orch = seats.find((s) => s.name === 'orchestrator');
const enh = seats.find((s) => s.name === 'enhancer');
// RULE 2: floor + lead get persistent_persona.
expect(orch?.persistentPersona).toBe(true);
expect(enh?.persistentPersona).toBe(true);
// RULE 3: floor/lead do NOT reset between tasks.
expect(orch?.resetBetweenTasks).toBeUndefined();
expect(enh?.resetBetweenTasks).toBeUndefined();
// RULE 4: default runtime claude.
expect(orch?.runtime).toBe('claude');
// Scaffold round-trips through the real parser.
expect(yaml).toContain('version: 1');
expect(yaml).toContain('transport: tmux');
expect(yaml).toContain('socket_name: mosaic-fleet');
});
});
describe('provision --full', () => {
it('expands the entire roster, including multiplicity, deterministically', async () => {
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
const { seats } = await generateRoster(profile, { full: true, profilesDir, rolesDir });
const names = seats.map((s) => s.name);
// code multiplicity 2 -> code0/code1 (RULE 1).
expect(names).toContain('code0');
expect(names).toContain('code1');
expect(names).not.toContain('code');
// Singleton seats keep the bare class name.
expect(names).toContain('planner');
expect(names).toContain('merge-gate');
// Deterministic ordering: profile roster order, multiplicity expanded inline.
const codeIdx0 = names.indexOf('code0');
expect(names[codeIdx0 + 1]).toBe('code1');
// RULE 3: a non-floor, non-lead execution seat resets between tasks.
const code0 = seats.find((s) => s.name === 'code0');
expect(code0?.resetBetweenTasks).toBe(true);
expect(code0?.persistentPersona).toBeUndefined();
// Seat count == sum of multiplicities.
const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0);
expect(seats.length).toBe(expected);
});
});
describe('generated roster round-trips through the real parser', () => {
it('feeds generated YAML back through loadFleetRoster (key correctness proof)', async () => {
const home = await freshMosaicHome();
try {
const result = await runProvision('software-delivery', {
mosaicHome: home,
profilesDir,
rolesDir,
full: true,
write: true,
});
expect(result.wrote).toBe(join(home, 'fleet', 'roster.yaml'));
const parsed = await loadFleetRoster(result.wrote!);
// It parses with no error and carries every seat.
const profile = await loadProfile('software-delivery', { profilesDir, rolesDir });
const expected = profile.roster.reduce((n, e) => n + e.multiplicity, 0);
expect(parsed.agents.length).toBe(expected);
// Classes survive the round-trip.
expect(parsed.agents.some((a) => a.className === 'orchestrator')).toBe(true);
expect(parsed.agents.filter((a) => a.className === 'code').length).toBe(2);
// reports_to must NOT have been emitted (parser rejects unknown keys).
expect(result.yaml).not.toContain('reports_to');
} finally {
await rm(home, { recursive: true, force: true });
}
});
});
describe('override-aware persona validation', () => {
let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'mosaic-provision-ov-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('resolves a user-added (roles.local-only) persona without a false unresolved error', async () => {
const overrideDir = join(dir, 'roles.local');
const customProfilesDir = join(dir, 'profiles');
await mkdir(overrideDir, { recursive: true });
await mkdir(customProfilesDir, { recursive: true });
// A brand-new class that exists ONLY in roles.local.
await writeFile(
join(overrideDir, 'widget-maker.md'),
'# widget-maker\n\nThe widget-maker persona (`class: widget-maker`).\n',
);
await writeFile(
join(customProfilesDir, 'custom.yaml'),
[
'id: custom',
'title: Custom',
'description: a custom system',
'lead: widget-maker',
'floor: [widget-maker]',
'roster:',
' - class: widget-maker',
'',
].join('\n'),
);
const result = await runProvision('custom', {
mosaicHome: dir,
profilesDir: customProfilesDir,
// Point baseline rolesDir at a missing dir so resolution depends on override.
rolesDir: join(dir, 'no-baseline'),
overrideDir,
});
expect(result.yaml).toContain('class: widget-maker');
// It resolved from the override layer.
// (generateRoster records personaLayer; the seat is present.)
expect(result.summary).toContain('persona=override');
});
it('FAILS with a clear message when a profile references a bogus class', async () => {
const customProfilesDir = join(dir, 'profiles');
await mkdir(customProfilesDir, { recursive: true });
await writeFile(
join(customProfilesDir, 'bogus.yaml'),
[
'id: bogus',
'title: Bogus',
'description: bad system',
'lead: orchestrator',
'floor: [orchestrator]',
'roster:',
' - class: orchestrator',
' - class: not-a-real-persona-xyz',
' reports_to: orchestrator',
'',
].join('\n'),
);
await expect(
runProvision('bogus', {
mosaicHome: dir,
profilesDir: customProfilesDir,
rolesDir,
overrideDir: join(dir, 'roles.local'),
}),
).rejects.toThrow(/not-a-real-persona-xyz|not a known persona class/);
});
});
describe('--write protection', () => {
it('refuses to clobber an existing roster.yaml without --force', async () => {
const home = await freshMosaicHome();
try {
const rosterPath = join(home, 'fleet', 'roster.yaml');
await writeFile(rosterPath, 'version: 1\n# operator customizations\n');
await expect(
runProvision('software-delivery', { mosaicHome: home, profilesDir, rolesDir, write: true }),
).rejects.toThrow(/Refusing to overwrite/);
// The original file is untouched.
const { readFile } = await import('node:fs/promises');
expect(await readFile(rosterPath, 'utf8')).toContain('operator customizations');
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('--write --force overwrites an existing roster', async () => {
const home = await freshMosaicHome();
try {
const rosterPath = join(home, 'fleet', 'roster.yaml');
await writeFile(rosterPath, 'version: 1\n# old\n');
const result = await runProvision('software-delivery', {
mosaicHome: home,
profilesDir,
rolesDir,
write: true,
force: true,
});
expect(result.wrote).toBe(rosterPath);
const parsed = await loadFleetRoster(rosterPath);
expect(parsed.agents.length).toBeGreaterThan(0);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('--write to a fresh mosaicHome creates the roster file', async () => {
const home = await freshMosaicHome();
try {
const result = await runProvision('software-delivery', {
mosaicHome: home,
profilesDir,
rolesDir,
write: true,
});
expect(await fileExists(result.wrote!)).toBe(true);
} finally {
await rm(home, { recursive: true, force: true });
}
});
it('dry run (no --write) writes nothing', async () => {
const home = await freshMosaicHome();
try {
const result = await runProvision('software-delivery', {
mosaicHome: home,
profilesDir,
rolesDir,
});
expect(result.wrote).toBeUndefined();
expect(await fileExists(join(home, 'fleet', 'roster.yaml'))).toBe(false);
} finally {
await rm(home, { recursive: true, force: true });
}
});
});

View File

@@ -1,406 +0,0 @@
/**
* `mosaic fleet provision --profile <id>` — turn a declared SYSTEM TYPE (a
* profile) into a concrete fleet roster (North Star H3).
*
* A profile (fleet/profiles/<id>.yaml) is a DECLARATIVE mapping from a system
* type to a persona roster + org topology (H2). This command MATERIALIZES that
* declaration into the concrete `roster.yaml` shape the live fleet consumes — the
* same shape `fleet.ts` parses (version/transport/tmux/defaults/runtimes/agents).
*
* DRY-RUN-FIRST + REVIEWABLE: with no --write it prints the roster it WOULD
* generate plus a topology summary and writes nothing. --write persists it, and
* REFUSES to clobber an existing roster.yaml without --force (protects operator
* customizations).
*
* DRY: profile parsing/validation is reused wholesale from fleet-profiles.ts
* (loadProfile/validateProfile) and persona resolution from fleet-personas.ts
* (resolvePersona, override-aware). This module owns ONLY the profile→roster
* generation policy, documented inline below so each default is reviewable.
*/
import { access, mkdir, writeFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import type { Command } from 'commander';
import YAML from 'yaml';
import {
loadProfile,
validateProfile,
type FleetProfile,
type ProfileRosterEntry,
defaultProfilesDir,
defaultRolesDir,
listPersonaClassesWithOverrides,
} from './fleet-profiles.js';
import {
defaultOverrideDir,
extractClassesFromDir,
resolvePersonaFrom,
type PersonaLayer,
} from './fleet-personas.js';
function defaultMosaicHome(): string {
return process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
}
// ---------------------------------------------------------------------------
// GENERATION RULES — each default below is intentionally simple and documented
// so a reviewer can ratify or override the policy. See the PR body for the open
// runtime-per-class question (RULE 4).
// ---------------------------------------------------------------------------
/**
* RULE 4 — Runtime assignment policy (THE one open design question).
*
* Default: EVERY seat → `runtime: claude`. Claude runs every persona, so it is
* the safe universal default and guarantees a structurally-valid, launchable
* roster regardless of how the policy ultimately lands. We deliberately do NOT
* hardcode pi / gpt-5.5 per class here. The live roster today runs coders on
* pi + openai-codex/gpt-5.5:high — whether provisioning should encode a
* class→runtime/model map (and WHERE: the profile schema vs a separate
* runtime-policy file) is flagged in the PR body for ratification.
*
* Centralized so changing the policy is a ONE-edit change. If a future profile
* entry (or persona) declares a runtime/model preference, honor it here; until
* then everything defaults to claude.
*/
export const DEFAULT_RUNTIME = 'claude';
/** Result of applying the runtime policy to one seat. */
interface RuntimeChoice {
runtime: string;
modelHint?: string;
}
/**
* The single centralized runtime-policy function. Today it returns the universal
* `claude` default for every seat. To encode a class→runtime/model map later,
* edit ONLY this function (and/or extend the profile schema and read it here).
*/
export function resolveSeatRuntime(
_klass: string,
_isFloor: boolean,
_isLead: boolean,
): RuntimeChoice {
return { runtime: DEFAULT_RUNTIME };
}
/** One generated seat, fully resolved for emission + topology display. */
export interface GeneratedSeat {
name: string;
className: string;
runtime: string;
modelHint?: string;
persistentPersona?: boolean;
resetBetweenTasks?: boolean;
/** Topology edge from the profile (NOT emitted to roster.yaml — see RULE 6). */
reportsTo?: string;
/** Which persona layer the class resolved from (baseline/override). */
personaLayer: PersonaLayer;
}
export interface GenerateRosterResult {
seats: GeneratedSeat[];
/** The roster.yaml text (parser-valid, drop-in). */
yaml: string;
}
export interface ProvisionOptions {
/** Materialize the entire profile roster (multiplicity expanded). */
full?: boolean;
mosaicHome?: string;
/** Test overrides — mirror fleet-profiles LoadProfilesOptions. */
profilesDir?: string;
rolesDir?: string;
overrideDir?: string;
}
function resolveDirs(opts: ProvisionOptions): {
mosaicHome: string;
profilesDir: string;
rolesDir: string;
overrideDir: string;
} {
const mosaicHome = opts.mosaicHome ?? defaultMosaicHome();
return {
mosaicHome,
profilesDir: opts.profilesDir ?? defaultProfilesDir(mosaicHome),
rolesDir: opts.rolesDir ?? defaultRolesDir(mosaicHome),
overrideDir: opts.overrideDir ?? defaultOverrideDir(mosaicHome),
};
}
/**
* RULE 1 — Seat naming. multiplicity 1 → name = class (e.g. `planner`).
* multiplicity N>1 → `<class>0`,`<class>1`,… (e.g. `code` ×2 → code0/code1).
* Names are deterministic, following profile roster order.
*/
function seatNames(entry: ProfileRosterEntry): string[] {
if (entry.multiplicity <= 1) return [entry.class];
return Array.from({ length: entry.multiplicity }, (_, i) => `${entry.class}${i}`);
}
/**
* Generate the concrete seats + roster.yaml for a validated profile.
*
* Seat selection:
* --full → the ENTIRE profile roster, multiplicity expanded.
* default → ONLY the `floor` classes (the always-staffed minimum), matching
* the profile note "two-agent floor always staffed; every other seat
* added on demand."
*
* Per-seat flags:
* RULE 2 persistent_persona: true for floor classes AND the lead; else omitted.
* RULE 3 reset_between_tasks: true for non-floor, non-lead execution seats;
* floor/lead omit it (mirrors today's coders resetting while the
* orchestrator/enhancer do not).
* RULE 4 runtime: via resolveSeatRuntime (defaults claude).
* RULE 6 reports_to: tracked on the seat for the topology summary but NOT
* emitted to roster.yaml — the fleet.ts parser rejects unknown agent
* keys, so writing reports_to would break round-trip. Confirmed against
* normalizeAgent's allow-list in fleet.ts.
*
* Persona resolution: every emitted class is resolved override-aware via
* resolvePersona so we can (a) record the layer for the summary and (b) refuse
* to emit a roster that references a nonexistent persona.
*/
export async function generateRoster(
profile: FleetProfile,
opts: ProvisionOptions,
): Promise<GenerateRosterResult> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const floor = new Set(profile.floor);
const lead = profile.lead;
const selected: ProfileRosterEntry[] = opts.full
? profile.roster
: profile.roster.filter((e) => floor.has(e.class));
// Scan the persona directories ONCE, then resolve every roster entry against
// the in-memory maps. resolvePersona() would otherwise re-scan both dirs per
// entry — O(entries × files) redundant reads that push --full provisioning
// past the test timeout on slow/contended filesystems.
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const seats: GeneratedSeat[] = [];
for (const entry of selected) {
const isFloor = floor.has(entry.class);
const isLead = entry.class === lead;
const resolved = await resolvePersonaFrom(entry.class, { rolesDir, overrideDir, base, over });
if (!resolved) {
// Defensive: validateProfile already guards this, but a class can resolve
// for membership yet have no readable file. Fail loudly rather than emit a
// roster pointing at a persona we cannot load.
throw new Error(
`Cannot provision: roster class "${entry.class}" does not resolve to a readable persona.`,
);
}
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
for (const name of seatNames(entry)) {
const seat: GeneratedSeat = {
name,
className: entry.class,
runtime: runtimeChoice.runtime,
personaLayer: resolved.layer,
};
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
if (isFloor || isLead) seat.persistentPersona = true;
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
if (entry.reportsTo) seat.reportsTo = entry.reportsTo;
seats.push(seat);
}
}
if (seats.length === 0) {
throw new Error(
`Profile "${profile.id}" produced no seats. ` +
(opts.full ? 'Its roster is empty.' : 'No floor seats are defined — try --full.'),
);
}
return { seats, yaml: renderRosterYaml(seats) };
}
/**
* RULE 5 — Standard roster scaffolding. We emit the same generic, non-personal
* scaffold the committed example presets use (socket_name: mosaic-fleet,
* holder_session: _holder, working_directory: ~, claude + pi runtimes) so the
* output is a drop-in valid roster. No operator-personal data is copied.
*
* Built via the `yaml` lib (same serializer the parser uses) so the result
* round-trips. reports_to is intentionally NOT included on agents (RULE 6).
*/
function renderRosterYaml(seats: GeneratedSeat[]): string {
const agents = seats.map((s) => {
const a: Record<string, unknown> = {
name: s.name,
runtime: s.runtime,
class: s.className,
};
if (s.persistentPersona) a['persistent_persona'] = true;
if (s.modelHint) a['model_hint'] = s.modelHint;
if (s.resetBetweenTasks) a['reset_between_tasks'] = true;
return a;
});
const doc = {
version: 1,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
defaults: { working_directory: '~' },
runtimes: {
claude: { reset_command: '/clear' },
pi: { reset_command: '/new' },
},
agents,
};
return YAML.stringify(doc);
}
// ---------------------------------------------------------------------------
// Validation — reuse fleet-profiles.validateProfile (override-aware classes) and
// name any unresolved class clearly. Never generate a roster referencing a
// nonexistent persona.
// ---------------------------------------------------------------------------
/**
* Validate the profile against the override-aware persona library. Throws with a
* clear, class-naming message if any referenced class is unresolved.
*/
export async function validateProfileForProvision(
profile: FleetProfile,
opts: ProvisionOptions,
): Promise<void> {
const { rolesDir, overrideDir } = resolveDirs(opts);
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
const problems = validateProfile(profile, validClasses);
if (problems.length > 0) {
throw new Error(
`Profile "${profile.id}" is invalid; cannot provision:\n - ${problems.join('\n - ')}`,
);
}
}
// ---------------------------------------------------------------------------
// Topology summary (printed in dry-run and after write).
// ---------------------------------------------------------------------------
function formatTopologySummary(seats: GeneratedSeat[]): string {
const lines: string[] = [];
lines.push(`Topology (${seats.length} seat(s)):`);
for (const s of seats) {
const reports = s.reportsTo ? `reports_to=${s.reportsTo}` : 'reports_to=- (lead)';
lines.push(
` - ${s.name}\tclass=${s.className}\truntime=${s.runtime}\t${reports}\tpersona=${s.personaLayer}`,
);
}
return lines.join('\n');
}
// ---------------------------------------------------------------------------
// CLI wiring — mirror registerFleetProfileCommand / registerFleetPersonaCommand.
// ---------------------------------------------------------------------------
export interface ProvisionRunResult {
yaml: string;
summary: string;
wrote?: string;
}
/**
* Core provision flow shared by the CLI: load + validate the profile, generate
* the roster, optionally write it. Returns the artifacts for printing/testing.
*/
export async function runProvision(
profileId: string,
opts: ProvisionOptions & { write?: boolean; force?: boolean },
): Promise<ProvisionRunResult> {
const dirs = resolveDirs(opts);
const profile = await loadProfile(profileId, {
mosaicHome: dirs.mosaicHome,
profilesDir: dirs.profilesDir,
rolesDir: dirs.rolesDir,
overrideDir: dirs.overrideDir,
});
// loadProfile already validates, but re-run with our explicit error wording so
// an unresolved class is named clearly even if invoked directly.
await validateProfileForProvision(profile, opts);
const { seats, yaml } = await generateRoster(profile, opts);
const summary = formatTopologySummary(seats);
if (!opts.write) {
return { yaml, summary };
}
const rosterPath = join(dirs.mosaicHome, 'fleet', 'roster.yaml');
if (!opts.force) {
let exists = false;
try {
await access(rosterPath, constants.F_OK);
exists = true;
} catch {
exists = false;
}
if (exists) {
throw new Error(
`Refusing to overwrite existing roster: ${rosterPath}. ` +
`Pass --force to overwrite, or edit it by hand.`,
);
}
}
await mkdir(dirname(rosterPath), { recursive: true });
await writeFile(rosterPath, yaml, 'utf8');
return { yaml, summary, wrote: rosterPath };
}
/**
* Register `provision` under an existing `fleet` command. `mosaicHomeFor`
* resolves the active --mosaic-home (parent flag) at call time, exactly like the
* profile/persona/backlog subcommands.
*/
export function registerFleetProvisionCommand(
fleetCmd: Command,
mosaicHomeFor: () => string,
): Command {
const provisionCmd = fleetCmd
.command('provision')
.description('Materialize a roster.yaml from a system-type profile (H3). DRY-RUN by default.')
.requiredOption('--profile <id>', 'System-type profile id to provision')
.option('--full', 'Materialize the entire profile roster (default: floor seats only)')
.option('--write', 'Write the generated roster to <mosaicHome>/fleet/roster.yaml')
.option('--force', 'Overwrite an existing roster.yaml (requires --write)')
.action(async (opts: { profile: string; full?: boolean; write?: boolean; force?: boolean }) => {
try {
const result = await runProvision(opts.profile, {
mosaicHome: mosaicHomeFor(),
full: opts.full,
write: opts.write,
force: opts.force,
});
if (result.wrote) {
console.log(`Wrote roster: ${result.wrote}`);
console.log('');
console.log(result.summary);
} else {
// DRY RUN: print the roster it WOULD generate + topology, write nothing.
console.log('# DRY RUN — no files written. Re-run with --write to persist.');
console.log(result.yaml.trimEnd());
console.log('');
console.log(result.summary);
}
} catch (err) {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
});
return provisionCmd;
}

View File

@@ -82,9 +82,7 @@ describe('registerFleetCommand', () => {
'init', 'init',
'install', 'install',
'install-systemd', 'install-systemd',
'persona',
'profile', 'profile',
'provision',
'ps', 'ps',
'remove', 'remove',
'restart', 'restart',
@@ -104,19 +102,6 @@ describe('registerFleetCommand', () => {
expect(profile!.commands.map((command) => command.name()).sort()).toEqual(['list', 'show']); expect(profile!.commands.map((command) => command.name()).sort()).toEqual(['list', 'show']);
}); });
it('registers the persona subcommand with list, show, and customize', () => {
const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet');
const persona = fleet!.commands.find((command) => command.name() === 'persona');
expect(persona).toBeDefined();
expect(persona!.commands.map((command) => command.name()).sort()).toEqual([
'customize',
'list',
'show',
]);
});
it('registers the backlog subcommand with its operations', () => { it('registers the backlog subcommand with its operations', () => {
const program = buildProgram(); const program = buildProgram();
const fleet = program.commands.find((command) => command.name() === 'fleet'); const fleet = program.commands.find((command) => command.name() === 'fleet');
@@ -247,8 +232,6 @@ describe('fleet roster parsing', () => {
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe( expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toBe(
[ [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
// Reflects the roster's non-default `class: implementer` (A3a).
'MOSAIC_AGENT_CLASS=implementer',
'MOSAIC_AGENT_RUNTIME=codex', 'MOSAIC_AGENT_RUNTIME=codex',
'MOSAIC_AGENT_MODEL=', 'MOSAIC_AGENT_MODEL=',
'MOSAIC_AGENT_WORKDIR=/srv/mosaic', 'MOSAIC_AGENT_WORKDIR=/srv/mosaic',
@@ -258,40 +241,6 @@ describe('fleet roster parsing', () => {
); );
}); });
it('emits MOSAIC_AGENT_CLASS=worker for an agent that declares no class', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain(
'MOSAIC_AGENT_CLASS=worker\n',
);
});
it('shell-escapes MOSAIC_AGENT_CLASS so a launcher reads it verbatim', async () => {
cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.json');
await writeFile(
rosterPath,
JSON.stringify({
version: 1,
transport: 'tmux',
agents: [{ name: 'coder0', runtime: 'codex', class: 'orchestrator' }],
}),
);
const roster = await loadFleetRoster(rosterPath);
expect(generateAgentEnv(roster, getRosterAgent(roster, 'coder0'))).toContain(
'MOSAIC_AGENT_CLASS=orchestrator\n',
);
});
it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => { it('preserves site-owned agent EnvironmentFile overrides while refreshing roster keys', () => {
const generated = [ const generated = [
'MOSAIC_AGENT_NAME=coder0', 'MOSAIC_AGENT_NAME=coder0',
@@ -323,28 +272,6 @@ describe('fleet roster parsing', () => {
); );
}); });
it('updates (does not duplicate) MOSAIC_AGENT_CLASS on re-launch', () => {
const generated = [
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_CLASS=orchestrator',
'MOSAIC_AGENT_RUNTIME=codex',
'',
].join('\n');
const existing = [
'MOSAIC_AGENT_NAME=coder0',
'MOSAIC_AGENT_CLASS=worker',
'MOSAIC_AGENT_RUNTIME=codex',
'',
].join('\n');
const merged = mergeAgentEnv(generated, existing);
// mergeAgentEnv keys by VAR name, so the regenerated CLASS wins and there is
// exactly one MOSAIC_AGENT_CLASS line (no stale worker value left behind).
expect(merged).toContain('MOSAIC_AGENT_CLASS=orchestrator');
expect(merged).not.toContain('MOSAIC_AGENT_CLASS=worker');
expect(merged.match(/^MOSAIC_AGENT_CLASS=/gm)).toHaveLength(1);
});
it('rejects unknown roster fields instead of silently defaulting', async () => { it('rejects unknown roster fields instead of silently defaulting', async () => {
cleanup = await tempDir(); cleanup = await tempDir();
const rosterPath = join(cleanup, 'roster.yaml'); const rosterPath = join(cleanup, 'roster.yaml');

View File

@@ -9,9 +9,7 @@ import type { Command } from 'commander';
import YAML from 'yaml'; import YAML from 'yaml';
import { resolveCommsBlock } from '../fleet/comms-onboarding.js'; import { resolveCommsBlock } from '../fleet/comms-onboarding.js';
import { registerFleetBacklogCommand } from './fleet-backlog.js'; import { registerFleetBacklogCommand } from './fleet-backlog.js';
import { registerFleetPersonaCommand } from './fleet-personas.js';
import { registerFleetProfileCommand } from './fleet-profiles.js'; import { registerFleetProfileCommand } from './fleet-profiles.js';
import { registerFleetProvisionCommand } from './fleet-provision.js';
/** /**
* A function that spawns a command with inherited stdio (TTY passthrough). * A function that spawns a command with inherited stdio (TTY passthrough).
@@ -491,9 +489,6 @@ export function generateAgentEnv(roster: FleetRoster, agent: FleetAgent): string
const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory; const workingDirectory = agent.workingDirectory ?? roster.defaults.workingDirectory;
return [ return [
`MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`, `MOSAIC_AGENT_NAME=${shellEnvValue(agent.name)}`,
// Per-agent class → start-agent-session.sh / launcher reads this to inject the
// matching persona contract for the agent's class (default `worker`).
`MOSAIC_AGENT_CLASS=${shellEnvValue(agent.className)}`,
`MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`, `MOSAIC_AGENT_RUNTIME=${shellEnvValue(agent.runtime)}`,
// Per-agent model hint → start-agent-session.sh appends `--model <hint>` to // Per-agent model hint → start-agent-session.sh appends `--model <hint>` to
// the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on // the `mosaic yolo` launch so workers run on the roster's model (e.g. pi on
@@ -1716,15 +1711,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag. // from <mosaicHome>/fleet/profiles/*.yaml using the same --mosaic-home flag.
registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); registerFleetProfileCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// Update-surviving persona overrides (H4): baseline fleet/roles/ ⊕ the
// PRESERVE-protected fleet/roles.local/ override layer, resolved via the same
// --mosaic-home flag.
registerFleetPersonaCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
// Provisioning (H3): materialize a concrete roster.yaml from a system-type
// profile. DRY-RUN by default; --write persists under the same --mosaic-home.
registerFleetProvisionCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome);
return cmd; return cmd;
} }

View File

@@ -20,7 +20,6 @@ import { homedir } from 'node:os';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import type { Command } from 'commander'; import type { Command } from 'commander';
import { readFleetCommsBlock } from '../fleet/comms-onboarding.js'; import { readFleetCommsBlock } from '../fleet/comms-onboarding.js';
import { readPersonaContractBlock } from '../fleet/persona-contract.js';
const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic'); const MOSAIC_HOME = process.env['MOSAIC_HOME'] ?? join(homedir(), '.config', 'mosaic');
@@ -385,16 +384,6 @@ For required push/merge/issue-close/release actions, execute without routine con
// Runtime-specific contract // Runtime-specific contract
parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8')); parts.push('\n\n# Runtime-Specific Contract\n\n' + readFileSync(runtimeFile, 'utf-8'));
// Persona contract (A3b): when this agent was spawned with a class
// (MOSAIC_AGENT_CLASS, exported into the pane env by A3a), inject its resolved
// role contract so its identity (mandate + boundaries) is resident from the
// first turn. Override-aware via the persona resolver: a user-customized
// persona in fleet/roles.local/ wins over the baseline (AC-NS-7 launch proof).
// Placed BEFORE fleet comms: identity first, then how-to-reach-peers. No-ops
// silently when the class is unset/unknown (mirrors the comms block).
const persona = readPersonaContractBlock(mosaicHome, process.env['MOSAIC_AGENT_CLASS']);
if (persona) parts.push('\n\n' + persona);
// Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set // Fleet onboarding: when this is a spawned fleet agent (MOSAIC_AGENT_NAME set
// and present in the roster), inject a comms cheat-sheet + peer roster so it // and present in the roster), inject a comms cheat-sheet + peer roster so it
// knows how to reach the orchestrator and its peers from its first turn. // knows how to reach the orchestrator and its peers from its first turn.

View File

@@ -1,106 +0,0 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readPersonaContractBlock } from './persona-contract.js';
/**
* Persona-contract launch injection (A3b). Asserts the override-aware resolver
* is wired so a customized persona in roles.local/ wins at launch (AC-NS-7), and
* that any miss (unset/empty/unknown class, missing file) no-ops silently —
* never throws — mirroring readFleetCommsBlock's tolerant contract.
*/
const BASELINE_CODER = `# Coder — fleet role definition
The **coder** persona (\`class: coder\`, \`domain: engineering\`).
## Mandate
BASELINE-MANDATE: implement the assigned lane.
`;
const OVERRIDE_CODER = `# Coder — fleet role definition (override)
The **coder** persona (\`class: coder\`).
## Mandate
OVERRIDE-MANDATE: implement the assigned lane, the user's way.
`;
function makeHome(): string {
const root = mkdtempSync(join(tmpdir(), 'mosaic-persona-'));
return join(root, 'mosaic-home');
}
function seedBaseline(home: string, klass: string, body: string): void {
const dir = join(home, 'fleet', 'roles');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${klass}.md`), body);
}
function seedOverride(home: string, klass: string, body: string): void {
const dir = join(home, 'fleet', 'roles.local');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, `${klass}.md`), body);
}
describe('readPersonaContractBlock — launch-time persona injection (A3b)', () => {
let home: string;
beforeEach(() => {
home = makeHome();
});
afterEach(() => {
// root is the parent of mosaic-home
rmSync(join(home, '..'), { recursive: true, force: true });
});
it('injects the baseline persona when the class has a fleet/roles/<class>.md', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
const block = readPersonaContractBlock(home, 'coder');
expect(block).toContain('# Persona Contract (coder)');
expect(block).toContain('BASELINE-MANDATE');
expect(block).toContain('baseline `fleet/roles/` layer');
});
it('OVERRIDE WINS: roles.local/<class>.md content is injected over the baseline (AC-NS-7)', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
seedOverride(home, 'coder', OVERRIDE_CODER);
const block = readPersonaContractBlock(home, 'coder');
expect(block).toContain('# Persona Contract (coder)');
expect(block).toContain('OVERRIDE-MANDATE'); // override body present
expect(block).not.toContain('BASELINE-MANDATE'); // baseline NOT used
expect(block).toContain('roles.local'); // layer note names the override layer
});
it('injects an override-only (user-added) persona with no baseline at all', () => {
seedOverride(home, 'reviewer', '# Reviewer\n\n(`class: reviewer`)\n\nCUSTOM-ROLE.\n');
const block = readPersonaContractBlock(home, 'reviewer');
expect(block).toContain('# Persona Contract (reviewer)');
expect(block).toContain('CUSTOM-ROLE');
});
it('no-ops (empty string) when the class is undefined', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, undefined)).toBe('');
});
it('no-ops (empty string) when the class is empty/whitespace', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, '')).toBe('');
expect(readPersonaContractBlock(home, ' ')).toBe('');
});
it('no-ops (empty string) for an unknown class with no role file', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, 'nonexistent')).toBe('');
});
it('no-ops (empty string, no throw) when no roles directories exist at all', () => {
expect(() => readPersonaContractBlock(home, 'coder')).not.toThrow();
expect(readPersonaContractBlock(home, 'coder')).toBe('');
});
});

View File

@@ -1,63 +0,0 @@
/**
* Persona-contract injection at launch (North Star A3b).
*
* A spawned fleet agent should boot already knowing WHO it is: its class's role
* contract (mandate + boundaries). The companion goal A3a exports the agent's
* resolved class into the pane env as `MOSAIC_AGENT_CLASS`; here we read that
* class at launch (composeContract → system prompt) and inject the resolved
* persona contract so the identity is resident from the agent's first turn.
*
* OVERRIDE-AWARE: resolution goes through fleet-personas' resolver, so a
* user-customized persona in the PRESERVE-protected `fleet/roles.local/` layer
* WINS over the baseline `fleet/roles/` of the same class. That is the
* launch-time proof of AC-NS-7 — a customized persona actually reaches the model
* when the agent boots, not just in `mosaic fleet persona show`.
*
* Tolerant by contract (mirrors readFleetCommsBlock): an empty/missing class, an
* unknown class, or a missing role file all yield '' so the launcher no-ops
* silently. This MUST never throw during launch.
*
* Standalone module (no fleet.ts import) to keep launch.ts's prompt path free of
* the heavy fleet command module; it depends only on the lightweight persona
* resolver.
*/
import {
resolvePersonaSync,
defaultRolesDir,
defaultOverrideDir,
} from '../commands/fleet-personas.js';
/**
* Resolve `klass`'s persona contract (override-aware) and render it as a
* clearly-delimited launch block. Returns '' on any miss (falsy class, unknown
* class, missing/unreadable file) so composeContract can push it unconditionally
* and have it no-op silently. Never throws.
*/
export function readPersonaContractBlock(mosaicHome: string, klass: string | undefined): string {
if (!klass || !klass.trim()) return '';
let resolved: ReturnType<typeof resolvePersonaSync>;
try {
resolved = resolvePersonaSync(klass.trim(), {
rolesDir: defaultRolesDir(mosaicHome),
overrideDir: defaultOverrideDir(mosaicHome),
});
} catch {
// Best-effort onboarding: a resolver hiccup must not abort the launch.
return '';
}
if (!resolved) return '';
const layerNote =
resolved.layer === 'override'
? '_(resolved from the `fleet/roles.local/` override layer — wins over baseline)_'
: '_(resolved from the baseline `fleet/roles/` layer)_';
return `# Persona Contract (${resolved.klass})
${layerNote}
You are operating as the **${resolved.klass}** persona. The role contract below is your identity — its mandate and boundaries govern what you own and what you must not do for this assignment.
${resolved.content.trim()}`;
}

View File

@@ -4,6 +4,5 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
environment: 'node', environment: 'node',
testTimeout: 30_000,
}, },
}); });

View File

@@ -16,10 +16,6 @@
# --framework Install/upgrade framework only (skip npm CLI) # --framework Install/upgrade framework only (skip npm CLI)
# --cli Install/upgrade npm CLI only (skip framework) # --cli Install/upgrade npm CLI only (skip framework)
# --ref <branch> Git ref for framework archive (default: main) # --ref <branch> Git ref for framework archive (default: main)
# --dev Build CLI + gateway FROM SOURCE at --ref instead of the
# registry @latest. Zero registry writes — packs local
# tarballs and installs them globally. Use to test a branch
# end-to-end before cutting a release.
# --yes Accept all defaults; headless/non-interactive install # --yes Accept all defaults; headless/non-interactive install
# --no-auto-launch Skip automatic mosaic wizard + gateway install on first install # --no-auto-launch Skip automatic mosaic wizard + gateway install on first install
# --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line # --uninstall Reverse the install: remove framework dir, CLI package, and npmrc line
@@ -31,7 +27,6 @@
# MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global) # MOSAIC_PREFIX — npm global prefix (default: ~/.npm-global)
# MOSAIC_NO_COLOR — disable colour (set to 1) # MOSAIC_NO_COLOR — disable colour (set to 1)
# MOSAIC_REF — git ref for framework (default: main) # MOSAIC_REF — git ref for framework (default: main)
# MOSAIC_DEV — equivalent to --dev (set to 1)
# MOSAIC_ASSUME_YES — equivalent to --yes (set to 1) # MOSAIC_ASSUME_YES — equivalent to --yes (set to 1)
# ────────────────────────────────────────────────────────────────────────────── # ──────────────────────────────────────────────────────────────────────────────
# #
@@ -48,7 +43,6 @@ FLAG_CLI=true
FLAG_NO_AUTO_LAUNCH=false FLAG_NO_AUTO_LAUNCH=false
FLAG_YES=false FLAG_YES=false
FLAG_UNINSTALL=false FLAG_UNINSTALL=false
FLAG_DEV=false
GIT_REF="${MOSAIC_REF:-main}" GIT_REF="${MOSAIC_REF:-main}"
# MOSAIC_ASSUME_YES env var acts the same as --yes # MOSAIC_ASSUME_YES env var acts the same as --yes
@@ -56,18 +50,12 @@ if [[ "${MOSAIC_ASSUME_YES:-0}" == "1" ]]; then
FLAG_YES=true FLAG_YES=true
fi fi
# MOSAIC_DEV env var acts the same as --dev
if [[ "${MOSAIC_DEV:-0}" == "1" ]]; then
FLAG_DEV=true
fi
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--check) FLAG_CHECK=true; shift ;; --check) FLAG_CHECK=true; shift ;;
--framework) FLAG_CLI=false; shift ;; --framework) FLAG_CLI=false; shift ;;
--cli) FLAG_FRAMEWORK=false; shift ;; --cli) FLAG_FRAMEWORK=false; shift ;;
--ref) GIT_REF="${2:-main}"; shift 2 ;; --ref) GIT_REF="${2:-main}"; shift 2 ;;
--dev) FLAG_DEV=true; shift ;;
--yes|-y) FLAG_YES=true; shift ;; --yes|-y) FLAG_YES=true; shift ;;
--no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;; --no-auto-launch) FLAG_NO_AUTO_LAUNCH=true; shift ;;
--uninstall) FLAG_UNINSTALL=true; shift ;; --uninstall) FLAG_UNINSTALL=true; shift ;;
@@ -84,17 +72,6 @@ CLI_PKG="${SCOPE}/mosaic"
REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack" REPO_BASE="https://git.mosaicstack.dev/mosaicstack/stack"
ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz" ARCHIVE_URL="${REPO_BASE}/archive/${GIT_REF}.tar.gz"
# In dev (build-from-source) mode the gateway is installed globally from a
# locally-built tarball. Tell the wizard / gateway-config stage NOT to overwrite
# it with the registry @latest build (honored by gatewayConfigStage).
if [[ "$FLAG_DEV" == "true" ]]; then
export MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1
fi
# Shared monorepo checkout (populated on demand by ensure_monorepo).
WORK_DIR=""
EXTRACTED_DIR=""
# ─── uninstall path ─────────────────────────────────────────────────────────── # ─── uninstall path ───────────────────────────────────────────────────────────
# Shell-level uninstall for when the CLI is broken or not available. # Shell-level uninstall for when the CLI is broken or not available.
# Handles: framework directory, npm CLI package, npmrc scope line. # Handles: framework directory, npm CLI package, npmrc scope line.
@@ -262,99 +239,6 @@ framework_version() {
fi fi
} }
# Download + extract the monorepo archive at $GIT_REF exactly once per run.
# Sets the script-level EXTRACTED_DIR to the repo root. Reused by both the
# framework install (Part 1) and the dev build-from-source path (Part 2).
ensure_monorepo() {
if [[ -n "$EXTRACTED_DIR" ]] && [[ -d "$EXTRACTED_DIR" ]]; then
return 0
fi
require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
# shellcheck disable=SC2317
cleanup_work() { [[ -n "$WORK_DIR" ]] && rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading source from ${GIT_REF}"
if command -v curl &>/dev/null; then
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
fail "curl or wget required to download source."
exit 1
fi
# Gitea archives extract to <repo-name>/ inside the work dir
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
if [[ -z "$EXTRACTED_DIR" ]] || [[ ! -d "$EXTRACTED_DIR" ]]; then
fail "Could not locate extracted source in archive."
ls -la "$WORK_DIR" >&2
exit 1
fi
}
# Build @mosaicstack/mosaic + @mosaicstack/gateway from source and install both
# globally from locally-packed tarballs. ZERO registry writes. Workspace deps
# (brain/config/db/…) are pulled from the registry at the versions pinned in
# each package.json — `pnpm pack` rewrites `workspace:*` to those versions.
install_cli_from_source() {
local src="$EXTRACTED_DIR"
local out_dir="$WORK_DIR/dist-tarballs"
mkdir -p "$out_dir"
# pnpm via corepack (ships with Node >= 16.9; required by Node >= 20 preflight).
# Pin to the repo's packageManager version so the build matches CI. Surface
# corepack failures so the fresh-machine case gives an actionable error
# instead of a bare "command not found".
if ! command -v pnpm &>/dev/null; then
info "Activating pnpm via corepack…"
corepack enable 2>&1 | sed 's/^/ /' || warn "corepack enable failed — pnpm may need manual install."
corepack prepare pnpm@10.6.2 --activate 2>&1 | sed 's/^/ /' \
|| warn "corepack prepare failed — pnpm may need manual install."
fi
if ! command -v pnpm &>/dev/null; then
fail "pnpm not available after corepack activation."
echo " Install pnpm manually (https://pnpm.io/installation) and re-run with --dev."
exit 1
fi
info "Installing workspace dependencies (pnpm install)…"
( cd "$src" && pnpm install ) 2>&1 | sed 's/^/ /'
info "Building CLI + gateway from source…"
( cd "$src" && pnpm --filter "@mosaicstack/mosaic..." --filter "@mosaicstack/gateway..." run build ) 2>&1 | sed 's/^/ /'
info "Packing local tarballs…"
( cd "$src/packages/mosaic" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
( cd "$src/apps/gateway" && pnpm pack --pack-destination "$out_dir" ) 2>&1 | sed 's/^/ /'
local cli_tgz gw_tgz
cli_tgz="$(ls -1t "$out_dir"/mosaicstack-mosaic-*.tgz 2>/dev/null | head -1)"
gw_tgz="$(ls -1t "$out_dir"/mosaicstack-gateway-*.tgz 2>/dev/null | head -1)"
if [[ ! -f "$cli_tgz" ]]; then
fail "CLI tarball was not produced by pnpm pack."
exit 1
fi
if [[ ! -f "$gw_tgz" ]]; then
fail "Gateway tarball was not produced by pnpm pack."
exit 1
fi
# Gateway first so it is present globally before the CLI's wizard runs (which
# skips its own gateway install via MOSAIC_GATEWAY_SKIP_NPM_INSTALL=1).
info "Installing gateway from source tarball (global)…"
npm install -g "$gw_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
info "Installing CLI from source tarball (global)…"
npm install -g "$cli_tgz" --prefix="$PREFIX" 2>&1 | sed 's/^/ /'
ok "Installed from source: CLI $(installed_cli_version)"
}
# ─── preflight ──────────────────────────────────────────────────────────────── # ─── preflight ────────────────────────────────────────────────────────────────
require_cmd node require_cmd node
@@ -398,8 +282,25 @@ if [[ "$FLAG_FRAMEWORK" == "true" ]]; then
warn "Framework not installed." warn "Framework not installed."
fi fi
else else
# Download repo archive and extract framework (shared with the dev build) # Download repo archive and extract framework
ensure_monorepo require_cmd tar
WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-install-XXXXXX")"
cleanup_work() { rm -rf "$WORK_DIR"; }
trap cleanup_work EXIT
info "Downloading framework from ${GIT_REF}"
if command -v curl &>/dev/null; then
curl -fsSL "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
elif command -v wget &>/dev/null; then
wget -qO- "$ARCHIVE_URL" | tar xz -C "$WORK_DIR"
else
fail "curl or wget required to download framework."
exit 1
fi
# Gitea archives extract to <repo-name>/ inside the work dir
EXTRACTED_DIR="$(find "$WORK_DIR" -maxdepth 1 -mindepth 1 -type d | head -1)"
FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework" FRAMEWORK_SRC="$EXTRACTED_DIR/packages/mosaic/framework"
if [[ ! -d "$FRAMEWORK_SRC" ]]; then if [[ ! -d "$FRAMEWORK_SRC" ]]; then
@@ -455,11 +356,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
fi fi
CURRENT="$(installed_cli_version)" CURRENT="$(installed_cli_version)"
if [[ "$FLAG_DEV" == "true" ]]; then LATEST="$(latest_cli_version)"
LATEST=""
else
LATEST="$(latest_cli_version)"
fi
if [[ -n "$CURRENT" ]]; then if [[ -n "$CURRENT" ]]; then
dim " Installed: ${CLI_PKG}@${CURRENT}" dim " Installed: ${CLI_PKG}@${CURRENT}"
@@ -467,9 +364,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
dim " Installed: (none)" dim " Installed: (none)"
fi fi
if [[ "$FLAG_DEV" == "true" ]]; then if [[ -n "$LATEST" ]]; then
dim " Source: ${REPO_BASE} (ref: ${GIT_REF}, build-from-source)"
elif [[ -n "$LATEST" ]]; then
dim " Latest: ${CLI_PKG}@${LATEST}" dim " Latest: ${CLI_PKG}@${LATEST}"
else else
dim " Latest: (registry unreachable)" dim " Latest: (registry unreachable)"
@@ -477,9 +372,7 @@ if [[ "$FLAG_CLI" == "true" ]]; then
echo "" echo ""
if [[ "$FLAG_CHECK" == "true" ]]; then if [[ "$FLAG_CHECK" == "true" ]]; then
if [[ "$FLAG_DEV" == "true" ]]; then if [[ -z "$LATEST" ]]; then
info "Dev mode: installed version is ${CURRENT:-(none)} (no registry comparison)."
elif [[ -z "$LATEST" ]]; then
warn "Could not reach registry." warn "Could not reach registry."
elif [[ -z "$CURRENT" ]]; then elif [[ -z "$CURRENT" ]]; then
warn "Not installed." warn "Not installed."
@@ -490,16 +383,6 @@ if [[ "$FLAG_CLI" == "true" ]]; then
else else
ok "Up to date (or ahead of registry)." ok "Up to date (or ahead of registry)."
fi fi
elif [[ "$FLAG_DEV" == "true" ]]; then
info "Dev mode — building CLI + gateway from source at ref ${GIT_REF}"
ensure_monorepo
install_cli_from_source
# PATH check for npm prefix
if [[ ":$PATH:" != *":$PREFIX/bin:"* ]]; then
warn "$PREFIX/bin is not on your PATH"
dim " Add to your shell rc: export PATH=\"$PREFIX/bin:\$PATH\""
fi
else else
if [[ -z "$LATEST" ]]; then if [[ -z "$LATEST" ]]; then
warn "Could not reach registry at $REGISTRY — skipping npm CLI." warn "Could not reach registry at $REGISTRY — skipping npm CLI."