@aeondave/pi-persona 1.15.2 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +32 -6
  2. package/CHANGELOG.md +97 -0
  3. package/README.md +27 -8
  4. package/SECURITY.md +42 -0
  5. package/docs/ARCHITECTURE.md +52 -7
  6. package/docs/EXPERIENCE.md +21 -5
  7. package/docs/README.md +2 -0
  8. package/docs/REFERENCE.md +84 -14
  9. package/docs/STRATEGIES.md +58 -31
  10. package/docs/TELEMETRY.md +14 -0
  11. package/package.json +18 -11
  12. package/src/bus/broker/paths.ts +3 -2
  13. package/src/core/brief.ts +1 -1
  14. package/src/core/nudge.ts +3 -4
  15. package/src/core/pi-compat.ts +3 -3
  16. package/src/engine/adapter.ts +11 -6
  17. package/src/engine/async.ts +12 -6
  18. package/src/engine/inproc.ts +14 -6
  19. package/src/extension/engine.ts +25 -4
  20. package/src/extension/hooks.ts +68 -21
  21. package/src/extension.ts +233 -57
  22. package/src/orchestration/model-retry.ts +32 -61
  23. package/src/orchestration/params.ts +27 -1
  24. package/src/orchestration/roster.ts +80 -3
  25. package/src/orchestration/sdk.ts +150 -36
  26. package/src/orchestration/strategies/compete.ts +14 -8
  27. package/src/orchestration/strategies/council-rounds.ts +8 -1
  28. package/src/orchestration/strategies/critic-loop.ts +25 -11
  29. package/src/orchestration/strategies/judge.ts +18 -9
  30. package/src/orchestration/strategies/magi.ts +42 -93
  31. package/src/orchestration/strategies/map.ts +41 -19
  32. package/src/orchestration/strategies/pipeline.ts +2 -2
  33. package/src/orchestration/strategies/synthesize.ts +18 -11
  34. package/src/orchestration/types.ts +15 -0
  35. package/src/persona/model-participants.ts +240 -0
  36. package/src/persona/orchestrate.ts +44 -2
  37. package/src/persona/persona.ts +86 -14
  38. package/src/telemetry/contract.ts +1 -1
  39. package/src/telemetry/producer.ts +241 -8
  40. package/src/tools/council.ts +36 -8
  41. package/src/tools/delegate-tool.ts +162 -24
  42. package/src/tools/intercom-tool.ts +119 -32
  43. package/src/ui/agent-overlay.ts +15 -6
  44. package/src/ui/agent-tree.ts +39 -2
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Participant-aware model resolution for an orchestrated run.
3
+ *
4
+ * A strategy's participants are NOT just its roster members: an arbiter (`params.judge`), a
5
+ * synthesiser, a critic or a generator is a real participant too — it spawns, it is billed, and its
6
+ * model matters exactly as much as a core's. This module is the ONE pure place that answers two
7
+ * questions for the UI layer:
8
+ *
9
+ * 1. WHO runs — roster members plus every auxiliary actor declared by an agent-typed strategy
10
+ * param (metadata-driven, so a new strategy needs no name switch here).
11
+ * 2. ON WHAT — the effective model by precedence:
12
+ * explicit inline member model
13
+ * > saved per-persona assignment for the ROLE key
14
+ * > saved per-persona assignment for the agent key
15
+ * > the agent's own frontmatter model
16
+ * > the session model (the default, NOT an assignment — it must not suppress the picker).
17
+ *
18
+ * The persisted assignment KEY is `agent` for a bare member — the historical shape, so every
19
+ * existing `config.json` keeps applying — and `agent#<hash-of-agent+role>` for a member carrying a
20
+ * role. The hash keeps the full role as the identity without persisting the role PROMPT (which is
21
+ * an instruction, not a key, and would leak strategy prose into the user's config file). The human
22
+ * label stays separate, so the picker and the tree can still say `reviewer · SECURITY`.
23
+ *
24
+ * Pure: no engine, no UI, no config store, no clock. The picker decides; this decides WHAT it may
25
+ * ask about.
26
+ */
27
+
28
+ import { createHash } from "node:crypto";
29
+
30
+ import { memberBaseLabel, parseRosterMember, type RosterMember, type RosterSpec } from "../orchestration/roster.ts";
31
+ import { knownParams } from "../orchestration/strategy.ts";
32
+ import type { StrategyParam } from "../orchestration/sdk.ts";
33
+
34
+ /** Where a participant's effective model came from — in precedence order. */
35
+ export type ModelSource = "inline" | "assignment" | "agent" | "session" | "none";
36
+
37
+ /** A strategy param that names a real participant (an arbiter, a synthesiser, …) is marked
38
+ * `type: "agent"` in the strategy's own metadata — the single declaration, read from
39
+ * `knownParams`, so adding a strategy never means editing this file. */
40
+ function isAgentParam(param: StrategyParam): boolean {
41
+ return param.type === "agent";
42
+ }
43
+
44
+ export interface ModelParticipant {
45
+ /** The agent that runs. */
46
+ agent: string;
47
+ /** The on-the-fly role prompt, when the participant carries one (never persisted in `key`). */
48
+ role?: string;
49
+ /** Stable model-assignment key: the agent name, or `agent#<hash>` for a role member. */
50
+ key: string;
51
+ /** What a human should see: `agent`, or `agent · HINT` for a role member. */
52
+ label: string;
53
+ /** A roster member, or an auxiliary actor declared by a strategy param. */
54
+ origin: "roster" | "param";
55
+ /** The strategy param that declares this auxiliary actor (roster members omit it). */
56
+ param?: string;
57
+ /** The effective model for this participant, by precedence; undefined when nothing supplies one. */
58
+ model?: string;
59
+ /** Which step of the precedence supplied `model`. */
60
+ source: ModelSource;
61
+ /** Someone already chose this participant's model (inline, a saved assignment, or the agent's own
62
+ * frontmatter) ⇒ never a picker candidate. The session default is deliberately NOT counted. */
63
+ assigned: boolean;
64
+ }
65
+
66
+ export interface ParticipantInput {
67
+ /** The run's members (the caller resolves inline-over-roster via
68
+ * `resolveOrchestrationMembers`). */
69
+ members?: RosterMember[];
70
+ /** The running strategy — used to look up its declared agent-typed params. */
71
+ strategy?: string;
72
+ /** The strategy's params, as the run will see them. */
73
+ params?: Record<string, unknown>;
74
+ /** Param schema override; defaults to `knownParams(strategy)`. Test/forward seam. */
75
+ schema?: Record<string, StrategyParam>;
76
+ /** The persona's saved assignments, keyed by `participantKey`. */
77
+ assigned?: Record<string, string>;
78
+ /** An agent's own declared model (frontmatter). */
79
+ agentModel?: (agent: string) => string | undefined;
80
+ /** The session's own model — the last-resort default. */
81
+ sessionModel?: string;
82
+ }
83
+
84
+ /**
85
+ * The digest behind a role key. SHA-256 (128 bits kept) rather than a short rolling hash: this
86
+ * value is the identity a model assignment is filed under, so a collision would hand one lens
87
+ * another's model — a silent, expensive wrong answer. `crypto` is a node builtin (no dependency,
88
+ * no host package), and the digest is deterministic across processes and runs, which is what lets
89
+ * a persisted key resolve to the same participant tomorrow.
90
+ */
91
+ function stableDigest(value: string): string {
92
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
93
+ }
94
+
95
+ /**
96
+ * The model-assignment key for a participant. A bare member (no role) keeps the agent name — the
97
+ * pre-role key shape, so assignments saved before roles existed keep applying to it.
98
+ */
99
+ export function participantKey(agent: string, role?: string): string {
100
+ const trimmed = role?.trim();
101
+ return trimmed ? `${agent}#${stableDigest(`${agent}\0${trimmed}`)}` : agent;
102
+ }
103
+
104
+ /** One participant's model, resolved in the one precedence order the UI and the engines share. */
105
+ function fromSpec(spec: RosterSpec, origin: "roster" | "param", param: string | undefined, input: ParticipantInput): ModelParticipant {
106
+ const assigned = input.assigned ?? {};
107
+ const key = participantKey(spec.agent, spec.role);
108
+ let model: string | undefined;
109
+ let source: ModelSource = "none";
110
+ if (spec.model) {
111
+ model = spec.model;
112
+ source = "inline";
113
+ } else if (assigned[key]) {
114
+ model = assigned[key];
115
+ source = "assignment";
116
+ } else if (assigned[spec.agent]) {
117
+ model = assigned[spec.agent];
118
+ source = "assignment";
119
+ } else {
120
+ const agentModel = input.agentModel?.(spec.agent);
121
+ if (agentModel) {
122
+ model = agentModel;
123
+ source = "agent";
124
+ } else if (input.sessionModel) {
125
+ model = input.sessionModel;
126
+ source = "session";
127
+ }
128
+ }
129
+ return {
130
+ agent: spec.agent,
131
+ ...(spec.role ? { role: spec.role } : {}),
132
+ key,
133
+ label: memberBaseLabel(asMember(spec)),
134
+ origin,
135
+ ...(param ? { param } : {}),
136
+ ...(model ? { model } : {}),
137
+ source,
138
+ assigned: source === "inline" || source === "assignment" || source === "agent",
139
+ };
140
+ }
141
+
142
+ /** Rebuild the member shape `memberBaseLabel` normalises (it takes a RosterMember, not a spec). */
143
+ function asMember(spec: RosterSpec): RosterMember {
144
+ return spec.role ? { agent: spec.agent, role: spec.role, ...(spec.model ? { model: spec.model } : {}) } : spec.agent;
145
+ }
146
+
147
+ /** An auxiliary actor resolves in ONE chain: the value the caller supplied → (only where the
148
+ * metadata says so) the roster member it names or points at → the real agent named by `default`.
149
+ * A default that is a SENTENCE rather than an agent ("the first roster agent") is no participant
150
+ * at all — taking a phrase literally would offer a model for a leg that can never run. */
151
+ function auxActorSpec(input: ParticipantInput, paramName: string, param: StrategyParam): RosterSpec | undefined {
152
+ const supplied = input.params?.[paramName];
153
+ const spec = toSpec(supplied);
154
+ if (spec) {
155
+ // `inheritRoster` is opt-in per param (e.g. `map.verify`'s documented "a member named X"
156
+ // lookup) and only ever rewrites a BARE selector. An arbiter/critic keeps its historic
157
+ // meaning — naming that agent IS the actor, whatever role a panel member happens to wear.
158
+ if (param.inheritRoster && typeof supplied === "string") {
159
+ const seated = rosterMemberNamed(input.members, spec.agent);
160
+ if (seated) return seated;
161
+ }
162
+ return spec;
163
+ }
164
+ const index = rosterIndexOf(param);
165
+ if (index !== undefined) {
166
+ const seated = toSpec(input.members?.[index]);
167
+ if (seated) return seated;
168
+ }
169
+ return isAgentName(param.default) ? toSpec(param.default) : undefined;
170
+ }
171
+
172
+ /** The roster entry an aux selector names, verbatim — its role/inline model included, so the aux
173
+ * actor and the panel entry stay ONE participant with ONE model key. */
174
+ function rosterMemberNamed(members: RosterMember[] | undefined, agent: string): RosterSpec | undefined {
175
+ for (const member of members ?? []) {
176
+ const spec = toSpec(member);
177
+ if (spec?.agent === agent) return spec;
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ /** The roster position an agent-typed param defaults to, when its metadata declares one. */
183
+ function rosterIndexOf(param: StrategyParam): number | undefined {
184
+ const index = param.rosterIndex;
185
+ return typeof index === "number" && Number.isInteger(index) && index >= 0 ? index : undefined;
186
+ }
187
+
188
+ /** Is this default an AGENT NAME (one token) rather than a documentation phrase? Agent names never
189
+ * contain whitespace, so a multi-word default is prose describing a default, not a participant. */
190
+ function isAgentName(value: unknown): boolean {
191
+ return typeof value === "string" && value.trim().length > 0 && !/\s/.test(value.trim());
192
+ }
193
+
194
+ /** Every participant a run really spawns: the roster first (in run order), then auxiliary actors. */
195
+ export function collectModelParticipants(input: ParticipantInput): ModelParticipant[] {
196
+ const out: ModelParticipant[] = [];
197
+ const seen = new Set<string>();
198
+ for (const raw of input.members ?? []) {
199
+ const spec = toSpec(raw);
200
+ if (!spec) continue;
201
+ const p = fromSpec(spec, "roster", undefined, input);
202
+ if (seen.has(p.key)) continue;
203
+ seen.add(p.key);
204
+ out.push(p);
205
+ }
206
+ const schema = input.schema ?? (input.strategy ? knownParams(input.strategy) : undefined);
207
+ if (schema) {
208
+ for (const [paramName, param] of Object.entries(schema)) {
209
+ if (!isAgentParam(param)) continue;
210
+ const spec = auxActorSpec(input, paramName, param);
211
+ if (!spec) continue;
212
+ const p = fromSpec(spec, "param", paramName, input);
213
+ if (seen.has(p.key)) continue;
214
+ seen.add(p.key);
215
+ out.push(p);
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
221
+ /** The participants a picker may still ask about: nobody has chosen a model for them, and this
222
+ * session has not already asked (and been dismissed) for that key. The caller records the keys it
223
+ * asks about BEFORE asking, so a dismissal is remembered too. */
224
+ export function pendingParticipants(
225
+ participants: readonly ModelParticipant[],
226
+ opts: { prompted: ReadonlySet<string> },
227
+ ): ModelParticipant[] {
228
+ return participants.filter((p) => !p.assigned && !opts.prompted.has(p.key));
229
+ }
230
+
231
+ // A roster member / param value is either a bare agent name or an inline specialisation map. Both
232
+ // come from the shared roster grammar (`parseRosterMember`), so an auxiliary actor named inline in
233
+ // a `params.judge` entry is normalised exactly like a `teams.yaml` member.
234
+ function toSpec(value: unknown): RosterSpec | undefined {
235
+ if (value === undefined || value === null || typeof value === "boolean" || typeof value === "number") return undefined;
236
+ const member = parseRosterMember(value);
237
+ if (!member) return undefined;
238
+ const spec = typeof member === "string" ? { agent: member } : member;
239
+ return spec.agent.trim() ? spec : undefined;
240
+ }
@@ -31,6 +31,31 @@ export function resolveStrategyName(orch: OrchestrationGrammar): string | undefi
31
31
  return undefined;
32
32
  }
33
33
 
34
+ /** The reserved team key under which an EPHEMERAL `members:` list is registered for one run.
35
+ * A CLONE of the shared team map carries it, so no team file, discovery map, or later call
36
+ * ever sees it — the reserved key is a per-call implementation detail, not a team name. */
37
+ export const ADHOC_MEMBERS_KEY = "__adhoc_members__";
38
+
39
+ /**
40
+ * The members a run actually uses: inline `orch.members` when present (they WIN the named
41
+ * team — ephemeral, one call, nothing written back), else the named `orch.roster`'s members.
42
+ * An unknown team name is a diagnostic naming the known teams, raised BEFORE any engine call.
43
+ * No roster at all ⇒ `[]`; whether that is fatal is the strategy's own call.
44
+ */
45
+ export function resolveOrchestrationMembers(
46
+ orch: Pick<OrchestrationGrammar, "roster" | "members">,
47
+ teams: Record<string, RosterMember[]>,
48
+ ): RosterMember[] {
49
+ if (orch.members && orch.members.length > 0) return orch.members;
50
+ if (!orch.roster) return [];
51
+ const members = teams[orch.roster];
52
+ if (!members) {
53
+ const known = Object.keys(teams).sort();
54
+ throw new Error(`unknown roster "${orch.roster}" (${known.length > 0 ? `available teams: ${known.join(", ")}` : "no teams are defined"})`);
55
+ }
56
+ return members;
57
+ }
58
+
34
59
  export interface RunStrategyDeps {
35
60
  engine: StrategyEngine;
36
61
  teams: Record<string, RosterMember[]>;
@@ -39,6 +64,11 @@ export interface RunStrategyDeps {
39
64
  /** The session's own model (`provider/id`) — a strategy's last-resort recovery model for a
40
65
  * member whose own model broke with no healthy peer to borrow from. */
41
66
  sessionModel?: string;
67
+ /** The session model read AT USE TIME (the session can switch models mid-run), forwarded
68
+ * beside the static `sessionModel` so a late leg still sees the current one. */
69
+ getSessionModel?: () => string | undefined;
70
+ /** A provider reroute was applied to one leg (the fallback layer's telemetry hook). */
71
+ onModelFallback?: (info: { agent: string; from?: string; to: string; key: string }) => void;
42
72
  log?: (message: string) => void;
43
73
  /** Per-agent lifecycle, for live UI (which roster agent is running/done + its result).
44
74
  * `key` is a run-unique display id (disambiguates same-agent roster-role members). */
@@ -66,9 +96,18 @@ export async function runPersonaStrategy(
66
96
  // so the council/flow surfaces it instead of an opaque "no ruling".
67
97
  if (!strategy) throw new Error(`unknown strategy "${name}" (available: ${strategyNames().join(", ")})`);
68
98
 
69
- const sdkDeps: SDKDeps = { engine: deps.engine, roster: makeRoster(deps.teams), limits: deps.limits };
99
+ // Ephemeral members ride a CLONE of the shared team map under a reserved key: the
100
+ // strategies read members by team name, so this is the one seam that lets an ad-hoc
101
+ // council run through the exact same path — without ever mutating `deps.teams`.
102
+ // An unknown NAMED roster is still left to the strategy (which fails with its own
103
+ // "a roster of … is required" before any engine call); only inline members change here.
104
+ const adhocMembers = orch.members && orch.members.length > 0 ? orch.members : undefined;
105
+ const teams = adhocMembers ? { ...deps.teams, [ADHOC_MEMBERS_KEY]: adhocMembers } : deps.teams;
106
+ const sdkDeps: SDKDeps = { engine: deps.engine, roster: makeRoster(teams), limits: deps.limits };
70
107
  if (deps.signal) sdkDeps.signal = deps.signal;
71
108
  if (deps.sessionModel) sdkDeps.sessionModel = deps.sessionModel;
109
+ if (deps.getSessionModel) sdkDeps.getSessionModel = deps.getSessionModel;
110
+ if (deps.onModelFallback) sdkDeps.onModelFallback = deps.onModelFallback;
72
111
  if (deps.log) sdkDeps.log = deps.log;
73
112
  if (deps.onAgentStatus) sdkDeps.onAgentStatus = deps.onAgentStatus;
74
113
  if (deps.onAgentProgress) sdkDeps.onAgentProgress = deps.onAgentProgress;
@@ -77,7 +116,10 @@ export async function runPersonaStrategy(
77
116
  if (deps.canSpawn) sdkDeps.canSpawn = deps.canSpawn;
78
117
 
79
118
  const input: StrategyInput = { task, params: orch.params ?? {} };
80
- if (orch.roster) input.roster = orch.roster;
119
+ // Inline members win the named team for this call (`resolveOrchestrationMembers` is the
120
+ // shared read path for callers that need the roster resolved up front — the tree seeding).
121
+ if (adhocMembers) input.roster = ADHOC_MEMBERS_KEY;
122
+ else if (orch.roster) input.roster = orch.roster;
81
123
 
82
124
  return strategy.run(input, makeSDK(sdkDeps));
83
125
  }
@@ -10,6 +10,7 @@
10
10
  import { asBoolean, asPermission, asStringArray, parseYamlSubset, splitFrontmatter } from "../core/frontmatter.ts";
11
11
  import type { Permission } from "../core/permissions.ts";
12
12
  import { asSystemPromptMode, type SystemPromptMode } from "../core/types.ts";
13
+ import { parseRuntimeRosterMember, type RosterMember } from "../orchestration/roster.ts";
13
14
 
14
15
  export type OrchestrationMode = "solo" | "parallel" | "pipeline" | "strategy" | "flow";
15
16
  const ORCHESTRATION_MODES: readonly OrchestrationMode[] = ["solo", "parallel", "pipeline", "strategy", "flow"];
@@ -20,6 +21,9 @@ export interface OrchestrationGrammar {
20
21
  strategy?: string;
21
22
  flow?: string;
22
23
  roster?: string;
24
+ /** Ephemeral members for THIS run — validated inline specialisations that win `roster`
25
+ * (see `resolveOrchestrationMembers`). Never written back to a team or persona file. */
26
+ members?: RosterMember[];
23
27
  /** Strategy parameters (e.g. rounds, aggregate, critic). */
24
28
  params?: Record<string, unknown>;
25
29
  }
@@ -41,6 +45,13 @@ export interface CouncilSpec {
41
45
  export interface CouncilDraft {
42
46
  strategy?: string;
43
47
  roster?: string;
48
+ /** Ephemeral members declared inline (bare agent names or `{ agent, … }` maps), validated
49
+ * at parse. They win `roster` for the calls this persona convenes. */
50
+ members?: RosterMember[];
51
+ /** Set when a `members:` block was AUTHORED but unusable (not a list, empty, or an entry
52
+ * that is not a member). Kept as a diagnostic so the call fails loudly instead of
53
+ * quietly convening some other roster. */
54
+ membersProblem?: string;
44
55
  params?: Record<string, unknown>;
45
56
  /** A named preset (presets/<name>.preset.json) providing defaults; authored fields override. */
46
57
  preset?: string;
@@ -165,23 +176,51 @@ function parseDelegationPolicy(value: unknown): DelegationPolicy | undefined {
165
176
  return Object.keys(policy).length > 0 ? policy : undefined;
166
177
  }
167
178
 
168
- /** Parse a persona's `council:` block (strategy + roster + params, or just a `preset`). */
179
+ /** Parse a persona's `council:` block (strategy + roster + members + params, or just a `preset`). */
169
180
  function parseCouncil(value: unknown): CouncilDraft | undefined {
170
181
  if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
171
182
  const o = value as Record<string, unknown>;
172
183
  const strategy = typeof o.strategy === "string" && o.strategy.trim() ? o.strategy.trim() : "";
173
184
  const preset = typeof o.preset === "string" && o.preset.trim() ? o.preset.trim() : "";
174
- if (!strategy && !preset) return undefined; // a council needs at least a strategy or a preset
185
+ // Validate `members:` BEFORE the a-council-needs-a-strategy gate: a members-only block
186
+ // with a broken list is a broken declaration, and returning undefined here would drop the
187
+ // diagnostic and let the call quietly convene the MAGI fallback instead.
188
+ const parsedMembers = Object.hasOwn(o, "members") ? parseMembersBlock(o.members) : undefined;
189
+ if (!strategy && !preset && !parsedMembers) return undefined;
175
190
  const spec: CouncilDraft = {};
176
191
  if (strategy) spec.strategy = strategy;
177
192
  if (preset) spec.preset = preset;
178
193
  if (typeof o.roster === "string" && o.roster.trim()) spec.roster = o.roster.trim();
194
+ if (parsedMembers) {
195
+ if (parsedMembers.ok) spec.members = parsedMembers.members;
196
+ else spec.membersProblem = parsedMembers.error;
197
+ }
179
198
  if (o.params && typeof o.params === "object" && !Array.isArray(o.params)) {
180
199
  spec.params = o.params as Record<string, unknown>;
181
200
  }
182
201
  return spec;
183
202
  }
184
203
 
204
+ /**
205
+ * Validate an authored `members:` block into roster members. A block that was explicitly
206
+ * supplied but is not a usable list is an ERROR, never a silent absence: dropping it would
207
+ * fall back to the persona's named team (or MAGI) and present an unrelated ensemble as the
208
+ * one the author asked for. Members are validated with the STRICT runtime parser, so a
209
+ * malformed specialisation field (`tools: ["read", 42]`, `model: 42`) is a diagnostic rather
210
+ * than a silently dropped field that widens or narrows what the leg may do.
211
+ */
212
+ export function parseMembersBlock(value: unknown): { ok: true; members: RosterMember[] } | { ok: false; error: string } {
213
+ if (!Array.isArray(value)) return { ok: false, error: "council.members must be a list of agent names or { agent, … } members" };
214
+ if (value.length === 0) return { ok: false, error: "council.members is empty — declare at least one member or drop the key" };
215
+ const members: RosterMember[] = [];
216
+ for (const [i, raw] of value.entries()) {
217
+ const parsed = parseRuntimeRosterMember(raw);
218
+ if (!parsed.ok) return { ok: false, error: `council.members[${i}]: ${parsed.error}` };
219
+ members.push(parsed.member);
220
+ }
221
+ return { ok: true, members };
222
+ }
223
+
185
224
  /** Expand a council `preset` (presets/<name>.preset.json) into concrete fields: the preset
186
225
  * supplies defaults, authored fields win, and `params` shallow-merge (authored over preset).
187
226
  * The `preset` key is consumed. An unknown preset just drops the key (authored fields kept). */
@@ -190,8 +229,14 @@ export function expandCouncilPreset(draft: CouncilDraft, presets: Record<string,
190
229
  const base = presets[draft.preset];
191
230
  const { preset: _consumed, ...authored } = draft;
192
231
  if (!base) return authored;
193
- const merged: CouncilDraft = { ...base, ...authored };
194
- if (base.params || authored.params) merged.params = { ...base.params, ...authored.params };
232
+ // Presets only declare strategy, roster, and params. Copy that allowlist instead of
233
+ // spreading a runtime object, so an extra `members` key cannot bypass parseMembersBlock.
234
+ const defaults: CouncilDraft = {};
235
+ if (typeof base.strategy === "string" && base.strategy.trim()) defaults.strategy = base.strategy.trim();
236
+ if (typeof base.roster === "string" && base.roster.trim()) defaults.roster = base.roster.trim();
237
+ if (base.params && typeof base.params === "object" && !Array.isArray(base.params)) defaults.params = base.params;
238
+ const merged: CouncilDraft = { ...defaults, ...authored };
239
+ if (defaults.params || authored.params) merged.params = { ...defaults.params, ...authored.params };
195
240
  return merged;
196
241
  }
197
242
 
@@ -203,12 +248,19 @@ export interface CouncilInvocation {
203
248
  persona?: string | undefined;
204
249
  strategy?: string | undefined;
205
250
  roster?: string | undefined;
251
+ /** Raw `members:` from the call — validated HERE so an unusable block is a diagnostic
252
+ * rather than a silent fall back to the named team (a tool schema cannot validate it). */
253
+ members?: unknown;
206
254
  params?: Record<string, unknown> | undefined;
207
255
  }
208
256
 
209
257
  export interface ResolvedCouncilInvocation {
210
258
  strategy: string;
259
+ /** The named team. EMPTY when inline `members` are in effect — there is no named team for
260
+ * an ephemeral call, and a misleading team name would mislabel the card and the tree. */
211
261
  roster: string;
262
+ /** The ephemeral members actually in effect (validated), when any. */
263
+ members?: RosterMember[];
212
264
  params: Record<string, unknown>;
213
265
  /** Persona whose council declaration supplied the defaults, when any. */
214
266
  persona?: string;
@@ -218,6 +270,10 @@ export type CouncilInvocationResolution =
218
270
  | { ok: true; value: ResolvedCouncilInvocation }
219
271
  | { ok: false; error: string };
220
272
 
273
+ function hasCouncilDeclaration(council: CouncilDraft | undefined): boolean {
274
+ return Boolean(council?.strategy || council?.members || council?.membersProblem);
275
+ }
276
+
221
277
  /** Resolve a council call without mutating the active persona.
222
278
  *
223
279
  * An explicit persona is intentionally strict: it must exist and declare a
@@ -236,7 +292,7 @@ export function resolveCouncilInvocation(
236
292
  sourcePersona = personas.find((persona) => persona.isPersona && persona.name === requestedName);
237
293
  if (!sourcePersona) {
238
294
  const available = personas
239
- .filter((persona) => persona.isPersona && Boolean(persona.council?.strategy))
295
+ .filter((persona) => persona.isPersona && hasCouncilDeclaration(persona.council))
240
296
  .map((persona) => persona.name)
241
297
  .sort();
242
298
  return {
@@ -244,30 +300,46 @@ export function resolveCouncilInvocation(
244
300
  error: `no persona named "${requestedName}". Council personas: ${available.join(", ") || "(none)"}`,
245
301
  };
246
302
  }
247
- if (!sourcePersona.council?.strategy) {
303
+ if (!hasCouncilDeclaration(sourcePersona.council)) {
248
304
  return {
249
305
  ok: false,
250
306
  error: `persona "${requestedName}" declares no usable council`,
251
307
  };
252
308
  }
253
- } else if (activePersona?.council?.strategy) {
309
+ } else if (hasCouncilDeclaration(activePersona?.council)) {
254
310
  sourcePersona = activePersona;
255
311
  }
256
312
 
257
313
  const base = sourcePersona?.council;
314
+ if (base?.membersProblem) {
315
+ return { ok: false, error: `${requestedName ? `persona "${requestedName}"` : `the active persona "${activePersona?.name ?? "?"}"`}: ${base.membersProblem}` };
316
+ }
258
317
  const strategy = request.strategy?.trim() || base?.strategy || "magi";
259
- const roster =
260
- request.roster?.trim() ||
261
- base?.roster ||
262
- sourcePersona?.orchestration?.roster ||
263
- (!requestedName ? activePersona?.orchestration?.roster : undefined) ||
264
- "magi";
318
+ // Ephemeral members win the named team, for this call only: nothing is written back to
319
+ // teams.yaml, the persona file, or any model/prompt/capability the profile would imply.
320
+ let members = base?.members;
321
+ if (request.members !== undefined) {
322
+ const parsed = parseMembersBlock(request.members);
323
+ if (!parsed.ok) return { ok: false, error: parsed.error };
324
+ members = parsed.members;
325
+ }
326
+ const roster = members
327
+ ? ""
328
+ : request.roster?.trim() ||
329
+ base?.roster ||
330
+ sourcePersona?.orchestration?.roster ||
331
+ (!requestedName ? activePersona?.orchestration?.roster : undefined) ||
332
+ "magi";
333
+ // Profile params belong to the profile's strategy. A per-call strategy switch starts
334
+ // with that strategy's own defaults; only explicitly supplied params cross the switch.
335
+ const inheritedParams = strategy !== (base?.strategy || "magi") ? {} : base?.params ?? {};
265
336
  const value: ResolvedCouncilInvocation = {
266
337
  strategy,
267
338
  roster,
268
- params: { ...(base?.params ?? {}), ...(request.params ?? {}) },
339
+ params: { ...inheritedParams, ...(request.params ?? {}) },
269
340
  };
270
341
  if (sourcePersona) value.persona = sourcePersona.name;
342
+ if (members) value.members = members;
271
343
  return { ok: true, value };
272
344
  }
273
345
 
@@ -4,7 +4,7 @@ export const TELEMETRY_EVENT_NAME = "pi:telemetry" as const;
4
4
  export const LEGACY_TELEMETRY_EVENT_NAME = "pi-persona:telemetry" as const;
5
5
  export const LEGACY_TELEMETRY_VERSION = 1 as const;
6
6
  export const TELEMETRY_PRODUCER_ID = "pi-persona" as const;
7
- export const TELEMETRY_PRODUCER_VERSION = "1.15.2" as const;
7
+ export const TELEMETRY_PRODUCER_VERSION = "1.16.1" as const;
8
8
 
9
9
  export type InstanceStatus = "active" | "idle" | "stopped" | "stale" | (string & {});
10
10
  export type AgentKind = "subagent" | "delegate" | "council" | "flow" | "phase" | (string & {});