feat(fleet): add shared role semantics (#768)
This commit was merged in pull request #768.
This commit is contained in:
@@ -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 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.
|
> the applicable acceptance evidence before merge. Issue #758 remains open until M5 closes.
|
||||||
|
|
||||||
| id | status | description | issue | agent | repo | branch | depends_on | estimate | notes |
|
| 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-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 | 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-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 | 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-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-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-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-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-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-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-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-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-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 |
|
| 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
|
## Thin-core prompt diet (#528) — feat/contract-thin-core
|
||||||
|
|
||||||
|
|||||||
54
docs/fleet/how-to/customize-roles.md
Normal file
54
docs/fleet/how-to/customize-roles.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# Customize Fleet Roles
|
||||||
|
|
||||||
|
Mosaic resolves persona contracts through two layers:
|
||||||
|
|
||||||
|
1. `fleet/roles/<canonical-class>.md` — seeded baseline contract.
|
||||||
|
2. `fleet/roles.local/<canonical-class>.md` — operator override or custom role; this layer wins.
|
||||||
|
|
||||||
|
The same shared resolver is used by profile validation, provisioning, roster-v2 semantic validation,
|
||||||
|
and launch-time persona injection.
|
||||||
|
|
||||||
|
## Override a baseline role
|
||||||
|
|
||||||
|
Create a readable Markdown contract under `roles.local` with the canonical filename and class marker:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Code — local role definition
|
||||||
|
|
||||||
|
The local code role (`class: code`) follows the operator's repository conventions.
|
||||||
|
```
|
||||||
|
|
||||||
|
Save it as `fleet/roles.local/code.md`. Do not edit generated or seeded baseline assets when the goal
|
||||||
|
is a durable local customization.
|
||||||
|
|
||||||
|
Legacy aliases canonicalize before lookup. Therefore `roles.local/implementer.md` does not override
|
||||||
|
`code`; use `roles.local/code.md`. See [Legacy Fleet Class Aliases](../migration/legacy-class-aliases.md).
|
||||||
|
|
||||||
|
## Add a custom class
|
||||||
|
|
||||||
|
A custom class remains supported when a readable contract exists for the exact identifier:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Release notes — local role definition
|
||||||
|
|
||||||
|
The release-notes role (`class: release-notes`) prepares operator-reviewed release copy.
|
||||||
|
```
|
||||||
|
|
||||||
|
Save it as `fleet/roles.local/release-notes.md`, then reference `class: release-notes` and a matching
|
||||||
|
`tool_policy: release-notes` in roster v2. Adding only a `LIBRARY.md` row is insufficient.
|
||||||
|
|
||||||
|
Names such as `worker`, `analyst`, and `canary` are not built-in aliases; they need genuine custom
|
||||||
|
contracts. `agents[].alias`, Tess, and Ultron are display names and cannot select a class.
|
||||||
|
|
||||||
|
## Validation and authority boundaries
|
||||||
|
|
||||||
|
Semantic validation reads the winning contract and rejects missing, unreadable, or empty files.
|
||||||
|
Protected authority is derived from canonical class metadata in code, never from role prose. A custom
|
||||||
|
contract cannot claim merge, validation-certificate, orchestration, lease, or interaction authority.
|
||||||
|
|
||||||
|
Roster v2 also fails closed when a protected class and tool policy do not match after canonicalization,
|
||||||
|
or when an unprotected class claims a protected tool policy. The legacy `operator-interaction` policy
|
||||||
|
canonicalizes to `interaction`.
|
||||||
|
|
||||||
|
Role customization does not issue leases, store validation certificates, mutate credentials, or
|
||||||
|
change lifecycle state.
|
||||||
40
docs/fleet/migration/legacy-class-aliases.md
Normal file
40
docs/fleet/migration/legacy-class-aliases.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Legacy Fleet Class Aliases
|
||||||
|
|
||||||
|
Fleet class compatibility is intentionally narrow. The shared resolver accepts exactly three legacy
|
||||||
|
class names and converts them to canonical classes before persona lookup:
|
||||||
|
|
||||||
|
| Legacy value | Canonical value | Migration action |
|
||||||
|
| ---------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `implementer` | `code` | Replace class and tool-policy references with `code`. |
|
||||||
|
| `reviewer` | `review` | Replace class and tool-policy references with `review`. |
|
||||||
|
| `operator-interaction` | `interaction` | Replace class and roster-v2 tool-policy references with `interaction`. The legacy service artifact remains compatible. |
|
||||||
|
|
||||||
|
Alias support preserves existing inputs while provisioning and typed semantic output use canonical
|
||||||
|
identities. Requested and canonical class values remain separately observable during semantic
|
||||||
|
validation.
|
||||||
|
|
||||||
|
## Lookup and override behavior
|
||||||
|
|
||||||
|
Canonicalization precedes baseline and `roles.local` lookup. A legacy-named override such as
|
||||||
|
`roles.local/implementer.md` is not a separate authority and is not selected for an `implementer`
|
||||||
|
request. Customize the canonical role instead, for example `roles.local/code.md`.
|
||||||
|
|
||||||
|
The compatibility file `operator-interaction.md` remains shipped, but `interaction` is the canonical
|
||||||
|
role class. Tess is an example display name only.
|
||||||
|
|
||||||
|
## Unresolved and custom classes
|
||||||
|
|
||||||
|
No names are inferred from historical usage, instance names, or similar wording. `worker`, `analyst`,
|
||||||
|
`canary`, Tess, and Ultron are not aliases. An otherwise unknown class is accepted only if the shared
|
||||||
|
resolver can read an actual baseline or `roles.local` contract for that exact class. A `LIBRARY.md`
|
||||||
|
row without a readable contract fails semantic validation.
|
||||||
|
|
||||||
|
Custom classes receive no protected authority implicitly. Protected class/tool-policy mismatches
|
||||||
|
fail closed.
|
||||||
|
|
||||||
|
## Retirement guidance
|
||||||
|
|
||||||
|
New configuration should emit canonical values. Existing inputs may use the three aliases during the
|
||||||
|
compatibility period, but operators should migrate class and tool-policy fields together. Do not
|
||||||
|
create new legacy-named role overrides; move their intended content to the canonical filename and
|
||||||
|
validate the roster/profile before removing the old artifact.
|
||||||
45
docs/fleet/reference/role-classes.md
Normal file
45
docs/fleet/reference/role-classes.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Fleet Role Classes and Authority
|
||||||
|
|
||||||
|
A fleet role class is a machine identity resolved from the persona library. Resolution uses the
|
||||||
|
canonical class before consulting the baseline `fleet/roles/` and operator `fleet/roles.local/`
|
||||||
|
layers. A readable role contract is required; an index entry alone is not semantic success.
|
||||||
|
|
||||||
|
## Canonicalization
|
||||||
|
|
||||||
|
Only these legacy class aliases are recognized:
|
||||||
|
|
||||||
|
| Requested class | Canonical class |
|
||||||
|
| ---------------------- | --------------- |
|
||||||
|
| `implementer` | `code` |
|
||||||
|
| `reviewer` | `review` |
|
||||||
|
| `operator-interaction` | `interaction` |
|
||||||
|
|
||||||
|
No other alias is inferred. In particular, `worker`, `analyst`, and `canary` are custom classes only
|
||||||
|
when an operator supplies a readable contract for that exact class. Tess and Ultron are instance
|
||||||
|
names, not classes. `agents[].alias` is display-only and cannot grant authority.
|
||||||
|
|
||||||
|
Canonicalization happens before role lookup. For example, requesting `implementer` resolves
|
||||||
|
`code.md`; a separate `roles.local/implementer.md` cannot redefine the legacy alias. A canonical
|
||||||
|
`roles.local/code.md` still overrides the baseline `roles/code.md` contract.
|
||||||
|
|
||||||
|
## Protected authority
|
||||||
|
|
||||||
|
Protected authority is immutable metadata derived only from canonical class. Role prose, instance
|
||||||
|
name, display alias, tool policy, runtime, and custom role files cannot grant it.
|
||||||
|
|
||||||
|
| Canonical class | Granted authority | Explicit limits |
|
||||||
|
| ----------------- | -------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
|
| `merge-gate` | Sole approve-to-land and merge authority | No authority is inferred by similarly named custom roles or policies. |
|
||||||
|
| `validator` | May issue a validation certificate | Cannot approve-to-land or merge. |
|
||||||
|
| `orchestrator` | May orchestrate, manage topology, and issue leases | Cannot approve-to-land or merge. |
|
||||||
|
| `team-leader` | May use orchestrator-leased capacity | Cannot issue leases or mutate roster, configuration, credentials, or merge state. |
|
||||||
|
| `interaction` | Request and status surface | Cannot orchestrate, issue leases, mutate roster/configuration, or merge. |
|
||||||
|
| all other classes | No protected authority implicitly | Custom contracts do not acquire protected powers from prose. |
|
||||||
|
|
||||||
|
Roster-v2 semantic validation requires a protected class and its canonical tool policy to match. It
|
||||||
|
also rejects an unprotected class paired with a protected tool policy. The legacy tool-policy name
|
||||||
|
`operator-interaction` canonicalizes to `interaction`.
|
||||||
|
|
||||||
|
This mapping describes authority metadata only. Lease issuance, validation-certificate storage or
|
||||||
|
workflow, lifecycle reconciliation, credentials, roster mutation, and merge execution are outside
|
||||||
|
this resolver contract.
|
||||||
@@ -81,6 +81,35 @@ agents:
|
|||||||
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
| `agents[].lifecycle.desired_state` | yes | `running` or `stopped` |
|
||||||
| `agents[].launch.yolo` | yes | boolean; structured data only, not an arbitrary command escape hatch |
|
| `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
|
## Fail-closed boundary
|
||||||
|
|
||||||
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
Every object is `additionalProperties: false`. The compiler rejects unknown, missing, malformed,
|
||||||
|
|||||||
148
docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md
Normal file
148
docs/scratchpads/758-fcm-m1-002-shared-role-resolution.md
Normal file
@@ -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.
|
||||||
@@ -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
|
implicit `engineering` domain); cross-domain personas carry a `domain:` key in
|
||||||
their intro so tooling can group them.
|
their intro so tooling can group them.
|
||||||
|
|
||||||
> This file is an index only — no code imports it. To add a persona, drop a new
|
> This file is an index, not an authority source. The fleet persona resolver reads
|
||||||
> `*.md` next to the others (mirroring the existing structure) and add a row here.
|
> 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
|
## engineering
|
||||||
|
|
||||||
| Persona | Purpose |
|
| Persona | Purpose |
|
||||||
| --------------- | ------------------------------------------------------------------------------ |
|
| --------------- | ------------------------------------------------------------------------------ |
|
||||||
| orchestrator | Always-on coordinator — runs the supervisor loop, dispatches ready work |
|
| 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 |
|
| board | Multi-lens deliberation panel; owns the mission's direction, not its execution |
|
||||||
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
| planner | Turns ratified objectives into a phased FR plan wired into a `depends_on` DAG |
|
||||||
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
| decomposition | Splits FRs into one-PR-each cards wired with `depends_on` edges |
|
||||||
| code | Primary executor — one card, one branch, one PR to green CI |
|
| code | Primary executor — one card, one branch, one PR to green CI |
|
||||||
| review | Correctness reviewer — judges an open PR on correctness, scope, and coverage |
|
| 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 |
|
| 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 |
|
| site-tester | Runtime verifier — runs the change and checks behavior vs. acceptance criteria |
|
||||||
| documentation | Prose maintainer — keeps human-facing docs and projections in sync |
|
| 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 |
|
| operator | Escalation and control surface — owns exceptions and the fleet pause switch |
|
||||||
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
| session-review | Post-task retrospective — turns finished work into improvement signals |
|
||||||
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
| enhancer | Continuous-improvement loop — upgrades the fleet's tools, skills, and harness |
|
||||||
|
| interaction | Operator request/status surface; routes orchestration and merge decisions |
|
||||||
|
|
||||||
## executive
|
## executive
|
||||||
|
|
||||||
|
|||||||
16
packages/mosaic/framework/fleet/roles/interaction.md
Normal file
16
packages/mosaic/framework/fleet/roles/interaction.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Interaction — fleet role definition
|
||||||
|
|
||||||
|
The **interaction** role (`class: interaction`) is the operator-facing request and status surface for Mosaic.
|
||||||
|
|
||||||
|
## Mandate
|
||||||
|
|
||||||
|
1. Receive operator requests and present observable fleet or runtime status.
|
||||||
|
2. Route orchestration requests to the orchestrator and merge decisions to the merge-gate.
|
||||||
|
3. Report supported actions and their outcomes without claiming another role's authority.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Request/status only; it does not orchestrate, issue leases, approve-to-land, or merge.
|
||||||
|
- It does not mutate roster configuration, role authority, or credentials.
|
||||||
|
- A configured instance name such as Tess is display data, never a class or authority source.
|
||||||
|
- `operator-interaction` remains a compatibility alias for this canonical class.
|
||||||
16
packages/mosaic/framework/fleet/roles/team-leader.md
Normal file
16
packages/mosaic/framework/fleet/roles/team-leader.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Team leader — fleet role definition
|
||||||
|
|
||||||
|
The **team-leader** (`class: team-leader`) coordinates a bounded project team using only capacity granted by an orchestrator-issued lease.
|
||||||
|
|
||||||
|
## Mandate
|
||||||
|
|
||||||
|
1. Direct the leased coder, reviewer, and validator capacity for the assigned project scope.
|
||||||
|
2. Track delivery status and return results or blockers to the orchestrator.
|
||||||
|
3. Stop using capacity when the lease or assignment ends.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- Leased capacity only; this role does not issue or expand its own lease.
|
||||||
|
- It cannot change fleet roster membership, role authority, fleet configuration, or credentials.
|
||||||
|
- It cannot approve-to-land or merge.
|
||||||
|
- It does not displace the orchestrator's topology and lease authority.
|
||||||
16
packages/mosaic/framework/fleet/roles/validator.md
Normal file
16
packages/mosaic/framework/fleet/roles/validator.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Validator — fleet role definition
|
||||||
|
|
||||||
|
The **validator** (`class: validator`) is the independent final evidence seat. It examines the accepted requirements, test evidence, review record, and candidate head and may issue a validation certificate for that exact evidence set.
|
||||||
|
|
||||||
|
## Mandate
|
||||||
|
|
||||||
|
1. Validate acceptance evidence independently from the implementation author.
|
||||||
|
2. Issue or withhold a final validation certificate for the reviewed candidate.
|
||||||
|
3. Report missing, stale, or contradictory evidence without altering it.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- **Certificate only:** the validator does not approve-to-land or merge.
|
||||||
|
- It does not replace correctness or security review.
|
||||||
|
- It does not write product code, mutate the roster, issue leases, or access credentials.
|
||||||
|
- A configured instance name such as Ultron is display data, never a class or authority source.
|
||||||
@@ -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 { 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 { fileURLToPath } from 'node:url';
|
||||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
authorityForCanonicalClass,
|
||||||
|
canonicalizeRoleClass,
|
||||||
extractClassesFromDir,
|
extractClassesFromDir,
|
||||||
|
extractClassesFromDirSync,
|
||||||
listPersonaClasses,
|
listPersonaClasses,
|
||||||
personaStatus,
|
personaStatus,
|
||||||
resolvePersona,
|
resolvePersona,
|
||||||
|
resolvePersonaFrom,
|
||||||
|
resolvePersonaSync,
|
||||||
} from './fleet-personas.js';
|
} from './fleet-personas.js';
|
||||||
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
|
import { loadProfiles, validateProfile, type FleetProfile } from './fleet-profiles.js';
|
||||||
|
|
||||||
@@ -54,13 +59,156 @@ afterEach(async () => {
|
|||||||
await rm(tmp, { recursive: true, force: true });
|
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)', () => {
|
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);
|
const base = await extractClassesFromDir(rolesDir);
|
||||||
|
expect(base.scanState).toBe('scanned');
|
||||||
expect(base.classes.has('ceo')).toBe(true);
|
expect(base.classes.has('ceo')).toBe(true);
|
||||||
expect(base.byClass.get('ceo')?.domain).toBe('executive');
|
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(missing.classes.size).toBe(0);
|
||||||
|
expect(extractClassesFromDirSync(missingDir).scanState).toBe('missing');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,6 +229,287 @@ describe('resolvePersona — override wins', () => {
|
|||||||
expect(resolved?.content).toContain('BASELINE');
|
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 () => {
|
it('returns null for an unknown class', async () => {
|
||||||
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
|
expect(await resolvePersona('does-not-exist', { rolesDir, overrideDir })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,10 +25,10 @@
|
|||||||
* can reference a customized or user-added persona.
|
* can reference a customized or user-added persona.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { lstatSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { lstat, readFile, readdir, stat } from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { basename, join } from 'node:path';
|
import { basename, isAbsolute, join, sep } from 'node:path';
|
||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
|
|
||||||
function defaultMosaicHome(): string {
|
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;
|
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. */
|
/** Optional `domain: Y` marker that travels alongside the class in the prose. */
|
||||||
const DOMAIN_MARKER = /`?domain:\s*\n?\s*([a-z][a-z0-9-]*)`?/;
|
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. */
|
/** Where a resolved persona's definition came from. */
|
||||||
export type PersonaLayer = 'baseline' | 'override';
|
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<Record<string, RoleAuthority>> =
|
||||||
|
Object.freeze({
|
||||||
|
'merge-gate': Object.freeze({ ...NO_PROTECTED_AUTHORITY, mayMerge: true }),
|
||||||
|
validator: Object.freeze({
|
||||||
|
...NO_PROTECTED_AUTHORITY,
|
||||||
|
mayIssueValidationCertificate: true,
|
||||||
|
}),
|
||||||
|
orchestrator: Object.freeze({
|
||||||
|
...NO_PROTECTED_AUTHORITY,
|
||||||
|
mayOrchestrate: true,
|
||||||
|
mayManageTopology: true,
|
||||||
|
mayIssueLeases: true,
|
||||||
|
}),
|
||||||
|
'team-leader': Object.freeze({ ...NO_PROTECTED_AUTHORITY, leasedCapacityOnly: true }),
|
||||||
|
interaction: Object.freeze({ ...NO_PROTECTED_AUTHORITY, requestStatusOnly: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Return protected authority for an already-canonical class; custom classes get none. */
|
||||||
|
export function authorityForCanonicalClass(canonicalClass: string): RoleAuthority {
|
||||||
|
return 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). */
|
/** One discovered persona file (a single role contract on disk). */
|
||||||
export interface PersonaFile {
|
export interface PersonaFile {
|
||||||
klass: string;
|
klass: string;
|
||||||
/** The markdown file the class was found in. */
|
/** The markdown file the class was found in. */
|
||||||
file: string;
|
file: string;
|
||||||
|
/** True when the first class marker, rather than filename, defined this mapping. */
|
||||||
|
markerDefined?: boolean;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DirScanState = 'scanned' | 'missing' | 'error';
|
||||||
|
|
||||||
/** The set of persona classes a directory of role contracts defines. */
|
/** The set of persona classes a directory of role contracts defines. */
|
||||||
export interface DirClasses {
|
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<string>;
|
classes: Set<string>;
|
||||||
|
/** Filename stems present on disk, retained even when a markdown entry is unreadable. */
|
||||||
|
fileStems: Set<string>;
|
||||||
/** For classes whose file carries a marker, the file + domain that defined it. */
|
/** For classes whose file carries a marker, the file + domain that defined it. */
|
||||||
byClass: Map<string, PersonaFile>;
|
byClass: Map<string, PersonaFile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scan one directory of role contracts and extract the persona classes it
|
* Scan one directory of role contracts and extract readable persona identities.
|
||||||
* defines. THIS is the shared extraction both fleet-personas and fleet-profiles
|
* Valid identities come only from a successfully read non-LIBRARY filename and
|
||||||
* rely on. Sources, unioned (each needed — see module doc):
|
* that file's first `class:` marker. LIBRARY rows and later prose mentions are
|
||||||
* 1. inline `class: X` markers in roles/*.md (primary; may wrap a newline),
|
* index/reference data, not independently readable role contracts.
|
||||||
* 2. persona-name cells from LIBRARY.md index tables,
|
|
||||||
* 3. the role filename stem (covers marker-less alias docs like planner).
|
|
||||||
*
|
*
|
||||||
* Missing dir / unreadable files degrade gracefully to whatever was found.
|
* Missing directories are distinguished from present-but-unscannable paths.
|
||||||
* `byClass` records the defining file+domain for marker-bearing classes so the
|
* `byClass` records the first marker-defined file+domain so the resolver can map
|
||||||
* resolver can map a class back to its file; filename-only and LIBRARY-only
|
* a class back to its contract; marker-less readable files map by filename.
|
||||||
* classes still appear in `classes` for membership checks.
|
|
||||||
*/
|
*/
|
||||||
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
||||||
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
|
const acc: DirClasses = {
|
||||||
|
scanState: 'scanned',
|
||||||
|
classes: new Set<string>(),
|
||||||
|
fileStems: new Set<string>(),
|
||||||
|
byClass: new Map<string, PersonaFile>(),
|
||||||
|
};
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = await readdir(dir);
|
entries = await readdir(dir);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
acc.scanState = await classifyScanFailure(dir, error);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.endsWith('.md')) continue;
|
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;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = await readFile(join(dir, entry), 'utf8');
|
text = await readFile(join(dir, entry), 'utf8');
|
||||||
@@ -117,16 +201,25 @@ export async function extractClassesFromDir(dir: string): Promise<DirClasses> {
|
|||||||
* cannot await. Missing dir / unreadable files degrade gracefully.
|
* cannot await. Missing dir / unreadable files degrade gracefully.
|
||||||
*/
|
*/
|
||||||
export function extractClassesFromDirSync(dir: string): DirClasses {
|
export function extractClassesFromDirSync(dir: string): DirClasses {
|
||||||
const acc: DirClasses = { classes: new Set<string>(), byClass: new Map<string, PersonaFile>() };
|
const acc: DirClasses = {
|
||||||
|
scanState: 'scanned',
|
||||||
|
classes: new Set<string>(),
|
||||||
|
fileStems: new Set<string>(),
|
||||||
|
byClass: new Map<string, PersonaFile>(),
|
||||||
|
};
|
||||||
let entries: string[];
|
let entries: string[];
|
||||||
try {
|
try {
|
||||||
entries = readdirSync(dir);
|
entries = readdirSync(dir);
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
acc.scanState = classifyScanFailureSync(dir, error);
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.endsWith('.md')) continue;
|
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;
|
let text: string;
|
||||||
try {
|
try {
|
||||||
text = readFileSync(join(dir, entry), 'utf8');
|
text = readFileSync(join(dir, entry), 'utf8');
|
||||||
@@ -138,6 +231,74 @@ export function extractClassesFromDirSync(dir: string): DirClasses {
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function classifyScanFailure(dir: string, error: unknown): Promise<DirScanState> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
* 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
|
* 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 {
|
function accumulateEntry(acc: DirClasses, dir: string, entry: string, text: string): void {
|
||||||
const { classes, byClass } = acc;
|
const { classes, byClass } = acc;
|
||||||
if (entry === 'LIBRARY.md') {
|
if (entry === 'LIBRARY.md') return;
|
||||||
for (const m of text.matchAll(LIBRARY_ROW)) {
|
|
||||||
const name = m[1];
|
// An explicit first marker owns identity. Filename identity is a fallback only
|
||||||
if (name && name !== 'persona') classes.add(name);
|
// for markerless compatibility contracts such as planner.md.
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// The filename stem is itself a valid class (covers marker-less alias docs).
|
|
||||||
const stem = basename(entry, '.md');
|
const stem = basename(entry, '.md');
|
||||||
classes.add(stem);
|
|
||||||
const domainMatch = DOMAIN_MARKER.exec(text);
|
const domainMatch = DOMAIN_MARKER.exec(text);
|
||||||
const domain = domainMatch?.[1];
|
const domain = domainMatch?.[1];
|
||||||
let markedClassForFile: string | undefined;
|
const firstMarker = text.matchAll(CLASS_MARKER).next().value as RegExpExecArray | undefined;
|
||||||
for (const m of text.matchAll(CLASS_MARKER)) {
|
const markedClassForFile = firstMarker?.[1];
|
||||||
const klass = m[1];
|
if (markedClassForFile) {
|
||||||
if (!klass) continue;
|
classes.add(markedClassForFile);
|
||||||
classes.add(klass);
|
byClass.set(markedClassForFile, {
|
||||||
// Record the FIRST marker as the file's defining class (the prose names
|
klass: markedClassForFile,
|
||||||
// the persona's own class up top; later mentions reference siblings).
|
file: join(dir, entry),
|
||||||
if (!markedClassForFile) {
|
markerDefined: true,
|
||||||
markedClassForFile = klass;
|
...(domain ? { domain } : {}),
|
||||||
byClass.set(klass, { klass, file: join(dir, entry), ...(domain ? { domain } : {}) });
|
});
|
||||||
}
|
} else {
|
||||||
}
|
classes.add(stem);
|
||||||
// A marker-less file still maps its stem to itself (no domain known).
|
if (!byClass.has(stem)) byClass.set(stem, { klass: stem, file: join(dir, entry) });
|
||||||
if (!markedClassForFile && !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
|
* Every class with an actually readable winning contract. Discovery applies the
|
||||||
* classes not present in the baseline, so callers (e.g. profile roster
|
* same override shadow/fail-closed semantics as resolution, so callers never
|
||||||
* validation) treat a user-added persona as a real class.
|
* advertise a class that cannot be used.
|
||||||
*/
|
*/
|
||||||
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
|
export async function listPersonaClasses(opts: PersonaDirs = {}): Promise<Set<string>> {
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
const { personas } = await collectUsablePersonas(opts);
|
||||||
const [base, over] = await Promise.all([
|
return new Set(personas.keys());
|
||||||
extractClassesFromDir(rolesDir),
|
|
||||||
extractClassesFromDir(overrideDir),
|
|
||||||
]);
|
|
||||||
const union = new Set<string>(base.classes);
|
|
||||||
for (const c of over.classes) union.add(c);
|
|
||||||
return union;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
|
export type PersonaStatus = 'baseline' | 'overridden' | 'custom';
|
||||||
|
|
||||||
export interface PersonaResolution {
|
export interface PersonaResolution extends CanonicalRoleClass {
|
||||||
|
/** Compatibility name for the canonical class. */
|
||||||
klass: string;
|
klass: string;
|
||||||
layer: PersonaLayer;
|
layer: PersonaLayer;
|
||||||
/** The file the resolved persona was read from (override wins). */
|
/** The file the resolved persona was read from (override wins). */
|
||||||
@@ -219,6 +368,118 @@ export interface PersonaResolution {
|
|||||||
domain?: string;
|
domain?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readPersonaFromLayer(
|
||||||
|
requestedClass: string,
|
||||||
|
canonicalClass: string,
|
||||||
|
dir: string,
|
||||||
|
extracted: DirClasses,
|
||||||
|
layer: PersonaLayer,
|
||||||
|
): Promise<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 = 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
|
* Resolve a persona class to its winning definition: the override file if
|
||||||
* roles.local/ defines that class, else the baseline. Match by inline `class:`
|
* 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.
|
* is identical to {@link resolvePersona}: override layer wins, then baseline.
|
||||||
*/
|
*/
|
||||||
export async function resolvePersonaFrom(
|
export async function resolvePersonaFrom(
|
||||||
klass: string,
|
requestedClass: string,
|
||||||
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
|
layers: { rolesDir: string; overrideDir: string; base: DirClasses; over: DirClasses },
|
||||||
): Promise<PersonaResolution | null> {
|
): Promise<PersonaResolution | null> {
|
||||||
|
const { requestedClass: requested, canonicalClass: klass } =
|
||||||
|
canonicalizeRoleClass(requestedClass);
|
||||||
const { rolesDir, overrideDir, base, over } = layers;
|
const { rolesDir, overrideDir, base, over } = layers;
|
||||||
|
|
||||||
const fromLayer = async (
|
const override = await readPersonaFromLayer(requested, klass, overrideDir, over, 'override');
|
||||||
dir: string,
|
if (override) return override;
|
||||||
extracted: DirClasses,
|
// An observed explicit override that cannot resolve/read shadows the baseline.
|
||||||
layer: PersonaLayer,
|
if (overrideShadowsBaseline(over, klass)) return null;
|
||||||
): Promise<PersonaResolution | null> => {
|
|
||||||
// Prefer the marker-defined file; fall back to the filename stem.
|
|
||||||
let pf = extracted.byClass.get(klass);
|
|
||||||
if (!pf) {
|
|
||||||
const byName = join(dir, `${klass}.md`);
|
|
||||||
if (!extracted.classes.has(klass)) return null;
|
|
||||||
// Class known only via filename/LIBRARY: read the stem file if present.
|
|
||||||
try {
|
|
||||||
const content = await readFile(byName, 'utf8');
|
|
||||||
const dm = DOMAIN_MARKER.exec(content);
|
|
||||||
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = await readFile(pf.file, 'utf8');
|
|
||||||
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
// Cached scans are only snapshots. Re-scan immediately before baseline fallback
|
||||||
(await fromLayer(overrideDir, over, 'override')) ??
|
// so a newly created canonical or marker-defined override cannot be skipped.
|
||||||
(await fromLayer(rolesDir, base, 'baseline'))
|
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.
|
* one module so the launch-time and command-time resolutions never diverge.
|
||||||
*/
|
*/
|
||||||
export function resolvePersonaSync(
|
export function resolvePersonaSync(
|
||||||
klass: string,
|
requestedClass: string,
|
||||||
opts: PersonaDirs = {},
|
opts: PersonaDirs = {},
|
||||||
): PersonaResolution | null {
|
): PersonaResolution | null {
|
||||||
|
const { requestedClass: requested, canonicalClass: klass } =
|
||||||
|
canonicalizeRoleClass(requestedClass);
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||||
const base = extractClassesFromDirSync(rolesDir);
|
const base = extractClassesFromDirSync(rolesDir);
|
||||||
const over = extractClassesFromDirSync(overrideDir);
|
const over = extractClassesFromDirSync(overrideDir);
|
||||||
|
|
||||||
const fromLayer = (
|
const override = readPersonaFromLayerSync(requested, klass, overrideDir, over, 'override');
|
||||||
dir: string,
|
if (override) return override;
|
||||||
extracted: DirClasses,
|
if (overrideShadowsBaseline(over, klass)) return null;
|
||||||
layer: PersonaLayer,
|
return readPersonaFromLayerSync(requested, klass, rolesDir, base, 'baseline');
|
||||||
): PersonaResolution | null => {
|
}
|
||||||
// Prefer the marker-defined file; fall back to the filename stem.
|
|
||||||
const pf = extracted.byClass.get(klass);
|
|
||||||
if (!pf) {
|
|
||||||
if (!extracted.classes.has(klass)) return null;
|
|
||||||
const byName = join(dir, `${klass}.md`);
|
|
||||||
try {
|
|
||||||
const content = readFileSync(byName, 'utf8');
|
|
||||||
const dm = DOMAIN_MARKER.exec(content);
|
|
||||||
return { klass, layer, file: byName, content, ...(dm?.[1] ? { domain: dm[1] } : {}) };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = readFileSync(pf.file, 'utf8');
|
|
||||||
return { klass, layer, file: pf.file, content, ...(pf.domain ? { domain: pf.domain } : {}) };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return fromLayer(overrideDir, over, 'override') ?? fromLayer(rolesDir, base, 'baseline');
|
interface UsablePersonaIndex {
|
||||||
|
personas: Map<string, PersonaResolution>;
|
||||||
|
readableBaseline: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectUsablePersonas(opts: PersonaDirs): Promise<UsablePersonaIndex> {
|
||||||
|
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<string>([...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<string, PersonaResolution>();
|
||||||
|
const readableBaseline = new Set<string>();
|
||||||
|
for (const { requestedClass, persona, baseline } of checked) {
|
||||||
|
if (persona) personas.set(requestedClass, persona);
|
||||||
|
if (baseline) readableBaseline.add(requestedClass);
|
||||||
|
}
|
||||||
|
return { personas, readableBaseline };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersonaStatusEntry {
|
export interface PersonaStatusEntry {
|
||||||
@@ -342,24 +608,20 @@ export interface PersonaStatusEntry {
|
|||||||
* Domain is taken from the WINNING layer (override domain wins if present).
|
* Domain is taken from the WINNING layer (override domain wins if present).
|
||||||
*/
|
*/
|
||||||
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
|
export async function personaStatus(opts: PersonaDirs = {}): Promise<PersonaStatusEntry[]> {
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
const { personas, readableBaseline } = await collectUsablePersonas(opts);
|
||||||
const [base, over] = await Promise.all([
|
const entries = [...personas.entries()].map(([klass, persona]): PersonaStatusEntry => {
|
||||||
extractClassesFromDir(rolesDir),
|
const status: PersonaStatus =
|
||||||
extractClassesFromDir(overrideDir),
|
persona.layer === 'baseline'
|
||||||
]);
|
? 'baseline'
|
||||||
|
: readableBaseline.has(klass)
|
||||||
const all = new Set<string>([...base.classes, ...over.classes]);
|
? 'overridden'
|
||||||
const domainOf = (extracted: DirClasses, klass: string): string | undefined =>
|
: 'custom';
|
||||||
extracted.byClass.get(klass)?.domain;
|
return {
|
||||||
|
klass,
|
||||||
const entries: PersonaStatusEntry[] = [];
|
status,
|
||||||
for (const klass of all) {
|
...(persona.domain ? { domain: persona.domain } : {}),
|
||||||
const inBase = base.classes.has(klass);
|
};
|
||||||
const inOver = over.classes.has(klass);
|
});
|
||||||
const status: PersonaStatus = inOver ? (inBase ? 'overridden' : 'custom') : 'baseline';
|
|
||||||
const domain = (inOver ? domainOf(over, klass) : undefined) ?? domainOf(base, klass);
|
|
||||||
entries.push({ klass, status, ...(domain ? { domain } : {}) });
|
|
||||||
}
|
|
||||||
entries.sort((a, b) => a.klass.localeCompare(b.klass));
|
entries.sort((a, b) => a.klass.localeCompare(b.klass));
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,91 @@ describe('loadProfiles with a temp override dir', () => {
|
|||||||
await rm(dir, { recursive: true, force: true });
|
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 () => {
|
it('throws when a profile references an unknown class (validated against real roles)', async () => {
|
||||||
await writeFile(
|
await writeFile(
|
||||||
join(dir, 'bad.yaml'),
|
join(dir, 'bad.yaml'),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
defaultOverrideDir,
|
defaultOverrideDir,
|
||||||
extractClassesFromDir,
|
extractClassesFromDir,
|
||||||
listPersonaClasses as listOverrideAwarePersonaClasses,
|
listPersonaClasses as listOverrideAwarePersonaClasses,
|
||||||
|
resolvePersonaFrom,
|
||||||
} from './fleet-personas.js';
|
} from './fleet-personas.js';
|
||||||
|
|
||||||
function defaultMosaicHome(): string {
|
function defaultMosaicHome(): string {
|
||||||
@@ -199,6 +200,61 @@ export function validateProfile(profile: FleetProfile, validClasses: Set<string>
|
|||||||
return problems;
|
return problems;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveProfilePersonas(
|
||||||
|
profile: FleetProfile,
|
||||||
|
rolesDir: string,
|
||||||
|
overrideDir: string,
|
||||||
|
): Promise<FleetProfile> {
|
||||||
|
const [base, over] = await Promise.all([
|
||||||
|
extractClassesFromDir(rolesDir),
|
||||||
|
extractClassesFromDir(overrideDir),
|
||||||
|
]);
|
||||||
|
const requestedClasses = new Set<string>([
|
||||||
|
profile.lead,
|
||||||
|
...profile.floor,
|
||||||
|
...profile.roster.flatMap((entry: ProfileRosterEntry): string[] =>
|
||||||
|
entry.reportsTo ? [entry.class, entry.reportsTo] : [entry.class],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const canonicalByRequested = new Map<string, string>();
|
||||||
|
for (const requestedClass of requestedClasses) {
|
||||||
|
const resolved = await resolvePersonaFrom(requestedClass, {
|
||||||
|
rolesDir,
|
||||||
|
overrideDir,
|
||||||
|
base,
|
||||||
|
over,
|
||||||
|
});
|
||||||
|
if (!resolved || resolved.content.trim() === '') {
|
||||||
|
throw new Error(`persona class "${requestedClass}" does not resolve to a readable persona`);
|
||||||
|
}
|
||||||
|
canonicalByRequested.set(requestedClass, resolved.canonicalClass);
|
||||||
|
}
|
||||||
|
const canonical = (requestedClass: string): string =>
|
||||||
|
canonicalByRequested.get(requestedClass) ?? requestedClass;
|
||||||
|
const roster = profile.roster.map(
|
||||||
|
(entry: ProfileRosterEntry): ProfileRosterEntry => ({
|
||||||
|
class: canonical(entry.class),
|
||||||
|
multiplicity: entry.multiplicity,
|
||||||
|
...(entry.reportsTo ? { reportsTo: canonical(entry.reportsTo) } : {}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const canonicalRosterClasses = roster.map((entry: ProfileRosterEntry): string => entry.class);
|
||||||
|
if (new Set(canonicalRosterClasses).size !== canonicalRosterClasses.length) {
|
||||||
|
throw new Error('profile roster contains duplicate classes after canonicalization');
|
||||||
|
}
|
||||||
|
const resolvedProfile: FleetProfile = {
|
||||||
|
...profile,
|
||||||
|
lead: canonical(profile.lead),
|
||||||
|
floor: profile.floor.map(canonical),
|
||||||
|
roster,
|
||||||
|
};
|
||||||
|
const problems = validateProfile(resolvedProfile, new Set(canonicalByRequested.values()));
|
||||||
|
if (problems.length > 0) {
|
||||||
|
throw new Error(problems.join('\n - '));
|
||||||
|
}
|
||||||
|
return resolvedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoadProfilesOptions {
|
export interface LoadProfilesOptions {
|
||||||
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
|
/** Override the profiles dir (tests). Defaults to <mosaicHome>/fleet/profiles. */
|
||||||
profilesDir?: string;
|
profilesDir?: string;
|
||||||
@@ -237,10 +293,8 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
|||||||
}
|
}
|
||||||
files.sort();
|
files.sort();
|
||||||
|
|
||||||
// Override-aware: a profile may reference a user-customized or user-ADDED
|
// Validation resolves each reference to an actually readable winning persona;
|
||||||
// persona living in the roles.local/ layer (H4), so validate against the
|
// LIBRARY membership alone is not semantic success.
|
||||||
// baseline ⊕ override union, not the baseline alone.
|
|
||||||
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
|
||||||
const profiles: FleetProfile[] = [];
|
const profiles: FleetProfile[] = [];
|
||||||
const seen = new Map<string, string>();
|
const seen = new Map<string, string>();
|
||||||
|
|
||||||
@@ -255,11 +309,14 @@ export async function loadProfiles(opts: LoadProfilesOptions = {}): Promise<Flee
|
|||||||
}
|
}
|
||||||
seen.set(profile.id, file);
|
seen.set(profile.id, file);
|
||||||
|
|
||||||
const problems = validateProfile(profile, validClasses);
|
let resolvedProfile: FleetProfile;
|
||||||
if (problems.length > 0) {
|
try {
|
||||||
throw new Error(`Profile ${file} is invalid:\n - ${problems.join('\n - ')}`);
|
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;
|
return profiles;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -172,6 +172,46 @@ describe('override-aware persona validation', () => {
|
|||||||
expect(result.summary).toContain('persona=override');
|
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 () => {
|
it('FAILS with a clear message when a profile references a bogus class', async () => {
|
||||||
const customProfilesDir = join(dir, 'profiles');
|
const customProfilesDir = join(dir, 'profiles');
|
||||||
await mkdir(customProfilesDir, { recursive: true });
|
await mkdir(customProfilesDir, { recursive: true });
|
||||||
|
|||||||
@@ -26,12 +26,11 @@ import type { Command } from 'commander';
|
|||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
import {
|
||||||
loadProfile,
|
loadProfile,
|
||||||
validateProfile,
|
|
||||||
type FleetProfile,
|
type FleetProfile,
|
||||||
type ProfileRosterEntry,
|
type ProfileRosterEntry,
|
||||||
defaultProfilesDir,
|
defaultProfilesDir,
|
||||||
defaultRolesDir,
|
defaultRolesDir,
|
||||||
listPersonaClassesWithOverrides,
|
resolveProfilePersonas,
|
||||||
} from './fleet-profiles.js';
|
} from './fleet-profiles.js';
|
||||||
import {
|
import {
|
||||||
defaultOverrideDir,
|
defaultOverrideDir,
|
||||||
@@ -201,18 +200,35 @@ export async function generateRoster(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeChoice = resolveSeatRuntime(entry.class, isFloor, isLead);
|
const canonicalEntry: ProfileRosterEntry = {
|
||||||
for (const name of seatNames(entry)) {
|
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 = {
|
const seat: GeneratedSeat = {
|
||||||
name,
|
name,
|
||||||
className: entry.class,
|
className: resolved.canonicalClass,
|
||||||
runtime: runtimeChoice.runtime,
|
runtime: runtimeChoice.runtime,
|
||||||
personaLayer: resolved.layer,
|
personaLayer: resolved.layer,
|
||||||
};
|
};
|
||||||
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
|
if (runtimeChoice.modelHint) seat.modelHint = runtimeChoice.modelHint;
|
||||||
if (isFloor || isLead) seat.persistentPersona = true;
|
if (isFloor || isLead) seat.persistentPersona = true;
|
||||||
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
|
if (!isFloor && !isLead) seat.resetBetweenTasks = true;
|
||||||
if (entry.reportsTo) seat.reportsTo = entry.reportsTo;
|
if (canonicalEntry.reportsTo) seat.reportsTo = canonicalEntry.reportsTo;
|
||||||
seats.push(seat);
|
seats.push(seat);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,13 +295,7 @@ export async function validateProfileForProvision(
|
|||||||
opts: ProvisionOptions,
|
opts: ProvisionOptions,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { rolesDir, overrideDir } = resolveDirs(opts);
|
const { rolesDir, overrideDir } = resolveDirs(opts);
|
||||||
const validClasses = await listPersonaClassesWithOverrides(rolesDir, overrideDir);
|
await resolveProfilePersonas(profile, 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 - ')}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -77,12 +77,25 @@ describe('readPersonaContractBlock — launch-time persona injection (A3b)', ()
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('injects an override-only (user-added) persona with no baseline at all', () => {
|
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');
|
seedOverride(home, 'mascot', '# Mascot\n\n(`class: mascot`)\n\nCUSTOM-ROLE.\n');
|
||||||
const block = readPersonaContractBlock(home, 'reviewer');
|
const block = readPersonaContractBlock(home, 'mascot');
|
||||||
expect(block).toContain('# Persona Contract (reviewer)');
|
expect(block).toContain('# Persona Contract (mascot)');
|
||||||
expect(block).toContain('CUSTOM-ROLE');
|
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', () => {
|
it('no-ops (empty string) when the class is undefined', () => {
|
||||||
seedBaseline(home, 'coder', BASELINE_CODER);
|
seedBaseline(home, 'coder', BASELINE_CODER);
|
||||||
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
expect(readPersonaContractBlock(home, undefined)).toBe('');
|
||||||
|
|||||||
@@ -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 { fileURLToPath } from 'node:url';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
ROSTER_V2_JSON_SCHEMA,
|
ROSTER_V2_JSON_SCHEMA,
|
||||||
RosterV2ValidationError,
|
RosterV2ValidationError,
|
||||||
parseRosterV2,
|
parseRosterV2,
|
||||||
renderRosterV2Yaml,
|
renderRosterV2Yaml,
|
||||||
|
validateRosterV2Semantics,
|
||||||
} from './roster-v2.js';
|
} from './roster-v2.js';
|
||||||
|
|
||||||
const validRoster = `
|
const validRoster = `
|
||||||
@@ -40,6 +43,127 @@ agents:
|
|||||||
yolo: true
|
yolo: true
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
let semanticTmp: string | undefined;
|
||||||
|
|
||||||
|
afterEach(async (): Promise<void> => {
|
||||||
|
if (semanticTmp) await rm(semanticTmp, { recursive: true, force: true });
|
||||||
|
semanticTmp = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function semanticDirs(): Promise<{ rolesDir: string; overrideDir: string }> {
|
||||||
|
semanticTmp = await mkdtemp(join(tmpdir(), 'roster-v2-semantics-'));
|
||||||
|
const rolesDir = join(semanticTmp, 'roles');
|
||||||
|
const overrideDir = join(semanticTmp, 'roles.local');
|
||||||
|
await mkdir(rolesDir, { recursive: true });
|
||||||
|
await mkdir(overrideDir, { recursive: true });
|
||||||
|
for (const klass of [
|
||||||
|
'code',
|
||||||
|
'review',
|
||||||
|
'interaction',
|
||||||
|
'orchestrator',
|
||||||
|
'merge-gate',
|
||||||
|
'validator',
|
||||||
|
'team-leader',
|
||||||
|
]) {
|
||||||
|
await writeFile(join(rolesDir, `${klass}.md`), `# ${klass}\n\n(\`class: ${klass}\`)\n`, 'utf8');
|
||||||
|
}
|
||||||
|
return { rolesDir, overrideDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rosterWithClass(klass: string, toolPolicy = klass): string {
|
||||||
|
return validRoster
|
||||||
|
.replace('class: code', `class: ${klass}`)
|
||||||
|
.replace('tool_policy: code', `tool_policy: ${toolPolicy}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('roster v2 semantic validation', (): void => {
|
||||||
|
it.each([
|
||||||
|
['implementer', 'code'],
|
||||||
|
['reviewer', 'review'],
|
||||||
|
['operator-interaction', 'interaction'],
|
||||||
|
])(
|
||||||
|
'canonicalizes requested alias %s while retaining requested and canonical class',
|
||||||
|
async (requested: string, canonical: string) => {
|
||||||
|
const dirs = await semanticDirs();
|
||||||
|
const roster = parseRosterV2(rosterWithClass(requested, requested), 'yaml');
|
||||||
|
const validated = await validateRosterV2Semantics(roster, dirs);
|
||||||
|
expect(validated.agents[0]).toMatchObject({
|
||||||
|
requestedClass: requested,
|
||||||
|
canonicalClass: canonical,
|
||||||
|
canonicalToolPolicy: canonical,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each(['worker', 'analyst', 'canary'])(
|
||||||
|
'rejects %s when no genuine custom role exists',
|
||||||
|
async (klass: string) => {
|
||||||
|
const dirs = await semanticDirs();
|
||||||
|
await expect(
|
||||||
|
validateRosterV2Semantics(parseRosterV2(rosterWithClass(klass), 'yaml'), dirs),
|
||||||
|
).rejects.toThrow(/unresolved|readable persona/i);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('accepts a genuine custom roles.local class without protected authority', async () => {
|
||||||
|
const dirs = await semanticDirs();
|
||||||
|
await writeFile(join(dirs.overrideDir, 'worker.md'), '# worker\n\n(`class: worker`)\n', 'utf8');
|
||||||
|
const validated = await validateRosterV2Semantics(
|
||||||
|
parseRosterV2(rosterWithClass('worker'), 'yaml'),
|
||||||
|
dirs,
|
||||||
|
);
|
||||||
|
expect(validated.agents[0]?.authority).toMatchObject({
|
||||||
|
mayMerge: false,
|
||||||
|
mayOrchestrate: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a LIBRARY-only class with no readable resolved persona', async () => {
|
||||||
|
const dirs = await semanticDirs();
|
||||||
|
await writeFile(
|
||||||
|
join(dirs.rolesDir, 'LIBRARY.md'),
|
||||||
|
'| Persona | Purpose |\n| --- | --- |\n| phantom | Missing |\n',
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
validateRosterV2Semantics(parseRosterV2(rosterWithClass('phantom'), 'yaml'), dirs),
|
||||||
|
).rejects.toThrow(/readable persona/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unreadable resolved persona', async () => {
|
||||||
|
const dirs = await semanticDirs();
|
||||||
|
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 => {
|
describe('roster v2 structural compiler', (): void => {
|
||||||
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
it('parses YAML into a normalized typed model and renders canonical YAML', (): void => {
|
||||||
const roster = parseRosterV2(validRoster, 'yaml');
|
const roster = parseRosterV2(validRoster, 'yaml');
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import YAML from 'yaml';
|
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_SUPPORTED_RUNTIMES = ['claude', 'codex', 'opencode', 'pi'] as const;
|
||||||
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
export const ROSTER_V2_REASONING_LEVELS = ['low', 'medium', 'high'] as const;
|
||||||
@@ -58,6 +69,80 @@ export interface FleetRosterV2 {
|
|||||||
readonly agents: readonly FleetRosterV2Agent[];
|
readonly agents: readonly FleetRosterV2Agent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SemanticallyValidatedRosterV2Agent extends FleetRosterV2Agent {
|
||||||
|
readonly requestedClass: string;
|
||||||
|
readonly canonicalClass: string;
|
||||||
|
readonly canonicalToolPolicy: string;
|
||||||
|
readonly persona: PersonaResolution;
|
||||||
|
readonly authority: RoleAuthority;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SemanticallyValidatedRosterV2 extends Omit<FleetRosterV2, 'agents'> {
|
||||||
|
readonly agents: readonly SemanticallyValidatedRosterV2Agent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTECTED_TOOL_POLICY_CLASSES = new Set([
|
||||||
|
'merge-gate',
|
||||||
|
'validator',
|
||||||
|
'orchestrator',
|
||||||
|
'team-leader',
|
||||||
|
'interaction',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate filesystem-backed roster semantics after synchronous structural parsing.
|
||||||
|
* Directory scans are batched once and every class must resolve to readable content.
|
||||||
|
*/
|
||||||
|
export async function validateRosterV2Semantics(
|
||||||
|
roster: FleetRosterV2,
|
||||||
|
opts: PersonaDirs = {},
|
||||||
|
): Promise<SemanticallyValidatedRosterV2> {
|
||||||
|
const rolesDir = opts.rolesDir ?? defaultRolesDir(opts.mosaicHome);
|
||||||
|
const overrideDir = opts.overrideDir ?? defaultOverrideDir(opts.mosaicHome);
|
||||||
|
const [base, over] = await Promise.all([
|
||||||
|
extractClassesFromDir(rolesDir),
|
||||||
|
extractClassesFromDir(overrideDir),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const agents: SemanticallyValidatedRosterV2Agent[] = [];
|
||||||
|
for (const agent of roster.agents) {
|
||||||
|
const requestedClass = agent.className;
|
||||||
|
const { canonicalClass } = canonicalizeRoleClass(requestedClass);
|
||||||
|
const canonicalToolPolicy = canonicalizeRoleClass(agent.toolPolicy).canonicalClass;
|
||||||
|
const persona = await resolvePersonaFrom(requestedClass, {
|
||||||
|
rolesDir,
|
||||||
|
overrideDir,
|
||||||
|
base,
|
||||||
|
over,
|
||||||
|
});
|
||||||
|
if (!persona || persona.content.trim() === '') {
|
||||||
|
throw new RosterV2ValidationError(
|
||||||
|
`Roster v2 agent "${agent.name}" class "${requestedClass}" does not resolve to a readable persona.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(PROTECTED_TOOL_POLICY_CLASSES.has(canonicalClass) ||
|
||||||
|
PROTECTED_TOOL_POLICY_CLASSES.has(canonicalToolPolicy)) &&
|
||||||
|
canonicalToolPolicy !== canonicalClass
|
||||||
|
) {
|
||||||
|
throw new RosterV2ValidationError(
|
||||||
|
`Roster v2 agent "${agent.name}" protected class "${canonicalClass}" tool policy must match its canonical class; received "${agent.toolPolicy}".`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
agents.push(
|
||||||
|
Object.freeze({
|
||||||
|
...agent,
|
||||||
|
requestedClass,
|
||||||
|
canonicalClass,
|
||||||
|
canonicalToolPolicy,
|
||||||
|
persona,
|
||||||
|
authority: authorityForCanonicalClass(canonicalClass),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Object.freeze({ ...roster, agents: Object.freeze(agents) });
|
||||||
|
}
|
||||||
|
|
||||||
export class RosterV2ValidationError extends Error {
|
export class RosterV2ValidationError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
super(message);
|
super(message);
|
||||||
|
|||||||
Reference in New Issue
Block a user