diff --git a/docs/TASKS.md b/docs/TASKS.md index 4097373..58ea16b 100644 --- a/docs/TASKS.md +++ b/docs/TASKS.md @@ -52,20 +52,20 @@ Active workstream is **W1 — Federation v1**. Workers should: > 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 | in-progress | Publish normative PRD requirements/acceptance criteria, this M0–M5 DAG, docs-IA checklist, and legacy example/profile disposition inventory; no implementation changes | #758 | sonnet | mosaicstack/stack | `docs/758-fleet-config-management` | — | 18K | M0 exit: approved docs; every shipped example/profile/service preset classified; docs-only PR | -| FCM-M1-001 | not-started | Implement narrow local-tmux v2 roster structural contract/compiler with YAML/JSON canonicalization and schema/parser parity tests | #758 | codex | mosaicstack/stack | `feat/758-roster-v2-compiler` | FCM-M0-001 | 30K | No lifecycle, remote, connector, secret, channel, or gateway work | -| FCM-M1-002 | not-started | Reuse existing profile/persona/provision resolver for roster semantics; add canonical class/authority validation and approved aliases | #758 | codex | mosaicstack/stack | `feat/758-shared-role-resolution` | FCM-M0-001 | 25K | Validator is certificate-only; merge-gate remains 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 | +| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes | +| ---------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------------- | ----------------- | --------------------------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| FCM-M0-001 | done | Publish normative PRD requirements/acceptance criteria, this M0–M5 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 diff --git a/docs/fleet/how-to/customize-roles.md b/docs/fleet/how-to/customize-roles.md new file mode 100644 index 0000000..2123898 --- /dev/null +++ b/docs/fleet/how-to/customize-roles.md @@ -0,0 +1,54 @@ +# Customize Fleet Roles + +Mosaic resolves persona contracts through two layers: + +1. `fleet/roles/.md` — seeded baseline contract. +2. `fleet/roles.local/.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. diff --git a/docs/fleet/migration/legacy-class-aliases.md b/docs/fleet/migration/legacy-class-aliases.md new file mode 100644 index 0000000..be9bb3e --- /dev/null +++ b/docs/fleet/migration/legacy-class-aliases.md @@ -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. diff --git a/docs/fleet/reference/role-classes.md b/docs/fleet/reference/role-classes.md new file mode 100644 index 0000000..50a59ec --- /dev/null +++ b/docs/fleet/reference/role-classes.md @@ -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. diff --git a/docs/fleet/reference/roster-v2-fields.md b/docs/fleet/reference/roster-v2-fields.md index 0e92d6c..13179ae 100644 --- a/docs/fleet/reference/roster-v2-fields.md +++ b/docs/fleet/reference/roster-v2-fields.md @@ -81,6 +81,35 @@ agents: | `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, diff --git a/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md new file mode 100644 index 0000000..5c6943a --- /dev/null +++ b/docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md @@ -0,0 +1,148 @@ +# 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. + +### Post-PR fail-closed remediation + +Independent rereview found resolver fail-open edges that the original green PR head did not cover. The +remediation remained uncommitted until every finding was reproduced red-first and the same reviewer +approved the complete two-file delta. + +Final regression evidence: + +```text +persona resolver: 47/47 +focused affected suites: 6 files / 138 tests +root-container resolver suites: 86/86 +full canonical run: 42/42 Turbo tasks; Mosaic 50 files / 733 tests +``` + +DB migration, typecheck, lint, Prettier, and `git diff --check` also passed. Coverage now proves: + +- unreadable, unscannable, direct-dangling, ancestor-dangling, and literal `..` traversal override paths + fail closed across async, sync, listing, and status APIs; +- genuinely missing override directories still permit baseline fallback; +- cached missing scans are revalidated before fallback; +- marker-defined identity and domain metadata are revalidated on the second read; +- `LIBRARY.md` rows and incidental later markers cannot define, shadow, or advertise personas. + +Exact-head pipeline `1819` passed for rebased head `4d990eee…`, but the independent reviewer-of-record +returned **REQUEST CHANGES** after reproducing three additional edge failures: a canonical filename could +inherit protected authority despite a conflicting explicit first marker, cached `scanned` absence could +miss an override created before baseline fallback, and inherited plain-object names such as `constructor` +could corrupt alias/authority lookup. Merge remained held. + +Each failure was reproduced red-first in the persona suite (4 failing assertions), then remediated without +expanding card scope. Explicit first markers now own identity and filename fallback applies only to +markerless contracts; every second read rejects a newly introduced conflicting marker regardless of +cached classification; cached async resolution re-scans the override layer immediately before every +baseline fallback; alias and authority registries require own-property matches. Current uncommitted +evidence is persona **52/52**, focused affected suites **6 files / 143 tests**, and full Mosaic package +**50 files / 738 tests**, plus typecheck, lint, Prettier, and `git diff --check`. Independent +finding-specific rereview **APPROVED** the complete uncommitted three-file remediation after direct +adversarial reproduction of all three findings and the follow-up markerless TOCTOU. All post-commit +exact-head gates remain required. + +## 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. diff --git a/packages/mosaic/framework/fleet/roles/LIBRARY.md b/packages/mosaic/framework/fleet/roles/LIBRARY.md index b605909..59d2440 100644 --- a/packages/mosaic/framework/fleet/roles/LIBRARY.md +++ b/packages/mosaic/framework/fleet/roles/LIBRARY.md @@ -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 diff --git a/packages/mosaic/framework/fleet/roles/interaction.md b/packages/mosaic/framework/fleet/roles/interaction.md new file mode 100644 index 0000000..8346ee0 --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/interaction.md @@ -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. diff --git a/packages/mosaic/framework/fleet/roles/team-leader.md b/packages/mosaic/framework/fleet/roles/team-leader.md new file mode 100644 index 0000000..c4ccfcc --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/team-leader.md @@ -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. diff --git a/packages/mosaic/framework/fleet/roles/validator.md b/packages/mosaic/framework/fleet/roles/validator.md new file mode 100644 index 0000000..08a092a --- /dev/null +++ b/packages/mosaic/framework/fleet/roles/validator.md @@ -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. diff --git a/packages/mosaic/src/commands/fleet-personas.spec.ts b/packages/mosaic/src/commands/fleet-personas.spec.ts index 7421d68..2b3eadc 100644 --- a/packages/mosaic/src/commands/fleet-personas.spec.ts +++ b/packages/mosaic/src/commands/fleet-personas.spec.ts @@ -1,13 +1,18 @@ -import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { cp, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { + authorityForCanonicalClass, + canonicalizeRoleClass, extractClassesFromDir, + extractClassesFromDirSync, listPersonaClasses, personaStatus, resolvePersona, + resolvePersonaFrom, + resolvePersonaSync, } from './fleet-personas.js'; import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js'; @@ -54,13 +59,156 @@ 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', 'constructor'])( + 'does not infer an alias for %s', + (requested: string) => { + expect(canonicalizeRoleClass(requested)).toEqual({ + requestedClass: requested, + canonicalClass: requested, + }); + }, + ); + + it('resolves inherited-property class names without granting protected authority', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'constructor.md'), + overridePersona('constructor', 'custom'), + 'utf8', + ); + + const resolved = await resolvePersona('constructor', { rolesDir, overrideDir }); + expect(resolved).toMatchObject({ + requestedClass: 'constructor', + canonicalClass: 'constructor', + klass: 'constructor', + layer: 'override', + }); + expect(authorityForCanonicalClass('constructor')).toMatchObject({ + mayMerge: false, + mayIssueValidationCertificate: false, + mayOrchestrate: false, + }); + }); + + 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 () => { + it('records class + domain and distinguishes scanned from missing directories', async () => { const base = await extractClassesFromDir(rolesDir); + expect(base.scanState).toBe('scanned'); expect(base.classes.has('ceo')).toBe(true); expect(base.byClass.get('ceo')?.domain).toBe('executive'); - const missing = await extractClassesFromDir(join(tmp, 'nope')); + + const missingDir = join(tmp, 'nope'); + const missing = await extractClassesFromDir(missingDir); + expect(missing.scanState).toBe('missing'); expect(missing.classes.size).toBe(0); + expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing'); }); }); @@ -81,6 +229,287 @@ describe('resolvePersona — override wins', () => { expect(resolved?.content).toContain('BASELINE'); }); + it('does not let a LIBRARY-only row shadow a readable baseline persona', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'LIBRARY.md'), + '| Persona | Purpose |\n| --- | --- |\n| code | Index only |\n', + 'utf8', + ); + + const resolved = await resolvePersona('code', { rolesDir, overrideDir }); + expect(resolved?.layer).toBe('baseline'); + expect(resolved?.content).toContain('BASELINE'); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not let an incidental later class marker shadow a readable baseline persona', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'mascot.md'), + '# mascot\n\n(`class: mascot`)\n\nSee also (`class: code`).\n', + 'utf8', + ); + + const resolved = await resolvePersona('code', { rolesDir, overrideDir }); + expect(resolved?.layer).toBe('baseline'); + expect(resolved?.content).toContain('BASELINE'); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('mascot')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not advertise an incidental-only class through listing or status APIs', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile( + join(overrideDir, 'mascot.md'), + '# mascot\n\n(`class: mascot`)\n\nSee also (`class: phantom`).\n', + 'utf8', + ); + + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('phantom')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'phantom'), + ).toBe(false); + }); + + it('rejects a canonical filename whose explicit marker names another class', async () => { + await mkdir(overrideDir, { recursive: true }); + await writeFile(join(overrideDir, 'merge-gate.md'), overridePersona('mascot', 'fun'), 'utf8'); + await writeFile( + join(rolesDir, 'merge-gate.md'), + baselinePersona('merge-gate', 'governance'), + 'utf8', + ); + + expect(await resolvePersona('merge-gate', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('merge-gate', { rolesDir, overrideDir })).toBeNull(); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('merge-gate')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'merge-gate'), + ).toBe(false); + }); + + it('fails closed if a marker-defined override becomes unreadable after extraction', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'engineering.md'); + await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await rm(overrideFile); + await mkdir(overrideFile); + + expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); + }); + + it('fails closed if a marker-defined override changes identity after extraction', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'engineering.md'); + await writeFile(overrideFile, overridePersona('code', 'engineering'), 'utf8'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); + + expect(await resolvePersonaFrom('code', { rolesDir, overrideDir, base, over })).toBeNull(); + + const classes = await listPersonaClasses({ rolesDir, overrideDir }); + expect(classes.has('code')).toBe(true); + expect(classes.has('mascot')).toBe(true); + const status = new Map( + (await personaStatus({ rolesDir, overrideDir })).map((entry) => [entry.klass, entry]), + ); + expect(status.get('code')?.status).toBe('baseline'); + expect(status.get('mascot')?.status).toBe('custom'); + }); + + it('fails closed if a markerless cached override gains a conflicting marker', async () => { + await mkdir(overrideDir, { recursive: true }); + const overrideFile = join(overrideDir, 'merge-gate.md'); + await writeFile(overrideFile, '# markerless merge gate\n', 'utf8'); + await writeFile( + join(rolesDir, 'merge-gate.md'), + baselinePersona('merge-gate', 'governance'), + 'utf8', + ); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + await writeFile(overrideFile, overridePersona('mascot', 'fun'), 'utf8'); + + expect( + await resolvePersonaFrom('merge-gate', { rolesDir, overrideDir, base, over }), + ).toBeNull(); + }); + + it('fails closed asynchronously when the override directory cannot be scanned', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed synchronously when the override directory cannot be scanned', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed asynchronously and synchronously for a dangling override-directory symlink', async () => { + await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('fails closed asynchronously and synchronously when an override ancestor is a dangling symlink', async () => { + const danglingAncestor = join(tmp, 'dangling-ancestor'); + await symlink(join(tmp, 'missing-ancestor-target'), danglingAncestor, 'dir'); + overrideDir = join(danglingAncestor, 'nested', 'roles.local'); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('fails closed when dot-dot traversal crosses a dangling override ancestor', async () => { + const danglingAncestor = join(tmp, 'dangling-dotdot-ancestor'); + await symlink(join(tmp, 'missing-dotdot-target'), danglingAncestor, 'dir'); + overrideDir = `${danglingAncestor}/../roles.local`; + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('fails closed when relative dot-dot traversal crosses a dangling override ancestor', async () => { + const danglingAncestor = join(tmp, 'relative-dangling-ancestor'); + await symlink(join(tmp, 'missing-relative-target'), danglingAncestor, 'dir'); + overrideDir = `${relative(process.cwd(), danglingAncestor)}/../roles.local`; + expect(overrideDir.startsWith('/')).toBe(false); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('revalidates a cached missing override before baseline fallback', async () => { + const cachedOverrideDir = join(tmp, 'mutable-ancestor', 'roles.local'); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(cachedOverrideDir), + ]); + expect(over.scanState).toBe('missing'); + await symlink(join(tmp, 'missing-mutable-target'), join(tmp, 'mutable-ancestor'), 'dir'); + + expect( + await resolvePersonaFrom('code', { + rolesDir, + overrideDir: cachedOverrideDir, + base, + over, + }), + ).toBeNull(); + }); + + it('revalidates a cached scanned override before baseline fallback', async () => { + await mkdir(overrideDir, { recursive: true }); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + expect(over.scanState).toBe('scanned'); + expect(over.classes.size).toBe(0); + + await writeFile(join(overrideDir, 'engineering.md'), overridePersona('code', 'engineering')); + + const resolved = await resolvePersonaFrom('code', { + rolesDir, + overrideDir, + base, + over, + }); + expect(resolved?.layer).toBe('override'); + expect(resolved?.file).toBe(join(overrideDir, 'engineering.md')); + }); + + it('falls back to baseline asynchronously and synchronously when override directory is missing', async () => { + const asyncResolution = await resolvePersona('code', { rolesDir, overrideDir }); + const syncResolution = resolvePersonaSync('code', { rolesDir, overrideDir }); + expect(asyncResolution?.layer).toBe('baseline'); + expect(syncResolution?.layer).toBe('baseline'); + }); + + it('fails closed asynchronously when the canonical override exists but is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect(await resolvePersona('code', { rolesDir, overrideDir })).toBeNull(); + }); + + it('does not advertise a shadowed baseline whose canonical override is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'code'), + ).toBe(false); + }); + + it('does not advertise baseline personas when the override directory is unscannable', async () => { + await writeFile(overrideDir, 'not a directory', 'utf8'); + + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('does not advertise baseline personas through a dangling override-directory symlink', async () => { + await symlink(join(tmp, 'missing-override-target'), overrideDir, 'dir'); + + expect(await listPersonaClasses({ rolesDir, overrideDir })).toEqual(new Set()); + expect(await personaStatus({ rolesDir, overrideDir })).toEqual([]); + }); + + it('preserves baseline listings when the override directory is missing', async () => { + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('code')).toBe(true); + expect( + (await personaStatus({ rolesDir, overrideDir })).find(({ klass }) => klass === 'code') + ?.status, + ).toBe('baseline'); + }); + + it('does not advertise an unreadable custom override as a valid persona class or status', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'ghost.md')); + + const extracted = await extractClassesFromDir(overrideDir); + expect(extracted.fileStems.has('ghost')).toBe(true); + expect(extracted.classes.has('ghost')).toBe(false); + expect((await listPersonaClasses({ rolesDir, overrideDir })).has('ghost')).toBe(false); + expect( + (await personaStatus({ rolesDir, overrideDir })).some(({ klass }) => klass === 'ghost'), + ).toBe(false); + }); + + it('fails closed synchronously when the canonical override exists but is unreadable', async () => { + await mkdir(overrideDir, { recursive: true }); + await mkdir(join(overrideDir, 'code.md')); + + expect(resolvePersonaSync('code', { rolesDir, overrideDir })).toBeNull(); + }); + it('returns null for an unknown class', async () => { expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull(); }); diff --git a/packages/mosaic/src/commands/fleet-personas.ts b/packages/mosaic/src/commands/fleet-personas.ts index a65d5fc..c3fe3fe 100644 --- a/packages/mosaic/src/commands/fleet-personas.ts +++ b/packages/mosaic/src/commands/fleet-personas.ts @@ -25,10 +25,10 @@ * can reference a customized or user-added persona. */ -import { readFileSync, readdirSync } from 'node:fs'; -import { readFile, readdir } from 'node:fs/promises'; +import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { lstat, readFile, readdir, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { basename, join } from 'node:path'; +import { basename, isAbsolute, join, sep } from 'node:path'; import type { Command } from 'commander'; function defaultMosaicHome(): string { @@ -53,52 +53,136 @@ export function defaultOverrideDir(mosaicHome = defaultMosaicHome()): string { const CLASS_MARKER = /`?class:\s*\n?\s*([a-z][a-z0-9-]*)`?/g; /** Optional `domain: Y` marker that travels alongside the class in the prose. */ const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/; -/** LIBRARY.md persona rows: the first table cell is the persona name. */ -const LIBRARY_ROW = /^\|\s*([a-z][a-z0-9-]*)\s*\|/gm; /** Where a resolved persona's definition came from. */ export type PersonaLayer = 'baseline' | 'override'; +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 = Object.hasOwn(ROLE_CLASS_ALIASES, requested) + ? 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> = + 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 Object.hasOwn(ROLE_AUTHORITY_BY_CANONICAL_CLASS, canonicalClass) + ? ROLE_AUTHORITY_BY_CANONICAL_CLASS[canonicalClass]! + : NO_PROTECTED_AUTHORITY; +} + /** One discovered persona file (a single role contract on disk). */ export interface PersonaFile { klass: string; /** The markdown file the class was found in. */ file: string; + /** True when the first class marker, rather than filename, defined this mapping. */ + markerDefined?: boolean; domain?: string; } +export type DirScanState = 'scanned' | 'missing' | 'error'; + /** The set of persona classes a directory of role contracts defines. */ export interface DirClasses { - /** Every class name the dir contributes (markers + filenames + LIBRARY rows). */ + /** Whether the directory was scanned, absent, or present-but-unscannable. */ + scanState: DirScanState; + /** Every readable class name the directory contributes by filename or first marker. */ classes: Set; + /** Filename stems present on disk, retained even when a markdown entry is unreadable. */ + fileStems: Set; /** For classes whose file carries a marker, the file + domain that defined it. */ byClass: Map; } /** - * Scan one directory of role contracts and extract the persona classes it - * defines. THIS is the shared extraction both fleet-personas and fleet-profiles - * rely on. Sources, unioned (each needed — see module doc): - * 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline), - * 2. persona-name cells from LIBRARY.md index tables, - * 3. the role filename stem (covers marker-less alias docs like planner). + * Scan one directory of role contracts and extract readable persona identities. + * Valid identities come only from a successfully read non-LIBRARY filename and + * that file's first `class:` marker. LIBRARY rows and later prose mentions are + * index/reference data, not independently readable role contracts. * - * Missing dir / unreadable files degrade gracefully to whatever was found. - * `byClass` records the defining file+domain for marker-bearing classes so the - * resolver can map a class back to its file; filename-only and LIBRARY-only - * classes still appear in `classes` for membership checks. + * Missing directories are distinguished from present-but-unscannable paths. + * `byClass` records the first marker-defined file+domain so the resolver can map + * a class back to its contract; marker-less readable files map by filename. */ export async function extractClassesFromDir(dir: string): Promise { - const acc: DirClasses = { classes: new Set(), byClass: new Map() }; + const acc: DirClasses = { + scanState: 'scanned', + classes: new Set(), + fileStems: new Set(), + byClass: new Map(), + }; let entries: string[]; try { entries = await readdir(dir); - } catch { + } catch (error) { + acc.scanState = await classifyScanFailure(dir, error); return acc; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; + // Preserve entry presence separately from valid readable classes. Resolvers + // use it to fail closed on an explicit unreadable canonical override without + // advertising that override through class listing/status APIs. + if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md')); let text: string; try { text = await readFile(join(dir, entry), 'utf8'); @@ -117,16 +201,25 @@ export async function extractClassesFromDir(dir: string): Promise { * cannot await. Missing dir / unreadable files degrade gracefully. */ export function extractClassesFromDirSync(dir: string): DirClasses { - const acc: DirClasses = { classes: new Set(), byClass: new Map() }; + const acc: DirClasses = { + scanState: 'scanned', + classes: new Set(), + fileStems: new Set(), + byClass: new Map(), + }; let entries: string[]; try { entries = readdirSync(dir); - } catch { + } catch (error) { + acc.scanState = classifyScanFailureSync(dir, error); return acc; } for (const entry of entries) { if (!entry.endsWith('.md')) continue; + // Keep sync extraction equivalent to the async scanner, including unreadable + // filename presence kept separate from valid readable classes. + if (entry !== 'LIBRARY.md') acc.fileStems.add(basename(entry, '.md')); let text: string; try { text = readFileSync(join(dir, entry), 'utf8'); @@ -138,6 +231,74 @@ export function extractClassesFromDirSync(dir: string): DirClasses { return acc; } +async function classifyScanFailure(dir: string, error: unknown): Promise { + if (!isMissingPathError(error)) return 'error'; + return (await hasBrokenSymlinkInPath(dir)) ? 'error' : 'missing'; +} + +function classifyScanFailureSync(dir: string, error: unknown): DirScanState { + if (!isMissingPathError(error)) return 'error'; + return hasBrokenSymlinkInPathSync(dir) ? 'error' : 'missing'; +} + +function traversalPrefixes(path: string): string[] { + const absolute = isAbsolute(path) ? path : `${process.cwd()}${sep}${path}`; + const parts = absolute.split(sep); + const prefixes: string[] = []; + let current: string = sep; + for (const part of parts) { + if (!part) continue; + current = current === sep ? `${sep}${part}` : `${current}${sep}${part}`; + prefixes.push(current); + } + return prefixes; +} + +async function hasBrokenSymlinkInPath(path: string): Promise { + for (const current of traversalPrefixes(path)) { + try { + const entry = await lstat(current); + if (entry.isSymbolicLink()) { + try { + await stat(current); + } catch { + return true; + } + } + } catch (error) { + if (!isMissingPathError(error)) return true; + } + } + return false; +} + +function hasBrokenSymlinkInPathSync(path: string): boolean { + for (const current of traversalPrefixes(path)) { + try { + const entry = lstatSync(current); + if (entry.isSymbolicLink()) { + try { + statSync(current); + } catch { + return true; + } + } + } catch (error) { + if (!isMissingPathError(error)) return true; + } + } + return false; +} + +function isMissingPathError(error: unknown): boolean { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ENOENT' + ); +} + /** * Apply the class-extraction rules for ONE role file's text into `acc`. Pure * over already-read content, so the async and sync directory scanners share a @@ -146,33 +307,26 @@ export function extractClassesFromDirSync(dir: string): DirClasses { */ function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void { const { classes, byClass } = acc; - if (entry === 'LIBRARY.md') { - for (const m of text.matchAll(LIBRARY_ROW)) { - const name = m[1]; - if (name && name !== 'persona') classes.add(name); - } - return; - } - // The filename stem is itself a valid class (covers marker-less alias docs). + if (entry === 'LIBRARY.md') return; + + // An explicit first marker owns identity. Filename identity is a fallback only + // for markerless compatibility contracts such as planner.md. const stem = basename(entry, '.md'); - classes.add(stem); const domainMatch = DOMAIN_MARKER.exec(text); const domain = domainMatch?.[1]; - let markedClassForFile: string | undefined; - for (const m of text.matchAll(CLASS_MARKER)) { - const klass = m[1]; - if (!klass) continue; - classes.add(klass); - // Record the FIRST marker as the file's defining class (the prose names - // the persona's own class up top; later mentions reference siblings). - if (!markedClassForFile) { - markedClassForFile = klass; - byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) }); - } - } - // A marker-less file still maps its stem to itself (no domain known). - if (!markedClassForFile && !byClass.has(stem)) { - byClass.set(stem, { klass: stem, file: join(dir, entry) }); + const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined; + const markedClassForFile = firstMarker?.[1]; + if (markedClassForFile) { + classes.add(markedClassForFile); + byClass.set(markedClassForFile, { + klass: markedClassForFile, + file: join(dir, entry), + markerDefined: true, + ...(domain ? { domain } : {}), + }); + } else { + classes.add(stem); + if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) }); } } @@ -193,24 +347,19 @@ function resolveDirs(opts: PersonaDirs): { rolesDir: string; overrideDir: string } /** - * UNION of baseline classes and override classes. Overrides may ADD entirely new - * classes not present in the baseline, so callers (e.g. profile roster - * validation) treat a user-added persona as a real class. + * Every class with an actually readable winning contract. Discovery applies the + * same override shadow/fail-closed semantics as resolution, so callers never + * advertise a class that cannot be used. */ export async function listPersonaClasses(opts: PersonaDirs = {}): Promise> { - const { rolesDir, overrideDir } = resolveDirs(opts); - const [base, over] = await Promise.all([ - extractClassesFromDir(rolesDir), - extractClassesFromDir(overrideDir), - ]); - const union = new Set(base.classes); - for (const c of over.classes) union.add(c); - return union; + const { personas } = await collectUsablePersonas(opts); + return new Set(personas.keys()); } 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). */ @@ -219,6 +368,118 @@ export interface PersonaResolution { domain?: string; } +async function readPersonaFromLayer( + requestedClass: string, + canonicalClass: string, + dir: string, + extracted: DirClasses, + layer: PersonaLayer, +): Promise { + const pf = extracted.byClass.get(canonicalClass); + if (!pf) { + if (!extracted.classes.has(canonicalClass)) return null; + const byName = join(dir, `${canonicalClass}.md`); + try { + const content = await readFile(byName, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: byName, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } + } + try { + const content = await readFile(pf.file, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: pf.file, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } +} + +function overrideShadowsBaseline(over: DirClasses, canonicalClass: string): boolean { + return ( + over.scanState === 'error' || + over.fileStems.has(canonicalClass) || + over.byClass.has(canonicalClass) + ); +} + +function readPersonaFromLayerSync( + requestedClass: string, + canonicalClass: string, + dir: string, + extracted: DirClasses, + layer: PersonaLayer, +): PersonaResolution | null { + const pf = extracted.byClass.get(canonicalClass); + if (!pf) { + if (!extracted.classes.has(canonicalClass)) return null; + const byName = join(dir, `${canonicalClass}.md`); + try { + const content = readFileSync(byName, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: byName, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } + } + try { + const content = readFileSync(pf.file, 'utf8'); + const currentMarker = content.matchAll(CLASS_MARKER).next().value as + | RegExpExecArray + | undefined; + if (currentMarker && currentMarker[1] !== canonicalClass) return null; + const dm = DOMAIN_MARKER.exec(content); + return { + requestedClass, + canonicalClass, + klass: canonicalClass, + layer, + file: pf.file, + content, + ...(dm?.[1] ? { domain: dm[1] } : {}), + }; + } catch { + return null; + } +} + /** * Resolve a persona class to its winning definition: the override file if * roles.local/ defines that class, else the baseline. Match by inline `class:` @@ -245,42 +506,32 @@ 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 { + const { requestedClass: requested, canonicalClass: klass } = + canonicalizeRoleClass(requestedClass); const { rolesDir, overrideDir, base, over } = layers; - const fromLayer = async ( - dir: string, - extracted: DirClasses, - layer: PersonaLayer, - ): Promise => { - // Prefer the marker-defined file; fall back to the filename stem. - let pf = extracted.byClass.get(klass); - if (!pf) { - const byName = join(dir, `${klass}.md`); - if (!extracted.classes.has(klass)) return null; - // Class known only via filename/LIBRARY: read the stem file if present. - try { - const content = await readFile(byName, 'utf8'); - const dm = DOMAIN_MARKER.exec(content); - return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) }; - } catch { - return null; - } - } - try { - const content = await readFile(pf.file, 'utf8'); - return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) }; - } catch { - return null; - } - }; + const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override'); + if (override) return override; + // An observed explicit override that cannot resolve/read shadows the baseline. + if (overrideShadowsBaseline(over, klass)) return null; - return ( - (await fromLayer(overrideDir, over, 'override')) ?? - (await fromLayer(rolesDir, base, 'baseline')) + // Cached scans are only snapshots. Re-scan immediately before baseline fallback + // so a newly created canonical or marker-defined override cannot be skipped. + const currentOver = await extractClassesFromDir(overrideDir); + const currentOverride = await readPersonaFromLayer( + requested, + klass, + overrideDir, + currentOver, + 'override', ); + if (currentOverride) return currentOverride; + if (overrideShadowsBaseline(currentOver, klass)) return null; + if (currentOver.scanState === 'error') return null; + return readPersonaFromLayer(requested, klass, rolesDir, base, 'baseline'); } /** @@ -292,40 +543,55 @@ 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); - const fromLayer = ( - dir: string, - extracted: DirClasses, - layer: PersonaLayer, - ): PersonaResolution | null => { - // Prefer the marker-defined file; fall back to the filename stem. - const pf = extracted.byClass.get(klass); - if (!pf) { - if (!extracted.classes.has(klass)) return null; - const byName = join(dir, `${klass}.md`); - try { - const content = readFileSync(byName, 'utf8'); - const dm = DOMAIN_MARKER.exec(content); - return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) }; - } catch { - return null; - } - } - try { - const content = readFileSync(pf.file, 'utf8'); - return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) }; - } catch { - return null; - } - }; + const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override'); + if (override) return override; + if (overrideShadowsBaseline(over, klass)) return null; + return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline'); +} - return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline'); +interface UsablePersonaIndex { + personas: Map; + readableBaseline: Set; +} + +async function collectUsablePersonas(opts: PersonaDirs): Promise { + const { rolesDir, overrideDir } = resolveDirs(opts); + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + if (over.scanState === 'error') { + return { personas: new Map(), readableBaseline: new Set() }; + } + + const candidates = new Set([...base.classes, ...over.classes]); + const checked = await Promise.all( + [...candidates].map(async (requestedClass) => { + const { canonicalClass } = canonicalizeRoleClass(requestedClass); + const [persona, baseline] = await Promise.all([ + resolvePersonaFrom(requestedClass, { rolesDir, overrideDir, base, over }), + readPersonaFromLayer(requestedClass, canonicalClass, rolesDir, base, 'baseline'), + ]); + return { requestedClass, persona, baseline }; + }), + ); + + const personas = new Map(); + const readableBaseline = new Set(); + for (const { requestedClass, persona, baseline } of checked) { + if (persona) personas.set(requestedClass, persona); + if (baseline) readableBaseline.add(requestedClass); + } + return { personas, readableBaseline }; } export interface PersonaStatusEntry { @@ -342,24 +608,20 @@ export interface PersonaStatusEntry { * Domain is taken from the WINNING layer (override domain wins if present). */ export async function personaStatus(opts: PersonaDirs = {}): Promise { - const { rolesDir, overrideDir } = resolveDirs(opts); - const [base, over] = await Promise.all([ - extractClassesFromDir(rolesDir), - extractClassesFromDir(overrideDir), - ]); - - const all = new Set([...base.classes, ...over.classes]); - const domainOf = (extracted: DirClasses, klass: string): string | undefined => - extracted.byClass.get(klass)?.domain; - - const entries: PersonaStatusEntry[] = []; - for (const klass of all) { - const inBase = base.classes.has(klass); - const inOver = over.classes.has(klass); - const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline'; - const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass); - entries.push({ klass, status, ...(domain ? { domain } : {}) }); - } + const { personas, readableBaseline } = await collectUsablePersonas(opts); + const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => { + const status: PersonaStatus = + persona.layer === 'baseline' + ? 'baseline' + : readableBaseline.has(klass) + ? 'overridden' + : 'custom'; + return { + klass, + status, + ...(persona.domain ? { domain: persona.domain } : {}), + }; + }); entries.sort((a, b) => a.klass.localeCompare(b.klass)); return entries; } diff --git a/packages/mosaic/src/commands/fleet-profiles.spec.ts b/packages/mosaic/src/commands/fleet-profiles.spec.ts index f44322d..2173b0f 100644 --- a/packages/mosaic/src/commands/fleet-profiles.spec.ts +++ b/packages/mosaic/src/commands/fleet-profiles.spec.ts @@ -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'), diff --git a/packages/mosaic/src/commands/fleet-profiles.ts b/packages/mosaic/src/commands/fleet-profiles.ts index 3a9ecb4..96899d9 100644 --- a/packages/mosaic/src/commands/fleet-profiles.ts +++ b/packages/mosaic/src/commands/fleet-profiles.ts @@ -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 return problems; } +export async function resolveProfilePersonas( + profile: FleetProfile, + rolesDir: string, + overrideDir: string, +): Promise { + const [base, over] = await Promise.all([ + extractClassesFromDir(rolesDir), + extractClassesFromDir(overrideDir), + ]); + const requestedClasses = new Set([ + profile.lead, + ...profile.floor, + ...profile.roster.flatMap((entry: ProfileRosterEntry): string[] => + entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class], + ), + ]); + const canonicalByRequested = new Map(); + 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 /fleet/profiles. */ profilesDir?: string; @@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise(); @@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise 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; } diff --git a/packages/mosaic/src/commands/fleet-provision.spec.ts b/packages/mosaic/src/commands/fleet-provision.spec.ts index 18799b0..5b1a947 100644 --- a/packages/mosaic/src/commands/fleet-provision.spec.ts +++ b/packages/mosaic/src/commands/fleet-provision.spec.ts @@ -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 }); diff --git a/packages/mosaic/src/commands/fleet-provision.ts b/packages/mosaic/src/commands/fleet-provision.ts index b2f64a1..aeff50e 100644 --- a/packages/mosaic/src/commands/fleet-provision.ts +++ b/packages/mosaic/src/commands/fleet-provision.ts @@ -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 { 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); } // --------------------------------------------------------------------------- diff --git a/packages/mosaic/src/fleet/persona-contract.spec.ts b/packages/mosaic/src/fleet/persona-contract.spec.ts index c4c1421..bd72383 100644 --- a/packages/mosaic/src/fleet/persona-contract.spec.ts +++ b/packages/mosaic/src/fleet/persona-contract.spec.ts @@ -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(''); diff --git a/packages/mosaic/src/fleet/roster-v2.spec.ts b/packages/mosaic/src/fleet/roster-v2.spec.ts index 358aebc..1fc0b0b 100644 --- a/packages/mosaic/src/fleet/roster-v2.spec.ts +++ b/packages/mosaic/src/fleet/roster-v2.spec.ts @@ -1,11 +1,14 @@ -import { readFile } from 'node:fs/promises'; +import { 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 { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ROSTER_V2_JSON_SCHEMA, RosterV2ValidationError, parseRosterV2, renderRosterV2Yaml, + validateRosterV2Semantics, } from './roster-v2.js'; const validRoster = ` @@ -40,6 +43,127 @@ agents: yolo: true `; +let semanticTmp: string | undefined; + +afterEach(async (): Promise => { + 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(); + await mkdir(join(dirs.overrideDir, 'worker.md')); + await expect( + validateRosterV2Semantics(parseRosterV2(rosterWithClass('worker'), 'yaml'), dirs), + ).rejects.toThrow(/readable persona/i); + }); + + 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'); diff --git a/packages/mosaic/src/fleet/roster-v2.ts b/packages/mosaic/src/fleet/roster-v2.ts index 87df60c..f0056f1 100644 --- a/packages/mosaic/src/fleet/roster-v2.ts +++ b/packages/mosaic/src/fleet/roster-v2.ts @@ -1,4 +1,15 @@ 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; @@ -58,6 +69,80 @@ export interface FleetRosterV2 { 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 { + 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 { + 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);