diff --git a/src/features/teams/index.ts b/src/features/teams/index.ts index ec940023..5542ff0e 100644 --- a/src/features/teams/index.ts +++ b/src/features/teams/index.ts @@ -22,3 +22,7 @@ export { type Team, type Position, type Relationship, type RelationshipArchetype, type CommunicationForm, type CommEdge, type PositionKind, } from "./lib/team"; +// The effective-team precedence resolver (#3152, epic #3151) — the planner's `teamFleet.ts` composes +// this with its own `bsc plan team get` binding + `blueprint.team` once #3152's plan.db/CLI half +// lands (blocked on this stream's write scope; see `bsc plan request 1`). +export { effectiveTeam, type TeamGraph } from "./lib/effectiveTeam"; diff --git a/src/features/teams/lib/effectiveTeam.test.ts b/src/features/teams/lib/effectiveTeam.test.ts new file mode 100644 index 00000000..aa13aa12 --- /dev/null +++ b/src/features/teams/lib/effectiveTeam.test.ts @@ -0,0 +1,32 @@ +import { describe, it, expect } from "vitest"; +import { effectiveTeam, type TeamGraph } from "./effectiveTeam"; + +const graph = (n: string): TeamGraph => ({ + positions: [{ nodeId: n, kind: "agent", personaId: `persona-${n}` }], + relationships: [], +}); + +describe("effectiveTeam (#3152)", () => { + it("falls back to the blueprint's team when no per-project binding has ever been set", () => { + const blueprintTeam = graph("architect"); + expect(effectiveTeam(null, blueprintTeam)).toBe(blueprintTeam); + expect(effectiveTeam(undefined, blueprintTeam)).toBe(blueprintTeam); + }); + + it("the per-project binding wins over the blueprint's team once set", () => { + const binding = graph("curator"); + const blueprintTeam = graph("architect"); + expect(effectiveTeam(binding, blueprintTeam)).toBe(binding); + }); + + it("an explicitly-set EMPTY binding still wins — a deliberate unpin is a recorded decision, not an absence", () => { + const empty: TeamGraph = { positions: [], relationships: [] }; + const blueprintTeam = graph("architect"); + expect(effectiveTeam(empty, blueprintTeam)).toBe(empty); + }); + + it("no binding and no blueprint team ⇒ no team", () => { + expect(effectiveTeam(null, undefined)).toBeUndefined(); + expect(effectiveTeam(undefined, undefined)).toBeUndefined(); + }); +}); diff --git a/src/features/teams/lib/effectiveTeam.ts b/src/features/teams/lib/effectiveTeam.ts new file mode 100644 index 00000000..306e4123 --- /dev/null +++ b/src/features/teams/lib/effectiveTeam.ts @@ -0,0 +1,32 @@ +// Effective team resolution (#3152, part of the Teams-in-Planner epic #3151) — a project's team can +// come from two places: its OWN per-project binding (planner-owned storage, `bsc plan team get/set`, +// decoupled from the blueprint per option (b)) or the BLUEPRINT it was seeded from (`blueprint.team`, +// #2450, a reusable template). The binding wins once a project has pinned its own team; an unset +// binding falls back to the blueprint's team so a project that never re-pins keeps working exactly as +// before (#3101/#3102 seeding stays byte-identical). Pure (no React/Tauri/bsc) so both this feature +// and the planner's fleet-seeding path (`teamFleet.ts`) can unit-test the precedence directly. +import type { Position, Relationship } from "./team"; + +/** The minimal team shape this resolver needs — structurally the same as this feature's {@link Team} + * minus its library-identity fields (id/name/blurb/builtin), and the same shape the planner's + * `BlueprintTeam` and a `bsc plan team get` blob carry. Kept local (not imported from the planner) so + * this feature never depends on it — the planner depends on teams, never the reverse. */ +export interface TeamGraph { + positions: Position[]; + relationships: Relationship[]; +} + +/** + * Resolve a project's EFFECTIVE team: its per-project `binding` (from `bsc plan team get`) if one has + * ever been set, else the `blueprintTeam` it was seeded from. `binding` is `null`/`undefined` exactly + * when no per-project team has been set yet (the planner's blob store returns nothing until the first + * `bsc plan team set`) — that, and only that, is what falls back; an explicitly-set EMPTY binding + * (`{ positions: [], relationships: [] }`, a project deliberately unpinning its team) still wins, since + * it is a real, its own recorded decision. + */ +export function effectiveTeam( + binding: TeamGraph | null | undefined, + blueprintTeam: TeamGraph | undefined, +): TeamGraph | undefined { + return binding ?? blueprintTeam; +}