4b48d2d921
Four top-level keys (leaders:, members:, leadScan:, defaultProfile:) become one
`fleet:` block, and a member's role becomes the map key that contains it rather
than a `role:` field inside it.
Why the key and not a field: a misspelled `role: architct` used to produce a
member with no contract, which nothing rejected. A misspelled pool name declares
nothing, which is a shape the loader can see.
`fleet.architects/developers/reviewers` are pools of profiles a role MAY run on.
That replaces the single global `defaultProfile:`, so an unqualified spawn now
resolves its profile from the pool of the role it asked for. Role and profile
stay orthogonal: a reviewer may run on the same profile as the dev it reviews,
and one profile may appear in several pools.
Tab labels are role-first — `dev: sonnet #4`. The template lives on `fleet:`
because a profile cannot know the role of the member launched on it; a profile
may still override it. The `{n}` counter is scoped per role+profile, so a dev
and a reviewer on one profile each start at #1. Making {role} the first field
also turns the lead/member namespace check into a structural guarantee: roles
are a closed enum, so only hand-written templates can still collide with a lead
tabPrefix.
Removed keys are hard errors that name their successor. `defaultProfile:` has no
single successor key, so its message explains the new model instead of pointing
at a key that does not exist.
Map order is kept with LinkedHashMap, deliberately not Map.copyOf — the latter
salts iteration order per JVM run, which would destroy the YAML definition order
that `placement: fixed` selects on.
Not yet wired: SessionManager still hands the launchers one effectiveDefault-
Profile, so pools are not enforced at spawn time yet, and placement still ranges
over all profiles.
595 tests pass.
123 lines
4.7 KiB
Java
123 lines
4.7 KiB
Java
package dev.ltms.bridged.peer;
|
|
|
|
import java.util.Locale;
|
|
|
|
/**
|
|
* What a member is <em>for</em> — the contract it runs under.
|
|
*
|
|
* <p>A member is anything a lead spawns. Every member carries two independent attributes:
|
|
*
|
|
* <ul>
|
|
* <li><b>role</b> (this enum) — <em>which contract</em>: the launch charter it is given, the role
|
|
* file it reads, the playbook skill it loads, and its authorization row.</li>
|
|
* <li><b>profile</b> (a {@code profiles:} key) — <em>which backend</em>: model, CLI adapter,
|
|
* credentials, cost.</li>
|
|
* </ul>
|
|
*
|
|
* <p>These are separate axes on purpose. A {@code REVIEWER} may run on the same profile as the
|
|
* {@code DEV} whose diff it reviews — same backend, different contract. That case is what proves
|
|
* role and profile cannot be collapsed into one field.
|
|
*
|
|
* <p>A lead is deliberately <em>not</em> a role here. A lead is not spawned: it is a pre-existing,
|
|
* human-facing session that config recognises. Only spawned peers have a member role.
|
|
*/
|
|
public enum MemberRole {
|
|
|
|
/**
|
|
* Refines a ticket before anyone implements it: scope, acceptance criteria, risks, unit split.
|
|
*
|
|
* <p>Reads the repo and writes analysis. Never commits code and never opens a pull request —
|
|
* an architect that starts implementing has stopped doing the job that makes it useful.
|
|
*
|
|
* <p>Architects are the one member kind declared in config, because a lead addresses the same
|
|
* slots across many tickets and needs a stable name for them.
|
|
*/
|
|
ARCHITECT,
|
|
|
|
/**
|
|
* Implements one unit of work: provisions a worktree, writes the code, commits, pushes, and
|
|
* opens its own pull request.
|
|
*
|
|
* <p>Never merges. The lead is the gate, and a member that merged its own work would remove the
|
|
* only independent check in the flow.
|
|
*/
|
|
DEV,
|
|
|
|
/**
|
|
* Reviews a diff it did not write and reports one structured finding.
|
|
*
|
|
* <p>Never commits, never merges, and is never the member that wrote the scope under review.
|
|
* Briefed from the diff rather than from the implementer's rationale, because that rationale
|
|
* carries the same blind spot that produced the bug.
|
|
*/
|
|
REVIEWER;
|
|
|
|
/** The lowercase spelling used in config and on the wire ({@code architect}, {@code dev}, …). */
|
|
public String wireName() {
|
|
return name().toLowerCase(Locale.ROOT);
|
|
}
|
|
|
|
/**
|
|
* The {@code fleet:} block that holds this role's pool — {@code architects},
|
|
* {@code developers}, {@code reviewers}.
|
|
*
|
|
* <p>Plural, and not always the wire name: the pool of things a {@code dev} may run on reads
|
|
* naturally as {@code developers:}. The wire name stays the singular {@code dev}, because that
|
|
* is what a tab label and a roster row say.
|
|
*/
|
|
public String configKey() {
|
|
return switch (this) {
|
|
case ARCHITECT -> "architects";
|
|
case DEV -> "developers";
|
|
case REVIEWER -> "reviewers";
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The role owning the {@code fleet:} pool named {@code key}, or {@code null} when the key is not
|
|
* a role pool ({@code leaders}, {@code tabLabel}, …). Null rather than a throw: callers use this
|
|
* to sort a fleet block's children, where a non-pool key is normal rather than an error.
|
|
*/
|
|
public static MemberRole fromConfigKey(String key) {
|
|
if (key == null) {
|
|
return null;
|
|
}
|
|
String k = key.trim().toLowerCase(Locale.ROOT);
|
|
for (MemberRole r : values()) {
|
|
if (r.configKey().equals(k)) {
|
|
return r;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Parse a config/wire spelling, case-insensitively.
|
|
*
|
|
* @param s the spelling to parse
|
|
* @return the matching role
|
|
* @throws IllegalArgumentException when {@code s} is null, blank, or not a known role — the
|
|
* message lists the valid spellings, because a typo'd role in
|
|
* config should fail at startup with the fix in the error
|
|
*/
|
|
public static MemberRole parse(String s) {
|
|
if (s != null && !s.isBlank()) {
|
|
String t = s.trim().toLowerCase(Locale.ROOT);
|
|
for (MemberRole r : values()) {
|
|
if (r.wireName().equals(t)) {
|
|
return r;
|
|
}
|
|
}
|
|
}
|
|
StringBuilder valid = new StringBuilder();
|
|
for (MemberRole r : values()) {
|
|
if (!valid.isEmpty()) {
|
|
valid.append(", ");
|
|
}
|
|
valid.append(r.wireName());
|
|
}
|
|
throw new IllegalArgumentException(
|
|
"unknown member role '" + s + "'; valid roles are: " + valid);
|
|
}
|
|
}
|