@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,143 @@
1
+ import type { ExtensionAPI, 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 { summarizePlanPromptName } from "../config/planName.ts";
6
+ import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
7
+ import type { OpinionHost } from "../opinion/runOpinion.ts";
8
+ import { resolveContextWindow } from "../moa/modelRuntime.ts";
9
+ import { type CancelSession, subscribeCancelOverlayOnEsc } from "../runtime/cancelRun.ts";
10
+ import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
11
+ import { type SendUserMessageOptions, type ThinkingLevel } from "../shared/modelRefs.ts";
12
+ import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
13
+ import type { ObserveSession } from "../ui/observeOverlay.ts";
14
+ import { showDebateModelPicker } from "../ui/debateModelPicker.ts";
15
+ import { showPromptEditor } from "../ui/promptEditor.ts";
16
+ import { repoDebateDisplayPath, saveRepoDebateFile } from "./debateFile.ts";
17
+ import { runDebateRounds } from "./debateFanout.ts";
18
+ import { collectDebateOutcomes, formatDebateMarkdown } from "./debateResults.ts";
19
+
20
+ /** Same host surface as the opinion flow — all three MoA flows share the controller. */
21
+ export type DebateHost = OpinionHost;
22
+
23
+ export async function runInteractiveDebate(
24
+ pi: ExtensionAPI,
25
+ host: DebateHost,
26
+ ctx: ExtensionContext,
27
+ initialPrompt?: string,
28
+ ): Promise<void> {
29
+ if (host.getActiveCancelSession()?.run) {
30
+ ctx.ui.notify("A plan, opinion, or debate run is already in progress.", "warning");
31
+ return;
32
+ }
33
+ if (!ctx.hasUI) {
34
+ ctx.ui.notify("/mf-debate requires an interactive session.", "warning");
35
+ return;
36
+ }
37
+
38
+ installShippedAgents();
39
+ const session: CancelSession = { title: "Debate agents", run: undefined, overlayOpen: false };
40
+ host.setActiveCancelSession(session);
41
+ const unsubscribeEsc = subscribeCancelOverlayOnEsc(ctx, host, session);
42
+
43
+ try {
44
+ let prefill = initialPrompt?.trim() ?? "";
45
+ let skipEditorOnce = Boolean(prefill);
46
+ while (true) {
47
+ let topic: string | undefined;
48
+ if (skipEditorOnce) {
49
+ topic = prefill;
50
+ skipEditorOnce = false;
51
+ } else {
52
+ topic = await showPromptEditor(ctx, "Debate — pick a topic to argue about this repo", prefill);
53
+ }
54
+ if (!topic?.trim()) {
55
+ ctx.ui.notify("Debate cancelled.");
56
+ return;
57
+ }
58
+ topic = topic.trim();
59
+ prefill = topic;
60
+
61
+ const selection = await showDebateModelPicker(ctx, host.currentThinkingLevel());
62
+ if (!selection) return;
63
+ const { models, thinking, thinkingSelections, rounds } = selection;
64
+ const settings = loadMoaConfig();
65
+ saveMoaConfig({
66
+ ...settings,
67
+ debateModels: models,
68
+ debateRounds: rounds,
69
+ thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections },
70
+ });
71
+
72
+ const slug = await summarizePlanPromptName(ctx, topic);
73
+ try {
74
+ saveRepoDebateFile(topic, ctx.cwd, slug, "debate-prompt");
75
+ } catch {
76
+ ctx.ui.notify("Could not save the debate prompt artifact.", "warning");
77
+ }
78
+
79
+ const closeStacked = () => {
80
+ session.closeOverlay?.();
81
+ host.getActiveObserveSession()?.closeOverlay?.();
82
+ };
83
+ const widget = new MoaProgressWidget(
84
+ ctx,
85
+ (ref) => resolveContextWindow(ctx, ref),
86
+ {
87
+ closeStacked,
88
+ title: "MoA Debate",
89
+ phaseLabels: { Plan: "Debate" },
90
+ fanoutWorkingText: "round 1 · forming positions",
91
+ },
92
+ slug,
93
+ );
94
+ widget.setPhaseModels({ Plan: models });
95
+ const agents = withAuthoritativeMoaAgents(
96
+ discoverAgents(ctx.cwd, "user").agents,
97
+ shippedAgentsDir(),
98
+ );
99
+ const tripwire = new MutationTripwire();
100
+ await tripwire.arm(ctx.cwd);
101
+ host.setRunningProgressWidget(widget);
102
+ let outcome;
103
+ try {
104
+ outcome = await runDebateRounds({ host, ctx, topic, models, thinking, rounds, session, widget, agents });
105
+ } finally {
106
+ host.setRunningProgressWidget(undefined);
107
+ widget.stopWidget();
108
+ const changed = await tripwire.check(ctx.cwd);
109
+ if (changed.length > 0) {
110
+ ctx.ui.notify(formatMutationWarning("MoA debate rounds", changed), "warning");
111
+ }
112
+ }
113
+
114
+ if (outcome.status === "cancelled") {
115
+ session.closeOverlay?.();
116
+ ctx.ui.notify("Debate run cancelled.");
117
+ continue;
118
+ }
119
+
120
+ const outcomes = collectDebateOutcomes(models, thinking, outcome.rounds);
121
+ const markdown = formatDebateMarkdown(topic, outcomes, outcome.rounds, slug, outcome.stoppedEarly, rounds);
122
+ const displayPath = repoDebateDisplayPath(slug, "debate");
123
+ let saved = true;
124
+ try {
125
+ saveRepoDebateFile(markdown, ctx.cwd, slug, "debate");
126
+ } catch {
127
+ saved = false;
128
+ }
129
+ const appendOnly: SendUserMessageOptions = { triggerTurn: false };
130
+ await pi.sendUserMessage(markdown, appendOnly);
131
+ const active = outcomes.filter((item) => item.finalStatus === "active-at-close").length;
132
+ const suffix = saved ? ` · saved to ${displayPath}` : " · artifact save failed";
133
+ ctx.ui.notify(
134
+ `${outcome.rounds.length} round${outcome.rounds.length === 1 ? "" : "s"} · ${active} of ${outcomes.length} debaters completed${suffix}`,
135
+ active === 0 ? "error" : saved ? "info" : "warning",
136
+ );
137
+ return;
138
+ }
139
+ } finally {
140
+ unsubscribeEsc?.();
141
+ host.setActiveCancelSession(undefined);
142
+ }
143
+ }
package/src/index.ts ADDED
@@ -0,0 +1,283 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Key } from "@earendil-works/pi-tui";
5
+
6
+ import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "./agents/authoritative.ts";
7
+ import { discoverAgents } from "./agents/discovery.ts";
8
+ import {
9
+ buildImplementationKickoffMessage,
10
+ resolveHandoffPlan,
11
+ type ImplementationHandoff,
12
+ } from "./moa/implementationRetry.ts";
13
+ import { modelRefLabel, TRIGGER_TURN } from "./shared/modelRefs.ts";
14
+ import { loadMoaConfig } from "./config/settings.ts";
15
+ import { getPlanFilePath, getRepoPlanDirectory, isValidPlanSlug, readRepoPlanFile, saveRepoPlanFile } from "./planning/planFile.ts";
16
+ import { showImplementingModelPicker } from "./ui/moaModelPicker.ts";
17
+ import { showPlanReview } from "./ui/planReviewOverlay.ts";
18
+ import { showMoaSetup } from "./ui/moaSetupOverlay.ts";
19
+ import { runCriteriaGeneration } from "./moa/verificationCriteria.ts";
20
+ import { stripSynthSections } from "./moa/verdicts.ts";
21
+ import { runInteractiveOpinion, type OpinionHost } from "./opinion/runOpinion.ts";
22
+ import { runInteractiveDebate } from "./debate/runDebate.ts";
23
+ import { createPlanModeController } from "./planning/planMode.ts";
24
+ import { registerEnterPlanModeTool, runInteractivePlanMode } from "./planning/tools/enterPlanMode.ts";
25
+ import { registerExitPlanModeTool } from "./planning/tools/exitPlanMode.ts";
26
+ import { registerMfPlanSubagentTool } from "./planning/tools/mfPlanSubagent.ts";
27
+ import { registerWritePlanTool } from "./planning/tools/writePlan.ts";
28
+
29
+ export default function mfPlanExtension(pi: ExtensionAPI): void {
30
+ const controller = createPlanModeController(pi);
31
+ const opinionHost: OpinionHost = {
32
+ currentThinkingLevel: controller.currentThinkingLevel,
33
+ getActiveCancelSession: controller.getActiveCancelSession,
34
+ setActiveCancelSession: controller.setActiveCancelSession,
35
+ openCancelOverlayIfActive: controller.openCancelOverlayIfActive,
36
+ getActiveObserveSession: controller.getActiveObserveSession,
37
+ setActiveObserveSession: (session) => { controller.moaRunHost.setActiveObserveSession(session); },
38
+ setRunningProgressWidget: controller.moaRunHost.setRunningProgressWidget,
39
+ };
40
+ const debateHost = opinionHost;
41
+ const togglePlanMode = async (ctx: Parameters<typeof controller.exitPlanMode>[0], prompt?: string): Promise<void> => {
42
+ if (controller.questionnaireBusy(ctx)) return;
43
+ if (controller.getActiveCancelSession()?.run) {
44
+ ctx.ui.notify("An MoA run is in progress.", "warning");
45
+ return;
46
+ }
47
+ if (controller.isEnabled()) controller.exitPlanMode(ctx);
48
+ else await runInteractivePlanMode(pi, controller, ctx, prompt);
49
+ };
50
+
51
+ pi.registerFlag("mf-plan", {
52
+ description: "Start in MoA Fusion plan mode (read-only with 5-phase workflow)",
53
+ type: "boolean",
54
+ default: false,
55
+ });
56
+ pi.registerCommand("mf-plan", {
57
+ description: "Toggle 'Mixture of Agents' plan mode — append a prompt (e.g. /mf-plan add dark mode) to skip the editor",
58
+ handler: async (args, ctx) => togglePlanMode(ctx, args.trim() || undefined),
59
+ });
60
+ pi.registerCommand("mf-preview", {
61
+ description: "Toggle the MoA live preview under the agent table in the active run",
62
+ handler: async (_args, ctx) => { controller.toggleLivePreview(ctx); },
63
+ });
64
+ pi.registerCommand("mf-opinion", {
65
+ description: "Ask up to 5 models for independent read-only opinions — append a question (e.g. /mf-opinion is X sound?) to skip the editor",
66
+ handler: async (args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await runInteractiveOpinion(pi, opinionHost, ctx, args.trim() || undefined); },
67
+ });
68
+ pi.registerCommand("mf-debate", {
69
+ 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
+ handler: async (args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await runInteractiveDebate(pi, debateHost, ctx, args.trim() || undefined); },
71
+ });
72
+ pi.registerCommand("mf-plan-clear", {
73
+ description: "Clear the completed plan state so /mf-plan starts a fresh planning round (approved plans stay in .pi/mf-plan/)",
74
+ handler: async (_args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await controller.clearCompletedPlan(ctx); },
75
+ });
76
+ pi.registerCommand("mf-plan-settings", {
77
+ description: "Configure the explore and cheap/fast agents, agent rosters for MoA roles, and plan options",
78
+ handler: async (_args, ctx) => {
79
+ if (controller.questionnaireBusy(ctx)) return;
80
+ installShippedAgents();
81
+ const saved = await showMoaSetup(ctx, controller.currentThinkingLevel());
82
+ if (saved) ctx.ui.notify("MoA Fusion Settings saved.");
83
+ },
84
+ });
85
+ pi.registerCommand("mf-plan-implement", {
86
+ description: "Retry implementation of the last approved MoA plan, optionally with a different model",
87
+ handler: async (_args, ctx) => {
88
+ if (controller.questionnaireBusy(ctx)) return;
89
+ if (controller.isEnabled()) controller.exitPlanMode(ctx);
90
+ let handoff = controller.getImplementationHandoff();
91
+ let plan: string | null = null;
92
+ let filePath = getPlanFilePath();
93
+
94
+ if (handoff) {
95
+ plan = resolveHandoffPlan(handoff, ctx.cwd);
96
+ filePath = handoff.planFilePath || filePath;
97
+ }
98
+
99
+ if (!plan) {
100
+ // Fall back to scanning repo plan directory for saved plans
101
+ const repoDir = getRepoPlanDirectory(ctx.cwd);
102
+ let planFiles: { filename: string; fullPath: string; mtime: number; slug: string }[] = [];
103
+ try {
104
+ if (fs.existsSync(repoDir)) {
105
+ const entries = fs.readdirSync(repoDir);
106
+ planFiles = entries
107
+ .filter((file) => file.endsWith("__plan.md") && !file.endsWith("__plan-prompt.md"))
108
+ .map((file) => {
109
+ const slug = file.slice(0, -"__plan.md".length);
110
+ const fullPath = path.join(repoDir, file);
111
+ const stat = fs.statSync(fullPath);
112
+ return { filename: file, fullPath, mtime: stat.mtimeMs, slug };
113
+ })
114
+ .filter((file) => isValidPlanSlug(file.slug))
115
+ .sort((a, b) => b.mtime - a.mtime);
116
+ }
117
+ } catch {
118
+ // Fall through if reading directory fails
119
+ }
120
+
121
+ if (planFiles.length === 0) {
122
+ ctx.ui.notify("No approved plan found — run /mf-plan first.", "warning");
123
+ return;
124
+ }
125
+
126
+ if (!ctx.hasUI) {
127
+ ctx.ui.notify("No in-session approved plan found — run /mf-plan in an interactive session first.", "warning");
128
+ return;
129
+ }
130
+
131
+ const options = planFiles.map((f) => f.slug);
132
+ const selectedSlug = await ctx.ui.select("Select plan found on disk to review:", options);
133
+ if (!selectedSlug) return;
134
+ const chosenFile = planFiles.find((f) => f.slug === selectedSlug);
135
+ if (!chosenFile) {
136
+ ctx.ui.notify("Selected plan could not be resolved.", "error");
137
+ return;
138
+ }
139
+
140
+ let diskPlan: string;
141
+ try {
142
+ diskPlan = fs.readFileSync(chosenFile.fullPath, "utf8");
143
+ } catch {
144
+ ctx.ui.notify(`Failed to read plan from ${chosenFile.fullPath}.`, "error");
145
+ return;
146
+ }
147
+
148
+ // Plans found on disk are repo artifacts, not something this session's user
149
+ // necessarily authored or has seen — require an explicit content review
150
+ // before treating one as approved, unlike the in-session handoff above.
151
+ let approved = false;
152
+ if (ctx.mode === "tui") {
153
+ while (true) {
154
+ const decision = await showPlanReview(ctx, diskPlan, undefined, chosenFile.slug, false);
155
+ if (decision === "edit") {
156
+ const edited = await ctx.ui.editor("Edit Plan", diskPlan);
157
+ if (edited?.trim()) diskPlan = stripSynthSections(edited.trim());
158
+ continue;
159
+ }
160
+ approved = decision === "approve";
161
+ break;
162
+ }
163
+ } else {
164
+ approved = await ctx.ui.confirm(
165
+ `Approve plan found on disk: ${chosenFile.slug}?`,
166
+ `This plan was found in .pi/mf-plan/ and may not have been written in this session. Review before approving:\n\n${diskPlan}`,
167
+ );
168
+ }
169
+
170
+ if (!approved) {
171
+ ctx.ui.notify("Plan not approved — implementation not started.", "warning");
172
+ return;
173
+ }
174
+
175
+ plan = diskPlan;
176
+ filePath = chosenFile.fullPath;
177
+ handoff = {
178
+ plan,
179
+ planFilePath: filePath,
180
+ repoPlanSlug: chosenFile.slug,
181
+ model: ctx.model,
182
+ timestamp: Date.now(),
183
+ };
184
+ }
185
+
186
+ const selection = await showImplementingModelPicker(
187
+ ctx,
188
+ controller.currentThinkingLevel(),
189
+ "Resume implementation — choose a model and thinking level",
190
+ );
191
+
192
+ if (ctx.hasUI && !selection) {
193
+ ctx.ui.notify("Implementation resume cancelled.", "warning");
194
+ return;
195
+ }
196
+
197
+ if (selection) {
198
+ await controller.applyImplementingSelection(
199
+ ctx,
200
+ selection,
201
+ `Switched to ${modelRefLabel(selection.ref)} for implementation.`,
202
+ );
203
+ }
204
+
205
+ // Verify a resumed implementation too: prefer the handoff's own verifier,
206
+ // else fall back to the configured verifier so /mf-plan-implement after a
207
+ // restart (or on a plan that predates the verifier role) is still checked.
208
+ const config = loadMoaConfig();
209
+ const resolvedVerifier = handoff?.verifier ?? config.verifier;
210
+ const resolvedVerifierThinking = handoff?.verifierThinking
211
+ ?? (resolvedVerifier ? config.thinkingOverrides[modelRefLabel(resolvedVerifier)] : undefined);
212
+ let resolvedCriteria = handoff?.verificationCriteria
213
+ ?? (handoff?.repoPlanSlug ? readRepoPlanFile(ctx.cwd, handoff.repoPlanSlug, "criteria") : undefined);
214
+ if (!resolvedCriteria && resolvedVerifier && config.synthesizer) {
215
+ installShippedAgents();
216
+ const generated = await runCriteriaGeneration({
217
+ ctx,
218
+ agents: withAuthoritativeMoaAgents(discoverAgents(ctx.cwd, "user").agents, shippedAgentsDir()),
219
+ synthesizer: config.synthesizer,
220
+ thinking: config.thinkingOverrides[modelRefLabel(config.synthesizer)],
221
+ plan,
222
+ });
223
+ if (generated) {
224
+ resolvedCriteria = generated.markdown;
225
+ if (handoff?.repoPlanSlug) saveRepoPlanFile(resolvedCriteria, ctx.cwd, handoff.repoPlanSlug, "criteria");
226
+ } else {
227
+ ctx.ui.notify("Verification criteria could not be generated; the verifier will judge plan steps directly.", "warning");
228
+ }
229
+ }
230
+ const updatedHandoff: ImplementationHandoff = {
231
+ ...handoff,
232
+ plan,
233
+ planFilePath: filePath,
234
+ model: selection?.ref ?? handoff?.model ?? ctx.model,
235
+ thinking: selection?.thinking ?? handoff?.thinking ?? controller.currentThinkingLevel(),
236
+ verifier: resolvedVerifier,
237
+ verifierThinking: resolvedVerifierThinking,
238
+ verificationCriteria: resolvedCriteria,
239
+ verificationRepairs: handoff?.verificationRepairs ?? 0,
240
+ timestamp: Date.now(),
241
+ };
242
+ controller.setImplementationHandoff(updatedHandoff);
243
+ controller.markImplementationPending(ctx);
244
+
245
+ const note = "This is a manual resume of the approved plan.";
246
+ await pi.sendUserMessage(
247
+ buildImplementationKickoffMessage(plan, filePath, note),
248
+ TRIGGER_TURN,
249
+ );
250
+ },
251
+ });
252
+ // Secondary cancel trigger, and the only one in the mf_plan_subagent
253
+ // tool path — there the main agent is streaming, so plain ESC must keep
254
+ // pi's default abort-the-whole-turn behavior.
255
+ pi.registerShortcut(Key.f4, {
256
+ description: "Cancel running plan subagents",
257
+ handler: async (ctx) => { if (controller.questionnaireBusy(ctx)) return; controller.openCancelOverlayIfActive(ctx); },
258
+ });
259
+ pi.registerShortcut(Key.f3, {
260
+ description: "Observe running plan agents",
261
+ handler: async (ctx) => { if (controller.questionnaireBusy(ctx)) return; controller.openObserveOverlayIfActive(ctx); },
262
+ });
263
+ pi.registerShortcut(Key.f2, {
264
+ description: "Toggle the live preview under tool activity",
265
+ handler: async (ctx) => { controller.toggleLivePreview(ctx); },
266
+ });
267
+
268
+ registerEnterPlanModeTool(pi, controller);
269
+ registerWritePlanTool(pi, controller.isEnabled);
270
+ registerMfPlanSubagentTool(pi, controller);
271
+ registerExitPlanModeTool(pi, controller);
272
+
273
+ pi.on("context", controller.onContext);
274
+ pi.on("before_agent_start", controller.onBeforeAgentStart);
275
+ pi.on("agent_start", controller.onAgentStart);
276
+ pi.on("message_start", controller.onMessageStart);
277
+ pi.on("message_update", controller.onMessageUpdate);
278
+ pi.on("tool_execution_start", controller.onToolExecutionStart);
279
+ pi.on("message_end", controller.onMessageEnd);
280
+ pi.on("agent_settled", controller.onAgentSettled);
281
+ pi.on("session_start", controller.onSessionStart);
282
+ pi.on("session_shutdown", controller.onSessionShutdown);
283
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Conflict-surfacing contract for the MoA synthesizer.
3
+ *
4
+ * The `## Conflicts` / `## Open Question` protocol lives in the
5
+ * moa-synthesizer agent prompt — but provider bridges replace that system
6
+ * prompt with their own harness (see planlessRetry.ts), so a bridged
7
+ * synthesizer silently plans solo: it resolves every disagreement in prose
8
+ * and never emits the markup the orchestrator's `## Conflicts` parser and
9
+ * conflict-review overlay need. Like the verdict contract (verdicts.ts),
10
+ * this block rides the task text — the only channel guaranteed to survive a
11
+ * bridge's prompt override.
12
+ *
13
+ * The embedded worked example is exported so tests can prove it parses into
14
+ * exactly the structure conflictOverlay.ts expects; if you change the markup
15
+ * here, conflictOverlay.ts, agents/moa-synthesizer.md, and that test must
16
+ * move in lockstep.
17
+ */
18
+
19
+ /** Parser-matched worked example embedded in the contract. */
20
+ export const CONFLICT_CONTRACT_EXAMPLE = [
21
+ "## Conflicts",
22
+ "",
23
+ "### Conflict: Auth storage",
24
+ "",
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
+ "- **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.",
28
+ "- **Alternative** (Proposer 2): Make sign-in data available to browser code",
29
+ "- **Details:** Store JWTs in `localStorage`; this can simplify cross-service access but lets an injected page script read the token.",
30
+ ].join("\n");
31
+
32
+ /** Task-text block demanding one `### Conflict:` block per substantive proposer disagreement. */
33
+ export function buildConflictContract(labels: string[]): string {
34
+ return [
35
+ "Conflict-surfacing requirement: when two or more proposers substantively disagree on one or more decision points, every full plan you emit MUST also include a `## Conflicts` section, placed after `## Proposer Verdicts`, with one `### Conflict:` block per disagreement. Use exactly this parser-matched markup:",
36
+ "",
37
+ CONFLICT_CONTRACT_EXAMPLE,
38
+ "",
39
+ "Rules:",
40
+ "- Write the full plan body first using your recommended choices in place, so the plan reads as final even before user confirmation; append `## Conflicts` AFTER all other sections — it is never your entire output.",
41
+ "- Omit the section entirely when the proposers agree on all decision points or when only one proposer covered a point — never include a conflict the user has no realistic alternative for, and never invent a disagreement to comply with this requirement.",
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
+ `- 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
+ "- 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
+ "- 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
+ "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.",
48
+ ].join("\n");
49
+ }
@@ -0,0 +1,153 @@
1
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
2
+
3
+ export const CHAT_VALUE = "__chat__";
4
+
5
+ export interface ConflictOption {
6
+ value: string;
7
+ /** Short, plain-language outcome shown on the primary selectable line. */
8
+ label: string;
9
+ /** Technical implementation details and tradeoffs shown dimmed below the label. */
10
+ description: string;
11
+ proposerLabel?: string;
12
+ recommended: boolean;
13
+ }
14
+
15
+ export interface Conflict {
16
+ id: string;
17
+ label: string;
18
+ prompt: string;
19
+ /** Already ordered: [recommended, ...alternatives, chat option]. */
20
+ options: ConflictOption[];
21
+ }
22
+
23
+ export interface ConflictAnswer {
24
+ optionValue: string;
25
+ kind: "recommended" | "alternative" | "chat";
26
+ chatText?: string;
27
+ }
28
+
29
+ export function chatOption(): ConflictOption {
30
+ return {
31
+ value: CHAT_VALUE,
32
+ label: "Chat about this",
33
+ description: "Ask a question or explain what you'd prefer instead.",
34
+ recommended: false,
35
+ };
36
+ }
37
+
38
+ const CONFLICTS_SECTION_RE = /^##\s*Conflicts\s*$/m;
39
+ const CONFLICT_HEADER_RE = /^###\s*Conflict:\s*(.+)$/gm;
40
+ const DECISION_RE = /^-\s*\*\*Decision:\*\*\s*(.+)$/;
41
+ // Legacy em-dash reasons require whitespace around the separator so hyphenated
42
+ // summaries such as “sign-in” remain intact.
43
+ const RECOMMENDED_RE = /^-\s*\*\*Recommended:\*\*\s*(.+?)(?:\s+[—-]\s+(.+))?$/;
44
+ const ALTERNATIVE_RE = /^-\s*\*\*Alternative\*\*\s*\((Proposer \d+)\):\s*(.+?)(?:\s+[—-]\s+(.+))?$/;
45
+ const DETAILS_RE = /^-\s*\*\*Details:\*\*\s*(.*)$/;
46
+
47
+ function slugify(label: string): string {
48
+ return (
49
+ label
50
+ .toLowerCase()
51
+ .trim()
52
+ .replace(/[^a-z0-9]+/g, "-")
53
+ .replace(/^-+|-+$/g, "") || "option"
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Extracts a `## Conflicts` section (if present) from synthesizer output,
59
+ * parsing it into structured `Conflict` records and returning the plan body
60
+ * with that section stripped out (so it never lands in the written plan
61
+ * file or the approval overlay). Malformed markup degrades gracefully to
62
+ * `{ conflicts: [], remainingPlan: output }`.
63
+ */
64
+ export function parseConflicts(output: string): { conflicts: Conflict[]; remainingPlan: string } {
65
+ const startMatch = CONFLICTS_SECTION_RE.exec(output);
66
+ if (!startMatch) return { conflicts: [], remainingPlan: output };
67
+
68
+ const sectionStart = startMatch.index;
69
+ const afterHeader = sectionStart + startMatch[0].length;
70
+ let sectionEnd = output.length;
71
+ const rest = output.slice(afterHeader);
72
+ const nextMatch = /^##\s+/m.exec(rest);
73
+ if (nextMatch) sectionEnd = afterHeader + nextMatch.index;
74
+
75
+ const block = output.slice(afterHeader, sectionEnd);
76
+ const remainingPlan = (output.slice(0, sectionStart) + output.slice(sectionEnd)).replace(/\n{3,}/g, "\n\n").trim();
77
+
78
+ const headerMatches = [...block.matchAll(CONFLICT_HEADER_RE)];
79
+ const conflicts: Conflict[] = [];
80
+
81
+ for (let i = 0; i < headerMatches.length; i++) {
82
+ const match = headerMatches[i];
83
+ const label = stripTerminalSequences(match[1].trim());
84
+ const start = match.index! + match[0].length;
85
+ const end = i + 1 < headerMatches.length ? headerMatches[i + 1].index! : block.length;
86
+ const body = block.slice(start, end);
87
+ const bodyLines = body.split("\n").map((l) => l.trim()).filter(Boolean);
88
+
89
+ const options: ConflictOption[] = [];
90
+ let decision: string | undefined;
91
+ let precedingOption: ConflictOption | undefined;
92
+ for (const line of bodyLines) {
93
+ const decisionMatch = DECISION_RE.exec(line);
94
+ if (decisionMatch) {
95
+ decision = stripTerminalSequences(decisionMatch[1].trim());
96
+ precedingOption = undefined;
97
+ continue;
98
+ }
99
+ const detailsMatch = DETAILS_RE.exec(line);
100
+ if (detailsMatch) {
101
+ // The protocol puts Details immediately after its option. Explicit details
102
+ // replace an em-dash reason, which remains only for legacy output.
103
+ if (precedingOption) precedingOption.description = stripTerminalSequences(detailsMatch[1].trim());
104
+ precedingOption = undefined;
105
+ continue;
106
+ }
107
+ const recMatch = RECOMMENDED_RE.exec(line);
108
+ if (recMatch) {
109
+ const summary = stripTerminalSequences(recMatch[1].trim());
110
+ const legacyReason = stripTerminalSequences(recMatch[2]?.trim() ?? "");
111
+ const option: ConflictOption = {
112
+ value: `${slugify(summary)}-${options.length}`,
113
+ label: `${summary} (Recommended)`,
114
+ description: legacyReason,
115
+ recommended: true,
116
+ };
117
+ options.push(option);
118
+ precedingOption = option;
119
+ continue;
120
+ }
121
+ const altMatch = ALTERNATIVE_RE.exec(line);
122
+ if (altMatch) {
123
+ const proposerLabel = altMatch[1].trim();
124
+ const summary = stripTerminalSequences(altMatch[2].trim());
125
+ const legacyReason = stripTerminalSequences(altMatch[3]?.trim() ?? "");
126
+ const option: ConflictOption = {
127
+ value: `${slugify(`${proposerLabel}-${summary}`)}-${options.length}`,
128
+ label: summary,
129
+ description: legacyReason,
130
+ proposerLabel,
131
+ recommended: false,
132
+ };
133
+ options.push(option);
134
+ precedingOption = option;
135
+ continue;
136
+ }
137
+ precedingOption = undefined;
138
+ }
139
+
140
+ if (options.length === 0) continue;
141
+ // Ensure the recommended option (if any) leads.
142
+ options.sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0));
143
+
144
+ conflicts.push({
145
+ id: `conflict-${i}-${slugify(label)}`,
146
+ label,
147
+ prompt: decision ?? `Choose how to handle “${label}”.`,
148
+ options: [...options, chatOption()],
149
+ });
150
+ }
151
+
152
+ return { conflicts, remainingPlan };
153
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Auditable-reasoning contract for the MoA synthesizer.
3
+ *
4
+ * The moa-synthesizer prompt requires the plan's Context section to carry
5
+ * three subsections — `### Evaluation dimensions`, `### Proposer alignment`,
6
+ * and `### Synthesis decisions` — but models skip them under output pressure,
7
+ * collapsing the MoA reconciliation into ordinary prose (recent plan files
8
+ * show exactly that drift). Like the verdict contract (verdicts.ts), the
9
+ * orchestrator knows what a complete synthesis looks like, so it validates
10
+ * presence and rejects (one corrective retry) a first full plan that omits
11
+ * any of the three. The requirement rides the task text because provider
12
+ * bridges drop the agent system prompt (see planlessRetry.ts).
13
+ */
14
+
15
+ /** Exact H3 headings the synthesizer must place under `## Context`. */
16
+ export const CONTEXT_SUBSECTION_HEADINGS = [
17
+ "Evaluation dimensions",
18
+ "Proposer alignment",
19
+ "Synthesis decisions",
20
+ ] as const;
21
+
22
+ /** Task-text block demanding the three Context subsections in every full plan. */
23
+ export function buildContextSubsectionsContract(): string {
24
+ return [
25
+ "Auditable-reasoning requirement: every full plan you emit MUST open with a `## Context` section containing these three subsections, in this order, directly after the normal Context content:",
26
+ "",
27
+ ...CONTEXT_SUBSECTION_HEADINGS.map((heading) => `- \`### ${heading}\``),
28
+ "",
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.",
31
+ "- `### Synthesis decisions` — what you recombined, added, or dropped, and one-line decisions for each disagreement using blinded slot labels.",
32
+ "",
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.",
34
+ ].join("\n");
35
+ }
36
+
37
+ /**
38
+ * Required headings absent from the output. Matching is case-insensitive
39
+ * with flexible whitespace so casing drift does not burn a corrective retry;
40
+ * the contract text still demands the exact headings.
41
+ */
42
+ export function missingContextSubsections(output: string): string[] {
43
+ return CONTEXT_SUBSECTION_HEADINGS.filter((heading) => {
44
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
45
+ return !new RegExp(`^#{3}\\s*${escaped}\\s*$`, "im").test(output);
46
+ });
47
+ }
48
+
49
+ export function buildContextRetryHeader(missing: string[]): string {
50
+ const list = missing.map((heading) => `\`### ${heading}\``).join(", ");
51
+ return `IMPORTANT: Your previous output (quoted below) is rejected because its \`## Context\` section is missing required subsection(s): ${list}. Re-emit the COMPLETE plan with \`## Context\` opening on the normal context content followed by all three subsections — \`### Evaluation dimensions\`, \`### Proposer alignment\`, \`### Synthesis decisions\` — carrying your MoA reconciliation reasoning. Do not drop or alter the rest of the plan.`;
52
+ }