@booplex/bpx-consult 0.1.0

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.
package/src/debate.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * debate — sequential adversarial mode.
3
+ *
4
+ * Advocate proposes → critic attacks → advocate rebuts, for N rounds (default 2),
5
+ * then a synthesizer issues a closing verdict. Reuses council's stance-injected
6
+ * persona prompts and my-zen's challenge.py "critically reassess, do not
7
+ * reflexively agree" framing for the attack step, so the critic genuinely
8
+ * stress-tests rather than rubber-stamps.
9
+ *
10
+ * Unlike council (parallel, stateless members), debate is STATEFUL-SEQUENTIAL:
11
+ * each round must thread the prior round's argument so the critic can attack
12
+ * something specific and the advocate can rebut the actual critique. Four
13
+ * things the design therefore nails down:
14
+ *
15
+ * 1. ROUND-TO-ROUND THREADING — the prior position is passed as a user
16
+ * message in the next call, so round-2's critic references round-1's
17
+ * advocate by substance, not from a blank slate.
18
+ * 2. PER-ROUND §C RE-FIT — the debate transcript GROWS each round (prior
19
+ * rounds accumulate), so the fitted context must be recomputed every
20
+ * call or the last round overflows. We re-fit on each step against the
21
+ * smaller of the two debaters' windows.
22
+ * 3. GENUINE CLASH → CLOSING VERDICT — stances are for/against by design;
23
+ * the synthesizer is told to resolve, not paper over. A debate that
24
+ * converges is pointless, so the prompt asks for the strongest version
25
+ * of each side and a decisive call.
26
+ * 4. SEQUENTIAL LATENCY BUDGET — total time is sum-of-rounds (advocate +
27
+ * critic + rebut per round × rounds + synth), so it has its own budget
28
+ * distinct from council's per-member one. Configurable rounds (1-4).
29
+ */
30
+
31
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@earendil-works/pi-coding-agent";
32
+ import type { Message } from "@earendil-works/pi-ai";
33
+ import { buildSessionContext, convertToLlm } from "@earendil-works/pi-coding-agent";
34
+ import { callAdvisor, resolveAdvisor, type ResolvedAdvisor } from "./advisor.js";
35
+ import { buildConsultContext, type ContextBudget } from "./context-engine.js";
36
+ import type { BpxConsultConfig } from "./config.js";
37
+ import { personaSystemPrompt, resolvePersona } from "./personas.js";
38
+ import { linkSignal, withTimeout } from "./timeout.js";
39
+
40
+ export interface DebateDetails {
41
+ mode: "debate";
42
+ rounds: number;
43
+ advocate: string;
44
+ critic: string;
45
+ synthesizer: string;
46
+ steps: Array<{ round: number; role: "advocate" | "critic"; status: string }>;
47
+ /** Estimated tokens of the final synthesizer input (grown transcript). */
48
+ finalTranscriptTokens?: number;
49
+ usage?: { input: number; output: number; total: number };
50
+ stopReason?: string;
51
+ errorMessage?: string;
52
+ }
53
+
54
+ const SYNTHESIZER_SYSTEM_PROMPT = `You are the synthesizer closing an adversarial debate. An advocate argued FOR a position across multiple rounds; a critic attacked it. Your job is to issue a decisive verdict for the executor.
55
+
56
+ Rules:
57
+ - The debate existed to stress-test the position. If the critic landed real blows, say so and adjust the verdict. If the advocate held, say so.
58
+ - A debate that "agreed to disagree" is a failure of synthesis — make a call. PLAN, CORRECTION, or STOP, with reasoning.
59
+ - You never call tools. You issue the verdict.`;
60
+
61
+ // my-zen challenge.py framing, adapted: forces genuine critique, not reflexive agreement.
62
+ const CRITIC_ATTACK_FRAME = (priorPosition: string) =>
63
+ `The advocate just argued:\n\n${priorPosition}\n\nCritically reassess this position. Do NOT reflexively agree to avoid conflict — think hard about where it's wrong, what it assumed, what it overlooked. If it's sound on a point, concede that point and attack the weaker ones. But pressure-test it for real. Your job is the strongest case AGAINST, backed by reason.`;
64
+
65
+ const ADVOCATE_REBUT_FRAME = (critique: string) =>
66
+ `The critic attacked your position:\n\n${critique}\n\nRebut. Where the critic was right, concede and adjust. Where the critic was wrong, defend with reason. Then restate your strongest case FOR, incorporating what survived the critique. Do not just repeat round 1 — respond to THIS critique.`;
67
+
68
+ export interface ExecuteDebateInput {
69
+ ctx: ExtensionContext;
70
+ config: BpxConsultConfig;
71
+ signal: AbortSignal | undefined;
72
+ onUpdate: AgentToolUpdateCallback<DebateDetails> | undefined;
73
+ question?: string;
74
+ }
75
+
76
+ export async function executeDebate(input: ExecuteDebateInput): Promise<AgentToolResult<DebateDetails>> {
77
+ const { ctx, config, signal: parentSignal, onUpdate, question } = input;
78
+ const debateConfig = config.modes?.debate;
79
+ const rounds = clampRounds(debateConfig?.rounds);
80
+
81
+ const advocatePersona = resolvePersona(debateConfig?.advocate ?? "architect", config.personas as never);
82
+ const criticPersona = resolvePersona(debateConfig?.critic ?? "critic", config.personas as never);
83
+ if (!advocatePersona || !criticPersona) {
84
+ const missing = !advocatePersona ? debateConfig?.advocate : debateConfig?.critic;
85
+ return err(`Unknown persona "${missing}". Check modes.debate in config.`, emptyDetails(config));
86
+ }
87
+
88
+ const advocate = resolveAdvisor(ctx, advocatePersona.defaultModel ?? config.modes?.solo?.model);
89
+ const critic = resolveAdvisor(ctx, criticPersona.defaultModel ?? config.modes?.solo?.model);
90
+ const synthKey = config.modes?.council?.synthesizer?.model ?? config.modes?.solo?.model;
91
+ const synth = resolveAdvisor(ctx, synthKey);
92
+ if (!advocate || !critic || !synth) {
93
+ const unresolved = [
94
+ !advocate && `advocate (${advocatePersona.defaultModel ?? config.modes?.solo?.model})`,
95
+ !critic && `critic (${criticPersona.defaultModel ?? config.modes?.solo?.model})`,
96
+ !synth && `synthesizer (${synthKey})`,
97
+ ].filter(Boolean).join("; ");
98
+ return err(`Could not resolve debate models: ${unresolved}.`, emptyDetails(config));
99
+ }
100
+
101
+ const details: DebateDetails = {
102
+ mode: "debate",
103
+ rounds,
104
+ advocate: advocate.label,
105
+ critic: critic.label,
106
+ synthesizer: synth.label,
107
+ steps: [],
108
+ };
109
+ const pushStep = (round: number, role: "advocate" | "critic", status: string) => {
110
+ details.steps.push({ round, role, status });
111
+ onUpdate?.({ content: [{ type: "text", text: `Debate round ${round}/${rounds}, ${role}: ${status}` }], details });
112
+ };
113
+
114
+ // --- Build the seed context once (the executor's compacted session) ---
115
+ const contextBudget = config.contextBudget as ContextBudget;
116
+ const { messages: sessionMessages } = buildSessionContext(
117
+ ctx.sessionManager.getEntries(),
118
+ ctx.sessionManager.getLeafId(),
119
+ );
120
+ const branchMessages: Message[] = convertToLlm(sessionMessages);
121
+ const directive = question?.trim() ? `Specific question from the executor: ${question.trim()}` : undefined;
122
+ const sessionId = ctx.sessionManager.getSessionId();
123
+
124
+ // The debate transcript grows each round — re-fit per call to the smaller of
125
+ // the two debaters' windows so the last round can't overflow (§C invariant).
126
+ const fitWindow = Math.min(advocate.model.contextWindow, critic.model.contextWindow);
127
+
128
+ function fitWithContext(extra: string): Message[] {
129
+ const fit = buildConsultContext({
130
+ sessionMessages: branchMessages,
131
+ advisorContextWindow: fitWindow,
132
+ budget: contextBudget,
133
+ directive: [directive, extra].filter(Boolean).join("\n\n") || undefined,
134
+ });
135
+ return fit.messages;
136
+ }
137
+
138
+ // Wall-clock budget across all rounds + synth. consult() is executor-callable,
139
+ // so an autonomous debate can hang mid-round with no human to interrupt —
140
+ // this is the last unprotected path after council (per-member abort) and CLI
141
+ // (resolveShellTimeoutMs). withTimeout fires an AbortController whose signal
142
+ // propagates into every callStep, so the in-flight round aborts cleanly.
143
+ const debateTimeoutMs = debateConfig?.timeoutMs ?? 180000;
144
+
145
+ const outcome = await withTimeout(debateTimeoutMs, parentSignal, async (debateSignal) => {
146
+ try {
147
+ // Round 1: advocate opens with the strongest FOR case.
148
+ pushStep(1, "advocate", "running");
149
+ const r1Advocate = await callStep(ctx, advocate, advocatePersona.systemPrompt, fitWithContext(
150
+ "OPENING: make the strongest case FOR the position under debate.",
151
+ ), advocatePersona.thinkingLevel, debateSignal, sessionId);
152
+ if (!r1Advocate.ok) { pushStep(1, "advocate", "error"); return err(`Round 1 advocate failed: ${r1Advocate.error}`, details); }
153
+ pushStep(1, "advocate", "ok");
154
+
155
+ // Walk the rounds. Round 1's critic attacks the round-1 advocate; for
156
+ // rounds > 1, the advocate rebuts the prior critique then the critic
157
+ // attacks the rebuttal. We thread the immediately-prior argument each step.
158
+ let lastAdvocateText = r1Advocate.text;
159
+ let lastCriticText: string | undefined;
160
+
161
+ for (let round = 1; round <= rounds; round++) {
162
+ if (round > 1) {
163
+ // Advocate rebuts the prior round's critique.
164
+ pushStep(round, "advocate", "running");
165
+ const rebut = await callStep(ctx, advocate, personaSystemPrompt(advocatePersona), fitWithContext(
166
+ ADVOCATE_REBUT_FRAME(lastCriticText ?? ""),
167
+ ), advocatePersona.thinkingLevel, debateSignal, sessionId);
168
+ if (!rebut.ok) { pushStep(round, "advocate", "error"); return err(`Round ${round} advocate rebuttal failed: ${rebut.error}`, details); }
169
+ pushStep(round, "advocate", "ok");
170
+ lastAdvocateText = rebut.text;
171
+ }
172
+
173
+ // Critic attacks the current advocate position.
174
+ pushStep(round, "critic", "running");
175
+ const attack = await callStep(ctx, critic, personaSystemPrompt(criticPersona), fitWithContext(
176
+ CRITIC_ATTACK_FRAME(lastAdvocateText),
177
+ ), criticPersona.thinkingLevel, debateSignal, sessionId);
178
+ if (!attack.ok) { pushStep(round, "critic", "error"); return err(`Round ${round} critic attack failed: ${attack.error}`, details); }
179
+ pushStep(round, "critic", "ok");
180
+ lastCriticText = attack.text;
181
+ }
182
+
183
+ // Synthesize the verdict from the full grown transcript.
184
+ const transcript = [
185
+ `### Round 1 — Advocate (FOR)\n${r1Advocate.text}`,
186
+ lastCriticText ? `### Final Critique (AGAINST)\n${lastCriticText}` : "",
187
+ ].filter(Boolean).join("\n\n---\n\n");
188
+ const synthInput = `The debate is complete. Here is the exchange:\n\n${transcript}\n\nIssue a decisive verdict for the executor.`;
189
+
190
+ // Re-fit the synthesizer input to its own window (it may be larger than
191
+ // the debaters', but the grown transcript can still be substantial).
192
+ const synthFitWindow = Math.min(synth.model.contextWindow, fitWindow * 2);
193
+ const synthFit = buildConsultContext({
194
+ sessionMessages: [{ role: "user", content: synthInput, timestamp: Date.now() }],
195
+ advisorContextWindow: synthFitWindow,
196
+ budget: contextBudget,
197
+ });
198
+ details.finalTranscriptTokens = synthFit.estimatedTokens;
199
+
200
+ const synthResult = await callAdvisor({
201
+ ctx,
202
+ advisor: synth,
203
+ systemPrompt: SYNTHESIZER_SYSTEM_PROMPT,
204
+ messages: synthFit.messages,
205
+ thinkingLevel: config.modes?.council?.synthesizer?.thinkingLevel,
206
+ signal: debateSignal,
207
+ sessionId,
208
+ maxTokens: contextBudget.responseReserveTokens,
209
+ });
210
+
211
+ details.usage = synthResult.usage;
212
+ details.stopReason = synthResult.stopReason;
213
+ details.errorMessage = synthResult.errorMessage;
214
+
215
+ if (!synthResult.text) {
216
+ return err("Debate synthesizer returned no usable text.", { ...details, errorMessage: synthResult.errorMessage ?? "empty synthesis" });
217
+ }
218
+ return ok(synthResult.text, details);
219
+ } catch (e) {
220
+ const message = e instanceof Error ? e.message : String(e);
221
+ return err(`Debate threw: ${message}`, { ...details, errorMessage: message });
222
+ }
223
+ }); // end withTimeout body
224
+
225
+ // Unwrap the timeout outcome.
226
+ if (outcome.timedOut) {
227
+ return err(`Debate timed out after ${debateTimeoutMs}ms (all rounds + synth budget).`, { ...details, errorMessage: `timeout after ${debateTimeoutMs}ms` });
228
+ }
229
+ if (!outcome.ok) {
230
+ // A non-timeout error inside the body — the catch already converted it to
231
+ // an err() result, but withTimeout re-throws on the error path. Surface it.
232
+ const message = outcome.error instanceof Error ? outcome.error.message : String(outcome.error);
233
+ return err(`Debate failed: ${message}`, { ...details, errorMessage: message });
234
+ }
235
+ return outcome.value;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Helpers
240
+ // ---------------------------------------------------------------------------
241
+
242
+ async function callStep(
243
+ ctx: ExtensionContext,
244
+ advisor: ResolvedAdvisor,
245
+ systemPrompt: string,
246
+ messages: Message[],
247
+ thinkingLevel: import("@earendil-works/pi-ai").ThinkingLevel | undefined,
248
+ parentSignal: AbortSignal | undefined,
249
+ sessionId: string | undefined,
250
+ ): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
251
+ try {
252
+ const result = await callAdvisor({
253
+ ctx,
254
+ advisor,
255
+ systemPrompt,
256
+ messages,
257
+ thinkingLevel,
258
+ signal: linkSignal(parentSignal),
259
+ sessionId,
260
+ });
261
+ if (result.stopReason === "error" || result.stopReason === "aborted" || !result.text) {
262
+ return { ok: false, error: result.errorMessage ?? result.stopReason };
263
+ }
264
+ return { ok: true, text: result.text };
265
+ } catch (e) {
266
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
267
+ }
268
+ }
269
+
270
+ function clampRounds(n: number | undefined): number {
271
+ if (typeof n !== "number" || !Number.isFinite(n)) return 2;
272
+ return Math.max(1, Math.min(4, Math.floor(n)));
273
+ }
274
+
275
+ function emptyDetails(config: BpxConsultConfig): DebateDetails {
276
+ const debateConfig = config.modes?.debate;
277
+ return {
278
+ mode: "debate",
279
+ rounds: clampRounds(debateConfig?.rounds),
280
+ advocate: "(unresolved)",
281
+ critic: "(unresolved)",
282
+ synthesizer: "(unresolved)",
283
+ steps: [],
284
+ };
285
+ }
286
+
287
+ function ok(text: string, details: DebateDetails): AgentToolResult<DebateDetails> {
288
+ return { content: [{ type: "text", text }], details };
289
+ }
290
+ function err(text: string, details: DebateDetails): AgentToolResult<DebateDetails> {
291
+ return { content: [{ type: "text", text }], details };
292
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * messages — constants: tool name, labels, and the error/advisory strings.
3
+ *
4
+ * Kept in one place so the tool description, error results, and UI labels all
5
+ * reference the same source. The tone matches the persona: direct, no slop.
6
+ */
7
+
8
+ export const CONSULT_TOOL_NAME = "consult";
9
+ export const TOOL_LABEL = "consult";
10
+
11
+ export const CONSULT_DESCRIPTION =
12
+ "Escalate to an advisor model for guidance. When you need stronger judgment — " +
13
+ "a complex decision, an ambiguous failure, a problem you're circling without progress — " +
14
+ "call consult() and the conversation is forwarded to a reviewer model. " +
15
+ "Takes optional { mode, persona, question }. No args → solo (one advisor model). " +
16
+ "Use mode: \"council\" for a multi-model consensus, \"debate\" for adversarial, " +
17
+ "\"gut-check\" for a fast cheap read. The advisor sees the task and every tool call you've made.";
18
+
19
+ export const DEFAULT_PROMPT_SNIPPET =
20
+ "Escalate to an advisor model for guidance when stuck, before substantive work, or before declaring done";
21
+
22
+ export const DEFAULT_PROMPT_GUIDELINES: string[] = [
23
+ "Call `consult` BEFORE substantive work — before writing, before committing to an interpretation, before building on an assumption. Orientation (finding files, fetching a source, seeing what's there) is not substantive work; writing, editing, and declaring an answer are.",
24
+ "Also call `consult` when you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, save the result, commit the change. The advisor call takes time; if the session ends during it, a durable result persists and an unwritten one doesn't.",
25
+ "Also call `consult` when stuck — errors recurring, approach not converging, results that don't fit — or when considering a change of approach.",
26
+ "On tasks longer than a few steps, call `consult` at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling.",
27
+ "Give the advisor's advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim, adapt — a passing self-test is not evidence the advice is wrong, it's evidence your test doesn't check what the advice is checking.",
28
+ ];
29
+
30
+ // --- Error / advisory text (returned to the executor as tool result text) ---
31
+
32
+ export const ERR_NO_MODEL = "No advisor model configured. Run /consult to pick one.";
33
+ export const ERR_NO_MODEL_DETAIL = "No advisor model is set. Open the picker with /consult, or set modes.solo.model in ~/.pi/agent/bpx-consult.json.";
34
+ export const ERR_NO_API_KEY = (label: string) => `No API key for ${label}. Check ~/.pi/agent/auth.json or run /login.`;
35
+ export const ERR_NO_API_KEY_DETAIL = (provider: string) => `Provider ${provider} has no configured auth. Add a key via /login or models.json.`;
36
+ export const ERR_CALL_ABORTED = "Advisor call aborted.";
37
+ export const ERR_ABORTED_DETAIL = "The advisor call was aborted (user cancel or session end).";
38
+ export const ERR_EMPTY_RESPONSE = "Advisor returned no usable text.";
39
+ export const ERR_EMPTY_RESPONSE_DETAIL = "The advisor replied with no text content (reasoning-only or empty). Retry, or pick a different advisor model.";
40
+ export const errCallFailed = (detail?: string) => `Advisor call failed${detail ? `: ${detail}` : "."}`;
41
+ export const errCallThrew = (detail: string) => `Advisor call threw: ${detail}`;
42
+ export const errMisconfigured = (label: string, detail?: string) =>
43
+ `Advisor ${label} is misconfigured${detail ? ` (${detail})` : ""}. Fix in /consult or the config file.`;
44
+
45
+ // --- UI status text ---
46
+
47
+ export const msgConsulting = (label: string) => `Consulting ${label}…`;
48
+ export const msgAdvisorEnabled = (label: string) => `consult on — ${label}`;
49
+ export const msgAdvisorDisabled = "consult off";
@@ -0,0 +1,163 @@
1
+ /**
2
+ * personas — bundled defaults + stance-injected system-prompt assembly.
3
+ *
4
+ * Each persona is a named viewpoint the council can seat. The stance
5
+ * (for/against/neutral) biases what the persona hunts for and how hard it
6
+ * stress-tests — never the verdict. A `for` persona must still be able to land
7
+ * on "don't do this"; a persona structurally incapable of dissent is theater.
8
+ * This guardrail is baked into the stance wrappers below (lifted from
9
+ * my-zen's consensus.py stance prompts, which explicitly warn against
10
+ * "purely contrarian" and "artificial balance" failure modes).
11
+ */
12
+
13
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
14
+
15
+ export type Stance = "for" | "against" | "neutral";
16
+
17
+ export interface Persona {
18
+ name: string;
19
+ systemPrompt: string;
20
+ stance: Stance;
21
+ defaultModel?: string;
22
+ thinkingLevel?: ThinkingLevel;
23
+ /** Domain seats (security/performance) only — seated when the call touches them. */
24
+ conditional?: boolean;
25
+ }
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Stance framing — the non-negotiable bit
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Wrap a persona's base prompt with stance framing. The framing biases EMPHASIS
33
+ * only, never the verdict — every stance wrapper explicitly permits dissent,
34
+ * which is what stops a "for" persona from rubber-stamping.
35
+ *
36
+ * Lifted from my-zen tools/consensus.py stance prompts (~lines 677-772), which
37
+ * hard-coded these guardrails after my-zen hit the "artificial balance" and
38
+ * "purely contrarian" failure modes.
39
+ */
40
+ export function applyStance(basePrompt: string, stance: Stance): string {
41
+ const common = `\n\nCRITICAL: Your stance biases what you hunt for and how hard you push — never your verdict. If the evidence says the plan is bad, say so plainly even if your stance is "for". If it's sound, say so even if your stance is "against". Do not be artificially balanced, and do not be purely contrarian. Manufactured agreement is worse than honest dissent.`;
42
+
43
+ if (stance === "for") {
44
+ return (
45
+ basePrompt +
46
+ `\n\nYOUR STANCE: ADVOCACY. Make the strongest case FOR the approach. Find what's genuinely sound and argue it forcefully. Surface the risks only after you've made the positive case.` +
47
+ common
48
+ );
49
+ }
50
+ if (stance === "against") {
51
+ return (
52
+ basePrompt +
53
+ `\n\nYOUR STANCE: CRITIQUE. Pressure-test the approach hard. Find the flaws, the unstated assumptions, the failure modes, the cheaper alternative. Your job is to make the plan survive contact with reality — if it can't, say so.` +
54
+ common
55
+ );
56
+ }
57
+ return (
58
+ basePrompt +
59
+ `\n\nYOUR STANCE: BALANCED. Weigh the approach on its merits — neither advocate nor attack. State what works, what doesn't, and what you'd want to know before committing.` +
60
+ common
61
+ );
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Bundled default personas
66
+ // ---------------------------------------------------------------------------
67
+
68
+ const ARCHITECT: Persona = {
69
+ name: "architect",
70
+ stance: "for",
71
+ systemPrompt:
72
+ "You are a lead engineer assessing design soundness. Focus on whether the approach is structurally coherent, fits the system it lives in, and will hold up under change. Favor designs that are easy to reason about. Cite the specific file, component, or assumption you're reacting to.",
73
+ };
74
+
75
+ const CRITIC: Persona = {
76
+ name: "critic",
77
+ stance: "against",
78
+ systemPrompt:
79
+ "You are a sharp critic looking for what will go wrong. Hunt unstated assumptions, edge cases the approach ignores, and places where 'probably fine' is doing a lot of work. Fold worst-case thinking in here — what breaks at 3am, at scale, under malformed input. Be specific about the failure, not vague about 'risk'.",
80
+ };
81
+
82
+ const SIMPLIFIER: Persona = {
83
+ name: "simplifier",
84
+ stance: "neutral",
85
+ systemPrompt:
86
+ "You are a pragmatist who hates unnecessary complexity. Ask: is this needed? Is there a simpler path that gets 80% of the value? What could be removed without losing the core? Complexity must justify itself — if it doesn't, say so. Champion the boring solution that ships.",
87
+ };
88
+
89
+ const PRAGMATIST: Persona = {
90
+ name: "pragmatist",
91
+ stance: "neutral",
92
+ systemPrompt:
93
+ "You weigh effort against payoff. How long will this take to build, to maintain, to debug? Is there a cheaper version that solves the real problem? Push back on gold-plating and scope creep, but also flag where spending more now saves pain later. ROI thinking, not just code thinking.",
94
+ };
95
+
96
+ const TESTER: Persona = {
97
+ name: "tester",
98
+ stance: "neutral",
99
+ systemPrompt:
100
+ "You think in failure modes and edge cases. What inputs break this? What does the error path look like? What's the test that would catch a regression here? Name the specific scenarios you'd write tests for, and the boundaries that look fragile.",
101
+ };
102
+
103
+ const SECURITY: Persona = {
104
+ name: "security",
105
+ stance: "neutral",
106
+ conditional: true,
107
+ systemPrompt:
108
+ "You assess security implications: input handling, auth/authz boundaries, secrets, injection surface, trust boundaries. Only weigh in on what's actually security-relevant — don't force a security read onto a CSS change. When there's nothing to flag, say so.",
109
+ };
110
+
111
+ const PERFORMANCE: Persona = {
112
+ name: "performance",
113
+ stance: "neutral",
114
+ conditional: true,
115
+ systemPrompt:
116
+ "You assess performance implications: hot paths, unnecessary work, N+1 patterns, allocation, blocking I/O. Only weigh in where perf actually matters — don't bikeshed micro-opts. When the approach is fine, say so.",
117
+ };
118
+
119
+ export const DEFAULT_PERSONAS: Record<string, Persona> = {
120
+ architect: ARCHITECT,
121
+ critic: CRITIC,
122
+ simplifier: SIMPLIFIER,
123
+ pragmatist: PRAGMATIST,
124
+ tester: TESTER,
125
+ security: SECURITY,
126
+ performance: PERFORMANCE,
127
+ };
128
+
129
+ export const DEFAULT_COUNCIL_ROSTER = ["architect", "critic", "simplifier"];
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Resolution: merge defaults with user config overrides
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Resolve a persona by name, layering user overrides on top of the bundled
137
+ * default. Returns undefined for unknown names (caller surfaces the error).
138
+ *
139
+ * User overrides are partial — they can set just { defaultModel } without
140
+ * re-stating the whole systemPrompt.
141
+ */
142
+ export function resolvePersona(
143
+ name: string,
144
+ userOverrides: Record<string, Partial<Persona>> | undefined,
145
+ ): Persona | undefined {
146
+ const base = DEFAULT_PERSONAS[name];
147
+ if (!base) {
148
+ // User-defined persona with no bundled base — require a systemPrompt.
149
+ const override = userOverrides?.[name];
150
+ if (!override?.systemPrompt) return undefined;
151
+ return { name, stance: "neutral", ...override } as Persona;
152
+ }
153
+ const override = userOverrides?.[name];
154
+ if (!override) return base;
155
+ return { ...base, ...override, name };
156
+ }
157
+
158
+ /**
159
+ * Build the final system prompt for a seated persona: base prompt + stance wrap.
160
+ */
161
+ export function personaSystemPrompt(persona: Persona): string {
162
+ return applyStance(persona.systemPrompt, persona.stance);
163
+ }