@underactive/pi-topping-moa-fusion 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.
Files changed (88) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -0
  4. package/agents/mf-plan.md +43 -0
  5. package/agents/moa-debater.md +37 -0
  6. package/agents/moa-explore.md +56 -0
  7. package/agents/moa-opinion.md +29 -0
  8. package/agents/moa-proposer.md +49 -0
  9. package/agents/moa-synthesizer.md +124 -0
  10. package/agents/moa-verifier.md +67 -0
  11. package/index.ts +3 -0
  12. package/package.json +61 -0
  13. package/src/activityMeter.ts +193 -0
  14. package/src/agents/authoritative.ts +91 -0
  15. package/src/agents/defaults.ts +123 -0
  16. package/src/agents/discovery.ts +119 -0
  17. package/src/config/modelCatalogue.ts +54 -0
  18. package/src/config/planName.ts +74 -0
  19. package/src/config/rosters.ts +118 -0
  20. package/src/config/settings.ts +161 -0
  21. package/src/debate/debateContract.ts +89 -0
  22. package/src/debate/debateFanout.ts +285 -0
  23. package/src/debate/debateFile.ts +38 -0
  24. package/src/debate/debateResults.ts +115 -0
  25. package/src/debate/debateRounds.ts +61 -0
  26. package/src/debate/runDebate.ts +143 -0
  27. package/src/index.ts +283 -0
  28. package/src/moa/conflictContract.ts +49 -0
  29. package/src/moa/conflicts.ts +153 -0
  30. package/src/moa/contextContract.ts +52 -0
  31. package/src/moa/fanout.ts +152 -0
  32. package/src/moa/fanoutWiring.ts +88 -0
  33. package/src/moa/implementationRetry.ts +292 -0
  34. package/src/moa/modelRuntime.ts +87 -0
  35. package/src/moa/orchestration.ts +105 -0
  36. package/src/moa/planInfo.ts +57 -0
  37. package/src/moa/planlessRetry.ts +72 -0
  38. package/src/moa/reviewLoop.ts +170 -0
  39. package/src/moa/runContext.ts +118 -0
  40. package/src/moa/synthesis.ts +420 -0
  41. package/src/moa/verdicts.ts +81 -0
  42. package/src/moa/verification.ts +791 -0
  43. package/src/moa/verificationCriteria.ts +127 -0
  44. package/src/moa/verifyGate.ts +137 -0
  45. package/src/opinion/opinionContract.ts +21 -0
  46. package/src/opinion/opinionFanout.ts +135 -0
  47. package/src/opinion/opinionFile.ts +38 -0
  48. package/src/opinion/opinionResults.ts +73 -0
  49. package/src/opinion/runOpinion.ts +156 -0
  50. package/src/planning/askUserQuestion.ts +83 -0
  51. package/src/planning/instructions.ts +146 -0
  52. package/src/planning/modeState.ts +61 -0
  53. package/src/planning/planFile.ts +273 -0
  54. package/src/planning/planMode.ts +673 -0
  55. package/src/planning/tools/enterPlanMode.ts +165 -0
  56. package/src/planning/tools/exitPlanMode.ts +159 -0
  57. package/src/planning/tools/mfPlanSubagent.ts +311 -0
  58. package/src/planning/tools/shared.ts +19 -0
  59. package/src/planning/tools/writePlan.ts +33 -0
  60. package/src/runtime/activityTracking.ts +141 -0
  61. package/src/runtime/cancelRun.ts +134 -0
  62. package/src/runtime/mutationTripwire.ts +251 -0
  63. package/src/runtime/processPool.ts +55 -0
  64. package/src/runtime/results.ts +103 -0
  65. package/src/runtime/runner.ts +538 -0
  66. package/src/runtime/wire.ts +177 -0
  67. package/src/shared/functionKeys.ts +30 -0
  68. package/src/shared/modelRefs.ts +91 -0
  69. package/src/ui/agentStatus.ts +84 -0
  70. package/src/ui/agentTranscript.ts +112 -0
  71. package/src/ui/cancelOverlay.ts +191 -0
  72. package/src/ui/chrome.ts +151 -0
  73. package/src/ui/conflictOverlay.ts +363 -0
  74. package/src/ui/debateModelPicker.ts +273 -0
  75. package/src/ui/menu.ts +679 -0
  76. package/src/ui/moaModelPicker.ts +900 -0
  77. package/src/ui/moaProgressWidget.ts +910 -0
  78. package/src/ui/moaSetupOverlay.ts +368 -0
  79. package/src/ui/modelLabel.ts +61 -0
  80. package/src/ui/observeOverlay.ts +206 -0
  81. package/src/ui/opinionModelPicker.ts +246 -0
  82. package/src/ui/planReviewOverlay.ts +315 -0
  83. package/src/ui/promptEditor.ts +87 -0
  84. package/src/ui/rosterEditor.ts +310 -0
  85. package/src/ui/shimmer.ts +77 -0
  86. package/src/ui/toolActivity.ts +35 -0
  87. package/src/ui/twoPaneModelThinking.ts +272 -0
  88. package/src/ui/verificationFindingsOverlay.ts +137 -0
@@ -0,0 +1,105 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
4
+ import { discoverAgents } from "../agents/discovery.ts";
5
+ import { cleanupProposalFiles, sweepStaleProposalFiles } from "../planning/planFile.ts";
6
+ import type { CancelSession } from "../runtime/cancelRun.ts";
7
+ import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
8
+ import type { ModelRef, ThinkingLevel } from "../shared/modelRefs.ts";
9
+ import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
10
+ import { runFanoutPhase } from "./fanout.ts";
11
+ import { resolveContextWindow } from "./modelRuntime.ts";
12
+ import { runReviewLoop } from "./reviewLoop.ts";
13
+ import type { MoaRoles, MoaRunContext, MoaRunHost } from "./runContext.ts";
14
+ import { runSynthesisPhase } from "./synthesis.ts";
15
+
16
+ export async function runMoaOrchestration(
17
+ host: MoaRunHost,
18
+ ctx: ExtensionContext,
19
+ prompt: string,
20
+ proposers: ModelRef[],
21
+ synthesizer: ModelRef,
22
+ proposerThinking: (ThinkingLevel | undefined)[],
23
+ initialSynthesizerThinking: ThinkingLevel | undefined,
24
+ session: CancelSession,
25
+ roles?: MoaRoles,
26
+ ): Promise<"done" | "cancelled"> {
27
+ let runContext: MoaRunContext;
28
+ const widget = new MoaProgressWidget(ctx, (ref) => resolveContextWindow(ctx, ref), {
29
+ closeStacked: () => {
30
+ session.closeOverlay?.();
31
+ runContext.observeSession?.closeOverlay?.();
32
+ },
33
+ }, host.getPlanRepoSlug());
34
+ host.setRunningProgressWidget(widget);
35
+ widget.setPhaseModels({
36
+ Plan: proposers,
37
+ Synthesize: synthesizer,
38
+ Implement: roles?.implementer,
39
+ Verify: roles?.verifier,
40
+ });
41
+ widget.queueRoleRow("Synthesize", synthesizer, initialSynthesizerThinking);
42
+ if (roles) {
43
+ widget.queueRoleRow("Implement", roles.implementer, roles.implementerThinking);
44
+ widget.queueRoleRow("Verify", roles.verifier, roles.verifierThinking);
45
+ }
46
+ runContext = {
47
+ host,
48
+ session,
49
+ widget,
50
+ synthesizer,
51
+ synthesizerThinking: initialSynthesizerThinking,
52
+ synthConversation: [],
53
+ latestVerdicts: null,
54
+ proposalFiles: null,
55
+ observeSession: undefined,
56
+ previousObserveSession: undefined,
57
+ roles,
58
+ };
59
+ try {
60
+ installShippedAgents();
61
+ sweepStaleProposalFiles();
62
+ const discovery = discoverAgents(ctx.cwd, "user");
63
+ const agents = withAuthoritativeMoaAgents(discovery.agents, shippedAgentsDir());
64
+ const tripwire = new MutationTripwire();
65
+ await tripwire.arm(ctx.cwd);
66
+ const warnIfMutated = async (phase: string): Promise<void> => {
67
+ const changed = await tripwire.check(ctx.cwd);
68
+ if (changed.length > 0) ctx.ui.notify(formatMutationWarning(phase, changed), "error");
69
+ };
70
+ const fanout = await runFanoutPhase({
71
+ host,
72
+ runContext,
73
+ ctx,
74
+ prompt,
75
+ proposers,
76
+ proposerThinking,
77
+ session,
78
+ widget,
79
+ agents,
80
+ warnIfMutated,
81
+ });
82
+ if (fanout.status !== "continue") return fanout.status;
83
+ const synthesis = await runSynthesisPhase(runContext, {
84
+ ctx,
85
+ prompt,
86
+ proposers,
87
+ succeeded: fanout.succeeded,
88
+ observe: fanout.observe,
89
+ agents,
90
+ warnIfMutated,
91
+ });
92
+ if (synthesis.status !== "review") return synthesis.status;
93
+ return await runReviewLoop(synthesis.options);
94
+ } finally {
95
+ if (host.getRunningProgressWidget() === widget) host.setRunningProgressWidget(undefined);
96
+ if (host.getActiveProgressWidget() !== widget) widget.stopWidget();
97
+ session.run = undefined;
98
+ session.getExtras = undefined;
99
+ if (runContext.observeSession && host.getActiveObserveSession() === runContext.observeSession) {
100
+ host.setActiveObserveSession(runContext.previousObserveSession);
101
+ }
102
+ runContext.observeSession?.closeOverlay?.();
103
+ cleanupProposalFiles(runContext.proposalFiles);
104
+ }
105
+ }
@@ -0,0 +1,57 @@
1
+ import { isModelRef, type ModelRef } from "../shared/modelRefs.ts";
2
+
3
+ export type PlanReviewDecision = "approve" | "keep" | "edit" | "chat";
4
+
5
+ export interface MoaProposerPlan {
6
+ /** Zero-based proposer slot, retained when another proposer fails. */
7
+ proposerIndex: number;
8
+ model: ModelRef;
9
+ markdown: string;
10
+ }
11
+
12
+ export interface MfPlanInfo {
13
+ proposers: ModelRef[];
14
+ synthesizer: ModelRef;
15
+ proposerPlans?: MoaProposerPlan[];
16
+ /** The synthesizer's `## Proposer Verdicts` section — its proof of judging every proposal. */
17
+ verdictsMarkdown?: string;
18
+ }
19
+
20
+ function isRecord(value: unknown): value is Record<string, unknown> {
21
+ return !!value && typeof value === "object" && !Array.isArray(value);
22
+ }
23
+
24
+ /** Validate MoA review metadata restored from untrusted session entries. */
25
+ export function validateMfPlanInfo(value: unknown): MfPlanInfo | undefined {
26
+ if (!isRecord(value) || !Array.isArray(value.proposers) || !value.proposers.every(isModelRef) || !isModelRef(value.synthesizer)) {
27
+ return undefined;
28
+ }
29
+ if (value.verdictsMarkdown !== undefined && typeof value.verdictsMarkdown !== "string") return undefined;
30
+ const proposers = value.proposers as ModelRef[];
31
+ let proposerPlans: MoaProposerPlan[] | undefined;
32
+ if (value.proposerPlans !== undefined) {
33
+ if (!Array.isArray(value.proposerPlans)) return undefined;
34
+ const seenIndices = new Set<number>();
35
+ if (!value.proposerPlans.every((proposal) => {
36
+ if (!isRecord(proposal)
37
+ || typeof proposal.proposerIndex !== "number"
38
+ || !Number.isInteger(proposal.proposerIndex)
39
+ || proposal.proposerIndex < 0
40
+ || proposal.proposerIndex >= proposers.length
41
+ || seenIndices.has(proposal.proposerIndex)
42
+ || !isModelRef(proposal.model)
43
+ || typeof proposal.markdown !== "string") return false;
44
+ const expectedModel = proposers[proposal.proposerIndex];
45
+ if (proposal.model.provider !== expectedModel.provider || proposal.model.id !== expectedModel.id) return false;
46
+ seenIndices.add(proposal.proposerIndex);
47
+ return true;
48
+ })) return undefined;
49
+ proposerPlans = value.proposerPlans as unknown as MoaProposerPlan[];
50
+ }
51
+ return {
52
+ proposers,
53
+ synthesizer: value.synthesizer,
54
+ ...(proposerPlans ? { proposerPlans } : {}),
55
+ ...(typeof value.verdictsMarkdown === "string" ? { verdictsMarkdown: value.verdictsMarkdown } : {}),
56
+ };
57
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Recovery for planning agents that exit cleanly without delivering a plan.
3
+ *
4
+ * Bridged agentic providers can override the moa-proposer/moa-synthesizer
5
+ * prompt with their own interactive harness, producing two planless shapes:
6
+ *
7
+ * - Question stops: Cursor's plan mode researches and then stops to ask the
8
+ * user a clarifying question before writing the plan. In a headless
9
+ * `-p --no-session` child nobody can answer, so the process exits with the
10
+ * question as its final output.
11
+ * - Read-only stalls: a bridged synthesizer that lost the moa-synthesizer
12
+ * prompt reads "Original user request: implement X and verify" as its own
13
+ * marching orders, discovers it has no edit/shell tools, and exits with an
14
+ * "I'm blocked" refusal instead of a synthesized plan.
15
+ * - Missing output contract: a bridged proposer that lost the moa-proposer
16
+ * prompt never sees the required output sections, explores interactively,
17
+ * and exits with questions or prose instead of a plan.
18
+ *
19
+ * These helpers detect both shapes so the orchestrator can re-run the agent
20
+ * once with an explicit instruction to answer its own questions / emit the
21
+ * plan, and carry the read-only planning contract inside the task text —
22
+ * the only channel guaranteed to survive a bridge's prompt override.
23
+ */
24
+
25
+ /**
26
+ * Headings from the required moa-proposer output format that mark a proposal
27
+ * as having reached an actual plan. Question-stop outputs are prose (or a
28
+ * bare question) and contain none of these; matching loosely on heading text
29
+ * tolerates models that rename sections (e.g. "## Implementation Plan").
30
+ */
31
+ const PLAN_HEADING = /^#{1,6}\s.*\b(plan|files to modify)\b/im;
32
+
33
+ export function looksLikePlan(output: string): boolean {
34
+ return PLAN_HEADING.test(output);
35
+ }
36
+
37
+ export const PROPOSER_RETRY_HEADER =
38
+ "IMPORTANT: You already attempted this planning task once and stopped without emitting the required plan — your previous output (quoted below) ended in clarifying questions or was otherwise incomplete. You are running headless: there is no user and no way to receive answers. Do not ask questions and do not wait. Answer any open questions yourself with best-judgment assumptions, record them under \"## Open Questions / Assumptions\", and emit the COMPLETE plan in the required output format now.";
39
+
40
+ export function buildRetryCorrection(header: string, previousOutput: string): string {
41
+ const previous = previousOutput.trim().replaceAll('"""', "''") || "(no output)";
42
+ return [header, "", "Your previous output:", '"""', previous, '"""'].join("\n");
43
+ }
44
+
45
+ export function buildProposerRetryTask(originalTask: string, previousOutput: string): string {
46
+ return [originalTask, "", "---", "", buildRetryCorrection(PROPOSER_RETRY_HEADER, previousOutput)].join("\n");
47
+ }
48
+
49
+ /**
50
+ * Read-only planning contract, prepended to the proposer's task text so it
51
+ * reaches the model even when a provider bridge replaces the moa-proposer
52
+ * system prompt with its own harness. Required section names are mentioned
53
+ * mid-line or in backticks only: a model that parrots this preamble back must
54
+ * not satisfy PLAN_HEADING (see the anti-echo test).
55
+ */
56
+ export const PROPOSER_TASK_PREAMBLE =
57
+ "You are one of several read-only PROPOSERS in a Mixture-of-Agents planning run: each proposer independently drafts its own implementation plan, and a separate synthesizer merges them later. Your only deliverable is ONE complete implementation plan, emitted as markdown text in your reply using the required sections (heading names like `## Context`, `## Plan`, and `## Files to Modify` are spelled out in the agent instructions). Do NOT implement anything, do NOT edit files, and do NOT run commands, builds, or tests — read-only tools are expected and are never a blocker. You are running headless: there is no user and no way to receive answers, so resolve any ambiguity with best-judgment assumptions recorded in your plan rather than asking questions or stopping. The user request below is the subject the plan is ABOUT, not an instruction for you to carry out now, even if it says \"implement\", \"fix\", or \"verify\". The plan must be the final content of your reply — start it as soon as you have enough context and do not append anything after it.";
58
+
59
+ export function buildProposerTask(prompt: string): string {
60
+ return [PROPOSER_TASK_PREAMBLE, "", "User request:", prompt].join("\n");
61
+ }
62
+
63
+ /**
64
+ * Read-only synthesis contract, prepended to the synthesizer's task text so
65
+ * it reaches the model even when a provider bridge replaces the
66
+ * moa-synthesizer system prompt with its own harness.
67
+ */
68
+ export const SYNTHESIZER_TASK_PREAMBLE =
69
+ "You are the read-only SYNTHESIZER in a Mixture-of-Agents planning run. Your only deliverable is ONE synthesized implementation plan, emitted as markdown text in your reply. This is a planning phase: do NOT implement anything, do NOT edit files, and do NOT run commands, builds, or tests. Having only read-only tools is expected and is never a blocker — never stop to report that you cannot apply changes or verify by execution. The original user request below is the subject the plan is ABOUT, not an instruction for you to carry out now, even if it says \"implement\", \"fix\", or \"verify\".";
70
+
71
+ export const SYNTHESIZER_RETRY_HEADER =
72
+ "IMPORTANT: You already attempted this synthesis once and stopped without emitting the required plan — your previous output (quoted below) refused, stalled on missing edit/shell tools, or was otherwise not a synthesized plan. Implementation and verification-by-execution are out of scope for this read-only planning phase by design, so limited tools never block you. Do not refuse and do not wait. Emit the COMPLETE synthesized plan now in the required output format, starting with ## Context and including ## Plan, ## Files to Modify, and the remaining sections.";
@@ -0,0 +1,170 @@
1
+ import { getPlanFilePath, saveRepoPlanFile, writePlan } from "../planning/planFile.ts";
2
+ import { getFinalOutput, getResultOutput } from "../runtime/results.ts";
3
+ import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
4
+ import { showPlanReview } from "../ui/planReviewOverlay.ts";
5
+ import { showImplementingModelPicker } from "../ui/moaModelPicker.ts";
6
+ import { buildImplementationKickoffMessage, type ImplementationHandoff } from "./implementationRetry.ts";
7
+ import { parseConflicts } from "./conflicts.ts";
8
+ import type { ReviewLoopOptions } from "./runContext.ts";
9
+ import type { MoaProposerPlan, PlanReviewDecision } from "./planInfo.ts";
10
+ import { parseProposerVerdicts, stripSynthSections } from "./verdicts.ts";
11
+
12
+ export const MAX_REVIEW_CHAT_ROUNDS = 5;
13
+
14
+ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done"> {
15
+ const {
16
+ host, ctx, session, widget, proposers, succeeded, synthConversation,
17
+ getSynthesizer, getSynthesizerThinking, getLatestVerdicts, setLatestVerdicts, rebuildSynthTask,
18
+ getSynthTask, runSynthWithRecovery, warnIfMutated,
19
+ } = options;
20
+ let currentPlan = options.initialPlan;
21
+ writePlan(currentPlan);
22
+ const proposerPlans: MoaProposerPlan[] = succeeded.map(({ ref, plan, originalIndex }) => ({
23
+ proposerIndex: originalIndex,
24
+ model: ref,
25
+ markdown: plan,
26
+ }));
27
+ host.setActiveRunMoaInfo({
28
+ proposers,
29
+ synthesizer: getSynthesizer(),
30
+ proposerPlans,
31
+ verdictsMarkdown: getLatestVerdicts() ?? undefined,
32
+ });
33
+ host.persistState();
34
+ let chatRounds = 0;
35
+
36
+ while (true) {
37
+ session.closeOverlay?.();
38
+ const synthesizer = getSynthesizer();
39
+ let decision: PlanReviewDecision;
40
+ if (ctx.mode === "tui") {
41
+ decision = await showPlanReview(
42
+ ctx,
43
+ currentPlan,
44
+ { proposers, synthesizer, proposerPlans, verdictsMarkdown: getLatestVerdicts() ?? undefined },
45
+ host.getPlanRepoSlug(),
46
+ chatRounds < MAX_REVIEW_CHAT_ROUNDS,
47
+ );
48
+ } else {
49
+ const choice = await ctx.ui.select("Exit plan mode?", ["Approve — start implementing", "Keep planning", "Edit plan", "Chat with synthesizer"]);
50
+ decision = choice?.startsWith("Approve") ? "approve" : choice?.startsWith("Edit") ? "edit" : choice?.startsWith("Chat") ? "chat" : "keep";
51
+ }
52
+
53
+ if (decision === "edit") {
54
+ session.closeOverlay?.();
55
+ const edited = await ctx.ui.editor("Edit Plan", currentPlan);
56
+ if (edited?.trim()) {
57
+ currentPlan = stripSynthSections(edited.trim());
58
+ writePlan(currentPlan);
59
+ const repoPlanSlug = host.getPlanRepoSlug();
60
+ if (repoPlanSlug) saveRepoPlanFile(currentPlan, ctx.cwd, repoPlanSlug, "plan");
61
+ }
62
+ continue;
63
+ }
64
+
65
+ if (decision === "chat") {
66
+ if (chatRounds >= MAX_REVIEW_CHAT_ROUNDS) {
67
+ ctx.ui.notify("Maximum synthesizer review rounds reached; edit or approve the plan instead.", "warning");
68
+ continue;
69
+ }
70
+ session.closeOverlay?.();
71
+ const feedback = await ctx.ui.editor("Describe the changes you want:");
72
+ if (!feedback?.trim()) continue;
73
+ chatRounds++;
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
+ rebuildSynthTask();
76
+ widget.switchToSynthesizing(getSynthesizer(), "synthesizing plan…", getSynthesizerThinking());
77
+ const synthRound = await runSynthWithRecovery(getSynthTask());
78
+ widget.stopWidget();
79
+ await warnIfMutated("MoA synthesis");
80
+ if (synthRound.cancelled) {
81
+ ctx.ui.notify("Synthesis cancelled.");
82
+ continue;
83
+ }
84
+ if (synthRound.failed) {
85
+ ctx.ui.notify(`Synthesizer failed: ${getResultOutput(synthRound.result)} — keeping the previous plan.`, "error");
86
+ continue;
87
+ }
88
+ const revisionVerdicts = parseProposerVerdicts(parseConflicts(getFinalOutput(synthRound.result.messages)).remainingPlan);
89
+ if (revisionVerdicts.verdictsMarkdown) setLatestVerdicts(revisionVerdicts.verdictsMarkdown);
90
+ const activeRunMoaInfo = host.getActiveRunMoaInfo();
91
+ if (activeRunMoaInfo) {
92
+ activeRunMoaInfo.synthesizer = getSynthesizer();
93
+ if (revisionVerdicts.verdictsMarkdown) activeRunMoaInfo.verdictsMarkdown = revisionVerdicts.verdictsMarkdown;
94
+ host.persistState();
95
+ }
96
+ currentPlan = revisionVerdicts.remainingPlan;
97
+ writePlan(currentPlan);
98
+ const repoPlanSlug = host.getPlanRepoSlug();
99
+ if (repoPlanSlug) saveRepoPlanFile(currentPlan, ctx.cwd, repoPlanSlug, "plan");
100
+ continue;
101
+ }
102
+
103
+ if (decision === "approve") {
104
+ if (ctx.mode !== "tui") host.saveApprovedPlanToRepo(ctx, currentPlan);
105
+ const roles = options.roles;
106
+ let generatedCriteria: Awaited<ReturnType<NonNullable<typeof options.generateVerificationCriteria>>> | undefined;
107
+ if (roles?.verifier && options.generateVerificationCriteria) {
108
+ generatedCriteria = await options.generateVerificationCriteria(currentPlan);
109
+ if (generatedCriteria) {
110
+ const repoPlanSlug = host.getPlanRepoSlug();
111
+ if (repoPlanSlug) saveRepoPlanFile(generatedCriteria.markdown, ctx.cwd, repoPlanSlug, "criteria");
112
+ ctx.ui.notify(`Verification criteria ready: ${generatedCriteria.criteria.length} checks.`);
113
+ } else {
114
+ ctx.ui.notify("Verification criteria could not be generated; the verifier will judge plan steps directly.", "warning");
115
+ }
116
+ }
117
+ // The implementer was picked up front in the MoA picker, so approval
118
+ // applies it directly with no second picker. The post-approval picker
119
+ // is kept only as a fallback for a run restored without a roles roster.
120
+ let implementationSelection: { ref: ModelRef; thinking: ThinkingLevel } | undefined;
121
+ if (roles?.implementer) {
122
+ implementationSelection = { ref: roles.implementer, thinking: roles.implementerThinking ?? host.currentThinkingLevel() };
123
+ } else {
124
+ implementationSelection = await showImplementingModelPicker(ctx, host.currentThinkingLevel());
125
+ }
126
+ if (implementationSelection) {
127
+ await host.applyImplementingSelection(
128
+ ctx,
129
+ implementationSelection,
130
+ `Switched to ${modelRefLabel(implementationSelection.ref)} for implementation.`,
131
+ );
132
+ }
133
+ host.exitPlanMode(ctx);
134
+ const filePath = getPlanFilePath();
135
+ const handoff: ImplementationHandoff = {
136
+ plan: currentPlan,
137
+ planFilePath: filePath,
138
+ repoPlanSlug: host.getPlanRepoSlug(),
139
+ model: implementationSelection?.ref ?? ctx.model,
140
+ thinking: implementationSelection?.thinking ?? host.currentThinkingLevel(),
141
+ verifier: roles?.verifier,
142
+ verifierThinking: roles?.verifierThinking,
143
+ verificationCriteria: generatedCriteria?.markdown,
144
+ verificationRepairs: 0,
145
+ timestamp: Date.now(),
146
+ };
147
+ host.setImplementationHandoff(handoff);
148
+ if (handoff.model) {
149
+ // The review overlay pauses the table without settling synthesis. Close
150
+ // that row before implementation so its status and last tool activity
151
+ // cannot continue rendering as a second active agent.
152
+ widget.settleRoleRow("Synthesize", "done");
153
+ widget.setPhaseModels({
154
+ Plan: proposers,
155
+ Synthesize: getSynthesizer(),
156
+ Implement: handoff.model,
157
+ Verify: roles?.verifier,
158
+ });
159
+ widget.switchToImplementing(handoff.model, "implementing plan…", handoff.thinking);
160
+ host.adoptProgressWidget(widget);
161
+ }
162
+ host.markImplementationPending(ctx);
163
+ host.pi.sendUserMessage(buildImplementationKickoffMessage(currentPlan, filePath), TRIGGER_TURN);
164
+ } else {
165
+ ctx.ui.notify("Continuing to refine the MoA-synthesized plan.");
166
+ host.pi.sendUserMessage(`The plan file already contains a Mixture-of-Agents synthesized plan for this request. Read it, refine it further if needed, and call exit_plan_mode when ready.`, TRIGGER_TURN);
167
+ }
168
+ return "done";
169
+ }
170
+ }
@@ -0,0 +1,118 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { ProposalFileSet } from "../planning/planFile.ts";
4
+ import type { CancelSession } from "../runtime/cancelRun.ts";
5
+ import type { SingleResult } from "../runtime/results.ts";
6
+ import type { ThinkingLevel, ModelRef } from "../shared/modelRefs.ts";
7
+ import type { ObserveSession } from "../ui/observeOverlay.ts";
8
+ import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
9
+ import type { MfPlanInfo } from "./planInfo.ts";
10
+
11
+ import type { ImplementationHandoff } from "./implementationRetry.ts";
12
+ import type { VerificationCriterion } from "./verificationCriteria.ts";
13
+
14
+ export interface MoaRunHost {
15
+ pi: ExtensionAPI;
16
+ getPlanRepoSlug(): string | undefined;
17
+ getActiveObserveSession(): ObserveSession | undefined;
18
+ setActiveObserveSession(session: ObserveSession | undefined): void;
19
+ getActiveRunMoaInfo(): MfPlanInfo | undefined;
20
+ setActiveRunMoaInfo(info: MfPlanInfo | undefined): void;
21
+ persistState(): void;
22
+ currentThinkingLevel(): ThinkingLevel;
23
+ applyImplementingSelection(
24
+ ctx: ExtensionContext,
25
+ selection: { ref: ModelRef; thinking: ThinkingLevel },
26
+ successNotice: string,
27
+ ): Promise<void>;
28
+ exitPlanMode(ctx: ExtensionContext): void;
29
+ saveApprovedPlanToRepo(ctx: ExtensionContext, plan: string): void;
30
+ getImplementationHandoff(): ImplementationHandoff | undefined;
31
+ setImplementationHandoff(handoff: ImplementationHandoff | undefined): void;
32
+ markImplementationPending(ctx?: ExtensionContext): void;
33
+ /**
34
+ * Latch the footer's unrecoverable-error dot and repaint. Only for flows that
35
+ * stop with nothing left to try automatically — not for warnings, fallbacks,
36
+ * retry dialogs, or user cancellation.
37
+ */
38
+ noteRunError(ctx: ExtensionContext, message: string): void;
39
+ /**
40
+ * Take ownership of the MoA progress widget from orchestration, so it stays
41
+ * mounted across in-session implementation and verification. Orchestration's
42
+ * own `finally` skips its stop when the controller holds the widget.
43
+ */
44
+ adoptProgressWidget(widget: MoaProgressWidget): void;
45
+ getActiveProgressWidget(): MoaProgressWidget | undefined;
46
+ /** The orchestration widget while a run is in fan-out, synthesis, or review. */
47
+ getRunningProgressWidget(): MoaProgressWidget | undefined;
48
+ setRunningProgressWidget(widget: MoaProgressWidget | undefined): void;
49
+ stopActiveProgressWidget(): void;
50
+ /**
51
+ * Publish (or clear) the F4-cancellable session the input triggers open the
52
+ * cancel overlay against. The verification phase uses this to make its
53
+ * read-only verifier subprocess cancellable, mirroring how fan-out/synthesis
54
+ * publish their run via the plan-prompt session.
55
+ */
56
+ setActiveCancelSession(session: CancelSession | undefined): void;
57
+ }
58
+
59
+ /**
60
+ * Models chosen up front in the MoA picker for the post-approval phases:
61
+ * the implementer that writes the approved plan in-session, and the verifier
62
+ * that judges the result against the plan. Threaded from the picker through
63
+ * orchestration into the review loop and the implementation handoff.
64
+ */
65
+ export interface MoaRoles {
66
+ implementer: ModelRef;
67
+ implementerThinking?: ThinkingLevel;
68
+ verifier: ModelRef;
69
+ verifierThinking?: ThinkingLevel;
70
+ }
71
+
72
+ export interface SynthesisRoundResult {
73
+ result: SingleResult;
74
+ cancelled?: boolean;
75
+ failed?: boolean;
76
+ }
77
+
78
+ export interface SucceededProposal {
79
+ ref: ModelRef;
80
+ plan: string;
81
+ originalIndex: number;
82
+ }
83
+
84
+ export interface ReviewLoopOptions {
85
+ host: MoaRunHost;
86
+ ctx: ExtensionContext;
87
+ session: CancelSession;
88
+ widget: MoaProgressWidget;
89
+ proposers: ModelRef[];
90
+ succeeded: SucceededProposal[];
91
+ initialPlan: string;
92
+ synthConversation: string[];
93
+ getSynthesizer(): ModelRef;
94
+ /** Current synthesizer thinking level, read live so review-round activations show the active selection. */
95
+ getSynthesizerThinking(): ThinkingLevel | undefined;
96
+ getLatestVerdicts(): string | null;
97
+ setLatestVerdicts(markdown: string): void;
98
+ rebuildSynthTask(): void;
99
+ getSynthTask(): string;
100
+ runSynthWithRecovery(task: string): Promise<SynthesisRoundResult>;
101
+ warnIfMutated(phase: string): Promise<void>;
102
+ roles?: MoaRoles;
103
+ generateVerificationCriteria?(plan: string): Promise<{ criteria: VerificationCriterion[]; markdown: string } | undefined>;
104
+ }
105
+
106
+ export interface MoaRunContext {
107
+ host: MoaRunHost;
108
+ session: CancelSession;
109
+ widget: MoaProgressWidget;
110
+ synthesizer: ModelRef;
111
+ synthesizerThinking: ThinkingLevel | undefined;
112
+ synthConversation: string[];
113
+ latestVerdicts: string | null;
114
+ proposalFiles: ProposalFileSet | null;
115
+ observeSession: ObserveSession | undefined;
116
+ previousObserveSession: ObserveSession | undefined;
117
+ roles?: MoaRoles;
118
+ }