@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,127 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { AgentConfig } from "../agents/discovery.ts";
4
+ import { CancelRun, type CancelSession } from "../runtime/cancelRun.ts";
5
+ import { getFinalOutput, isFailedResult } from "../runtime/results.ts";
6
+ import { runSingleAgent } from "../runtime/runner.ts";
7
+ import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
8
+ import { activityLoopCount } from "../ui/agentStatus.ts";
9
+ import type { ObserveSession } from "../ui/observeOverlay.ts";
10
+ import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
11
+ import { buildRetryCorrection } from "./planlessRetry.ts";
12
+ import { modelExtensionOptions, resolveContextWindow, resolveModelCost } from "./modelRuntime.ts";
13
+
14
+ export const VERIFICATION_CRITERIA_HEADING = "## Verification Criteria";
15
+ export const MAX_VERIFICATION_CRITERIA = 30;
16
+
17
+ export const CRITERIA_TASK_PREAMBLE =
18
+ "You are the read-only SYNTHESIZER in a Mixture-of-Agents planning run. Your only deliverable is a pass/fail verification checklist for the already approved plan, emitted as markdown text in your reply. Do NOT implement, edit, or run anything. Having only read-only tools is expected and is never a blocker.";
19
+
20
+ export interface VerificationCriterion {
21
+ id: string;
22
+ text: string;
23
+ }
24
+
25
+ export function buildCriteriaTask(plan: string): string {
26
+ return [
27
+ CRITERIA_TASK_PREAMBLE,
28
+ "---",
29
+ `## Approved plan (frozen)\n${plan}`,
30
+ "---",
31
+ `Your output must contain exactly one ${VERIFICATION_CRITERIA_HEADING} section and no prose outside it. Emit 3–${MAX_VERIFICATION_CRITERIA} bullets in this exact form:\n- **C<n>:** <observable binary condition> — <how to check: file, symbol, grep, expected state>\n\nEvery criterion must be checkable by reading the repository only; do not require execution. Cover at least one criterion per plan step in plan order, every named file or call site, and implied regression/scope constraints (such as preserving an existing export or avoiding unrelated files). Never reference proposer labels or model names.`,
32
+ ].join("\n\n");
33
+ }
34
+
35
+ /** Collect `- …`/`* …` bullets under a heading, stopping at the next heading. */
36
+ export function sectionBullets(output: string, heading: RegExp): string[] {
37
+ const lines = output.split("\n");
38
+ const start = lines.findIndex((line) => heading.test(line));
39
+ if (start < 0) return [];
40
+ const bullets: string[] = [];
41
+ for (let i = start + 1; i < lines.length; i++) {
42
+ const line = lines[i];
43
+ if (/^\s*#{1,6}\s/.test(line)) break;
44
+ const match = line.match(/^\s*[-*]\s+(.*\S)\s*$/);
45
+ if (match) bullets.push(match[1].trim());
46
+ }
47
+ return bullets;
48
+ }
49
+
50
+ export function parseVerificationCriteria(output: string): VerificationCriterion[] {
51
+ const seen = new Set<string>();
52
+ const criteria: VerificationCriterion[] = [];
53
+ for (const bullet of sectionBullets(output, /^\s*##\s+Verification Criteria\b/i)) {
54
+ const match = bullet.match(/^\*\*(C\d+):?\*\*:?[\s]*(.+)$/i);
55
+ if (!match) continue;
56
+ const id = match[1].toUpperCase();
57
+ if (seen.has(id)) continue;
58
+ seen.add(id);
59
+ criteria.push({ id, text: match[2].trim() });
60
+ if (criteria.length >= MAX_VERIFICATION_CRITERIA) break;
61
+ }
62
+ return criteria;
63
+ }
64
+
65
+ export function formatCriteriaMarkdown(criteria: VerificationCriterion[]): string {
66
+ return [VERIFICATION_CRITERIA_HEADING, ...criteria.map((criterion) => `- **${criterion.id}:** ${criterion.text}`)].join("\n");
67
+ }
68
+
69
+ export const CRITERIA_RETRY_HEADER =
70
+ "IMPORTANT: Your previous output did not contain any parseable verification criteria. Emit only the required ## Verification Criteria section now, with 3–30 bullets in the exact **C<n>:** format. Do not ask questions, implement anything, or add prose outside the section.";
71
+
72
+ export function buildCriteriaRetryTask(originalTask: string, previousOutput: string): string {
73
+ return [originalTask, "", "---", "", buildRetryCorrection(CRITERIA_RETRY_HEADER, previousOutput)].join("\n");
74
+ }
75
+
76
+ export async function runCriteriaGeneration(input: {
77
+ ctx: ExtensionContext;
78
+ agents: AgentConfig[];
79
+ synthesizer: ModelRef;
80
+ thinking: ThinkingLevel | undefined;
81
+ plan: string;
82
+ session?: CancelSession;
83
+ widget?: MoaProgressWidget;
84
+ observe?: ObserveSession;
85
+ }): Promise<{ criteria: VerificationCriterion[]; markdown: string } | undefined> {
86
+ const { ctx, agents, synthesizer, thinking, plan, session, widget } = input;
87
+ widget?.switchToSynthesizing(synthesizer, "writing verification criteria…", thinking);
88
+ const run = new CancelRun();
89
+ const slot = run.add(modelRefLabel(synthesizer));
90
+ if (session) {
91
+ session.title = "MoA verification criteria";
92
+ session.run = run;
93
+ session.getExtras = () => {
94
+ const status = widget?.getRoleStatus("Synthesize");
95
+ return { contextTokens: status?.contextTokens, contextWindow: status?.ref ? resolveContextWindow(ctx, status.ref) : undefined, activity: status?.activity, loopCount: activityLoopCount(status?.activity, status?.activityHistory) };
96
+ };
97
+ }
98
+ const runTask = async (task: string): Promise<string | undefined> => {
99
+ widget?.updateRoleTranscript("Synthesize", []);
100
+ try {
101
+ const result = await runSingleAgent(ctx.cwd, agents, "moa-synthesizer", task, undefined, slot.signal, undefined, modelRefLabel(synthesizer), thinking, {
102
+ ...modelExtensionOptions(ctx, synthesizer),
103
+ resolveOnAbort: true,
104
+ onProgress: (progress) => {
105
+ widget?.updateRoleUsage("Synthesize", progress.usage.contextTokens, progress.usage.turns, progress.usage.toolCalls, resolveModelCost(ctx, synthesizer, progress.usage));
106
+ if (progress.activity) widget?.updateRoleActivity("Synthesize", progress.activity);
107
+ if (progress.outputActivity) widget?.updateRoleOutput("Synthesize", progress.outputActivity.tokens, progress.outputActivity.revision);
108
+ widget?.updateRoleTranscript("Synthesize", progress.messages, progress.partialAssistant);
109
+ },
110
+ });
111
+ widget?.updateRoleTranscript("Synthesize", result.messages);
112
+ if (result.cancelled || run.cancelAllRequested || isFailedResult(result)) return undefined;
113
+ return getFinalOutput(result.messages);
114
+ } catch { return undefined; }
115
+ };
116
+ try {
117
+ let output = await runTask(buildCriteriaTask(plan));
118
+ let criteria = output ? parseVerificationCriteria(output) : [];
119
+ if (criteria.length === 0 && output !== undefined) {
120
+ output = await runTask(buildCriteriaRetryTask(buildCriteriaTask(plan), output));
121
+ criteria = output ? parseVerificationCriteria(output) : [];
122
+ }
123
+ return criteria.length > 0 ? { criteria, markdown: formatCriteriaMarkdown(criteria) } : undefined;
124
+ } finally {
125
+ if (session) { session.run = undefined; session.getExtras = undefined; }
126
+ }
127
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Deterministic script gate + working-tree diff for the verification phase.
3
+ *
4
+ * The read-only verifier subprocess cannot run tests itself (it is spawned with
5
+ * the same `--tools read,grep,find,ls` + read-only env handshake as every other
6
+ * planning agent), so the orchestrator runs the project's own check/lint/test
7
+ * scripts in the parent process and hands the results to the verifier as
8
+ * evidence. `captureImplementationDiff` likewise runs in the parent — never in
9
+ * the read-only subprocess — so a git diff of what the implementer changed can
10
+ * be attached to the verifier task.
11
+ *
12
+ * The gate (`discoverVerifyScripts`/`runVerifyScript`) is ported nearly verbatim
13
+ * from the sibling pi-topping-persona-audit's `src/verify.ts`, with a local
14
+ * `tail` and a local result type so it carries no persona-audit dependencies.
15
+ * It runs AFTER the read-only verifier's mutation-tripwire check, because
16
+ * `npm test` can legitimately write snapshots/coverage and must not trip a
17
+ * false "verifier modified the working tree" alarm.
18
+ */
19
+
20
+ import { execFile } from "node:child_process";
21
+ import { readFile } from "node:fs/promises";
22
+ import { promisify } from "node:util";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ /** Preferred script discovery order. */
27
+ const SCRIPT_ORDER = ["check", "lint", "test"] as const;
28
+
29
+ const SCRIPT_TIMEOUT_MS = 5 * 60_000;
30
+ const OUTPUT_TAIL_CHARS = 4_000;
31
+
32
+ /** Keep the trailing `maxChars` of long output, prefixing an ellipsis when cut. */
33
+ function tail(text: string, maxChars: number): string {
34
+ const trimmed = text.trim();
35
+ return trimmed.length > maxChars ? `…${trimmed.slice(-maxChars)}` : trimmed;
36
+ }
37
+
38
+ export interface VerifyResult {
39
+ script: string;
40
+ command: string;
41
+ status: "pass" | "fail";
42
+ exitCode: number;
43
+ relevantOutput: string;
44
+ }
45
+
46
+ /** Discover which of check/lint/test exist in package.json#scripts, in that order. */
47
+ export async function discoverVerifyScripts(cwd: string): Promise<string[]> {
48
+ let raw: string;
49
+ try {
50
+ raw = await readFile(`${cwd}/package.json`, "utf-8");
51
+ } catch {
52
+ return [];
53
+ }
54
+
55
+ let scripts: Record<string, unknown>;
56
+ try {
57
+ const pkg = JSON.parse(raw) as { scripts?: Record<string, unknown> };
58
+ scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
59
+ } catch {
60
+ return [];
61
+ }
62
+
63
+ return SCRIPT_ORDER.filter((name) => typeof scripts[name] === "string");
64
+ }
65
+
66
+ /** Run one discovered verification script via `npm run <script>`. Never throws. */
67
+ export async function runVerifyScript(cwd: string, script: string, signal?: AbortSignal): Promise<VerifyResult> {
68
+ const command = `npm run ${script}`;
69
+ try {
70
+ const { stdout, stderr } = await execFileAsync("npm", ["run", script], {
71
+ cwd,
72
+ encoding: "utf-8",
73
+ timeout: SCRIPT_TIMEOUT_MS,
74
+ maxBuffer: 16 * 1024 * 1024,
75
+ signal,
76
+ });
77
+ return {
78
+ script,
79
+ command,
80
+ status: "pass",
81
+ exitCode: 0,
82
+ relevantOutput: tail(`${stdout}\n${stderr}`, OUTPUT_TAIL_CHARS),
83
+ };
84
+ } catch (error) {
85
+ const err = error as { code?: number; stdout?: string; stderr?: string; message?: string };
86
+ return {
87
+ script,
88
+ command,
89
+ status: "fail",
90
+ exitCode: typeof err.code === "number" ? err.code : 1,
91
+ relevantOutput: tail(`${err.stdout ?? ""}\n${err.stderr ?? ""}`, OUTPUT_TAIL_CHARS) || (err.message ?? "unknown error"),
92
+ };
93
+ }
94
+ }
95
+
96
+ const DIFF_MAX_BYTES = 64 * 1024;
97
+
98
+ /**
99
+ * Best-effort git diff of the current working tree against HEAD, for the
100
+ * verifier task. Returns null outside a git repo. The diff includes any
101
+ * pre-existing uncommitted edits, so the verifier task caveats it as a pointer
102
+ * to what moved rather than proof of what this implementation changed.
103
+ */
104
+ export async function captureImplementationDiff(cwd: string): Promise<string | null> {
105
+ const run = async (args: string[]): Promise<string | null> => {
106
+ try {
107
+ const { stdout } = await execFileAsync("git", args, {
108
+ cwd,
109
+ encoding: "utf-8",
110
+ maxBuffer: 16 * 1024 * 1024,
111
+ });
112
+ return stdout;
113
+ } catch {
114
+ return null;
115
+ }
116
+ };
117
+
118
+ const stat = await run(["diff", "HEAD", "--stat"]);
119
+ if (stat === null) return null; // not a git repo (or git unavailable)
120
+
121
+ let full = (await run(["diff", "HEAD"])) ?? "";
122
+ let truncated = false;
123
+ if (Buffer.byteLength(full, "utf-8") > DIFF_MAX_BYTES) {
124
+ full = full.slice(0, DIFF_MAX_BYTES);
125
+ truncated = true;
126
+ }
127
+
128
+ const untracked = await run(["ls-files", "--others", "--exclude-standard"]);
129
+
130
+ const sections: string[] = [];
131
+ if (stat.trim()) sections.push(`Diffstat (git diff HEAD --stat):\n${stat.trim()}`);
132
+ if (untracked?.trim()) sections.push(`Untracked files:\n${untracked.trim()}`);
133
+ if (full.trim()) {
134
+ sections.push(`Diff (git diff HEAD)${truncated ? " — TRUNCATED to 64 KB" : ""}:\n${full.trim()}`);
135
+ }
136
+ return sections.length > 0 ? sections.join("\n\n") : "(no changes detected against HEAD)";
137
+ }
@@ -0,0 +1,21 @@
1
+ import { buildRetryCorrection } from "../moa/planlessRetry.ts";
2
+
3
+ export const OPINION_TASK_PREAMBLE =
4
+ "You are a read-only analyst in a multi-model opinion run. Your only deliverable is one independent, repo-grounded opinion emitted as markdown in your reply. Do not implement anything, edit files, or run commands, builds, or tests. Read-only tools are expected and never a blocker. No later agent will merge or judge your answer, so commit to a clear recommendation. You are running headless: resolve open questions with stated assumptions rather than asking the user or waiting.";
5
+
6
+ export const OPINION_HEADING = /^#{1,6}\s.*\b(opinion|answer|recommendation|assessment|verdict)\b/im;
7
+
8
+ export function looksLikeOpinion(output: string): boolean {
9
+ return OPINION_HEADING.test(output);
10
+ }
11
+
12
+ export const OPINION_RETRY_HEADER =
13
+ "IMPORTANT: You already attempted this opinion task once and stopped without emitting a complete opinion. You are running headless: do not ask questions, wait for clarification, or refuse because only read-only tools are available. Re-emit the COMPLETE independent opinion now, using the required headings beginning with ## Opinion.";
14
+
15
+ export function buildOpinionTask(question: string): string {
16
+ return [OPINION_TASK_PREAMBLE, "", "Question:", question].join("\n");
17
+ }
18
+
19
+ export function buildOpinionRetryTask(originalTask: string, previousOutput: string): string {
20
+ return [originalTask, "", "---", "", buildRetryCorrection(OPINION_RETRY_HEADER, previousOutput)].join("\n");
21
+ }
@@ -0,0 +1,135 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { AgentConfig } from "../agents/discovery.ts";
4
+ import { runWidgetFanout } from "../moa/fanoutWiring.ts";
5
+ import { modelExtensionOptions, resolveContextWindow } from "../moa/modelRuntime.ts";
6
+ import type { CancelSession } from "../runtime/cancelRun.ts";
7
+ import { CancelRun } from "../runtime/cancelRun.ts";
8
+ import { getFinalOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
9
+ import type { ModelParallelAgentTask } from "../runtime/runner.ts";
10
+ import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
11
+ import { activityLoopCount } from "../ui/agentStatus.ts";
12
+ import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
13
+ import type { ObserveSession } from "../ui/observeOverlay.ts";
14
+ import { buildOpinionRetryTask, buildOpinionTask, looksLikeOpinion } from "./opinionContract.ts";
15
+
16
+ export interface OpinionFanoutHost {
17
+ getActiveObserveSession(): ObserveSession | undefined;
18
+ setActiveObserveSession(session: ObserveSession | undefined): void;
19
+ }
20
+
21
+ export interface OpinionFanoutOptions {
22
+ host: OpinionFanoutHost;
23
+ ctx: ExtensionContext;
24
+ question: string;
25
+ models: ModelRef[];
26
+ thinking: (ThinkingLevel | undefined)[];
27
+ session: CancelSession;
28
+ widget: MoaProgressWidget;
29
+ agents: AgentConfig[];
30
+ }
31
+
32
+ export type OpinionFanoutResult =
33
+ | { status: "done"; results: SingleResult[] }
34
+ | { status: "cancelled" };
35
+
36
+ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<OpinionFanoutResult> {
37
+ const { host, ctx, question, models, thinking, session, widget, agents } = options;
38
+ const originalTask = buildOpinionTask(question);
39
+ widget.startFanout(models, thinking);
40
+ const observe: ObserveSession = {
41
+ title: "MoA opinions",
42
+ phase: "fanout",
43
+ agents: models.map((ref) => ({
44
+ label: ref.id,
45
+ model: ref.id,
46
+ task: originalTask,
47
+ messages: [],
48
+ state: "working",
49
+ })),
50
+ overlayOpen: false,
51
+ };
52
+ const previousObserveSession = host.getActiveObserveSession();
53
+ host.setActiveObserveSession(observe);
54
+
55
+ try {
56
+ const runBatch = (
57
+ tasks: ModelParallelAgentTask[],
58
+ indexMap: (index: number) => number,
59
+ currentRun: CancelRun,
60
+ ): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap });
61
+
62
+ const run = new CancelRun();
63
+ session.title = "Opinion agents";
64
+ session.getExtras = (index) => {
65
+ const status = widget.getStatus(index);
66
+ if (!status) return undefined;
67
+ return {
68
+ contextTokens: status.contextTokens,
69
+ contextWindow: resolveContextWindow(ctx, status.ref),
70
+ activity: status.activity,
71
+ loopCount: activityLoopCount(status.activity, status.activityHistory),
72
+ };
73
+ };
74
+ const tasks = models.map((ref, index) => {
75
+ const label = modelRefLabel(ref);
76
+ return {
77
+ agent: "moa-opinion",
78
+ task: originalTask,
79
+ model: label,
80
+ thinking: thinking[index],
81
+ ...modelExtensionOptions(ctx, ref),
82
+ signal: run.add(label).signal,
83
+ };
84
+ });
85
+ const results = await runBatch(tasks, (index) => index, run);
86
+ if (run.cancelAllRequested) return { status: "cancelled" };
87
+
88
+ const retryIndices: number[] = [];
89
+ for (let index = 0; index < results.length; index++) {
90
+ const result = results[index];
91
+ if (!result.cancelled && !isFailedResult(result) && !looksLikeOpinion(getFinalOutput(result.messages))) retryIndices.push(index);
92
+ }
93
+ if (retryIndices.length > 0) {
94
+ const retryRun = new CancelRun();
95
+ session.title = "Opinion agents (retry)";
96
+ session.getExtras = (index) => {
97
+ const status = widget.getStatus(retryIndices[index]);
98
+ if (!status) return undefined;
99
+ return {
100
+ contextTokens: status.contextTokens,
101
+ contextWindow: resolveContextWindow(ctx, status.ref),
102
+ activity: status.activity,
103
+ loopCount: activityLoopCount(status.activity, status.activityHistory),
104
+ };
105
+ };
106
+ const retryTasks = retryIndices.map((opinionIndex) => {
107
+ const ref = models[opinionIndex];
108
+ const label = modelRefLabel(ref);
109
+ const retryTask = buildOpinionRetryTask(originalTask, getFinalOutput(results[opinionIndex].messages));
110
+ widget.update(opinionIndex, "working", "retrying: no opinion produced");
111
+ const observed = observe.agents[opinionIndex];
112
+ if (observed) {
113
+ observed.state = "working";
114
+ observed.task = retryTask;
115
+ }
116
+ return {
117
+ agent: "moa-opinion",
118
+ task: retryTask,
119
+ model: label,
120
+ thinking: thinking[opinionIndex],
121
+ ...modelExtensionOptions(ctx, ref),
122
+ signal: retryRun.add(label).signal,
123
+ };
124
+ });
125
+ const retryResults = await runBatch(retryTasks, (index) => retryIndices[index], retryRun);
126
+ for (let index = 0; index < retryResults.length; index++) results[retryIndices[index]] = retryResults[index];
127
+ if (retryRun.cancelAllRequested) return { status: "cancelled" };
128
+ }
129
+
130
+ return { status: "done", results };
131
+ } finally {
132
+ observe.closeOverlay?.();
133
+ host.setActiveObserveSession(previousObserveSession);
134
+ }
135
+ }
@@ -0,0 +1,38 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
4
+ import { isValidPlanSlug } from "../planning/planFile.ts";
5
+
6
+ export type RepoOpinionFileKind = "opinion-prompt" | "opinions";
7
+
8
+ function repoOpinionSuffix(kind: RepoOpinionFileKind): string {
9
+ return kind === "opinion-prompt" ? "__opinion-prompt" : "__opinions";
10
+ }
11
+
12
+ export function getRepoOpinionDirectory(repoCwd: string): string {
13
+ const opinionDir = path.join(repoCwd, CONFIG_DIR_NAME, "mf-opinion");
14
+ try {
15
+ fs.mkdirSync(opinionDir, { recursive: true });
16
+ } catch {
17
+ // Best effort; the write will report a useful error if this failed.
18
+ }
19
+ return opinionDir;
20
+ }
21
+
22
+ export function saveRepoOpinionFile(
23
+ content: string,
24
+ repoCwd: string,
25
+ baseSlug: string,
26
+ kind: RepoOpinionFileKind,
27
+ ): string {
28
+ if (!isValidPlanSlug(baseSlug)) throw new Error("Invalid repository opinion slug");
29
+ const opinionDir = getRepoOpinionDirectory(repoCwd);
30
+ const filePath = path.join(opinionDir, `${baseSlug}${repoOpinionSuffix(kind)}.md`);
31
+ fs.writeFileSync(filePath, content, { encoding: "utf-8" });
32
+ return filePath;
33
+ }
34
+
35
+ export function repoOpinionDisplayPath(baseSlug: string, kind: RepoOpinionFileKind): string {
36
+ if (!isValidPlanSlug(baseSlug)) throw new Error("Invalid repository opinion slug");
37
+ return `${CONFIG_DIR_NAME}/mf-opinion/${baseSlug}${repoOpinionSuffix(kind)}.md`;
38
+ }
@@ -0,0 +1,73 @@
1
+ import type { ModelRef, ThinkingLevel } from "../shared/modelRefs.ts";
2
+ import { modelRefLabel } from "../shared/modelRefs.ts";
3
+ import type { SingleResult } from "../runtime/results.ts";
4
+ import { getFinalOutput, getResultOutput, isFailedResult } from "../runtime/results.ts";
5
+
6
+ export interface OpinionOutcome {
7
+ ref: ModelRef;
8
+ thinking: ThinkingLevel | undefined;
9
+ status: "done" | "error" | "cancelled";
10
+ text: string;
11
+ }
12
+
13
+ export function collectOpinionOutcomes(
14
+ models: ModelRef[],
15
+ thinking: (ThinkingLevel | undefined)[],
16
+ results: SingleResult[],
17
+ ): OpinionOutcome[] {
18
+ return models.map((ref, index) => {
19
+ const result = results[index];
20
+ if (!result) {
21
+ return { ref, thinking: thinking[index], status: "error", text: "No result was returned." };
22
+ }
23
+ if (result.cancelled) {
24
+ return { ref, thinking: thinking[index], status: "cancelled", text: getResultOutput(result) };
25
+ }
26
+ if (isFailedResult(result)) {
27
+ return { ref, thinking: thinking[index], status: "error", text: getResultOutput(result) };
28
+ }
29
+ return {
30
+ ref,
31
+ thinking: thinking[index],
32
+ status: "done",
33
+ text: getFinalOutput(result.messages).trim() || "(no output)",
34
+ };
35
+ });
36
+ }
37
+
38
+ export function demoteHeadings(markdown: string): string {
39
+ let fence: "```" | "~~~" | undefined;
40
+ return markdown.split("\n").map((line) => {
41
+ const fenceMatch = line.match(/^\s*(```|~~~)/);
42
+ if (fenceMatch) {
43
+ const marker = fenceMatch[1] as "```" | "~~~";
44
+ if (!fence) fence = marker;
45
+ else if (fence === marker) fence = undefined;
46
+ return line;
47
+ }
48
+ if (fence) return line;
49
+ return line.replace(/^(\s*)(#{1,6})(\s+)/, (_match, indent: string, hashes: string, space: string) =>
50
+ `${indent}${"#".repeat(Math.min(6, hashes.length + 1))}${space}`,
51
+ );
52
+ }).join("\n");
53
+ }
54
+
55
+ function firstLine(text: string): string {
56
+ return text.trim().split(/\r?\n/, 1)[0] || "Unknown error";
57
+ }
58
+
59
+ export function formatOpinionsMarkdown(
60
+ question: string,
61
+ outcomes: OpinionOutcome[],
62
+ name?: string,
63
+ ): string {
64
+ const title = name ? `# MoA Opinions — ${name}` : "# MoA Opinions";
65
+ const sections = outcomes.map((outcome, index) => {
66
+ const thinking = outcome.thinking ?? "default";
67
+ const heading = `## Opinion ${index + 1} — ${modelRefLabel(outcome.ref)} (thinking: ${thinking})`;
68
+ if (outcome.status === "cancelled") return `${heading}\n\n_Cancelled by user._`;
69
+ if (outcome.status === "error") return `${heading}\n\n_Failed: ${firstLine(outcome.text)}_`;
70
+ return `${heading}\n\n${demoteHeadings(outcome.text)}`;
71
+ });
72
+ return [title, "", `**Question:** ${question}`, "", sections.join("\n\n---\n\n")].join("\n").trimEnd();
73
+ }