Compare commits

..

9 Commits

Author SHA1 Message Date
Jarvis
67516adef4 chore(docs): normalize fleet task table
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:52:19 -05:00
Jarvis
01202767df feat(fleet): add shared role semantics
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:46:38 -05:00
Jarvis
32e75c67b0 chore(orchestrator): reassign FCM-M1-002 to fresh worker
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:09:33 -05:00
Jarvis
462c58a1ac chore(orchestrator): start FCM-M1-002 shared role resolution
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:05:53 -05:00
aa5b43bba2 feat(fleet): add roster v2 structural compiler (#764)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 20:37:52 +00:00
ba13c08890 Fixes #756 (#763)
Some checks failed
ci/woodpecker/push/ci-image Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline was canceled
2026-07-14 20:26:15 +00:00
c32d85a337 docs(fleet): define declarative configuration M0 (#760)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:53:12 +00:00
48b2bc42c9 docs(mos): format Option 2 qualification report (#762)
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
2026-07-14 19:33:09 +00:00
5e832049bb docs(mos): preserve Option 2 qualification evidence (#759)
Some checks failed
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
2026-07-14 19:16:11 +00:00
26 changed files with 2584 additions and 38 deletions

View File

@@ -79,6 +79,52 @@ Jarvis (v0.2.0) is a self-hosted AI assistant with a Python FastAPI backend and
---
## Fleet Declarative Configuration Management Workstream (FCM, #758)
### Problem and objective
The local Mosaic fleet has a roster, generated agent environment files, user-systemd units, tmux
sessions, heartbeat files, examples, profiles, and separate gateway-backed agent records. These
planes have drifted and are not one safe operator lifecycle. The objective is one **local fleet
roster** as the desired-state SSOT, with generated environment, systemd, tmux, and heartbeat
artifacts as rebuildable projections; it does not merge the local fleet control plane with the
gateway-backed agent catalog.
### Normative requirements
| ID | Requirement |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FCM-REQ-01` | The roster SHALL be the sole writable desired-state source for local fleet membership, launch policy, and persisted lifecycle target. Generated environment files, systemd enablement, tmux sessions, and heartbeat state SHALL be non-authoritative projections. |
| `FCM-REQ-02` | The implementation SHALL provide one executable structural contract for YAML/JSON input and one shared semantic validator. Roster load, profile validation, provision, migration, and apply SHALL reuse the existing baseline-plus-`roles.local` profile/persona resolver; a parallel role resolver is forbidden. |
| `FCM-REQ-03` | The local fleet CLI SHALL expose documented programmatic validate, show, plan, apply/reconcile, create, inspect, update, delete, start, stop, restart, status, verify, and doctor operations with stable JSON and exit-code behavior. Existing `fleet add/remove` compatibility aliases may remain during the stated deprecation window. |
| `FCM-REQ-04` | A fresh create SHALL persist `enabled:true` and `desired_state:stopped` unless an explicit persisted start is requested. The model SHALL distinguish enabled state, persisted desired state, and observed state. Migration, apply, reboot, and rollback SHALL not start an agent that was observed stopped before cutover. |
| `FCM-REQ-05` | The launch chain SHALL consume deterministic, digest-stamped generated input only. Optional local overrides SHALL be parsed as strict data, may not shadow authoritative generated keys, and may not contain arbitrary commands, credential values, channels, or unknown `MOSAIC_AGENT_*` keys. Forbidden legacy keys, including `MOSAIC_AGENT_COMMAND`, SHALL be privately quarantined before launch and reported only by key name and content hash. |
| `FCM-REQ-06` | Mutations and apply SHALL validate before mutation, use an expected generation/lock, write projections atomically, produce a deterministic plan, and emit recovery information on partial failure. Reconciliation SHALL act only on local, enabled, roster-owned projections and SHALL not kill unmanaged tmux sessions by fuzzy name. |
| `FCM-REQ-07` | Canonical required classes are `code`, `review`, `validator`, `orchestrator`, `team-leader`, `enhancer`, and `interaction`. `validator` issues an independent final certificate but has no merge authority; `merge-gate` remains sole approve-to-land/merge authority. Team-leader capacity is bounded by an orchestrator-issued lease, and interaction is request/status only. Tess and Ultron are configurable instance/display names, not required machine identities. |
| `FCM-REQ-08` | v1 migration SHALL be field-complete, reversible, and explicit about aliases, unresolved classes, lifecycle inference, generated-file regeneration, local override quarantine, schema-only remote/connector fields, and rollback. Every shipped example, profile, and service preset SHALL be migrated and executable, retained as an explicitly versioned v1 fixture, or retired with a replacement and deprecation note. |
| `FCM-REQ-09` | M1M5 SHALL remain local tmux/systemd control-plane work. Remote/SSH reconciliation, connector mutation, secret references, arbitrary command/channel overrides, gateway/API convergence, and UI configuration storage are excluded and require a separate PRD/threat model. |
| `FCM-REQ-10` | Documentation and examples are delivery gates. The M0 checklist at [docs/fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) and the baseline disposition inventory at [docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md) SHALL be maintained as acceptance evidence. |
### Acceptance criteria
1. `AC-FCM-01`: A valid local v2 roster can be parsed from YAML or JSON, validated structurally and semantically through the shared resolver, and rendered canonically; invalid fields, duplicate names, unresolved classes, unsupported runtime/model combinations, socket ambiguity, and incompatible options fail closed.
2. `AC-FCM-02`: `plan` reports deterministic desired-versus-observed differences for roster, generated environment, systemd enablement, tmux/session, heartbeat, installed-asset revision, and provable orphans without mutation; `apply --check` reports drift without mutation.
3. `AC-FCM-03`: Local create/update/delete is generation-guarded, atomic, idempotent, and safe by default; it permits supported runtime/model/harness/effort/workdir/role changes without direct editing of generated environment files and does not start a newly created agent unless explicitly persisted.
4. `AC-FCM-04`: The generated-env/local-override launch chain rejects generated-key shadowing, arbitrary command override, unknown keys, shell evaluation, and sensitive-value diagnostics before any agent starts; known-safe legacy input is regenerated or strictly relocated, and forbidden input is quarantined.
5. `AC-FCM-05`: Local lifecycle reconciliation implements the persisted/transient start-stop rules, exact default/named tmux socket targeting, systemd/tmux status, stale generated state, unmanaged-session reporting, and rollback without surprise restarts or fuzzy destructive targeting.
6. `AC-FCM-06`: A v1 roster migration previews field-by-field disposition, preserves observed stopped/running state, inventories rather than reconciles remote/schema-only entries, supports a canary and rollback, and classifies every shipped example, profile, and service preset according to the M0 inventory.
7. `AC-FCM-07`: Required role authority is validated: validator certificate is consumed but does not merge, merge-gate is the sole merge authority, team-leader leases do not change roster/credentials/authority, and interaction/Tess cannot claim orchestration or merge powers.
8. `AC-FCM-08`: Documentation, examples, migration, troubleshooting, operational recovery, package/update asset drift, schema/example/profile validation, independent code/security review, validator certificate, and terminal-green CI are complete before #758 closes.
### M0 implementation gate
No source, schema, role, example, profile, systemd, or live-fleet change is authorized before M0
lands. M0 consists only of these normative requirements, the complete task DAG, the scoped
documentation IA checklist, and the legacy example/profile disposition inventory. Subsequent cards
are defined in [docs/TASKS.md](./TASKS.md) and must remain one card/one PR.
---
## Tess Interaction Agent Workstream (TESS)
### Problem and Objective

View File

@@ -49,3 +49,10 @@
- [Retention and deprecation evidence](tess/M5-MIGRATION-RETENTION-DEPRECATION.md)
- [Verification matrix](tess/VERIFICATION-MATRIX.md)
- [Documentation checklist](tess/M5-003-DOCUMENTATION-CHECKLIST.md)
- [Independent Option 2 runtime-portability qualification (2026-07-14)](tess/qualification/2026-07-14-option2-runtime-portability.md)
## Runtime-neutral Mos portability
- [Optional AI egress gateway ADR](architecture/ADR-MOS-EGRESS-GATEWAYS.md) — placement and gates for LiteLLM, Bifrost, and purpose-built translation proxies.
- [Runtime-neutral Mos identity and failover mission](https://git.mosaicstack.dev/mosaicstack/stack/issues/754)
- [Logical identity and connector lease/fencing implementation](https://git.mosaicstack.dev/mosaicstack/stack/issues/755)

View File

@@ -14,11 +14,12 @@
## Workstream Rollup
| id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ---------------------- | ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
| id | status | workstream | progress | tasks file | notes |
| --- | ----------------- | ------------------------------ | ---------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| W1 | planning-complete | Federation v1 (FED) | 0 / 7 milestones | [docs/federation/TASKS.md](./federation/TASKS.md) | M1 task breakdown populated; M2M7 deferred to mission planning |
| W2 | planning-complete | Tess interaction agent | 0 / 5 milestones | [docs/tess/TASKS.md](./tess/TASKS.md) | Issue #706; independent planning gate PASS; M1 issue #707 ready |
| W3 | planning-complete | Native Kanban/SOT | 0 / 4 phases | [docs/native-kanban-sot/TASKS.md](./native-kanban-sot/TASKS.md) | Issue #751; canon independently approved; implementation held until canon merges |
| W4 | planning-complete | Fleet configuration management | 0 / 12 cards | This file (§ Fleet configuration management #758) | Issue #758; M0 docs gate defines the implementation DAG before any fleet mutation |
## Cross-Cutting Tracking
@@ -42,6 +43,30 @@ Active workstream is **W1 — Federation v1**. Workers should:
2. Read [docs/federation/TASKS.md](./federation/TASKS.md) for the next pending task
3. Follow per-task agent + tier guidance from the workstream manifest
## Fleet configuration management (#758) — M0M5 implementation DAG
> **PRD:** [Fleet declarative configuration management](./PRD.md#fleet-declarative-configuration-management-workstream-fcm-758) · **M0 acceptance:** [docs IA checklist](./fleet/FLEET-CONFIG-DOCS-IA-CHECKLIST.md) · **baseline dispositions:** [legacy example/profile inventory](./fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md)
>
> Every row below is one independently reviewable card and **one PR**. `depends_on` is a
> hard DAG edge; no card may silently absorb another card's scope. All source cards require
> the repository quality gates, independent code and security review, terminal-green CI, and
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | Merged via #760 (`c32d85a`); parent #758 intentionally remains open through M5 |
| FCM-M1-001 | done | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | coder0 | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | #764 squash `aa5b43b`; exact-head RoR and PR/main terminal-green CI; no lifecycle or live mutation |
| FCM-M1-002 | in-progress | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | native-sonnet | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Started 2026-07-14 from `aa5b43b`; one shared resolver only; validator certificate-only; merge-gate sole merge authority |
| FCM-M1-003 | not-started | Convert the M0 legacy inventory into executable example/profile/service-preset validation and explicit v1-version/retirement checks | #758 | codex | mosaicstack/stack | `test/758-example-profile-dispositions` | FCM-M1-001, FCM-M1-002 | 20K | Every shipped artifact must validate, be versioned v1, or be retired with replacement |
| FCM-M2-001 | not-started | Migrate generic launch chain to deterministic `.env.generated` plus strict data-only `.env.local`; quarantine forbidden legacy keys | #758 | codex | mosaicstack/stack | `feat/758-generated-env-boundary` | FCM-M1-001, FCM-M1-002 | 30K | No arbitrary command compatibility path; diagnostics expose key names/hashes only |
| FCM-M2-002 | not-started | Add generation-guarded local fleet agent create/get/update/delete mutations with plan/dry-run, atomic roster writes, and recovery output | #758 | codex | mosaicstack/stack | `feat/758-fleet-agent-crud` | FCM-M1-001, FCM-M2-001 | 30K | Fresh create persists stopped unless explicit persisted start |
| FCM-M3-001 | not-started | Implement local roster-owned reconcile/apply plus lifecycle/status/verify/doctor contracts and stable JSON/exit codes | #758 | codex | mosaicstack/stack | `feat/758-local-reconciler` | FCM-M2-001, FCM-M2-002 | 35K | Exact systemd/tmux ownership; remote/schema-only entries are inventory only |
| FCM-M3-002 | not-started | Add isolated systemd/tmux lifecycle, drift, socket, unmanaged-session, crash, and rollback acceptance coverage | #758 | sonnet | mosaicstack/stack | `test/758-reconciler-lifecycle-gates` | FCM-M3-001 | 25K | Proves stopped-state preservation and zero fuzzy destructive targeting |
| FCM-M4-001 | not-started | Implement field-complete v1-to-v2 inventory/preview/migrator with alias, lifecycle, env-quarantine, and remote/connector disposition evidence | #758 | codex | mosaicstack/stack | `feat/758-v1-v2-migrator` | FCM-M1-003, FCM-M3-001 | 35K | Preview first; no unreviewed lifecycle inference |
| FCM-M4-002 | not-started | Add reversible canary migration, rollback, stale-projection/orphan classification, and current-host 9-managed/3-unmanaged fixture coverage | #758 | sonnet | mosaicstack/stack | `test/758-migration-rollback-gates` | FCM-M4-001, FCM-M3-002 | 25K | Never starts a previously stopped agent or kills an unproven unmanaged session |
| FCM-M5-001 | not-started | Deliver the accepted fleet documentation IA, how-to/operations/migration references, and link/example validation | #758 | haiku | mosaicstack/stack | `docs/758-fleet-config-operator-docs` | FCM-M1-003, FCM-M2-002, FCM-M3-001, FCM-M4-001 | 24K | Must close every checklist item or record an approved deferral |
| FCM-M5-002 | not-started | Package/update asset-drift checks, rolling local canary, independent validation certificate, and release evidence | #758 | sonnet | mosaicstack/stack | `feat/758-fleet-config-release-gate` | FCM-M3-002, FCM-M4-002, FCM-M5-001 | 30K | Final #758 gate: quality, independent code/security review, validator certificate, merge-gate approval, green CI |
## Thin-core prompt diet (#528) — feat/contract-thin-core
- Status: PR open, awaiting maintainer merge ratification (fleet-governing change).

View File

@@ -0,0 +1,151 @@
# ADR: Optional AI egress gateways for runtime-neutral Mos
**Status:** Proposed for controlled prototypes; not approved as Mosaic core
**Date:** 2026-07-14
**Issues:** #754, #755
**Decision owner:** Mosaic Gateway / provider-adapter architecture
## Context
The emergency Mos continuity path kept Claude Code as the harness and translated Anthropic Messages traffic to Codex OAuth through a small localhost proxy. That preserved the existing Claude Discord plugin and transcript, but exposed two architectural facts:
1. Harness identity, channel entitlement, provider credentials, and inference transport are separate concerns.
2. A generic AI gateway can improve provider routing, budgets, and observability, but must not become Mosaic's identity, authorization, tenant, or orchestration boundary.
The Tess qualification report also found that current provider rebinding is not identity-continuous failover. Mosaic still needs a logical agent identity, durable connector lease/fencing, canonical handoff/checkpoint, exactly-once receipts, concrete harness adapters, and cross-harness rollback E2E.
## Decision
Mosaic MAY support LiteLLM, Bifrost, the purpose-built Claude/Codex proxy, or future gateways as optional egress implementations behind `IProviderAdapter` / `AgentRuntimeProvider`.
Mosaic Gateway remains authoritative for:
- authenticated actor and tenant identity;
- logical agent identity and connector binding;
- authorization, approval, and policy;
- lease epoch and stale-holder fencing;
- audit correlation and redaction;
- canonical handoff/checkpoint state;
- idempotency and side-effect receipts.
An egress gateway MUST NOT:
- receive channel ingress directly;
- authorize tools or connector ownership;
- define Mosaic tenant or agent identity;
- persist raw Mosaic handoffs or channel credentials;
- bypass adapter capability negotiation;
- silently fail over when policy, lease, or provider health is uncertain.
Allowed topology:
```text
Discord / Matrix / CLI / web
Mosaic Gateway: identity, authz, lease/fence, approvals, audit
IProviderAdapter / AgentRuntimeProvider
optional egress gateway
upstream provider or subscription-backed OAuth session
```
## Candidate assessment
### Purpose-built `raine/claude-code-proxy`
**Disposition:** Approved only for the verified emergency localhost bridge.
Strengths:
- explicit Codex device OAuth flow;
- small operational surface;
- Anthropic Messages translation suitable for Claude Code;
- model and reasoning-effort enforcement;
- straightforward loopback systemd supervision and rollback.
Constraints:
- not a Mosaic multi-tenant control plane;
- Claude built-in channels still depend on Claude subscription entitlement and feature lookup;
- model aliases can obscure the upstream model unless proxy policy/logs are treated as evidence;
- no replacement for connector leasing, canonical handoff, or exactly-once effects.
### LiteLLM
**Disposition:** Candidate for a formal adapter-only prototype and terms/security review.
Current documentation states that ChatGPT subscription access is available through an OAuth device-code flow. LiteLLM also provides broad provider routing, virtual keys, budgets, observability, and OpenAI/Anthropic-compatible surfaces.
Required prototype gates:
- verify the exact ChatGPT subscription OAuth flow and supported models against current provider terms;
- document token location, encryption, revocation, refresh, scope, and incident response;
- prove tenant isolation and prevent virtual keys from becoming Mosaic principals;
- verify streaming, tool calls, reasoning controls, cancellation, and idempotency metadata;
- fail closed instead of selecting an unhealthy provider merely to return a result;
- demonstrate that Mosaic audit correlation survives gateway retries/failover;
- keep channel ingress and connector credentials outside LiteLLM.
Source references:
- [LiteLLM ChatGPT subscription provider](https://docs.litellm.ai/docs/providers/chatgpt)
- [LiteLLM providers](https://docs.litellm.ai/docs/providers)
### Bifrost
**Disposition:** Candidate for governance/routing research; subscription OAuth compatibility unverified.
Useful concepts include virtual keys, budgets, rate limits, weighted load balancing, and automatic provider failover. Those features may inform Mosaic egress policy, but Bifrost virtual keys are downstream credentials—not Mosaic actors or tenants.
Required prototype gates:
- verify Codex/ChatGPT subscription OAuth rather than assuming API-key compatibility;
- map budgets and virtual keys to server-derived Mosaic tenants without duplicating authority;
- prove failover does not violate connector lease, approval, or exactly-once semantics;
- ensure request/response logs are redacted before persistence;
- disable or constrain automatic failover when policy or side-effect state is ambiguous.
Source references:
- [Bifrost overview](https://docs.getbifrost.ai/overview)
- [Bifrost repository](https://github.com/maximhq/bifrost)
### `teremterem/claude-code-gpt-5-codex`
**Disposition:** Not selected as the emergency implementation; useful as a historical LiteLLM recipe.
The reviewed repository uses `OPENAI_API_KEY`, tells previously authenticated Claude users to log out, and documents a Claude Web Search schema incompatibility. Logging Claude out conflicts with the channel-entitlement requirement observed in the live Mos cutover. The repository therefore does not, as provided, satisfy subscription-OAuth plus built-in-channel continuity.
Source references:
- [Repository](https://github.com/teremterem/claude-code-gpt-5-codex)
- [Environment template](https://github.com/teremterem/claude-code-gpt-5-codex/blob/main/.env.template)
## Security consequences
- Subscription OAuth grants are high-value credentials and require the same lifecycle controls as service credentials.
- Downstream virtual keys reduce provider-key exposure but do not establish user, tenant, or agent authority.
- Automatic retry/failover can duplicate tool or external side effects unless Mosaic owns operation IDs and receipts.
- Gateway telemetry can contain prompts, tool schemas, and model output; redaction and retention policy must apply before persistence.
- A localhost unauthenticated translation endpoint must remain loopback-only and process-isolated.
## Acceptance before production use
1. Threat model and provider-terms review approved.
2. Credential lifecycle and revocation drill documented and exercised.
3. Adapter contract tests pass for streaming, tools, cancellation, reasoning policy, errors, and audit correlation.
4. Tenant-bound authorization remains entirely in Mosaic Gateway.
5. Failure injection proves no duplicate side effects across retries or provider failover.
6. Rollback to the prior provider path is exercised.
7. Independent code and security reviews approve the exact deployed revision.
## Follow-up
- #754 owns cross-harness logical identity, checkpoint, receipt, adapter, and failover work.
- #755 / PR #757 implements the first logical identity and connector lease/fencing boundary.
- A later issue should prototype LiteLLM and Bifrost behind the provider adapter after #755 is merged and independently qualified.

View File

@@ -0,0 +1,86 @@
# Fleet Configuration Management — Documentation IA Acceptance Checklist
**Issue:** #758 · **Scope:** M0 documentation gate for the local fleet declarative-configuration program.
This checklist is an acceptance contract for documentation and examples. It does not authorize
schema, runtime, systemd, role, profile, or live-fleet changes. An item is complete only when its
named artifact exists, is linked from the fleet documentation entry point, and its evidence is
recorded in the M0 task/PR.
## M0 baseline acceptance
- [ ] `docs/PRD.md` states the roster as desired-state SSOT; generated environment, systemd,
tmux, and heartbeat artifacts as non-authoritative projections; and fail-closed handling of
unsupported or quarantined legacy input.
- [ ] `docs/PRD.md` defines the required classes and authority boundary: `validator` certifies but
does not merge; `merge-gate` remains sole approve-to-land/merge authority; `team-leader`
capacity is lease-bounded; `interaction` is request/status only; instance names such as Tess
and Ultron remain configurable.
- [ ] `docs/PRD.md` defines local lifecycle semantics for `enabled`, persisted desired state, and
observed state, including stopped-state preservation through migration, apply, and reboot.
- [ ] `docs/PRD.md` defines the generated-env/local-override boundary, explicitly denies arbitrary
command overrides in M1M5, and requires key-name/hash-only quarantine diagnostics.
- [ ] `docs/PRD.md` identifies the M1M5 local-tmux scope and excludes remote reconciliation,
connector mutation, secret references, arbitrary commands/channels, gateway convergence, and
UI configuration storage.
- [ ] `docs/TASKS.md` contains the complete M0M5 one-card/one-PR dependency DAG for #758 with
agent tier, branch, dependency, estimate, and evidence expectations.
- [ ] `docs/fleet/LEGACY-EXAMPLE-PROFILE-DISPOSITION-INVENTORY.md` classifies every current shipped
fleet example, profile, and service preset before M1 implementation starts.
## Required documentation IA for M1M5
| Path | Minimum content | Delivery gate |
| ------------------------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| `docs/fleet/README.md` | Fleet configuration entry point, desired-vs-observed decision tree, link map | M5 |
| `docs/fleet/concepts/desired-vs-observed-state.md` | SSOT/projection model, drift, generation and ownership | M5 |
| `docs/fleet/concepts/identity-class-runtime.md` | Stable name, display alias, class, runtime/provider/model separation | M5 |
| `docs/fleet/concepts/role-authority-and-leases.md` | Required roles, validator/merge-gate separation, lease limits | M5 |
| `docs/fleet/concepts/generated-env-launch-chain.md` | Generated/local files, precedence, quarantine and non-shell parsing | M5 |
| `docs/fleet/reference/roster-v2.schema.json` | Executable v2 structural contract | M1 |
| `docs/fleet/reference/roster-v2-fields.md` | Every field, default, constraint, compatibility behavior and examples | M1 |
| `docs/fleet/reference/cli.md` | `config`, `agent`, lifecycle, plan/apply, JSON and exit-code contracts | M2M3 |
| `docs/fleet/reference/role-classes.md` | Canonical classes, aliases, authority matrix and instance-name rule | M1 |
| `docs/fleet/reference/lifecycle-transitions.md` | Create/start/stop/restart/apply/reboot/rollback transition table | M3 |
| `docs/fleet/reference/status-and-drift.md` | Desired/observed/managed state, orphans, revision mismatch, doctor output | M3 |
| `docs/fleet/how-to/create-update-delete-agent.md` | Safe CRUD, expected generation, dry-run and rollback | M2 |
| `docs/fleet/how-to/start-stop-restart.md` | Persisted versus one-shot lifecycle actions | M3 |
| `docs/fleet/how-to/configure-tess-interaction.md` | Configurable interaction instance; no hardcoded identity | M5 |
| `docs/fleet/how-to/configure-ultron-validator.md` | Configurable validator instance; no merge authority | M5 |
| `docs/fleet/how-to/customize-roles.md` | Existing baseline + `roles.local` resolution and validation | M1 |
| `docs/fleet/operations/reconcile-and-recover.md` | Plan/apply failure recovery, generation lock and canary rollout | M3 |
| `docs/fleet/operations/env-quarantine.md` | Legacy-key inventory, private quarantine and redaction behavior | M2 |
| `docs/fleet/operations/systemd-tmux-troubleshooting.md` | Socket ambiguity, ownership proof, systemd/tmux drift | M3 |
| `docs/fleet/operations/backup-restore.md` | Roster/projection backup and rollback boundaries | M4 |
| `docs/fleet/operations/upgrade-assets.md` | Source-vs-installed asset revision detection and safe refresh | M5 |
| `docs/fleet/migration/v1-to-v2.md` | Normative field map, observed-state preservation and rollback | M4 |
| `docs/fleet/migration/example-profile-disposition.md` | Final disposition of every shipped example/profile | M1M4 |
| `docs/fleet/migration/legacy-class-aliases.md` | Alias, unresolved-class, and retirement rules | M1 |
## PRD acceptance-criteria mapping
| PRD acceptance criterion | Owning card(s) | Required evidence |
| ------------------------------------------------------------ | ---------------------------------- | --------------------------------------------------------------------------------------- |
| `AC-FCM-01` schema, semantic validation, canonical rendering | FCM-M1-001, FCM-M1-002 | YAML/JSON positive/negative and schema/parser/resolver parity tests |
| `AC-FCM-02` deterministic plan and no-mutation check | FCM-M3-001 | Stable JSON/exit-code and desired-versus-observed fixture tests |
| `AC-FCM-03` safe generation-guarded CRUD | FCM-M2-002 | Create/update/delete idempotency, expected-generation, dry-run, and recovery tests |
| `AC-FCM-04` generated/local boundary and quarantine | FCM-M2-001 | Launch-chain, shadow, injection, redaction, and forbidden-key tests |
| `AC-FCM-05` lifecycle/reconcile/socket/drift safety | FCM-M3-001, FCM-M3-002 | Isolated systemd/tmux, stopped-state, orphan, socket, and rollback evidence |
| `AC-FCM-06` v1 migration and example/profile disposition | FCM-M4-001, FCM-M4-002, FCM-M1-003 | Preview/canary/rollback fixture plus executable disposition inventory |
| `AC-FCM-07` authority and lease boundaries | FCM-M1-002 | Role/authority/lease denial tests and resolved role contracts |
| `AC-FCM-08` documentation and final release gate | FCM-M5-001, FCM-M5-002 | Checklist closure, link/example validation, reviews, certificate, and terminal-green CI |
## Cross-cutting evidence gates
- [ ] Every retained or migrated YAML/JSON example, profile, and service preset validates through the
same executable schema and shared baseline-plus-`roles.local` resolver used by the CLI.
- [ ] Every retired example/profile/service preset has a replacement link and deprecation note; no
unresolved legacy class or tool-policy alias remains silently shipped.
- [ ] Documentation examples contain no secret values, arbitrary command override, or product-hardcoded
Tess/Ultron identity.
- [ ] CLI snippets distinguish local fleet desired-state commands from the separate gateway-backed
`mosaic agent` catalog.
- [ ] Migration, quarantine, lifecycle, status, and troubleshooting documentation state that values of
legacy sensitive keys are never printed.
- [ ] M5 release review verifies links, schema/example validation, and that all checklist rows have
owner/evidence or an explicit approved deferral.

View File

@@ -0,0 +1,56 @@
# Fleet Configuration Management — Legacy Example, Profile, and Service Disposition Inventory
**Issue:** #758 · **Baseline:** `origin/main` `49e8a541` · **Status:** M0 inventory; no source
examples or profiles are changed by this document.
The v2 compiler may not silently accept an unresolved class. Before M1 exits, every shipped file
below must be either migrated and executable, retained as an explicitly versioned v1 fixture, or
retired with a replacement/deprecation note. Class resolution must use the existing
profile/persona/provision baseline-plus-`roles.local` resolver; this inventory does not create a
parallel resolver.
## Examples
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
| `framework/fleet/examples/coding.yaml` | `orchestrator`, `enhancer`, `implementer`, `reviewer` | Migrate: `implementer → code`, `reviewer → review`; retain orchestration/enhancer intent | v2 fixture validates; role aliases and authority matrix tested |
| `framework/fleet/examples/general.yaml` | `orchestrator`, `enhancer`, `worker` | Migrate only after operator chooses a concrete canonical role for `worker`; no implicit conversion | Explicit replacement class, or versioned v1 fixture/retirement note |
| `framework/fleet/examples/hybrid.yaml` | `orchestrator`, `enhancer`, `implementer`, `researcher`, `reviewer` | Migrate aliases; resolve `researcher` through existing role resolver or retain/version | Shared resolver validation; no ad-hoc class scanner |
| `framework/fleet/examples/local-canary.yaml` | `orchestrator`, `implementer`, `reviewer` | Migrate aliases; preserve its local-tmux canary purpose | v2 fixture validates and preserves safe stopped/running behavior |
| `framework/fleet/examples/minimal.yaml` | `canary` | Retire or version as v1 unless an existing canonical role contract is selected deliberately | Replacement link/deprecation note or CI-valid v1 fixture |
| `framework/fleet/examples/operator-interaction.yaml` | `operator-interaction` | Migrate alias to `interaction`; preserve instance/display name as configuration, not schema identity | v2 interaction fixture validates; no Tess literal is required |
| `framework/fleet/examples/research.yaml` | `orchestrator`, `enhancer`, `researcher`, `analyst` | Resolve `researcher`/`analyst` through baseline + `roles.local`, or version/retire | Resolver evidence and explicit disposition for each unresolved class |
## Profiles
| Shipped file | Current class evidence | M0 disposition decision | Required M1/M4 evidence |
| -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- |
| `framework/fleet/profiles/business.yaml` | `ceo`, `coo`, `cfo`, `product-manager`, `marketing-lead`, `sales-lead`, `operations-manager`, `customer-success-manager`, `code`, `review` | Retain only if every class resolves through the existing role library/`roles.local`; otherwise version/retire rather than weakening validation | Shared resolver CI result for every class; documented role source or replacement |
| `framework/fleet/profiles/marketing.yaml` | `marketing-lead`, `content-strategist`, `copywriter`, `seo-specialist`, `social-media-manager`, `brand-strategist`, `growth-marketer`, `ux-designer` | Same resolver-or-version/retire rule | Per-class resolver CI result and replacement/deprecation record if unresolved |
| `framework/fleet/profiles/personal-assistant.yaml` | `personal-assistant`, `executive-assistant`, `scheduler`, `inbox-manager`, `researcher` | Same resolver-or-version/retire rule | Per-class resolver CI result; do not infer `interaction` equivalence |
| `framework/fleet/profiles/research.yaml` | `lead-researcher`, `researcher`, `data-analyst`, `data-scientist`, `market-analyst`, `documentation`, `review` | Same resolver-or-version/retire rule | Per-class resolver CI result and explicit compatibility posture |
| `framework/fleet/profiles/software-delivery.yaml` | `orchestrator`, `board`, `planner`, `decomposition`, `code`, `review`, `security-review`, `site-tester`, `documentation`, `merge-gate`, `rebase`, `operator`, `session-review`, `enhancer` | Retain as the governance reference; add `validator`, `team-leader`, and `interaction` only through approved role/profile work, not silent substitution | CI validates all current classes; separate fixture proves required M1 authority seats |
## Service presets
| Shipped file | Current policy evidence | M0 disposition decision | Required M1/M4 evidence |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `framework/fleet/services/operator-interaction.yaml` | Generic policy only: `runtime: pi`, `model: openai/gpt-5.6-sol`, `reasoning: high`, `tool_policy: operator-interaction`; provisioning supplies the agent name as data | Retain as a generic service policy, not a Tess identity. Migrate `tool_policy: operator-interaction` only through the approved interaction tool-policy alias/semantic resolver; do not infer a class or machine name from this file. | Service-policy fixture validates runtime/model/reasoning and alias behavior; generic provisioning proves a configured interaction instance is supplied without a hardcoded Tess name. |
## Required disposition controls
1. **No silent aliasing:** only `implementer → code`, `reviewer → review`, and
`operator-interaction → interaction` are approved deterministic aliases in this M0 baseline.
`worker`, `analyst`, `canary`, and domain-specific classes require resolver evidence or an
explicit version/retirement decision.
2. **No identity hardcoding:** Tess and Ultron are optional instance/display names. An example/profile
may demonstrate the capability but must not make a product name a required class or machine ID.
3. **No lifecycle inference from an example:** examples describe desired configuration only; migration
of an installed v1 roster separately preserves observed stopped/running state.
4. **No secret or command migration:** examples/profiles must not introduce credential values or
`MOSAIC_AGENT_COMMAND`; those legacy keys are M2 quarantine inputs, never v2 authoring fields.
5. **Service presets are included:** service policies are inventoried alongside examples/profiles.
They may express launch/tool policy, but do not create a class, a canonical agent identity, or a
second validation path.
6. **Evidence is executable:** M1/M4 CI must enumerate these exact files, validate retained/migrated
inputs through the shared resolver, and fail if a file lacks its documented disposition.

View File

@@ -0,0 +1,54 @@
# Customize Fleet Roles
Mosaic resolves persona contracts through two layers:
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
and launch-time persona injection.
## Override a baseline role
Create a readable Markdown contract under `roles.local` with the canonical filename and class marker:
```markdown
# Code — local role definition
The local code role (`class: code`) follows the operator's repository conventions.
```
Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal
is a durable local customization.
Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override
`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md).
## Add a custom class
A custom class remains supported when a readable contract exists for the exact identifier:
```markdown
# Release notes — local role definition
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
```
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
## Validation and authority boundaries
Semantic validation reads the winning contract and rejects missing, unreadable, or empty files.
Protected authority is derived from canonical class metadata in code, never from role prose. A custom
contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority.
Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization,
or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy
canonicalizes to `interaction`.
Role customization does not issue leases, store validation certificates, mutate credentials, or
change lifecycle state.

View File

@@ -0,0 +1,40 @@
# Legacy Fleet Class Aliases
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
class names and converts them to canonical classes before persona lookup:
| Legacy value | Canonical value | Migration action |
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `implementer` | `code` | Replace class and tool-policy references with `code`. |
| `reviewer` | `review` | Replace class and tool-policy references with `review`. |
| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. |
Alias support preserves existing inputs while provisioning and typed semantic output use canonical
identities. Requested and canonical class values remain separately observable during semantic
validation.
## Lookup and override behavior
Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as
`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer`
request. Customize the canonical role instead, for example `roles.local/code.md`.
The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical
role class. Tess is an example display name only.
## Unresolved and custom classes
No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`,
`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared
resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md`
row without a readable contract fails semantic validation.
Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches
fail closed.
## Retirement guidance
New configuration should emit canonical values. Existing inputs may use the three aliases during the
compatibility period, but operators should migrate class and tool-policy fields together. Do not
create new legacy-named role overrides; move their intended content to the canonical filename and
validate the roster/profile before removing the old artifact.

View File

@@ -0,0 +1,45 @@
# Fleet Role Classes and Authority
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/`
layers. A readable role contract is required; an index entry alone is not semantic success.
## Canonicalization
Only these legacy class aliases are recognized:
| Requested class | Canonical class |
| ---------------------- | --------------- |
| `implementer` | `code` |
| `reviewer` | `review` |
| `operator-interaction` | `interaction` |
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
names, not classes. `agents[].alias` is display-only and cannot grant authority.
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical
`roles.local/code.md` still overrides the baseline `roles/code.md` contract.
## Protected authority
Protected authority is immutable metadata derived only from canonical class. Role prose, instance
name, display alias, tool policy, runtime, and custom role files cannot grant it.
| Canonical class | Granted authority | Explicit limits |
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. |
| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. |
| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. |
| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. |
| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. |
| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. |
Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It
also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name
`operator-interaction` canonicalizes to `interaction`.
This mapping describes authority metadata only. Lease issuance, validation-certificate storage or
workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside
this resolver contract.

View File

@@ -0,0 +1,124 @@
# Fleet Roster v2 Structural Contract
**Status:** FCM-M1-001 local-tmux structural compiler contract. This document describes parsing,
strict structural validation, normalized in-memory representation, and deterministic rendering only.
It does not authorize role resolution, lifecycle reconciliation, mutation, migration, remote
placement, connector configuration, secret references, arbitrary commands, channels, gateway
mapping, or any live-fleet change.
The executable schema is [`roster-v2.schema.json`](./roster-v2.schema.json). The compiler exports
the same schema and its test parses this file and compares it structurally with the executable contract.
## Format and canonical shape
The compiler accepts YAML or JSON. It reads only snake_case source fields and renders canonical,
snake_case YAML. Rendering sorts runtime keys and agents by stable name. Agent names, class names,
and tool-policy names are structural identifiers; whether a class or policy resolves is a later
shared-resolver concern.
```yaml
version: 2
generation: 1
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
```
## Root fields
| Field | Required | Constraint | Meaning |
| ------------ | -------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `version` | yes | integer constant `2` | Identifies this contract. Version `1` is explicitly rejected by this compiler and remains on the existing v1 path until M4 migration. |
| `generation` | yes | positive safe integer | Desired-state generation. M2 uses it for mutation guards; M1 does not mutate it. |
| `transport` | yes | constant `tmux` | M1M5 support local tmux only. |
| `tmux` | yes | strict object | Explicit local socket and holder-session configuration. |
| `defaults` | yes | strict object | Default work directory and one supported local runtime. |
| `runtimes` | yes | non-empty object | Declared local runtime reset policy map. |
| `agents` | yes | non-empty array | Local fleet entries. Duplicate stable names are rejected. |
## Nested fields
| Path | Required | Constraint |
| ---------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------- |
| `tmux.socket_name` | yes | non-empty `[A-Za-z0-9_.-]+`; an explicit named socket prevents default-versus-named socket ambiguity |
| `tmux.holder_session` | yes | non-empty `[A-Za-z0-9_.-]+` |
| `defaults.working_directory` | yes | non-empty string |
| `defaults.runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `runtimes.<runtime>.reset_command` | yes | non-empty string; runtime key must be a supported local runtime |
| `agents[].name` | yes | unique `[A-Za-z0-9][A-Za-z0-9_.-]*` stable machine identity |
| `agents[].alias` | yes | non-empty display string |
| `agents[].class` | yes | `[a-z][a-z0-9-]*`; structural only in M1, semantic role resolution is FCM-M1-002 |
| `agents[].runtime` | yes | `claude`, `codex`, `opencode`, or `pi`; it must be declared in `runtimes` |
| `agents[].provider`, `model`, `working_directory` | yes | non-empty strings; provider/model capability resolution is a later card |
| `agents[].reasoning` | yes | `low`, `medium`, or `high` |
| `agents[].tool_policy` | yes | `[a-z][a-z0-9-]*`; structural only in M1 |
| `agents[].persistent_persona`, `reset_between_tasks` | yes | booleans |
| `agents[].lifecycle.enabled` | yes | boolean; stored now, reconciled in FCM-M3-001 |
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
## Semantic handoff
`parseRosterV2` and `normalizeRosterV2` remain synchronous and structural. After structural success,
call the asynchronous `validateRosterV2Semantics` handoff before using persona identity or authority.
That validator batches the baseline `fleet/roles/` and operator `fleet/roles.local/` scans, then
delegates every agent to the shared persona resolver.
Semantic validation:
- requires the winning role contract to be readable and non-empty; `LIBRARY.md` membership alone does
not resolve a class;
- retains `requestedClass` separately from `canonicalClass` in typed output;
- canonicalizes only `implementer` to `code`, `reviewer` to `review`, and
`operator-interaction` to `interaction`;
- canonicalizes `tool_policy` with the same exact alias table;
- rejects protected class/tool-policy mismatches in either direction, while accepting
`class: operator-interaction` with `tool_policy: operator-interaction` as canonical
`interaction`;
- derives immutable protected authority only from canonical class; and
- accepts custom baseline or `roles.local` classes without granting protected authority.
`agents[].alias` remains display-only. Tess and Ultron are instance names, never semantic classes.
Canonicalization happens before role-layer lookup, so a legacy-named override cannot redefine an
alias as separate authority. See [Role Classes and Authority](./role-classes.md) and
[Customize Fleet Roles](../how-to/customize-roles.md).
This handoff performs no filesystem, systemd, tmux, roster, credential, lease, certificate, or
lifecycle mutation.
## Fail-closed boundary
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
and wrong-type fields before producing a model. It specifically rejects remote/SSH/host/socket
per-agent fields, connector blocks, secret references, channel fields, arbitrary command fields,
and gateway fields because they are unsupported in the local-tmux M1 contract. It does not silently
ignore v1 camelCase input, version `1`, or a source that does not parse to an object.
The v2 compiler is intentionally isolated from the existing v1 loader. Existing v1 rosters and
current examples/profiles continue on their current path; FCM-M4 owns explicit inventory, preview,
migration, and rollback. FCM-M2 owns generated-file/local-override quarantine, and FCM-M3 owns
runtime lifecycle and reconciliation.

View File

@@ -0,0 +1,156 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json",
"title": "Mosaic local tmux fleet roster v2",
"type": "object",
"additionalProperties": false,
"required": ["version", "generation", "transport", "tmux", "defaults", "runtimes", "agents"],
"properties": {
"version": {
"const": 2
},
"generation": {
"type": "integer",
"minimum": 1,
"maximum": 9007199254740991
},
"transport": {
"const": "tmux"
},
"tmux": {
"type": "object",
"additionalProperties": false,
"required": ["socket_name", "holder_session"],
"properties": {
"socket_name": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
},
"holder_session": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]+$"
}
}
},
"defaults": {
"type": "object",
"additionalProperties": false,
"required": ["working_directory", "runtime"],
"properties": {
"working_directory": {
"type": "string",
"minLength": 1
},
"runtime": {
"enum": ["claude", "codex", "opencode", "pi"]
}
}
},
"runtimes": {
"type": "object",
"minProperties": 1,
"propertyNames": {
"enum": ["claude", "codex", "opencode", "pi"]
},
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["reset_command"],
"properties": {
"reset_command": {
"type": "string",
"minLength": 1
}
}
}
},
"agents": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"name",
"alias",
"class",
"runtime",
"provider",
"model",
"reasoning",
"tool_policy",
"working_directory",
"persistent_persona",
"reset_between_tasks",
"lifecycle",
"launch"
],
"properties": {
"name": {
"type": "string",
"pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]*$"
},
"alias": {
"type": "string",
"minLength": 1
},
"class": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"runtime": {
"enum": ["claude", "codex", "opencode", "pi"]
},
"provider": {
"type": "string",
"minLength": 1
},
"model": {
"type": "string",
"minLength": 1
},
"reasoning": {
"enum": ["low", "medium", "high"]
},
"tool_policy": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"working_directory": {
"type": "string",
"minLength": 1
},
"persistent_persona": {
"type": "boolean"
},
"reset_between_tasks": {
"type": "boolean"
},
"lifecycle": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "desired_state"],
"properties": {
"enabled": {
"type": "boolean"
},
"desired_state": {
"enum": ["running", "stopped"]
}
}
},
"launch": {
"type": "object",
"additionalProperties": false,
"required": ["yolo"],
"properties": {
"yolo": {
"type": "boolean"
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,108 @@
# FCM-M1-002 — Shared role resolution
- **Task:** `FCM-M1-002`
- **Issue:** `mosaicstack/stack#758`
- **Branch:** `feat/758-shared-role-resolution`
- **Starting head:** `32e75c67b094de443d37fe7d5ff8d25cdfc8b39d`
- **Role:** implementation worker; independent review and merge remain outside this worker
## Objective
Reuse the existing baseline-plus-`roles.local` persona resolver as the sole class authority for roster-v2 semantics, profile validation, provisioning, and launch/persona resolution. Add exact approved alias canonicalization, fail-closed semantic validation, immutable canonical-class authority contracts, required baseline roles, and operator documentation without implementing lifecycle, mutation, credentials, certificate workflow, or later FCM cards.
## Budget
- Soft budget: **25K tokens**.
- Strategy: inspect once, implement in small TDD units, run focused suites before the full package gate, and avoid unrelated refactors or M1-003/M2/M4 scope.
## Plan
1. Map the existing persona resolver, roster-v2 compiler, profile/provision consumers, launch resolution, role library, and focused tests.
2. Write denial/invariant tests first for aliases, canonicalization-before-override, unreadable roles, authority boundaries, policy mismatch, canonical provision output, and resolver parity.
3. Run the focused suites and record the expected red evidence.
4. Implement one shared canonical resolution and authority contract in/through `fleet-personas.ts`; delegate roster semantic validation and profile/provision paths to it.
5. Add baseline `validator`, `team-leader`, and `interaction` role contracts plus `LIBRARY.md` entries while retaining `operator-interaction` compatibility.
6. Add the required role reference, alias migration, customization guide, and roster-v2 semantic handoff documentation.
7. Run focused tests, the full `@mosaicstack/mosaic` suite, typecheck, lint, Prettier, `git diff --check`, situational verification, independent code/security review, and remediation.
8. Commit with the required co-author trailer, run the CI queue guard, push the existing branch, and create/update exactly one PR to `main` with `Refs #758`.
## TDD evidence
### Red
After installing worktree-local dependencies and building `@mosaicstack/db`, the pre-implementation
focused run collected the intended tests and failed as expected:
```text
2 test files failed; 32 tests failed; 32 tests passed
```
Expected failures named the missing `canonicalizeRoleClass`,
`authorityForCanonicalClass`, and `validateRosterV2Semantics` APIs, absent requested/canonical typed
output, and unresolved required canonical role contracts. An earlier run that failed before test
collection on an unresolved `yaml` dependency was treated as environment setup, not TDD evidence.
### Green
Focused role-resolution and affected service fixtures:
```text
6 test files passed; 109 tests passed
```
The focused set covers personas, profiles, provision, launch persona contract, roster-v2 semantics,
and the operator-interaction service fixture. The final profile tests also cover readable lead/floor
compatibility and canonical collision denial.
## Tests and gates
- Focused suites: pass, **6 files / 109 tests**.
- Full `@mosaicstack/mosaic` suite: pass, **50 files / 713 tests**. Workspace package build outputs
were prepared first because a clean worktree has no dependency `dist` entries.
- `pnpm --filter @mosaicstack/mosaic typecheck`: pass.
- `pnpm --filter @mosaicstack/mosaic lint`: pass.
- Prettier check over every changed file: pass.
- `git diff --check`: pass.
- Runtime/file-boundary evidence: real role library, profile/provision filesystem integration,
launch-time synchronous contract injection, v1 roster parser round-trip, roster-v2 semantic
filesystem checks, and operator-interaction service fixtures all pass without live mutation.
- Independent code review: **APPROVE**, no blocking or non-blocking findings; reviewed complete
tracked/untracked delta including the canonical collision guard. Residual: roster-v2 semantic
validation is an explicit async handoff with production caller wiring owned by later work.
- Independent security review: **APPROVE**, no verified authority/security findings on the final
delta.
## Risks and boundaries
- **Security-sensitive authority:** authority must derive only from canonical class, never role prose, aliases, display names, or tool-policy text.
- **Resolver divergence:** no second regex, registry, scanner, or prose parser may be introduced.
- **Alias capture:** aliases must canonicalize before baseline/`roles.local` lookup so local files cannot redefine legacy aliases as separate authority.
- **Readable persona requirement:** semantic success requires a resolved readable persona, not class-set membership.
- **Scope control:** no roster mutation, lifecycle, lease issuance, certificate workflow/storage, credentials, remote reconciliation, provision-v2 conversion, or shipped-example disposition execution.
- **Coordination:** `docs/TASKS.md` is read-only and remains orchestrator-owned.
## Acceptance-evidence mapping
| Requirement / criterion | Verification evidence |
| --- | --- |
| `FCM-REQ-02` shared semantic resolver | Async/sync resolver parity; roster-v2 delegates batched scans and resolution; profiles/provision and launch reuse `fleet-personas.ts`; no second scanner or class-marker regex added. |
| `FCM-REQ-07` canonical classes and authority boundaries | Exact alias and non-alias tests; immutable authority invariant tests; all required canonical contracts resolve through the real role library. |
| `AC-FCM-01` structural + semantic roster validation | Synchronous parser/normalizer tests remain intact; async semantic tests cover aliases, custom roles, unreadable/unresolved roles, `LIBRARY`-only rejection, and bidirectional protected policy mismatch. |
| `AC-FCM-07` protected authority invariants | Denial tests prove merge-gate-only merge, validator certificate-only, orchestrator/team-leader/interaction limits, no implicit custom-role authority, and canonical tool-policy matching. |
## Documentation
- `docs/fleet/reference/role-classes.md`
- `docs/fleet/migration/legacy-class-aliases.md`
- `docs/fleet/how-to/customize-roles.md`
- `docs/fleet/reference/roster-v2-fields.md` semantic handoff
- Baseline role contracts and `LIBRARY.md` rows for `validator`, `team-leader`, and `interaction`
## Residual risks
- Provisioning remains intentionally v1 and does not emit `reports_to`; canonical topology is retained
in its typed seat/summary path only, matching the existing v1 parser boundary.
- Alias support remains for compatibility; new configuration should emit canonical identities.
- This card defines authority metadata and validation only. Enforcement workflows for leases,
certificates, lifecycle, and mutation remain owned by later FCM cards.

View File

@@ -0,0 +1,238 @@
# Tess / Option 2 runtime-portability qualification — 2026-07-14
**Issue context:** #706#711 and runtime-neutral Mos follow-up #754
**Qualified revision:** `d0771835542d` (`origin/main` at review time)
**Reviewer/runtime:** Independent Pi lane requested as `openai-codex/gpt-5.6-sol:high`
**Runtime resolution note:** Mosaic warned that `gpt-5.6-sol` was not present in the provider model catalog and proceeded with it as a custom model ID. This warning was part of the original qualification log and is material provenance; downstream claims must not treat catalog recognition as verified.
**Verdict:** REQUEST CHANGES
**Evidence type:** Point-in-time qualification; later commits and PR #757 must be reviewed separately
## Purpose and provenance
This report preserves the complete independent qualification that was previously available only in `/tmp/tess-option2-qualification.log`. It distinguishes passing component tests from the missing operational proof required for identity-continuous Mos failover.
No credential values, OAuth tokens, Discord tokens, device codes, or auth-file contents are included. Commands and results are retained so another environment can reproduce or challenge the findings.
---
# 1. Verdict
## **REQUEST CHANGES**
The current Option 2 implementation is a useful portability foundation, but it is **not qualified against AC-TESS-01..11** and is not equivalent to true same-Mos-identity failover.
Primary blockers:
1. **AC-TESS-01/02:** The required `mosaic tess` command does not exist; only `mosaic interaction` is registered (`packages/mosaic/src/commands/interaction.ts:60`). The cross-surface test proves CLI enrollment followed by Discord approval/stop, not bidirectional Discord/CLI chat streaming.
2. **AC-TESS-04:** Fleet/tmux and Matrix providers are implemented as libraries but are not registered in the production gateway. `AgentModule` registers only Hermes (`apps/gateway/src/agent/agent.module.ts:34`).
3. **Mos handoff is not operational or durable:** Production uses `InMemoryInteractionCoordinationPort` (`apps/gateway/src/coord/coord.module.ts:18`), with no Mos-side consumer. Restart loses handoff ownership, idempotency, activity, and results.
4. **AC-TESS-06/10:** Restart tests are good local persistence tests, but no real connector/harness failover or exercised rollback exists. Rollback is documentation-only.
5. **AC-TESS-08:** The parity suite validates a selected shared intersection using mocked transports. Matrix is not production-wired and tmux drops the runtime message idempotency key before delivery.
6. **AC-TESS-09:** M5 qualification remains `not-started`; no live Discord, Matrix homeserver, tmux/Mos consumer, Claude Code/Pi/Codex failover, or deployment rollback was tested.
7. **PR #750 mismatch:** Its description promises send-error coverage as HTTP 400, but both gateway and TUI test use HTTP 403 (`packages/mosaic/src/tui/gateway-api.interaction-errors.test.ts:25-34`).
### AC disposition
| AC | Result | Evidence |
| --- | ----------------- | ------------------------------------------------------------------------------- |
| 01 | **Fail** | No `mosaic tess`; no bidirectional same-session chat/stream E2E |
| 02 | **Fail** | Generic CLI exists, but fleet/Matrix providers are unreachable in production |
| 03 | Pass | Pi profile/model/reasoning/effective-policy tests passed |
| 04 | **Fail** | No registered fleet provider or real Mos consumer |
| 05 | Partial | Hermes normalization/fail-closed matrix passes; live capability path is limited |
| 06 | Partial | PGlite restart/idempotency passes; no actual harness failover |
| 07 | Partial | Focused denial/replay tests pass; full M5 abuse qualification absent |
| 08 | Partial | Mocked shared-intersection parity passes; Matrix not operationally wired |
| 09 | **Fail** | Baselines/CI green, but required E2E/security/rollback qualification absent |
| 10 | **Fail** | Inventory incomplete/inconsistent; rollback not exercised |
| 11 | Pass/ledger stale | Documentation and sitemap exist; plugin/catalog ledger remains unresolved |
---
# 2. Exact test commands and results
Initial focused attempts failed before collection because this detached worktree had no dependencies:
```bash
pnpm --filter @mosaicstack/agent exec vitest run ...
```
Result: startup failure, `Cannot find module 'vitest/config'`.
Setup used:
```bash
corepack pnpm --store-dir /home/jarvis/.local/share/pnpm/store/v10 \
install --frozen-lockfile --ignore-scripts
```
Result: PASS, 1,240 packages linked.
```bash
corepack pnpm turbo run build \
--filter='@mosaicstack/gateway^...' \
--filter='@mosaicstack/mosaic^...'
```
Result: **17/17 dependency builds successful**.
### Focused suites
```bash
corepack pnpm --filter @mosaicstack/agent exec vitest run \
src/runtime-provider-parity.test.ts \
src/matrix-native-runtime-provider.test.ts \
src/tmux-fleet-runtime-provider.test.ts \
src/durable-session.test.ts \
src/hermes-runtime-provider.test.ts
```
Result: **5 files, 39/39 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
src/agent/durable-session.repository.test.ts \
src/__tests__/integration/tess-cross-surface.integration.test.ts \
src/plugin/discord-ingress.security.spec.ts \
src/coord/interaction-coordination.service.test.ts \
src/coord/interaction-coordination.routing.e2e.test.ts \
src/agent/hermes-runtime-reachability.e2e.test.ts
```
Result: **6 files, 36/36 tests passed**. PGlite close/reopen recovery passed in 504 ms.
```bash
corepack pnpm --filter @mosaicstack/mosaic exec vitest run \
src/fleet/matrix-native-runtime-transport.test.ts \
src/fleet/tess-service-profile.test.ts \
src/commands/interaction.test.ts \
src/tui/gateway-api.interaction-errors.test.ts
```
Result: **4 files, 15/15 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/coord exec vitest run \
src/__tests__/interaction-coordination.test.ts
```
Result: **1 file, 7/7 tests passed**.
```bash
corepack pnpm --filter @mosaicstack/gateway exec vitest run \
src/agent/interaction.controller.test.ts \
src/commands/command-authorization.service.spec.ts \
src/agent/__tests__/runtime-provider-registry.service.test.ts
```
Result: **3 files, 27/27 tests passed**.
Focused total: **124/124 tests passed** after dependency setup.
### Baselines
```bash
TURBO_FORCE=true corepack pnpm typecheck
```
Result: **42/42 tasks successful**.
```bash
TURBO_FORCE=true corepack pnpm lint
```
Result: **23/23 tasks successful**.
```bash
corepack pnpm format:check
```
Result: **PASS — all files matched Prettier style**.
```bash
~/.config/mosaic/tools/woodpecker/pipeline-status.sh \
-r mosaicstack/stack -n 1796
```
Result: **SUCCESS** at `d0771835542d`; all test, build, sanitization, typecheck, lint, format, and publish steps green.
No tracked files outside the pre-existing `.mosaic/orchestrator/*` launcher changes were modified.
---
# 3. Stale ledger inconsistencies
1. `docs/tess/MISSION-MANIFEST.md` still says:
- current milestone M1;
- progress 0/5;
- M2/M3/M5 not started.
2. `docs/tess/TASKS.md` says:
- M4-V failed;
- M4-W-001 and TESS-PLG-001 in progress;
- M5-V not started.
3. Provider issue state conflicts:
- #707#709 remain open although M1M3 rows are recorded done/pass.
- #710 and #711 are closed although M4-V failed and M5-V is not started.
4. M5 work was marked done despite depending on failed M4-V.
5. TESS-M3-002 says `mosaic tess` is done, but only `mosaic interaction` exists.
6. PR #750 removed stale service references from operational docs, but `docs/tess/TASKS.md` still contains `MosCoordinationService` in historical notes.
7. `docs/tess/MIGRATION-INVENTORY.md` remains an “initial inventory” with several capabilities marked `adapt`; `M5-MIGRATION-INVENTORY.md` marks grouped capabilities deferred/fail-closed. Neither supplies the complete owner/evidence matrix AC-TESS-10 requires.
8. TESS-M2-FUP-001 remains real: the unkeyed SHA-256 compatibility branch still exists at `durable-session.repository.ts:427-431`.
9. TESS-PLG-001 claims catalog registration was folded into W-001, but production evidence shows provider registration in the gateway—not a completed `packages/mosaic` plugin catalog.
---
# 4. Gap to true same-Mos-identity failover
Current code can relaunch the same roster name under another runtime and can rebind a durable interaction session to another provider/runtime ID. That is **replacement**, not identity-continuous failover.
Missing pieces:
- No canonical logical Mos identity independent of harness-native session IDs.
- No exclusive connector lease or monotonic fencing epoch; session rebinding is effectively last-write-wins.
- No stale-holder rejection preventing the old harness from continuing side effects.
- No normalized Claude Code/Pi/Codex checkpoint/import/export adapters.
- No durable Mos coordination transport or Mos consumer.
- No canonical handoff containing mission/task refs, git state, causal sequence, pending operations, capability requirements, and acknowledgements.
- No end-to-end receipt journal across connectors.
- Matrix has deterministic transaction IDs, but tmux delivery discards `RuntimeMessage.idempotencyKey`.
- No fault-injection test transferring Mos among Claude Code, Pi, and Codex and then rolling back.
---
# 5. Minimal follow-up issue decomposition
| Order | Issue | Minimum acceptance criteria |
| ----- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | **Logical identity and security fencing** | Server-derived `{tenant, logicalAgentId, connectorId, harness, leaseEpoch, scopes, expiry}`; signed/fenced execution grant; stale/forged/cross-tenant grants denied and audited; no connector credential in handoffs |
| 2 | **Durable connector lease** | PostgreSQL-backed exclusive lease with CAS, monotonic epoch, TTL/heartbeat, explicit takeover, and gateway rejection of stale holders; connectors for Claude Code, Pi, and Codex |
| 3 | **Canonical handoff/checkpoint** | Versioned, sealed schema containing canonical mission/task/git references, checkpoint digest, causal sequence, required capabilities, pending/ambiguous operation references, and source/destination acknowledgement; no raw secrets or mandatory harness transcript |
| 4 | **Exactly-once connector journal** | Durable operation IDs and receipts; idempotency propagated through every adapter; Matrix transaction mapping; tmux replaced or wrapped with receiver-side durable dedupe; ambiguous effects remain held for authorized reconciliation |
| 5 | **Cross-harness failover and rollback E2E** | Real Mos identity moves Claude Code → Pi → Codex and back; inject crashes before/after lease transfer, handoff persistence, send, and acknowledgement; stale connector fenced; no duplicate side effects; canonical state preserved; rollback evidence published |
| 6 | **Generic gateway research ADR** | Evaluate LiteLLM subscription OAuth and Bifrost concepts without adding either to core; include terms/security review, credential lifecycle, tenant mapping, budgets, failover semantics, and adapter-only prototype |
## Generic gateway placement
Allowed topology:
```text
Discord / CLI / web
Mosaic Gateway: auth, tenant scope, policy, approvals, audit
IProviderAdapter / AgentRuntimeProvider
optional LiteLLM or Bifrost egress proxy
upstream provider
```
- **LiteLLM ChatGPT subscription OAuth:** research-only, opt-in, behind an adapter. Subscription credentials require explicit terms, revocation, scope, token-storage, and audit review. They must never become Mosaic identity or core configuration.
- **Bifrost:** virtual keys are downstream proxy credentials, not Mosaic principals. Budget and failover concepts may inform Mosaic routing, but tenant policy, authorization, and audit remain in Mosaic.
- Neither product may introduce schemas into Mosaic core, receive direct calls from channels/agents, or bypass `IProviderAdapter`/`AgentRuntimeProvider`.
- Mosaic should also correct its existing “all providers unhealthy → use one anyway” fallback behavior before adopting more automatic failover (`routing-engine.service.ts:204-212`).

View File

@@ -12,19 +12,22 @@ on demand. Engineering personas have no explicit `domain:` marker (they are the
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
their intro so tooling can group them.
> This file is an index only — no code imports it. To add a persona, drop a new
> `*.md` next to the others (mirroring the existing structure) and add a row here.
> This file is an index, not an authority source. The fleet persona resolver reads
> its rows for discovery compatibility, then requires a readable `*.md` contract;
> authority is derived from canonical class metadata in code, never from this prose.
## engineering
| Persona | Purpose |
| --------------- | ------------------------------------------------------------------------------ |
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
| team-leader | Coordinates only orchestrator-leased capacity for one bounded project |
| 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 |
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
| code | Primary executor — one card, one branch, one PR to green CI |
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
| validator | Independent final evidence certificate; never approves-to-land or merges |
| security-review | Second line of review — secrets, auth, and forbidden-path safety |
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
@@ -33,6 +36,7 @@ their intro so tooling can group them.
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
| session-review | Post-task retrospective — turns finished work into improvement signals |
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
| interaction | Operator request/status surface; routes orchestration and merge decisions |
## executive

View File

@@ -0,0 +1,16 @@
# Interaction — fleet role definition
The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic.
## Mandate
1. Receive operator requests and present observable fleet or runtime status.
2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate.
3. Report supported actions and their outcomes without claiming another role's authority.
## Boundaries
- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge.
- It does not mutate roster configuration, role authority, or credentials.
- A configured instance name such as Tess is display data, never a class or authority source.
- `operator-interaction` remains a compatibility alias for this canonical class.

View File

@@ -0,0 +1,16 @@
# Team leader — fleet role definition
The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease.
## Mandate
1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope.
2. Track delivery status and return results or blockers to the orchestrator.
3. Stop using capacity when the lease or assignment ends.
## Boundaries
- Leased capacity only; this role does not issue or expand its own lease.
- It cannot change fleet roster membership, role authority, fleet configuration, or credentials.
- It cannot approve-to-land or merge.
- It does not displace the orchestrator's topology and lease authority.

View File

@@ -0,0 +1,16 @@
# Validator — fleet role definition
The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set.
## Mandate
1. Validate acceptance evidence independently from the implementation author.
2. Issue or withhold a final validation certificate for the reviewed candidate.
3. Report missing, stale, or contradictory evidence without altering it.
## Boundaries
- **Certificate only:** the validator does not approve-to-land or merge.
- It does not replace correctness or security review.
- It does not write product code, mutate the roster, issue leases, or access credentials.
- A configured instance name such as Ultron is display data, never a class or authority source.

View File

@@ -4,10 +4,13 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
authorityForCanonicalClass,
canonicalizeRoleClass,
extractClassesFromDir,
listPersonaClasses,
personaStatus,
resolvePersona,
resolvePersonaSync,
} from './fleet-personas.js';
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
@@ -54,6 +57,122 @@ afterEach(async () => {
await rm(tmp, { recursive: true, force: true });
});
describe('FCM class canonicalization and authority', () => {
it.each([
['implementer', 'code'],
['reviewer', 'review'],
['operator-interaction', 'interaction'],
])('canonicalizes the approved alias %s to %s', (requested: string, canonical: string) => {
expect(canonicalizeRoleClass(requested)).toEqual({
requestedClass: requested,
canonicalClass: canonical,
});
});
it.each(['worker', 'analyst', 'canary', 'tess', 'ultron'])(
'does not infer an alias for %s',
(requested: string) => {
expect(canonicalizeRoleClass(requested)).toEqual({
requestedClass: requested,
canonicalClass: requested,
});
},
);
it('canonicalizes before override lookup and lets the canonical override win', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'implementer.md'),
overridePersona('implementer', 'legacy'),
'utf8',
);
await writeFile(
join(overrideDir, 'code.md'),
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
'utf8',
);
const resolved = await resolvePersona('implementer', { rolesDir, overrideDir });
expect(resolved).toMatchObject({
requestedClass: 'implementer',
canonicalClass: 'code',
klass: 'code',
layer: 'override',
});
expect(resolved?.content).toContain('CANONICAL-OVERRIDE');
expect(resolved?.content).not.toContain('legacy');
});
it('keeps sync and async resolution equivalent for aliases and canonical overrides', async () => {
await mkdir(overrideDir, { recursive: true });
await writeFile(
join(overrideDir, 'code.md'),
overridePersona('code', 'engineering', 'CANONICAL-OVERRIDE'),
'utf8',
);
const asyncResolution = await resolvePersona('implementer', { rolesDir, overrideDir });
const syncResolution = resolvePersonaSync('implementer', { rolesDir, overrideDir });
expect(syncResolution).toEqual(asyncResolution);
});
it('derives immutable protected authority only from the canonical class', () => {
expect(authorityForCanonicalClass('merge-gate')).toMatchObject({ mayMerge: true });
for (const klass of [
'validator',
'orchestrator',
'team-leader',
'interaction',
'code',
'custom-role',
]) {
expect(authorityForCanonicalClass(klass).mayMerge).toBe(false);
}
expect(authorityForCanonicalClass('validator')).toMatchObject({
mayIssueValidationCertificate: true,
mayMerge: false,
});
expect(authorityForCanonicalClass('orchestrator')).toMatchObject({
mayOrchestrate: true,
mayIssueLeases: true,
mayMerge: false,
});
expect(authorityForCanonicalClass('team-leader')).toMatchObject({
leasedCapacityOnly: true,
mayMutateRoster: false,
mayAccessCredentials: false,
mayMerge: false,
});
expect(authorityForCanonicalClass('interaction')).toMatchObject({
requestStatusOnly: true,
mayOrchestrate: false,
mayMerge: false,
});
expect(Object.isFrozen(authorityForCanonicalClass('merge-gate'))).toBe(true);
});
});
describe('required FCM role library', () => {
it.each([
'code',
'review',
'validator',
'orchestrator',
'team-leader',
'enhancer',
'interaction',
'merge-gate',
])('resolves %s through the real framework role library', async (klass: string) => {
const resolved = await resolvePersona(klass, {
rolesDir: realRolesDir,
overrideDir: join(tmp, 'none'),
});
expect(resolved).not.toBeNull();
expect(resolved?.canonicalClass).toBe(klass);
expect(resolved?.content.trim()).not.toBe('');
});
});
describe('extractClassesFromDir (shared extraction)', () => {
it('records class + domain from inline markers and degrades on missing dir', async () => {
const base = await extractClassesFromDir(rolesDir);

View File

@@ -59,6 +59,73 @@ const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm;
/** Where a resolved persona's definition came from. */
export type PersonaLayer = 'baseline' | 'override';
export const ROLE_CLASS_ALIASES = Object.freeze({
implementer: 'code',
reviewer: 'review',
'operator-interaction': 'interaction',
} as const);
export interface CanonicalRoleClass {
readonly requestedClass: string;
readonly canonicalClass: string;
}
/** Canonicalize only the three explicitly approved compatibility aliases. */
export function canonicalizeRoleClass(requestedClass: string): CanonicalRoleClass {
const requested = requestedClass.trim();
const canonical = ROLE_CLASS_ALIASES[requested as keyof typeof ROLE_CLASS_ALIASES] ?? requested;
return Object.freeze({ requestedClass: requested, canonicalClass: canonical });
}
export interface RoleAuthority {
readonly mayMerge: boolean;
readonly mayIssueValidationCertificate: boolean;
readonly mayOrchestrate: boolean;
readonly mayManageTopology: boolean;
readonly mayIssueLeases: boolean;
readonly leasedCapacityOnly: boolean;
readonly requestStatusOnly: boolean;
readonly mayMutateRoster: boolean;
readonly mayMutateConfiguration: boolean;
readonly mayAccessCredentials: boolean;
}
const NO_PROTECTED_AUTHORITY: RoleAuthority = Object.freeze({
mayMerge: false,
mayIssueValidationCertificate: false,
mayOrchestrate: false,
mayManageTopology: false,
mayIssueLeases: false,
leasedCapacityOnly: false,
requestStatusOnly: false,
mayMutateRoster: false,
mayMutateConfiguration: false,
mayAccessCredentials: false,
});
/** Immutable authority contracts keyed only by canonical class identity. */
export const ROLE_AUTHORITY_BY_CANONICAL_CLASS: Readonly<Record<string, RoleAuthority>> =
Object.freeze({
'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }),
validator: Object.freeze({
...NO_PROTECTED_AUTHORITY,
mayIssueValidationCertificate: true,
}),
orchestrator: Object.freeze({
...NO_PROTECTED_AUTHORITY,
mayOrchestrate: true,
mayManageTopology: true,
mayIssueLeases: true,
}),
'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }),
interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }),
});
/** Return protected authority for an already-canonical class; custom classes get none. */
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
return ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass] ?? NO_PROTECTED_AUTHORITY;
}
/** One discovered persona file (a single role contract on disk). */
export interface PersonaFile {
klass: string;
@@ -210,7 +277,8 @@ export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<st
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
export interface PersonaResolution {
export interface PersonaResolution extends CanonicalRoleClass {
/** Compatibility name for the canonical class. */
klass: string;
layer: PersonaLayer;
/** The file the resolved persona was read from (override wins). */
@@ -245,9 +313,11 @@ export async function resolvePersona(
* is identical to {@link resolvePersona}: override layer wins, then baseline.
*/
export async function resolvePersonaFrom(
klass: string,
requestedClass: string,
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
): Promise<PersonaResolution | null> {
const { requestedClass: requested, canonicalClass: klass } =
canonicalizeRoleClass(requestedClass);
const { rolesDir, overrideDir, base, over } = layers;
const fromLayer = async (
@@ -264,14 +334,30 @@ export async function resolvePersonaFrom(
try {
const content = await readFile(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
return {
requestedClass: requested,
canonicalClass: klass,
klass,
layer,
file: byName,
content,
...(dm?.[1] ? { domain: dm[1] } : {}),
};
} catch {
return null;
}
}
try {
const content = await readFile(pf.file, 'utf8');
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
return {
requestedClass: requested,
canonicalClass: klass,
klass,
layer,
file: pf.file,
content,
...(pf.domain ? { domain: pf.domain } : {}),
};
} catch {
return null;
}
@@ -292,9 +378,11 @@ export async function resolvePersonaFrom(
* one module so the launch-time and command-time resolutions never diverge.
*/
export function resolvePersonaSync(
klass: string,
requestedClass: string,
opts: PersonaDirs = {},
): PersonaResolution | null {
const { requestedClass: requested, canonicalClass: klass } =
canonicalizeRoleClass(requestedClass);
const { rolesDir, overrideDir } = resolveDirs(opts);
const base = extractClassesFromDirSync(rolesDir);
const over = extractClassesFromDirSync(overrideDir);
@@ -312,14 +400,30 @@ export function resolvePersonaSync(
try {
const content = readFileSync(byName, 'utf8');
const dm = DOMAIN_MARKER.exec(content);
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
return {
requestedClass: requested,
canonicalClass: klass,
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 } : {}) };
return {
requestedClass: requested,
canonicalClass: klass,
klass,
layer,
file: pf.file,
content,
...(pf.domain ? { domain: pf.domain } : {}),
};
} catch {
return null;
}

View File

@@ -203,6 +203,91 @@ describe('loadProfiles with a temp override dir', () => {
await rm(dir, { recursive: true, force: true });
});
it('canonicalizes approved aliases across lead, floor, roster, and topology', async () => {
await writeFile(
join(dir, 'aliases.yaml'),
[
'id: aliases',
'title: Aliases',
'description: legacy class compatibility',
'lead: operator-interaction',
'floor: [operator-interaction]',
'roster:',
' - class: operator-interaction',
' - class: implementer',
' reports_to: operator-interaction',
' - class: reviewer',
' reports_to: operator-interaction',
'',
].join('\n'),
);
const profile = await loadProfile('aliases', {
profilesDir: dir,
rolesDir,
overrideDir: join(dir, 'roles.local'),
});
expect(profile.lead).toBe('interaction');
expect(profile.floor).toEqual(['interaction']);
expect(profile.roster).toEqual([
{ class: 'interaction', multiplicity: 1 },
{ class: 'code', reportsTo: 'interaction', multiplicity: 1 },
{ class: 'review', reportsTo: 'interaction', multiplicity: 1 },
]);
});
it('rejects roster entries that collapse to the same canonical class', async () => {
await writeFile(
join(dir, 'alias-collision.yaml'),
[
'id: alias-collision',
'title: Alias collision',
'description: ambiguous canonical topology',
'lead: orchestrator',
'floor: [orchestrator]',
'roster:',
' - class: orchestrator',
' - class: code',
' reports_to: orchestrator',
' - class: implementer',
' reports_to: orchestrator',
'',
].join('\n'),
);
await expect(
loadProfile('alias-collision', {
profilesDir: dir,
rolesDir,
overrideDir: join(dir, 'roles.local'),
}),
).rejects.toThrow(/duplicate classes after canonicalization/);
});
it('allows a readable lead or floor persona that is not itself a roster seat', async () => {
await writeFile(
join(dir, 'external-topology.yaml'),
[
'id: external-topology',
'title: External topology',
'description: structural compatibility',
'lead: orchestrator',
'floor: [enhancer]',
'roster:',
' - class: code',
'',
].join('\n'),
);
const profile = await loadProfile('external-topology', {
profilesDir: dir,
rolesDir,
overrideDir: join(dir, 'roles.local'),
});
expect(profile.lead).toBe('orchestrator');
expect(profile.floor).toEqual(['enhancer']);
});
it('throws when a profile references an unknown class (validated against real roles)', async () => {
await writeFile(
join(dir, 'bad.yaml'),

View File

@@ -29,6 +29,7 @@ import {
defaultOverrideDir,
extractClassesFromDir,
listPersonaClasses as listOverrideAwarePersonaClasses,
resolvePersonaFrom,
} from './fleet-personas.js';
function defaultMosaicHome(): string {
@@ -199,6 +200,61 @@ export function validateProfile(profile: FleetProfile, validClasses: Set<string>
return problems;
}
export async function resolveProfilePersonas(
profile: FleetProfile,
rolesDir: string,
overrideDir: string,
): Promise<FleetProfile> {
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const requestedClasses = new Set<string>([
profile.lead,
...profile.floor,
...profile.roster.flatMap((entry: ProfileRosterEntry): string[] =>
entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class],
),
]);
const canonicalByRequested = new Map<string, string>();
for (const requestedClass of requestedClasses) {
const resolved = await resolvePersonaFrom(requestedClass, {
rolesDir,
overrideDir,
base,
over,
});
if (!resolved || resolved.content.trim() === '') {
throw new Error(`persona class "${requestedClass}" does not resolve to a readable persona`);
}
canonicalByRequested.set(requestedClass, resolved.canonicalClass);
}
const canonical = (requestedClass: string): string =>
canonicalByRequested.get(requestedClass) ?? requestedClass;
const roster = profile.roster.map(
(entry: ProfileRosterEntry): ProfileRosterEntry => ({
class: canonical(entry.class),
multiplicity: entry.multiplicity,
...(entry.reportsTo ? { reportsTo: canonical(entry.reportsTo) } : {}),
}),
);
const canonicalRosterClasses = roster.map((entry: ProfileRosterEntry): string => entry.class);
if (new Set(canonicalRosterClasses).size !== canonicalRosterClasses.length) {
throw new Error('profile roster contains duplicate classes after canonicalization');
}
const resolvedProfile: FleetProfile = {
...profile,
lead: canonical(profile.lead),
floor: profile.floor.map(canonical),
roster,
};
const problems = validateProfile(resolvedProfile, new Set(canonicalByRequested.values()));
if (problems.length > 0) {
throw new Error(problems.join('\n - '));
}
return resolvedProfile;
}
export interface LoadProfilesOptions {
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
profilesDir?: string;
@@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
}
files.sort();
// Override-aware: a profile may reference a user-customized or user-ADDED
// persona living in the roles.local/ layer (H4), so validate against the
// baseline ⊕ override union, not the baseline alone.
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
// Validation resolves each reference to an actually readable winning persona;
// LIBRARY membership alone is not semantic success.
const profiles: FleetProfile[] = [];
const seen = new Map<string, string>();
@@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
}
seen.set(profile.id, file);
const problems = validateProfile(profile, validClasses);
if (problems.length > 0) {
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`);
let resolvedProfile: FleetProfile;
try {
resolvedProfile = await resolveProfilePersonas(profile, rolesDir, overrideDir);
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(`Profile ${file} is invalid:\n - ${detail}`);
}
profiles.push(profile);
profiles.push(resolvedProfile);
}
return profiles;
}

View File

@@ -172,6 +172,46 @@ describe('override-aware persona validation', () => {
expect(result.summary).toContain('persona=override');
});
it('canonicalizes aliased profile classes and topology identities before emission', async () => {
const overrideDir = join(dir, 'roles.local');
const customProfilesDir = join(dir, 'profiles');
await mkdir(overrideDir, { recursive: true });
await mkdir(customProfilesDir, { recursive: true });
await writeFile(
join(customProfilesDir, 'aliases.yaml'),
[
'id: aliases',
'title: Aliases',
'description: legacy aliases',
'lead: operator-interaction',
'floor: [operator-interaction]',
'roster:',
' - class: operator-interaction',
' - class: implementer',
' reports_to: operator-interaction',
' - class: reviewer',
' reports_to: operator-interaction',
'',
].join('\n'),
);
const result = await runProvision('aliases', {
mosaicHome: dir,
profilesDir: customProfilesDir,
rolesDir,
overrideDir,
full: true,
});
expect(result.yaml).toContain('name: interaction');
expect(result.yaml).toContain('class: interaction');
expect(result.yaml).toContain('name: code');
expect(result.yaml).toContain('class: code');
expect(result.yaml).toContain('name: review');
expect(result.yaml).toContain('class: review');
expect(result.yaml).not.toContain('class: implementer');
expect(result.summary).toContain('reports_to=interaction');
});
it('FAILS with a clear message when a profile references a bogus class', async () => {
const customProfilesDir = join(dir, 'profiles');
await mkdir(customProfilesDir, { recursive: true });

View File

@@ -26,12 +26,11 @@ import type { Command } from 'commander';
import YAML from 'yaml';
import {
loadProfile,
validateProfile,
type FleetProfile,
type ProfileRosterEntry,
defaultProfilesDir,
defaultRolesDir,
listPersonaClassesWithOverrides,
resolveProfilePersonas,
} from './fleet-profiles.js';
import {
defaultOverrideDir,
@@ -201,18 +200,35 @@ export async function generateRoster(
);
}
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
for (const name of seatNames(entry)) {
const canonicalEntry: ProfileRosterEntry = {
class: resolved.canonicalClass,
multiplicity: entry.multiplicity,
...(entry.reportsTo
? {
reportsTo:
(
await resolvePersonaFrom(entry.reportsTo, {
rolesDir,
overrideDir,
base,
over,
})
)?.canonicalClass ?? entry.reportsTo,
}
: {}),
};
const runtimeChoice = resolveSeatRuntime(resolved.canonicalClass, isFloor, isLead);
for (const name of seatNames(canonicalEntry)) {
const seat: GeneratedSeat = {
name,
className: entry.class,
className: resolved.canonicalClass,
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;
if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
seats.push(seat);
}
}
@@ -279,13 +295,7 @@ export async function validateProfileForProvision(
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 - ')}`,
);
}
await resolveProfilePersonas(profile, rolesDir, overrideDir);
}
// ---------------------------------------------------------------------------

View File

@@ -77,12 +77,25 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
});
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)');
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
const block = readPersonaContractBlock(home, 'mascot');
expect(block).toContain('# Persona Contract (mascot)');
expect(block).toContain('CUSTOM-ROLE');
});
it('canonicalizes an approved alias before launch-time override lookup', () => {
seedBaseline(home, 'code', '# Code\n\n(`class: code`)\n\nCANONICAL-CODE.\n');
seedOverride(
home,
'implementer',
'# Legacy implementer\n\n(`class: implementer`)\n\nLEGACY-OVERRIDE.\n',
);
const block = readPersonaContractBlock(home, 'implementer');
expect(block).toContain('# Persona Contract (code)');
expect(block).toContain('CANONICAL-CODE');
expect(block).not.toContain('LEGACY-OVERRIDE');
});
it('no-ops (empty string) when the class is undefined', () => {
seedBaseline(home, 'coder', BASELINE_CODER);
expect(readPersonaContractBlock(home, undefined)).toBe('');

View File

@@ -0,0 +1,372 @@
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
ROSTER_V2_JSON_SCHEMA,
RosterV2ValidationError,
parseRosterV2,
renderRosterV2Yaml,
validateRosterV2Semantics,
} from './roster-v2.js';
const validRoster = `
version: 2
generation: 7
transport: tmux
tmux:
socket_name: mosaic-fleet
holder_session: _holder
defaults:
working_directory: ~/src
runtime: pi
runtimes:
pi:
reset_command: /new
agents:
- name: coder0
alias: Coder 0
class: code
runtime: pi
provider: openai
model: gpt-5.6-sol
reasoning: high
tool_policy: code
working_directory: ~/src
persistent_persona: false
reset_between_tasks: true
lifecycle:
enabled: true
desired_state: stopped
launch:
yolo: true
`;
let semanticTmp: string | undefined;
afterEach(async (): Promise<void> => {
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
semanticTmp = undefined;
});
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
const rolesDir = join(semanticTmp, 'roles');
const overrideDir = join(semanticTmp, 'roles.local');
await mkdir(rolesDir, { recursive: true });
await mkdir(overrideDir, { recursive: true });
for (const klass of [
'code',
'review',
'interaction',
'orchestrator',
'merge-gate',
'validator',
'team-leader',
]) {
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
}
return { rolesDir, overrideDir };
}
function rosterWithClass(klass: string, toolPolicy = klass): string {
return validRoster
.replace('class: code', `class: ${klass}`)
.replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
}
describe('roster v2 semantic validation', (): void => {
it.each([
['implementer', 'code'],
['reviewer', 'review'],
['operator-interaction', 'interaction'],
])(
'canonicalizes requested alias %s while retaining requested and canonical class',
async (requested: string, canonical: string) => {
const dirs = await semanticDirs();
const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
const validated = await validateRosterV2Semantics(roster, dirs);
expect(validated.agents[0]).toMatchObject({
requestedClass: requested,
canonicalClass: canonical,
canonicalToolPolicy: canonical,
});
},
);
it.each(['worker', 'analyst', 'canary'])(
'rejects %s when no genuine custom role exists',
async (klass: string) => {
const dirs = await semanticDirs();
await expect(
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
).rejects.toThrow(/unresolved|readable persona/i);
},
);
it('accepts a genuine custom roles.local class without protected authority', async () => {
const dirs = await semanticDirs();
await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
const validated = await validateRosterV2Semantics(
parseRosterV2(rosterWithClass('worker'), 'yaml'),
dirs,
);
expect(validated.agents[0]?.authority).toMatchObject({
mayMerge: false,
mayOrchestrate: false,
});
});
it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
const dirs = await semanticDirs();
await writeFile(
join(dirs.rolesDir, 'LIBRARY.md'),
'| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
'utf8',
);
await expect(
validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
).rejects.toThrow(/readable persona/i);
});
it('rejects an unreadable resolved persona', async () => {
const dirs = await semanticDirs();
const file = join(dirs.overrideDir, 'worker.md');
await writeFile(file, '# worker\n\n(`class: worker`)\n', 'utf8');
await chmod(file, 0o000);
try {
await expect(
validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs),
).rejects.toThrow(/readable persona/i);
} finally {
await chmod(file, 0o600);
}
});
it.each([
['merge-gate', 'code'],
['validator', 'merge-gate'],
['orchestrator', 'interaction'],
['team-leader', 'orchestrator'],
['interaction', 'orchestrator'],
['code', 'merge-gate'],
['worker', 'validator'],
])(
'denies protected class/tool-policy mismatch %s with %s',
async (klass: string, toolPolicy: string) => {
const dirs = await semanticDirs();
if (klass === 'worker') {
await writeFile(
join(dirs.overrideDir, 'worker.md'),
'# worker\n\n(`class: worker`)\n',
'utf8',
);
}
await expect(
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass, toolPolicy), 'yaml'), dirs),
).rejects.toThrow(/tool policy.*must match|mismatch/i);
},
);
});
describe('roster v2 structural compiler', (): void => {
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
const roster = parseRosterV2(validRoster, 'yaml');
expect(roster).toEqual({
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socketName: 'mosaic-fleet', holderSession: '_holder' },
defaults: { workingDirectory: '~/src', runtime: 'pi' },
runtimes: { pi: { resetCommand: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
className: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
toolPolicy: 'code',
workingDirectory: '~/src',
persistentPersona: false,
resetBetweenTasks: true,
lifecycle: { enabled: true, desiredState: 'stopped' },
launch: { yolo: true },
},
],
});
expect(renderRosterV2Yaml(roster)).toBe(validRoster.trimStart());
});
it('parses JSON and produces the same normalized model', (): void => {
const yaml = parseRosterV2(validRoster, 'yaml');
const json = JSON.stringify({
version: 2,
generation: 7,
transport: 'tmux',
tmux: { socket_name: 'mosaic-fleet', holder_session: '_holder' },
defaults: { working_directory: '~/src', runtime: 'pi' },
runtimes: { pi: { reset_command: '/new' } },
agents: [
{
name: 'coder0',
alias: 'Coder 0',
class: 'code',
runtime: 'pi',
provider: 'openai',
model: 'gpt-5.6-sol',
reasoning: 'high',
tool_policy: 'code',
working_directory: '~/src',
persistent_persona: false,
reset_between_tasks: true,
lifecycle: { enabled: true, desired_state: 'stopped' },
launch: { yolo: true },
},
],
});
expect(parseRosterV2(json, 'json')).toEqual(yaml);
});
it('sorts runtime and agent maps in the deterministic renderer', (): void => {
const roster = parseRosterV2(
validRoster
.replace(
'runtimes:\n pi:\n reset_command: /new',
'runtimes:\n pi:\n reset_command: /new\n codex:\n reset_command: /clear',
)
.replace(
'agents:\n - name: coder0',
'agents:\n - name: reviewer\n alias: Reviewer\n class: review\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: medium\n tool_policy: review\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
),
'yaml',
);
const rendered = renderRosterV2Yaml(roster);
expect(rendered.indexOf(' codex:')).toBeLessThan(rendered.indexOf(' pi:'));
expect(rendered.indexOf(' - name: coder0')).toBeLessThan(
rendered.indexOf(' - name: reviewer'),
);
});
it.each([
['v1 document', validRoster.replace('version: 2', 'version: 1'), /v1.*existing v1 path/i],
[
'unknown connector',
`${validRoster}\nconnector:\n kind: matrix\n`,
/unsupported field.*connector/i,
],
[
'remote host',
validRoster.replace(
'runtime: pi\n provider',
'runtime: pi\n host: remote\n provider',
),
/unsupported field.*host/i,
],
[
'secret reference',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n secret_ref: vault://x'),
/unsupported field.*secret_ref/i,
],
[
'channel override',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n channels: discord'),
/unsupported field.*channels/i,
],
[
'arbitrary command',
validRoster.replace('model: gpt-5.6-sol', 'model: gpt-5.6-sol\n command: whoami'),
/unsupported field.*command/i,
],
[
'gateway field',
`${validRoster}\ngateway:\n url: https://gateway.example\n`,
/unsupported field.*gateway/i,
],
[
'missing required field',
validRoster.replace(' model: gpt-5.6-sol\n', ''),
/model.*required/i,
],
[
'invalid type',
validRoster.replace('generation: 7', 'generation: seven'),
/generation.*integer/i,
],
[
'unsafe generation',
validRoster.replace('generation: 7', 'generation: 9007199254740992'),
/generation.*integer/i,
],
[
'duplicate names',
validRoster.replace(
' - name: coder0',
' - name: coder0\n alias: Duplicate\n class: code\n runtime: pi\n provider: openai\n model: gpt-5.6-sol\n reasoning: high\n tool_policy: code\n working_directory: ~/src\n persistent_persona: false\n reset_between_tasks: true\n lifecycle:\n enabled: true\n desired_state: stopped\n launch:\n yolo: true\n - name: coder0',
),
/duplicate agent name/i,
],
['invalid name', validRoster.replace('name: coder0', 'name: ../coder0'), /invalid agent name/i],
[
'invalid transport',
validRoster.replace('transport: tmux', 'transport: matrix'),
/transport.*tmux/i,
],
[
'invalid runtime',
validRoster.replace('runtime: pi', 'runtime: matrix'),
/runtime.*supported/i,
],
[
'invalid reasoning',
validRoster.replace('reasoning: high', 'reasoning: extreme'),
/reasoning.*low.*medium.*high/i,
],
[
'ambiguous socket',
validRoster.replace('socket_name: mosaic-fleet', 'socket_name: default/socket'),
/socket_name/i,
],
[
'agent socket override',
validRoster.replace(
'runtime: pi\n provider',
'runtime: pi\n socket: another\n provider',
),
/unsupported field.*socket/i,
],
])('rejects %s', (_name: string, source: string, expected: RegExp): void => {
expect((): void => {
parseRosterV2(source, 'yaml');
}).toThrow(expected);
});
it('rejects malformed JSON as a validation error', (): void => {
expect((): void => {
parseRosterV2('{', 'json');
}).toThrow(RosterV2ValidationError);
});
it('declares supported runtime map keys in the executable schema', (): void => {
expect(ROSTER_V2_JSON_SCHEMA).toMatchObject({
properties: {
runtimes: { propertyNames: { enum: ['claude', 'codex', 'opencode', 'pi'] } },
},
});
});
it('keeps the checked-in documentation schema structurally identical to the executable schema', async (): Promise<void> => {
const schemaPath = fileURLToPath(
new URL('../../../../docs/fleet/reference/roster-v2.schema.json', import.meta.url),
);
const documented = await readFile(schemaPath, 'utf8');
expect(JSON.parse(documented) as unknown).toEqual(ROSTER_V2_JSON_SCHEMA);
});
});

View File

@@ -0,0 +1,558 @@
import YAML from 'yaml';
import {
authorityForCanonicalClass,
canonicalizeRoleClass,
defaultOverrideDir,
defaultRolesDir,
extractClassesFromDir,
resolvePersonaFrom,
type PersonaDirs,
type PersonaResolution,
type RoleAuthority,
} from '../commands/fleet-personas.js';
export const ROSTER_V2_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
export const ROSTER_V2_DESIRED_STATES = ['running', 'stopped'] as const;
export type RosterV2RuntimeName = (typeof ROSTER_V2_SUPPORTED_RUNTIMES)[number];
export type RosterV2ReasoningLevel = (typeof ROSTER_V2_REASONING_LEVELS)[number];
export type RosterV2DesiredState = (typeof ROSTER_V2_DESIRED_STATES)[number];
export type RosterV2InputFormat = 'json' | 'yaml';
export interface FleetRosterV2Tmux {
readonly socketName: string;
readonly holderSession: string;
}
export interface FleetRosterV2Defaults {
readonly workingDirectory: string;
readonly runtime: RosterV2RuntimeName;
}
export interface FleetRosterV2Runtime {
readonly resetCommand: string;
}
export interface FleetRosterV2Lifecycle {
readonly enabled: boolean;
readonly desiredState: RosterV2DesiredState;
}
export interface FleetRosterV2Launch {
readonly yolo: boolean;
}
export interface FleetRosterV2Agent {
readonly name: string;
readonly alias: string;
readonly className: string;
readonly runtime: RosterV2RuntimeName;
readonly provider: string;
readonly model: string;
readonly reasoning: RosterV2ReasoningLevel;
readonly toolPolicy: string;
readonly workingDirectory: string;
readonly persistentPersona: boolean;
readonly resetBetweenTasks: boolean;
readonly lifecycle: FleetRosterV2Lifecycle;
readonly launch: FleetRosterV2Launch;
}
export interface FleetRosterV2 {
readonly version: 2;
readonly generation: number;
readonly transport: 'tmux';
readonly tmux: FleetRosterV2Tmux;
readonly defaults: FleetRosterV2Defaults;
readonly runtimes: Readonly<Record<string, FleetRosterV2Runtime>>;
readonly agents: readonly FleetRosterV2Agent[];
}
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
readonly requestedClass: string;
readonly canonicalClass: string;
readonly canonicalToolPolicy: string;
readonly persona: PersonaResolution;
readonly authority: RoleAuthority;
}
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
}
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
'merge-gate',
'validator',
'orchestrator',
'team-leader',
'interaction',
]);
/**
* Validate filesystem-backed roster semantics after synchronous structural parsing.
* Directory scans are batched once and every class must resolve to readable content.
*/
export async function validateRosterV2Semantics(
roster: FleetRosterV2,
opts: PersonaDirs = {},
): Promise<SemanticallyValidatedRosterV2> {
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
const [base, over] = await Promise.all([
extractClassesFromDir(rolesDir),
extractClassesFromDir(overrideDir),
]);
const agents: SemanticallyValidatedRosterV2Agent[] = [];
for (const agent of roster.agents) {
const requestedClass = agent.className;
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
const persona = await resolvePersonaFrom(requestedClass, {
rolesDir,
overrideDir,
base,
over,
});
if (!persona || persona.content.trim() === '') {
throw new RosterV2ValidationError(
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
);
}
if (
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
canonicalToolPolicy !== canonicalClass
) {
throw new RosterV2ValidationError(
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
);
}
agents.push(
Object.freeze({
...agent,
requestedClass,
canonicalClass,
canonicalToolPolicy,
persona,
authority: authorityForCanonicalClass(canonicalClass),
}),
);
}
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
}
export class RosterV2ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = 'RosterV2ValidationError';
}
}
type JsonSchema = string | number | boolean | null | JsonSchema[] | { [key: string]: JsonSchema };
/**
* Executable v2 structural contract. The checked-in documentation schema is
* structurally compared to this value in roster-v2.spec.ts.
*/
export const ROSTER_V2_JSON_SCHEMA: JsonSchema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://mosaicstack.dev/schemas/fleet/roster-v2.schema.json',
title: 'Mosaic local tmux fleet roster v2',
type: 'object',
additionalProperties: false,
required: ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'],
properties: {
version: { const: 2 },
generation: { type: 'integer', minimum: 1, maximum: Number.MAX_SAFE_INTEGER },
transport: { const: 'tmux' },
tmux: {
type: 'object',
additionalProperties: false,
required: ['socket_name', 'holder_session'],
properties: {
socket_name: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
holder_session: { type: 'string', pattern: '^[A-Za-z0-9_.-]+$' },
},
},
defaults: {
type: 'object',
additionalProperties: false,
required: ['working_directory', 'runtime'],
properties: {
working_directory: { type: 'string', minLength: 1 },
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
},
},
runtimes: {
type: 'object',
minProperties: 1,
propertyNames: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
additionalProperties: {
type: 'object',
additionalProperties: false,
required: ['reset_command'],
properties: { reset_command: { type: 'string', minLength: 1 } },
},
},
agents: {
type: 'array',
minItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: [
'name',
'alias',
'class',
'runtime',
'provider',
'model',
'reasoning',
'tool_policy',
'working_directory',
'persistent_persona',
'reset_between_tasks',
'lifecycle',
'launch',
],
properties: {
name: { type: 'string', pattern: '^[A-Za-z0-9][A-Za-z0-9_.-]*$' },
alias: { type: 'string', minLength: 1 },
class: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
runtime: { enum: [...ROSTER_V2_SUPPORTED_RUNTIMES] },
provider: { type: 'string', minLength: 1 },
model: { type: 'string', minLength: 1 },
reasoning: { enum: [...ROSTER_V2_REASONING_LEVELS] },
tool_policy: { type: 'string', pattern: '^[a-z][a-z0-9-]*$' },
working_directory: { type: 'string', minLength: 1 },
persistent_persona: { type: 'boolean' },
reset_between_tasks: { type: 'boolean' },
lifecycle: {
type: 'object',
additionalProperties: false,
required: ['enabled', 'desired_state'],
properties: {
enabled: { type: 'boolean' },
desired_state: { enum: [...ROSTER_V2_DESIRED_STATES] },
},
},
launch: {
type: 'object',
additionalProperties: false,
required: ['yolo'],
properties: { yolo: { type: 'boolean' } },
},
},
},
},
},
};
const ROOT_KEYS = ['version', 'generation', 'transport', 'tmux', 'defaults', 'runtimes', 'agents'];
const TMUX_KEYS = ['socket_name', 'holder_session'];
const DEFAULT_KEYS = ['working_directory', 'runtime'];
const RUNTIME_KEYS = ['reset_command'];
const AGENT_KEYS = [
'name',
'alias',
'class',
'runtime',
'provider',
'model',
'reasoning',
'tool_policy',
'working_directory',
'persistent_persona',
'reset_between_tasks',
'lifecycle',
'launch',
];
const LIFECYCLE_KEYS = ['enabled', 'desired_state'];
const LAUNCH_KEYS = ['yolo'];
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
const TMUX_IDENTIFIER = /^[A-Za-z0-9_.-]+$/;
const POLICY_IDENTIFIER = /^[a-z][a-z0-9-]*$/;
/** Parses YAML or JSON and compiles only the local-tmux roster v2 contract. */
export function parseRosterV2(source: string, format?: RosterV2InputFormat): FleetRosterV2 {
const parsed = parseSource(source, format);
return normalizeRosterV2(parsed);
}
/** Renders canonical snake_case YAML with sorted runtime and agent entries. */
export function renderRosterV2Yaml(roster: FleetRosterV2): string {
const normalized = normalizeRosterV2(toSourceShape(roster));
return YAML.stringify(toSourceShape(normalized));
}
function parseSource(source: string, format?: RosterV2InputFormat): unknown {
const resolvedFormat = format ?? (source.trimStart().startsWith('{') ? 'json' : 'yaml');
try {
if (resolvedFormat === 'json') return JSON.parse(source) as unknown;
return YAML.parse(source) as unknown;
} catch (error: unknown) {
const detail = error instanceof Error ? error.message : String(error);
throw new RosterV2ValidationError(`Roster v2 ${resolvedFormat} parse failed: ${detail}`);
}
}
export function normalizeRosterV2(raw: unknown): FleetRosterV2 {
const root = requiredObject(raw, 'Roster v2');
assertKnownKeys(root, 'Roster v2', ROOT_KEYS);
if (root.version === 1) {
throw new RosterV2ValidationError(
'Roster v2 compiler rejects v1 input; use the existing v1 path until migration.',
);
}
if (root.version !== 2) throw new RosterV2ValidationError('Roster v2 version must be 2.');
const generation = requiredPositiveInteger(root.generation, 'Roster v2 generation');
const transport = requiredEnum(root.transport, 'Roster v2 transport', ['tmux'] as const);
const tmux = normalizeTmux(root.tmux);
const defaults = normalizeDefaults(root.defaults);
const runtimes = normalizeRuntimes(root.runtimes);
const agents = normalizeAgents(root.agents, runtimes);
if (!runtimes[defaults.runtime]) {
throw new RosterV2ValidationError(
`Roster v2 defaults runtime "${defaults.runtime}" must be declared in runtimes.`,
);
}
return { version: 2, generation, transport, tmux, defaults, runtimes, agents };
}
function normalizeTmux(value: unknown): FleetRosterV2Tmux {
const raw = requiredObject(value, 'Roster v2 tmux');
assertKnownKeys(raw, 'Roster v2 tmux', TMUX_KEYS);
return {
socketName: requiredTmuxIdentifier(raw.socket_name, 'Roster v2 tmux socket_name'),
holderSession: requiredTmuxIdentifier(raw.holder_session, 'Roster v2 tmux holder_session'),
};
}
function normalizeDefaults(value: unknown): FleetRosterV2Defaults {
const raw = requiredObject(value, 'Roster v2 defaults');
assertKnownKeys(raw, 'Roster v2 defaults', DEFAULT_KEYS);
return {
workingDirectory: requiredString(raw.working_directory, 'Roster v2 defaults working_directory'),
runtime: requiredRuntime(raw.runtime, 'Roster v2 defaults runtime'),
};
}
function normalizeRuntimes(value: unknown): Readonly<Record<string, FleetRosterV2Runtime>> {
const raw = requiredObject(value, 'Roster v2 runtimes');
const names = Object.keys(raw);
if (names.length === 0)
throw new RosterV2ValidationError('Roster v2 runtimes must not be empty.');
const result: Record<string, FleetRosterV2Runtime> = {};
for (const name of names.sort()) {
const runtime = requiredRuntime(name, 'Roster v2 runtime name');
const config = requiredObject(raw[name], `Roster v2 runtime "${runtime}"`);
assertKnownKeys(config, `Roster v2 runtime "${runtime}"`, RUNTIME_KEYS);
result[runtime] = {
resetCommand: requiredString(
config.reset_command,
`Roster v2 runtime "${runtime}" reset_command`,
),
};
}
return result;
}
function normalizeAgents(
value: unknown,
runtimes: Readonly<Record<string, FleetRosterV2Runtime>>,
): readonly FleetRosterV2Agent[] {
if (!Array.isArray(value) || value.length === 0) {
throw new RosterV2ValidationError('Roster v2 agents must be a non-empty array.');
}
const seen = new Set<string>();
const agents = value.map((candidate: unknown, index: number): FleetRosterV2Agent => {
const raw = requiredObject(candidate, `Roster v2 agents[${index}]`);
assertKnownKeys(raw, `Roster v2 agents[${index}]`, AGENT_KEYS);
const name = requiredIdentifier(raw.name, `Roster v2 agents[${index}] name`);
if (seen.has(name))
throw new RosterV2ValidationError(`Roster v2 has duplicate agent name: ${name}.`);
seen.add(name);
const runtime = requiredRuntime(raw.runtime, `Roster v2 agent "${name}" runtime`);
if (!runtimes[runtime]) {
throw new RosterV2ValidationError(
`Roster v2 agent "${name}" runtime "${runtime}" must be declared in runtimes.`,
);
}
return {
name,
alias: requiredString(raw.alias, `Roster v2 agent "${name}" alias`),
className: requiredPolicyIdentifier(raw.class, `Roster v2 agent "${name}" class`),
runtime,
provider: requiredString(raw.provider, `Roster v2 agent "${name}" provider`),
model: requiredString(raw.model, `Roster v2 agent "${name}" model`),
reasoning: requiredEnum(
raw.reasoning,
`Roster v2 agent "${name}" reasoning`,
ROSTER_V2_REASONING_LEVELS,
),
toolPolicy: requiredPolicyIdentifier(
raw.tool_policy,
`Roster v2 agent "${name}" tool_policy`,
),
workingDirectory: requiredString(
raw.working_directory,
`Roster v2 agent "${name}" working_directory`,
),
persistentPersona: requiredBoolean(
raw.persistent_persona,
`Roster v2 agent "${name}" persistent_persona`,
),
resetBetweenTasks: requiredBoolean(
raw.reset_between_tasks,
`Roster v2 agent "${name}" reset_between_tasks`,
),
lifecycle: normalizeLifecycle(raw.lifecycle, name),
launch: normalizeLaunch(raw.launch, name),
};
});
return agents.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
);
}
function normalizeLifecycle(value: unknown, agentName: string): FleetRosterV2Lifecycle {
const raw = requiredObject(value, `Roster v2 agent "${agentName}" lifecycle`);
assertKnownKeys(raw, `Roster v2 agent "${agentName}" lifecycle`, LIFECYCLE_KEYS);
return {
enabled: requiredBoolean(raw.enabled, `Roster v2 agent "${agentName}" lifecycle enabled`),
desiredState: requiredEnum(
raw.desired_state,
`Roster v2 agent "${agentName}" lifecycle desired_state`,
ROSTER_V2_DESIRED_STATES,
),
};
}
function normalizeLaunch(value: unknown, agentName: string): FleetRosterV2Launch {
const raw = requiredObject(value, `Roster v2 agent "${agentName}" launch`);
assertKnownKeys(raw, `Roster v2 agent "${agentName}" launch`, LAUNCH_KEYS);
return { yolo: requiredBoolean(raw.yolo, `Roster v2 agent "${agentName}" launch yolo`) };
}
function requiredObject(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new RosterV2ValidationError(`${label} must be an object.`);
}
return value as Record<string, unknown>;
}
function assertKnownKeys(
value: Record<string, unknown>,
label: string,
allowedKeys: readonly string[],
): void {
const allowed = new Set(allowedKeys);
const unknown = Object.keys(value).filter((key: string): boolean => !allowed.has(key));
if (unknown.length > 0) {
throw new RosterV2ValidationError(`${label} has unsupported field(s): ${unknown.join(', ')}.`);
}
}
function requiredString(value: unknown, label: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new RosterV2ValidationError(`${label} is required and must be a non-empty string.`);
}
return value.trim();
}
function requiredIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!IDENTIFIER.test(result)) {
throw new RosterV2ValidationError(`Invalid agent name (${label}): ${result}.`);
}
return result;
}
function requiredTmuxIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!TMUX_IDENTIFIER.test(result))
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
return result;
}
function requiredPolicyIdentifier(value: unknown, label: string): string {
const result = requiredString(value, label);
if (!POLICY_IDENTIFIER.test(result))
throw new RosterV2ValidationError(`Invalid ${label}: ${result}.`);
return result;
}
function requiredPositiveInteger(value: unknown, label: string): number {
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
throw new RosterV2ValidationError(`${label} must be a positive integer.`);
}
return value;
}
function requiredBoolean(value: unknown, label: string): boolean {
if (typeof value !== 'boolean') throw new RosterV2ValidationError(`${label} must be a boolean.`);
return value;
}
function requiredRuntime(value: unknown, label: string): RosterV2RuntimeName {
return requiredEnum(value, label, ROSTER_V2_SUPPORTED_RUNTIMES);
}
function requiredEnum<T extends string>(value: unknown, label: string, allowed: readonly T[]): T {
if (typeof value !== 'string' || !allowed.includes(value as T)) {
throw new RosterV2ValidationError(
`${label} must be one of the supported values: ${allowed.join(', ')}.`,
);
}
return value as T;
}
function toSourceShape(roster: FleetRosterV2): Record<string, unknown> {
return {
version: roster.version,
generation: roster.generation,
transport: roster.transport,
tmux: { socket_name: roster.tmux.socketName, holder_session: roster.tmux.holderSession },
defaults: {
working_directory: roster.defaults.workingDirectory,
runtime: roster.defaults.runtime,
},
runtimes: Object.fromEntries(
Object.entries(roster.runtimes)
.sort(([left], [right]): number => left.localeCompare(right))
.map(([name, runtime]): [string, unknown] => [
name,
{ reset_command: runtime.resetCommand },
]),
),
agents: [...roster.agents]
.sort((left: FleetRosterV2Agent, right: FleetRosterV2Agent): number =>
left.name.localeCompare(right.name),
)
.map(
(agent: FleetRosterV2Agent): Record<string, unknown> => ({
name: agent.name,
alias: agent.alias,
class: agent.className,
runtime: agent.runtime,
provider: agent.provider,
model: agent.model,
reasoning: agent.reasoning,
tool_policy: agent.toolPolicy,
working_directory: agent.workingDirectory,
persistent_persona: agent.persistentPersona,
reset_between_tasks: agent.resetBetweenTasks,
lifecycle: {
enabled: agent.lifecycle.enabled,
desired_state: agent.lifecycle.desiredState,
},
launch: { yolo: agent.launch.yolo },
}),
),
};
}