Compare commits

..

1 Commits

Author SHA1 Message Date
Jarvis
92e4ee189a feat(fleet): update-surviving persona overrides (roles.local layer, resolver, persona CLI) (H4)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Add a PRESERVE-protected persona override layer at <mosaicHome>/fleet/roles.local/
that survives `mosaic update` while baseline fleet/roles/ keeps reseeding.

- fleet-personas.ts: shared class-extraction (single source of truth, DRY with
  fleet-profiles.ts), resolvePersona (override wins, baseline fallback),
  listPersonaClasses (baseline ⊕ override union), personaStatus
  (baseline/overridden/custom), and the `fleet persona list|show|customize` CLI.
- fleet-profiles.ts: roster validation now uses the override-aware union so a
  profile can reference a user-customized or user-ADDED persona; the old
  listPersonaClasses(rolesDir) is kept as a thin delegate to the shared helper.
- install.sh: add fleet/roles.local to PRESERVE_PATHS (AC-NS-7 guarantee).
- specs: override-wins, custom-add, status classification, AC-NS-7
  update-survival simulation, and profile-validation-accepts-custom-persona.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 11:11:00 -05:00
31 changed files with 81 additions and 2109 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

@@ -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

@@ -25,7 +25,6 @@
* can reference a customized or user-added persona. * can reference a customized or user-added persona.
*/ */
import { readFileSync, readdirSync } from 'node:fs';
import { readFile, readdir } from 'node:fs/promises'; import { readFile, readdir } from 'node:fs/promises';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';
@@ -89,12 +88,13 @@ export interface DirClasses {
* classes still appear in `classes` for membership checks. * classes still appear in `classes` for membership checks.
*/ */
export async function extractClassesFromDir(dir: string): Promise<DirClasses> { export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() }; const classes = new Set<string>();
const byClass = new Map<string, PersonaFile>();
let entries: string[]; let entries: string[];
try { try {
entries = await readdir(dir); entries = await readdir(dir);
} catch { } catch {
return acc; return { classes, byClass };
} }
for (const entry of entries) { for (const entry of entries) {
@@ -105,75 +105,36 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
} catch { } catch {
continue; continue;
} }
accumulateEntry(acc, dir, entry, text); if (entry === 'LIBRARY.md') {
} for (const m of text.matchAll(LIBRARY_ROW)) {
return acc; const name = m[1];
} if (name && name !== 'persona') classes.add(name);
}
/**
* 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; continue;
} }
accumulateEntry(acc, dir, entry, text); // The filename stem is itself a valid class (covers marker-less alias docs).
} const stem = basename(entry, '.md');
return acc; classes.add(stem);
} const domainMatch = DOMAIN_MARKER.exec(text);
const domain = domainMatch?.[1];
/** let markedClassForFile: string | undefined;
* Apply the class-extraction rules for ONE role file's text into `acc`. Pure for (const m of text.matchAll(CLASS_MARKER)) {
* over already-read content, so the async and sync directory scanners share a const klass = m[1];
* single definition of "what classes a file contributes" (DRY — no semantic if (!klass) continue;
* drift between the launch-time and command-time paths). classes.add(klass);
*/ // Record the FIRST marker as the file's defining class (the prose names
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void { // the persona's own class up top; later mentions reference siblings).
const { classes, byClass } = acc; if (!markedClassForFile) {
if (entry === 'LIBRARY.md') { markedClassForFile = klass;
for (const m of text.matchAll(LIBRARY_ROW)) { byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
const name = m[1]; }
if (name && name !== 'persona') classes.add(name);
} }
return; // A marker-less file still maps its stem to itself (no domain known).
} if (!markedClassForFile && !byClass.has(stem)) {
// The filename stem is itself a valid class (covers marker-less alias docs). byClass.set(stem, { klass: stem, file: join(dir, entry) });
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). return { classes, byClass };
if (!markedClassForFile && !byClass.has(stem)) {
byClass.set(stem, { klass: stem, file: join(dir, entry) });
}
} }
export interface PersonaDirs { export interface PersonaDirs {
@@ -234,21 +195,6 @@ export async function resolvePersona(
extractClassesFromDir(rolesDir), extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir), 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 ( const fromLayer = async (
dir: string, dir: string,
@@ -283,51 +229,6 @@ export async function resolvePersonaFrom(
); );
} }
/**
* 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 { export interface PersonaStatusEntry {
klass: string; klass: string;
status: PersonaStatus; status: PersonaStatus;

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

@@ -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

@@ -84,7 +84,6 @@ describe('registerFleetCommand', () => {
'install-systemd', 'install-systemd',
'persona', 'persona',
'profile', 'profile',
'provision',
'ps', 'ps',
'remove', 'remove',
'restart', 'restart',
@@ -247,8 +246,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 +255,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 +286,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

@@ -11,7 +11,6 @@ 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 { 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 +490,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
@@ -1721,10 +1717,6 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
// --mosaic-home flag. // --mosaic-home flag.
registerFleetPersonaCommand(cmd, () => cmd.opts<{ mosaicHome: string }>().mosaicHome); 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,
}, },
}); });