@underactive/pi-topping-moa-fusion 0.1.0 → 0.3.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +54 -12
  3. package/agents/moa-synthesizer.md +9 -6
  4. package/package.json +5 -5
  5. package/src/config/planName.ts +29 -24
  6. package/src/config/settings.ts +38 -1
  7. package/src/debate/debateFanout.ts +16 -6
  8. package/src/debate/runDebate.ts +13 -4
  9. package/src/index.ts +14 -3
  10. package/src/moa/conflictContract.ts +2 -1
  11. package/src/moa/contextContract.ts +1 -1
  12. package/src/moa/fanout.ts +115 -4
  13. package/src/moa/fanoutWiring.ts +13 -3
  14. package/src/moa/implementationRetry.ts +2 -2
  15. package/src/moa/orchestration.ts +3 -0
  16. package/src/moa/panelDependence.ts +87 -0
  17. package/src/moa/reviewLoop.ts +2 -2
  18. package/src/moa/synthesis.ts +12 -8
  19. package/src/moa/verification.ts +117 -20
  20. package/src/moa/verificationCriteria.ts +1 -1
  21. package/src/opinion/opinionFanout.ts +5 -4
  22. package/src/opinion/runOpinion.ts +14 -13
  23. package/src/planning/contextEdits.ts +62 -0
  24. package/src/planning/modeState.ts +17 -0
  25. package/src/planning/planFile.ts +30 -12
  26. package/src/planning/planMode.ts +144 -11
  27. package/src/planning/tools/exitPlanMode.ts +7 -2
  28. package/src/planning/tools/mfPlanSubagent.ts +3 -0
  29. package/src/planning/tools/shared.ts +1 -0
  30. package/src/runtime/activityTracking.ts +28 -12
  31. package/src/runtime/processPool.ts +2 -0
  32. package/src/runtime/results.ts +18 -0
  33. package/src/runtime/runner.ts +45 -9
  34. package/src/runtime/wire.ts +11 -0
  35. package/src/spec/runSpec.ts +343 -0
  36. package/src/spec/specContract.ts +324 -0
  37. package/src/ui/cancelOverlay.ts +1 -1
  38. package/src/ui/moaModelPicker.ts +32 -4
  39. package/src/ui/moaProgressWidget.ts +114 -12
  40. package/src/ui/moaSetupOverlay.ts +51 -7
  41. package/src/ui/planReviewOverlay.ts +16 -8
  42. package/src/ui/rosterEditor.ts +18 -12
  43. package/src/ui/workingOverlay.ts +265 -0
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from "./moa/implementationRetry.ts";
13
13
  import { modelRefLabel, TRIGGER_TURN } from "./shared/modelRefs.ts";
14
14
  import { loadMoaConfig } from "./config/settings.ts";
15
- import { getPlanFilePath, getRepoPlanDirectory, isValidPlanSlug, readRepoPlanFile, saveRepoPlanFile } from "./planning/planFile.ts";
15
+ import { getPlanFilePath, getRepoPlanDirectory, isApprovedRepoPlanFilename, isValidPlanSlug, readRepoPlanFile, saveRepoPlanFile } from "./planning/planFile.ts";
16
16
  import { showImplementingModelPicker } from "./ui/moaModelPicker.ts";
17
17
  import { showPlanReview } from "./ui/planReviewOverlay.ts";
18
18
  import { showMoaSetup } from "./ui/moaSetupOverlay.ts";
@@ -20,6 +20,7 @@ import { runCriteriaGeneration } from "./moa/verificationCriteria.ts";
20
20
  import { stripSynthSections } from "./moa/verdicts.ts";
21
21
  import { runInteractiveOpinion, type OpinionHost } from "./opinion/runOpinion.ts";
22
22
  import { runInteractiveDebate } from "./debate/runDebate.ts";
23
+ import { createSpecRunner } from "./spec/runSpec.ts";
23
24
  import { createPlanModeController } from "./planning/planMode.ts";
24
25
  import { registerEnterPlanModeTool, runInteractivePlanMode } from "./planning/tools/enterPlanMode.ts";
25
26
  import { registerExitPlanModeTool } from "./planning/tools/exitPlanMode.ts";
@@ -38,6 +39,7 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
38
39
  setRunningProgressWidget: controller.moaRunHost.setRunningProgressWidget,
39
40
  };
40
41
  const debateHost = opinionHost;
42
+ const runSpec = createSpecRunner(controller);
41
43
  const togglePlanMode = async (ctx: Parameters<typeof controller.exitPlanMode>[0], prompt?: string): Promise<void> => {
42
44
  if (controller.questionnaireBusy(ctx)) return;
43
45
  if (controller.getActiveCancelSession()?.run) {
@@ -69,12 +71,16 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
69
71
  description: "Run a read-only multi-round debate between up to 5 models — append a topic (e.g. /mf-debate is X sound?) to skip the editor",
70
72
  handler: async (args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await runInteractiveDebate(pi, debateHost, ctx, args.trim() || undefined); },
71
73
  });
74
+ pi.registerCommand("mf-spec", {
75
+ description: "Clarify a rough request into an approved planning brief for /mf-plan — append the request (e.g. /mf-spec add dark mode) to skip the editor",
76
+ handler: async (args, ctx) => runSpec(ctx, args.trim() || undefined),
77
+ });
72
78
  pi.registerCommand("mf-plan-clear", {
73
79
  description: "Clear the completed plan state so /mf-plan starts a fresh planning round (approved plans stay in .pi/mf-plan/)",
74
80
  handler: async (_args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await controller.clearCompletedPlan(ctx); },
75
81
  });
76
82
  pi.registerCommand("mf-plan-settings", {
77
- description: "Configure the explore and cheap/fast agents, agent rosters for MoA roles, and plan options",
83
+ description: "Configure the explore and cheap/fast agents, agent rosters for MoA roles, plan options, and max concurrent agents",
78
84
  handler: async (_args, ctx) => {
79
85
  if (controller.questionnaireBusy(ctx)) return;
80
86
  installShippedAgents();
@@ -104,7 +110,7 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
104
110
  if (fs.existsSync(repoDir)) {
105
111
  const entries = fs.readdirSync(repoDir);
106
112
  planFiles = entries
107
- .filter((file) => file.endsWith("__plan.md") && !file.endsWith("__plan-prompt.md"))
113
+ .filter(isApprovedRepoPlanFilename)
108
114
  .map((file) => {
109
115
  const slug = file.slice(0, -"__plan.md".length);
110
116
  const fullPath = path.join(repoDir, file);
@@ -270,6 +276,10 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
270
276
  registerMfPlanSubagentTool(pi, controller);
271
277
  registerExitPlanModeTool(pi, controller);
272
278
 
279
+ // Deliberately not context_with_system (pi 0.87.0): its result is sent
280
+ // verbatim, so a filtering mistake could drop pi's prompt and tool declarations.
281
+ // Keep injection in before_agent_start, filtering in context, and durable
282
+ // omissions in agent_before_settle so pi continues to own prompt/tool state.
273
283
  pi.on("context", controller.onContext);
274
284
  pi.on("before_agent_start", controller.onBeforeAgentStart);
275
285
  pi.on("agent_start", controller.onAgentStart);
@@ -277,6 +287,7 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
277
287
  pi.on("message_update", controller.onMessageUpdate);
278
288
  pi.on("tool_execution_start", controller.onToolExecutionStart);
279
289
  pi.on("message_end", controller.onMessageEnd);
290
+ pi.on("agent_before_settle", controller.onAgentBeforeSettle);
280
291
  pi.on("agent_settled", controller.onAgentSettled);
281
292
  pi.on("session_start", controller.onSessionStart);
282
293
  pi.on("session_shutdown", controller.onSessionShutdown);
@@ -24,7 +24,7 @@ export const CONFLICT_CONTRACT_EXAMPLE = [
24
24
  "",
25
25
  "- **Decision:** Choose where sign-in information is stored; this affects both security and how much of the existing sign-in flow can stay unchanged.",
26
26
  "- **Recommended:** Keep the current sign-in experience",
27
- "- **Details:** Store session IDs in `Secure`, `HttpOnly` cookies so the existing middleware stays compatible and page scripts cannot read them.",
27
+ "- **Details:** Store session IDs in `Secure`, `HttpOnly` cookies so the existing middleware stays compatible and page scripts cannot read them; backed by 2 of 3 proposals (2 distinct models).",
28
28
  "- **Alternative** (Proposer 2): Make sign-in data available to browser code",
29
29
  "- **Details:** Store JWTs in `localStorage`; this can simplify cross-service access but lets an injected page script read the token.",
30
30
  ].join("\n");
@@ -42,6 +42,7 @@ export function buildConflictContract(labels: string[]): string {
42
42
  "- Exactly one `**Decision:**` line per conflict, placed before its options. Exactly one `**Recommended:**` line per conflict, immediately followed by exactly one `**Details:**` line. One `**Alternative** (Proposer N)` line per other distinct approach worth surfacing, each immediately followed by its own `**Details:**` line.",
43
43
  `- Attribute alternatives only to blinded slot labels already present in your input. Blinded proposer slots in this run: ${labels.join(", ")}. Never invent or rename labels, and never output any model name, family, provider, or version anywhere.`,
44
44
  "- Write each Recommended/Alternative summary as an outcome the person can choose, understandable without code syntax, type names, or file paths; put technical specifics and tradeoffs in `**Details:**`, kept to one line.",
45
+ "- The `**Recommended:**` pick must be justified on the merits — repo evidence, engineering judgment, or a direct match with the user's request — and a majority of proposers is not by itself a reason, especially when that majority sits inside one same-model cluster named in the Panel dependence note. The recommended option's `**Details:**` line must end with `backed by K of N proposals (M distinct models)`, where K = proposals backing that option, N = proposals received, and M = distinct models among the K (slots in one cluster count once).",
45
46
  "- Supersession rule: if your input contains user conflict resolutions (a \"User reviewed your conflict recommendations\" block) or post-review user feedback, those directives override this requirement — honor every selection, resolve any free-form chat feedback, drop the `## Conflicts` section entirely from your revised output, and emit the complete final plan without it.",
46
47
  "",
47
48
  "Clarifying-question option: only when a disagreement is a genuine ambiguity in user intent that you cannot resolve from the plans alone, output a section titled exactly `## Open Question` containing only that one question, as your ENTIRE output — write nothing else in that reply, and none of the sections above. You will be re-invoked with the user's answer appended. Prefer resolving ambiguities yourself and noting the resolution in the final plan; if your input already contains an answer to an earlier question, incorporate it and do not emit `## Open Question` again.",
@@ -27,7 +27,7 @@ export function buildContextSubsectionsContract(): string {
27
27
  ...CONTEXT_SUBSECTION_HEADINGS.map((heading) => `- \`### ${heading}\``),
28
28
  "",
29
29
  "- `### Evaluation dimensions` — reason separately about correctness, completeness, feasibility & effort, risk, and simplicity; never collapse them into one impression.",
30
- "- `### Proposer alignment` — where proposers unanimously agreed, what they unanimously rejected, and where they differed, with shared assumptions sanity-checked.",
30
+ "- `### Proposer alignment` — where proposers unanimously agreed, what they unanimously rejected, and where they differed; tag every unanimous point as evidence-backed (two or more proposers cite a concrete repo location) or asserted (no citation, or all citations identical and unverified); sanity-check every asserted point against the repo with read/grep/find/ls before relying on it, stating what you checked and what you found; note when a unanimous point rests inside a single same-model cluster from the Panel dependence note; treat each same-model cluster as one vote.",
31
31
  "- `### Synthesis decisions` — what you recombined, added, or dropped, and one-line decisions for each disagreement using blinded slot labels.",
32
32
  "",
33
33
  "All MoA reconciliation commentary belongs inside `## Context` — nowhere else. A plan missing any of these three subsections will be rejected and sent back to you. They are required in every full-plan output, including revisions after conflict resolutions or user feedback.",
package/src/moa/fanout.ts CHANGED
@@ -7,8 +7,10 @@ import { getFinalOutput, isFailedResult } from "../runtime/results.ts";
7
7
  import type { ModelParallelAgentTask } from "../runtime/runner.ts";
8
8
  import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
9
9
  import { activityLoopCount } from "../ui/agentStatus.ts";
10
+ import { showModelThinkingPicker } from "../ui/moaModelPicker.ts";
10
11
  import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
11
12
  import type { ObserveSession } from "../ui/observeOverlay.ts";
13
+ import { duplicateModelSlotCount, proposerDiversityWarning } from "./panelDependence.ts";
12
14
  import { runWidgetFanout } from "./fanoutWiring.ts";
13
15
  import { buildProposerRetryTask, buildProposerTask, looksLikePlan } from "./planlessRetry.ts";
14
16
  import { modelExtensionOptions, resolveContextWindow } from "./modelRuntime.ts";
@@ -29,11 +31,12 @@ export interface FanoutPhaseOptions {
29
31
  widget: MoaProgressWidget;
30
32
  agents: AgentConfig[];
31
33
  warnIfMutated(phase: string): Promise<void>;
34
+ maxConcurrency: number;
32
35
  }
33
36
 
34
37
  export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<FanoutPhaseResult> {
35
- const { host, runContext, ctx, prompt, proposers, proposerThinking, session, widget, agents, warnIfMutated } = options;
36
- widget.startFanout(proposers, proposerThinking);
38
+ const { host, runContext, ctx, prompt, proposers, proposerThinking, session, widget, agents, warnIfMutated, maxConcurrency } = options;
39
+ widget.startFanout(proposers, proposerThinking, maxConcurrency);
37
40
  const observe: ObserveSession = {
38
41
  title: "MoA fan-out",
39
42
  phase: "fanout",
@@ -64,7 +67,7 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
64
67
  };
65
68
  };
66
69
  const runFanout = (tasks: ModelParallelAgentTask[], indexMap: (index: number) => number, currentRun: CancelRun) =>
67
- runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models: proposers, indexMap });
70
+ runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models: proposers, indexMap, maxConcurrency });
68
71
 
69
72
  // The planning contract travels inside the task text — the only channel
70
73
  // guaranteed to survive a bridge's system-prompt override. The observer keeps
@@ -89,6 +92,114 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
89
92
  return { status: "cancelled", observe, previousObserveSession };
90
93
  }
91
94
 
95
+ // A proposer cancelled from the cancel overlay must not silently drop out of
96
+ // the run: the user who killed it decides what replaces it, mirroring the
97
+ // synthesizer-failure precedent. A slot dropped here stays `cancelled` in
98
+ // results, so it is already excluded from `succeeded` — "continue without"
99
+ // needs no other handling. This runs after the cancel-all check and before
100
+ // the planless-retry pass, so a replacement that returns no plan then gets
101
+ // that retry for free.
102
+ const cancelledSlots = results.flatMap((r, i) => r.cancelled ? [i] : []);
103
+ if (cancelledSlots.length > 0 && ctx.hasUI) {
104
+ // Stop the stacked overlays and the sticky widget before the modal so the
105
+ // select/picker are not fighting them. session.run is already undefined
106
+ // (cleared in runWidgetFanout's finally), so ESC reaches the modal, not a
107
+ // reopened cancel overlay.
108
+ session.closeOverlay?.();
109
+ widget.stopWidget();
110
+ for (const index of cancelledSlots) {
111
+ while (true) {
112
+ const label = modelRefLabel(proposers[index]);
113
+ const choice = await ctx.ui.select(
114
+ `Proposer ${index + 1} (${label}) was cancelled — what next?`,
115
+ ["Select a different model", "Continue without this proposer"],
116
+ );
117
+ if (choice !== "Select a different model") break;
118
+ const picked = await showModelThinkingPicker(
119
+ ctx,
120
+ host.currentThinkingLevel(),
121
+ `Replacement for Proposer ${index + 1}`,
122
+ proposers[index],
123
+ proposerThinking[index],
124
+ );
125
+ if (!picked) continue;
126
+ // Rewrite the shared phase/synthesis/observe arrays in place: the
127
+ // same object is held by the widget band, the cost resolution,
128
+ // synthesis, and the persisted activeRunMoaInfo.
129
+ proposers[index] = picked.ref;
130
+ proposerThinking[index] = picked.thinking;
131
+ host.persistState();
132
+ if (duplicateModelSlotCount(proposers, picked.ref) >= 2) {
133
+ const warning = proposerDiversityWarning(proposers);
134
+ if (warning) ctx.ui.notify(warning, "warning");
135
+ }
136
+ widget.replaceProposerModel(index, picked.ref, picked.thinking);
137
+ // Rewrite the observe row's identity, not just its messages, so F3
138
+ // shows the replacement and not the cancelled agent's corpse.
139
+ const observed = observe.agents[index];
140
+ if (observed) {
141
+ observed.label = picked.ref.id;
142
+ observed.model = picked.ref.id;
143
+ observed.task = proposerTask;
144
+ observed.messages = [];
145
+ observed.partial = undefined;
146
+ observed.state = "working";
147
+ }
148
+ const replaceRun = new CancelRun();
149
+ session.title = "MoA fan-out (replacement)";
150
+ session.getExtras = () => {
151
+ const status = widget.getStatus(index);
152
+ if (!status) return undefined;
153
+ return {
154
+ contextTokens: status.contextTokens,
155
+ contextWindow: resolveContextWindow(ctx, status.ref),
156
+ activity: status.activity,
157
+ loopCount: activityLoopCount(status.activity, status.activityHistory),
158
+ };
159
+ };
160
+ // The slot was killed, not planless — rerun the fresh proposer task.
161
+ const [replacement] = await runFanout(
162
+ [
163
+ {
164
+ agent: "moa-proposer",
165
+ task: proposerTask,
166
+ model: modelRefLabel(picked.ref),
167
+ thinking: picked.thinking,
168
+ ...modelExtensionOptions(ctx, picked.ref),
169
+ signal: replaceRun.add(modelRefLabel(picked.ref)).signal,
170
+ },
171
+ ],
172
+ () => index,
173
+ replaceRun,
174
+ );
175
+ results[index] = replacement;
176
+ await warnIfMutated("MoA proposer replacement");
177
+ // Cancel ALL during a replacement ends the run, like the other exits.
178
+ if (replaceRun.cancelAllRequested) {
179
+ widget.stopWidget();
180
+ ctx.ui.notify("MoA run cancelled.");
181
+ return { status: "cancelled", observe, previousObserveSession };
182
+ }
183
+ // A cancelled or planless replacement re-asks the same two options
184
+ // until the user chooses to move on.
185
+ if (replacement.cancelled || isFailedResult(replacement)) {
186
+ session.closeOverlay?.();
187
+ widget.stopWidget();
188
+ ctx.ui.notify(
189
+ `Replacement proposer ${modelRefLabel(picked.ref)} did not produce a plan.`,
190
+ "warning",
191
+ );
192
+ continue;
193
+ }
194
+ break;
195
+ }
196
+ }
197
+ // A prompt stopped the widget; re-show the table so the planless-retry pass
198
+ // and synthesis render into a mounted table even when the user dropped
199
+ // every cancelled slot.
200
+ widget.resumeTable();
201
+ }
202
+
92
203
  const retryIndices: number[] = [];
93
204
  for (let index = 0; index < results.length; index++) {
94
205
  const result = results[index];
@@ -112,7 +223,7 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
112
223
  const ref = proposers[proposerIndex];
113
224
  const label = modelRefLabel(ref);
114
225
  const retryTask = buildProposerRetryTask(proposerTask, getFinalOutput(results[proposerIndex].messages));
115
- widget.update(proposerIndex, "working", "retrying: no plan produced");
226
+ widget.update(proposerIndex, "queued", "retrying: no plan produced");
116
227
  const observed = observe.agents[proposerIndex];
117
228
  if (observed) {
118
229
  observed.state = "working";
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { AgentConfig } from "../agents/discovery.ts";
4
4
  import type { CancelRun, CancelSession } from "../runtime/cancelRun.ts";
5
- import { getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
5
+ import { getResultOutput, isFailedResult, statusDetail, type SingleResult } from "../runtime/results.ts";
6
6
  import { runParallelAgentsWithModels, type ModelParallelAgentTask } from "../runtime/runner.ts";
7
7
  import type { ModelRef } from "../shared/modelRefs.ts";
8
8
  import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
@@ -19,6 +19,7 @@ export interface WidgetFanoutOptions {
19
19
  observe: ObserveSession;
20
20
  models: ModelRef[];
21
21
  indexMap: (index: number) => number;
22
+ maxConcurrency: number;
22
23
  }
23
24
 
24
25
  /**
@@ -27,7 +28,7 @@ export interface WidgetFanoutOptions {
27
28
  * settle/onProgress plumbing shared by every MoA fan-out variant.
28
29
  */
29
30
  export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<SingleResult[]> {
30
- const { ctx, agents, tasks, run, session, widget, observe, models, indexMap } = options;
31
+ const { ctx, agents, tasks, run, session, widget, observe, models, indexMap, maxConcurrency } = options;
31
32
  const unsubscribe = run.onChange(() => {
32
33
  run.agents.forEach((agent, index) => {
33
34
  if (agent.state === "cancelling") widget.update(indexMap(index), "cancelling");
@@ -54,7 +55,7 @@ export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<Sin
54
55
  if (observed) observed.state = "cancelled";
55
56
  } else if (isFailedResult(result)) {
56
57
  run.settle(index, "error");
57
- widget.update(widgetIndex, "error", getResultOutput(result).slice(0, 80));
58
+ widget.update(widgetIndex, "error", statusDetail(getResultOutput(result)));
58
59
  if (observed) observed.state = "error";
59
60
  } else {
60
61
  run.settle(index, "done");
@@ -80,6 +81,15 @@ export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<Sin
80
81
  observed.partial = result.partialAssistant;
81
82
  }
82
83
  },
84
+ {
85
+ maxConcurrency,
86
+ onStart: (index) => {
87
+ const widgetIndex = indexMap(index);
88
+ widget.update(widgetIndex, "working");
89
+ const observed = observe.agents[widgetIndex];
90
+ if (observed) observed.state = "working";
91
+ },
92
+ },
83
93
  );
84
94
  } finally {
85
95
  unsubscribe();
@@ -234,7 +234,7 @@ export async function runImplementationRetryFlow(
234
234
 
235
235
  if (selection.startsWith("Retry with")) {
236
236
  host.markImplementationPending(ctx);
237
- if (handoff.model) host.getActiveProgressWidget()?.switchToImplementing(handoff.model, "retrying implementation…", handoff.thinking);
237
+ if (handoff.model) host.getActiveProgressWidget()?.switchToImplementing(handoff.model, "retrying implementation", handoff.thinking);
238
238
  const note = failureReason
239
239
  ? `The previous implementation attempt failed with: ${failureReason}. Review the current state of the code and continue implementing the approved plan.`
240
240
  : "The previous implementation attempt failed. Review the current state of the code and continue implementing the approved plan.";
@@ -280,7 +280,7 @@ export async function runImplementationRetryFlow(
280
280
  Implement: newSelection.ref,
281
281
  Verify: updatedHandoff.verifier,
282
282
  });
283
- widget.switchToImplementing(newSelection.ref, "retrying implementation…", newSelection.thinking);
283
+ widget.switchToImplementing(newSelection.ref, "retrying implementation", newSelection.thinking);
284
284
  }
285
285
 
286
286
  const note = `Switched implementation model to ${modelRefLabel(newSelection.ref)}. Review the current state of the code and continue implementing the approved plan.`;
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
4
+ import { loadMoaConfig } from "../config/settings.ts";
4
5
  import { discoverAgents } from "../agents/discovery.ts";
5
6
  import { cleanupProposalFiles, sweepStaleProposalFiles } from "../planning/planFile.ts";
6
7
  import type { CancelSession } from "../runtime/cancelRun.ts";
@@ -59,6 +60,7 @@ export async function runMoaOrchestration(
59
60
  try {
60
61
  installShippedAgents();
61
62
  sweepStaleProposalFiles();
63
+ const maxConcurrency = loadMoaConfig().maxConcurrentAgents;
62
64
  const discovery = discoverAgents(ctx.cwd, "user");
63
65
  const agents = withAuthoritativeMoaAgents(discovery.agents, shippedAgentsDir());
64
66
  const tripwire = new MutationTripwire();
@@ -78,6 +80,7 @@ export async function runMoaOrchestration(
78
80
  widget,
79
81
  agents,
80
82
  warnIfMutated,
83
+ maxConcurrency,
81
84
  });
82
85
  if (fanout.status !== "continue") return fanout.status;
83
86
  const synthesis = await runSynthesisPhase(runContext, {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Dependence structure for the MoA proposer panel.
3
+ *
4
+ * Blinding invariant: nothing derived from a ModelRef (provider, id, label,
5
+ * family) may appear in synthesizer-facing output — only integers and
6
+ * proposerBlindedLabel slot numbers. Family-level clusters are excluded
7
+ * because they risk de-blinding a lopsided roster.
8
+ */
9
+
10
+ import { modelRefLabel, proposerBlindedLabel, type ModelRef } from "../shared/modelRefs.ts";
11
+
12
+ function pluralize(count: number, singular: string, plural = `${singular}s`): string {
13
+ return count === 1 ? singular : plural;
14
+ }
15
+
16
+ function formatClusterSentence(slots: number[]): string {
17
+ const numbers = slots.map((index) => proposerBlindedLabel(index).replace("Proposer ", ""));
18
+ if (numbers.length === 2) {
19
+ return `Proposers ${numbers[0]} and ${numbers[1]} share one model.`;
20
+ }
21
+ const last = numbers.pop();
22
+ return `Proposers ${numbers.join(", ")}, and ${last} share one model.`;
23
+ }
24
+
25
+ /** Blinded one-paragraph note describing panel dependence for the synthesizer. */
26
+ export function buildPanelDependenceNote(fed: readonly { ref: ModelRef; originalIndex: number }[]): string {
27
+ const proposalCount = fed.length;
28
+ const clusters = new Map<string, number[]>();
29
+ const providers = new Set<string>();
30
+
31
+ for (const { ref, originalIndex } of fed) {
32
+ providers.add(ref.provider);
33
+ const key = modelRefLabel(ref);
34
+ const slots = clusters.get(key);
35
+ if (slots) slots.push(originalIndex);
36
+ else clusters.set(key, [originalIndex]);
37
+ }
38
+
39
+ const distinctModels = clusters.size;
40
+ const distinctProviders = providers.size;
41
+
42
+ const countsLine = [
43
+ `Panel dependence: ${proposalCount} ${pluralize(proposalCount, "proposal")}`,
44
+ `from ${distinctModels} distinct ${pluralize(distinctModels, "model")}`,
45
+ `across ${distinctProviders} ${pluralize(distinctProviders, "provider")}.`,
46
+ ].join(" ");
47
+
48
+ const clusterSentences = [...clusters.values()]
49
+ .filter((slots) => slots.length >= 2)
50
+ .sort((a, b) => a[0] - b[0])
51
+ .map((slots) => formatClusterSentence([...slots].sort((a, b) => a - b)));
52
+
53
+ const caveat = "Every proposer ran from the same prompt template, so even distinct models are not fully independent; treat agreement inside a same-model cluster as one vote, not as separate confirmation.";
54
+
55
+ return [countsLine, ...clusterSentences, caveat].join(" ");
56
+ }
57
+
58
+ function duplicateModelSlotTotal(refs: readonly ModelRef[]): number {
59
+ const labelCounts = new Map<string, number>();
60
+ for (const ref of refs) {
61
+ const label = modelRefLabel(ref);
62
+ labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
63
+ }
64
+ let total = 0;
65
+ for (const count of labelCounts.values()) {
66
+ if (count >= 2) total += count;
67
+ }
68
+ return total;
69
+ }
70
+
71
+ /** Non-blocking warning for the pre-flight picker or replacement notify. */
72
+ export function proposerDiversityWarning(refs: readonly ModelRef[]): string | undefined {
73
+ const duplicateTotal = duplicateModelSlotTotal(refs);
74
+ if (duplicateTotal >= 2) {
75
+ return `${duplicateTotal} proposer slots share a model; agreement between them is not independent evidence.`;
76
+ }
77
+ if (refs.length >= 2 && new Set(refs.map((ref) => ref.provider)).size === 1) {
78
+ return `All ${refs.length} proposer slots share one provider; agreement between them is not independent evidence.`;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ /** Slots in `refs` whose model matches `ref` (including `ref`'s own slot). */
84
+ export function duplicateModelSlotCount(refs: readonly ModelRef[], ref: ModelRef): number {
85
+ const target = modelRefLabel(ref);
86
+ return refs.reduce((count, candidate) => count + (modelRefLabel(candidate) === target ? 1 : 0), 0);
87
+ }
@@ -73,7 +73,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
73
73
  chatRounds++;
74
74
  synthConversation.push(`The user reviewed your synthesized plan and gave this feedback:\n${feedback.trim()}\n\nRevise the plan accordingly and re-emit the complete final plan. Drop any ## Conflicts or ## Open Question sections.`);
75
75
  rebuildSynthTask();
76
- widget.switchToSynthesizing(getSynthesizer(), "synthesizing plan…", getSynthesizerThinking());
76
+ widget.switchToSynthesizing(getSynthesizer(), "synthesizing plan", getSynthesizerThinking());
77
77
  const synthRound = await runSynthWithRecovery(getSynthTask());
78
78
  widget.stopWidget();
79
79
  await warnIfMutated("MoA synthesis");
@@ -156,7 +156,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
156
156
  Implement: handoff.model,
157
157
  Verify: roles?.verifier,
158
158
  });
159
- widget.switchToImplementing(handoff.model, "implementing plan…", handoff.thinking);
159
+ widget.switchToImplementing(handoff.model, "implementing plan", handoff.thinking);
160
160
  host.adoptProgressWidget(widget);
161
161
  }
162
162
  host.markImplementationPending(ctx);
@@ -18,6 +18,7 @@ import { buildContextRetryHeader, buildContextSubsectionsContract, missingContex
18
18
  import { modelExtensionOptions, resolveContextWindow, resolveModelCost } from "./modelRuntime.ts";
19
19
  import { runCriteriaGeneration } from "./verificationCriteria.ts";
20
20
  import type { MoaRunContext, ReviewLoopOptions, SucceededProposal } from "./runContext.ts";
21
+ import { buildPanelDependenceNote } from "./panelDependence.ts";
21
22
  import { buildVerdictContract, buildVerdictRetryHeader, missingVerdictSlots, parseProposerVerdicts, stripSynthSections } from "./verdicts.ts";
22
23
 
23
24
  export interface SynthesisPhaseOptions {
@@ -40,7 +41,7 @@ export async function runSynthesisPhase(
40
41
  ): Promise<SynthesisPhaseResult> {
41
42
  const { host, session, widget } = runContext;
42
43
  const { ctx, prompt, proposers, succeeded, observe, agents, warnIfMutated } = options;
43
- widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans…", runContext.synthesizerThinking);
44
+ widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans", runContext.synthesizerThinking);
44
45
 
45
46
  // Proposals are both inlined and staged on disk. Inlining is what
46
47
  // guarantees the synthesizer actually weighs every proposal — behind a
@@ -71,7 +72,10 @@ export async function runSynthesisPhase(
71
72
  // reason: a bridged synthesizer that loses the agent prompt never learns
72
73
  // the ## Conflicts markup and silently resolves every disagreement solo.
73
74
  const fedLabels = succeeded.map(({ originalIndex }) => proposerBlindedLabel(originalIndex));
74
- const baseSynthTask = `${SYNTHESIZER_TASK_PREAMBLE}\n\nOriginal user request:\n${prompt}\n\n---\n\nIndependent proposer plans:\n\n${proposals}\n\n---\n\n${buildVerdictContract(fedLabels)}\n\n---\n\n${buildContextSubsectionsContract()}\n\n---\n\n${buildConflictContract(fedLabels)}`;
75
+ // Correlated-judge guard: the note exposes dependence structure (counts and
76
+ // same-model clusters) without unblinding — only slot numbers and integers.
77
+ const dependenceNote = buildPanelDependenceNote(succeeded);
78
+ const baseSynthTask = `${SYNTHESIZER_TASK_PREAMBLE}\n\nOriginal user request:\n${prompt}\n\n---\n\n${dependenceNote}\n\nIndependent proposer plans:\n\n${proposals}\n\n---\n\n${buildVerdictContract(fedLabels)}\n\n---\n\n${buildContextSubsectionsContract()}\n\n---\n\n${buildConflictContract(fedLabels)}`;
75
79
  const rebuildSynthTask = () => {
76
80
  synthTask = [baseSynthTask, ...runContext.synthConversation.slice(-2)].join("\n\n---\n\n");
77
81
  };
@@ -162,7 +166,7 @@ export async function runSynthesisPhase(
162
166
  widget.stopWidget();
163
167
  const action = await resolveSynthesizerFailure(getResultOutput(round.result));
164
168
  if (action === "abandon") break;
165
- widget.switchToSynthesizing(runContext.synthesizer, action === "switch" ? "retrying with a new model…" : "retrying…", runContext.synthesizerThinking);
169
+ widget.switchToSynthesizing(runContext.synthesizer, action === "switch" ? "retrying with a new model" : "retrying", runContext.synthesizerThinking);
166
170
  round = await runSynthesizerRound(task);
167
171
  }
168
172
  return round;
@@ -237,7 +241,7 @@ export async function runSynthesisPhase(
237
241
  }
238
242
  runContext.synthConversation.push(`Synthesizer asked: ${question}\nUser answered: ${answer.trim()}\n\nNow produce the full plan.`);
239
243
  rebuildSynthTask();
240
- widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans…", runContext.synthesizerThinking);
244
+ widget.switchToSynthesizing(runContext.synthesizer, "adjudicating proposed plans", runContext.synthesizerThinking);
241
245
  continue;
242
246
  }
243
247
 
@@ -251,7 +255,7 @@ export async function runSynthesisPhase(
251
255
  const synthRetryCorr = buildRetryCorrection(SYNTHESIZER_RETRY_HEADER, synthOutput);
252
256
  runContext.synthConversation.push(synthRetryCorr);
253
257
  rebuildSynthTask();
254
- widget.switchToSynthesizing(runContext.synthesizer, "retrying: no plan produced…", runContext.synthesizerThinking);
258
+ widget.switchToSynthesizing(runContext.synthesizer, "retrying: no plan produced", runContext.synthesizerThinking);
255
259
  continue;
256
260
  }
257
261
 
@@ -268,7 +272,7 @@ export async function runSynthesisPhase(
268
272
  const verdictRetryCorr = buildRetryCorrection(buildVerdictRetryHeader(missingVerdicts), synthOutput);
269
273
  runContext.synthConversation.push(verdictRetryCorr);
270
274
  rebuildSynthTask();
271
- widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing proposer verdicts…", runContext.synthesizerThinking);
275
+ widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing proposer verdicts", runContext.synthesizerThinking);
272
276
  continue;
273
277
  }
274
278
 
@@ -283,7 +287,7 @@ export async function runSynthesisPhase(
283
287
  const contextRetryCorr = buildRetryCorrection(buildContextRetryHeader(missingContext), synthOutput);
284
288
  runContext.synthConversation.push(contextRetryCorr);
285
289
  rebuildSynthTask();
286
- widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing context sections…", runContext.synthesizerThinking);
290
+ widget.switchToSynthesizing(runContext.synthesizer, "retrying: missing context sections", runContext.synthesizerThinking);
287
291
  continue;
288
292
  }
289
293
  if (!questionMatch && missingContext.length === 0) contextSubsectionsSeen = true;
@@ -341,7 +345,7 @@ export async function runSynthesisPhase(
341
345
 
342
346
  runContext.synthConversation.push(feedback);
343
347
  rebuildSynthTask();
344
- widget.switchToSynthesizing(runContext.synthesizer, "synthesizing plan…", runContext.synthesizerThinking);
348
+ widget.switchToSynthesizing(runContext.synthesizer, "synthesizing plan", runContext.synthesizerThinking);
345
349
  if (isLastRound) {
346
350
  const finalRound = await runSynthWithRecovery(synthTask);
347
351
  if (finalRound.cancelled) {