@underactive/pi-topping-moa-fusion 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +54 -12
  3. package/agents/moa-synthesizer.md +9 -6
  4. package/package.json +5 -5
  5. package/src/config/planName.ts +29 -24
  6. package/src/config/settings.ts +38 -1
  7. package/src/debate/debateFanout.ts +16 -6
  8. package/src/debate/runDebate.ts +13 -4
  9. package/src/index.ts +14 -3
  10. package/src/moa/conflictContract.ts +2 -1
  11. package/src/moa/contextContract.ts +1 -1
  12. package/src/moa/fanout.ts +115 -4
  13. package/src/moa/fanoutWiring.ts +13 -3
  14. package/src/moa/implementationRetry.ts +2 -2
  15. package/src/moa/orchestration.ts +3 -0
  16. package/src/moa/panelDependence.ts +87 -0
  17. package/src/moa/reviewLoop.ts +2 -2
  18. package/src/moa/synthesis.ts +12 -8
  19. package/src/moa/verification.ts +117 -20
  20. package/src/moa/verificationCriteria.ts +1 -1
  21. package/src/opinion/opinionFanout.ts +5 -4
  22. package/src/opinion/runOpinion.ts +14 -13
  23. package/src/planning/contextEdits.ts +62 -0
  24. package/src/planning/modeState.ts +17 -0
  25. package/src/planning/planFile.ts +30 -12
  26. package/src/planning/planMode.ts +144 -11
  27. package/src/planning/tools/exitPlanMode.ts +7 -2
  28. package/src/planning/tools/mfPlanSubagent.ts +3 -0
  29. package/src/planning/tools/shared.ts +1 -0
  30. package/src/runtime/activityTracking.ts +28 -12
  31. package/src/runtime/processPool.ts +2 -0
  32. package/src/runtime/results.ts +18 -0
  33. package/src/runtime/runner.ts +45 -9
  34. package/src/runtime/wire.ts +11 -0
  35. package/src/spec/runSpec.ts +343 -0
  36. package/src/spec/specContract.ts +324 -0
  37. package/src/ui/cancelOverlay.ts +1 -1
  38. package/src/ui/moaModelPicker.ts +32 -4
  39. package/src/ui/moaProgressWidget.ts +114 -12
  40. package/src/ui/moaSetupOverlay.ts +51 -7
  41. package/src/ui/planReviewOverlay.ts +16 -8
  42. package/src/ui/rosterEditor.ts +18 -12
  43. package/src/ui/workingOverlay.ts +265 -0
@@ -8,7 +8,8 @@
8
8
  * starting unassigned at `(none)`, plus a Load Roster row that applies a saved
9
9
  * roster to every slot wholesale, and a Start fan-out action. Enter on a slot
10
10
  * opens its two-pane model/thinking picker; confirming writes only that slot
11
- * and returns to the overview, cancelling returns without changing it. The
11
+ * and returns to the overview, cancelling returns without changing it.
12
+ * Backspace or Delete on a slot clears it back to `(none)`. The
12
13
  * overview's "Start fan-out" action stays visually disabled until at least two
13
14
  * proposer slots and all three required roles are assigned, then it finishes
14
15
  * the picker.
@@ -44,6 +45,7 @@ import {
44
45
  import { PLAN_OVERLAY_OPTIONS } from "./menu.ts";
45
46
  import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
46
47
  import { smartTruncateModelLabel } from "./modelLabel.ts";
48
+ import { proposerDiversityWarning } from "../moa/panelDependence.ts";
47
49
  import { getModelCatalogue } from "../config/modelCatalogue.ts";
48
50
  import {
49
51
  loadMoaConfig,
@@ -212,7 +214,7 @@ class MoaModelPickerComponent implements Component {
212
214
  return undefined;
213
215
  }
214
216
 
215
- private setRole(role: "synthesizer" | "implementer" | "verifier", ref: ModelRef, thinking: ThinkingLevel): void {
217
+ private setRole(role: "synthesizer" | "implementer" | "verifier", ref: ModelRef | undefined, thinking: ThinkingLevel | undefined): void {
216
218
  if (role === "synthesizer") { this.synthesizerRef = ref; this.synthesizerThinking = thinking; }
217
219
  else if (role === "implementer") { this.implementerRef = ref; this.implementerThinking = thinking; }
218
220
  else { this.verifierRef = ref; this.verifierThinking = thinking; }
@@ -239,11 +241,28 @@ class MoaModelPickerComponent implements Component {
239
241
  this.tui.requestRender();
240
242
  }
241
243
 
244
+ /** Return a slot to `(none)`; the overview cursor stays on its row. */
245
+ private clearSlot(screen: number): void {
246
+ const role = this.nonProposerRole(screen);
247
+ if (role) {
248
+ this.setRole(role, undefined, undefined);
249
+ } else {
250
+ this.proposerRefs[screen - 1] = undefined;
251
+ this.proposerThinking[screen - 1] = undefined;
252
+ }
253
+ this.tui.requestRender();
254
+ }
255
+
242
256
  /** Count of proposer slots holding an explicit assignment. */
243
257
  private assignedProposerCount(): number {
244
258
  return this.proposerRefs.reduce((n, ref) => (ref ? n + 1 : n), 0);
245
259
  }
246
260
 
261
+ private diversityWarning(): string | undefined {
262
+ const assigned = this.proposerRefs.filter((ref): ref is ModelRef => ref !== undefined);
263
+ return proposerDiversityWarning(assigned);
264
+ }
265
+
247
266
  /**
248
267
  * Ready to start once at least MIN_PROPOSERS proposer slots plus all three
249
268
  * required roles hold explicit assignments. Counts assigned slots, not
@@ -423,6 +442,11 @@ class MoaModelPickerComponent implements Component {
423
442
  // A disabled Start swallows Enter with no state change.
424
443
  return;
425
444
  }
445
+ if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
446
+ // Load Roster and Start have nothing to clear; every other row is a slot.
447
+ if (this.confirmIndex !== LOAD_ROW && this.confirmIndex !== START_ROW) this.clearSlot(this.confirmIndex);
448
+ return;
449
+ }
426
450
  if (matchesKey(data, Key.escape)) {
427
451
  // Back to the mode chooser; assignments are preserved.
428
452
  this.screen = 0;
@@ -587,14 +611,18 @@ class MoaModelPickerComponent implements Component {
587
611
  slotRow("Verifier", detail("Verifier", this.verifierRef, this.verifierThinking), VERIFIER_ROW),
588
612
  startRow,
589
613
  ];
590
- const reviewRows = [loadRow, row(""), ...slotRows.slice(1, -1), row(""), startRow];
614
+ const warning = this.diversityWarning();
615
+ const warningRows = warning
616
+ ? wrapWords(warning, bodyWidth - 1).map((line) => row(th.fg("dim", line)))
617
+ : [];
618
+ const reviewRows = [loadRow, row(""), ...slotRows.slice(1, -1), row(""), ...warningRows, startRow];
591
619
  const lines = [
592
620
  topBorder,
593
621
  row(th.fg("accent", "MoA Fusion Pre-flight")),
594
622
  sepBorder,
595
623
  ...reviewRows,
596
624
  sepBorder,
597
- row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 esc back")),
625
+ row(th.fg("dim", "\u2191\u2193 navigate \u2022 enter select \u2022 \u232B clear \u2022 esc back")),
598
626
  botBorder,
599
627
  ];
600
628
  if (lines.length <= viewport) return lines;
@@ -27,7 +27,7 @@
27
27
 
28
28
  import type { Message } from "@earendil-works/pi-ai";
29
29
  import type { ExtensionContext, ThemeColor } from "@earendil-works/pi-coding-agent";
30
- import { visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
30
+ import { visibleWidth, wrapTextWithAnsi, type Component, type TUI } from "@earendil-works/pi-tui";
31
31
  import { ACTIVITY_METER_WIDTH, ActivityMeter, rateToLevel, TokRateTracker } from "../activityMeter.ts";
32
32
  import { modelRefLabel, shortModelName, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
33
33
  import { contextPercent, CTX_COL_WIDTH, formatCost, formatElapsed, formatTokens } from "./agentStatus.ts";
@@ -122,6 +122,14 @@ const AGENT_COL_MIN = 8;
122
122
  /** Agent width below which a model ref stops being distinguishable from its siblings. */
123
123
  const AGENT_COL_READABLE = 24;
124
124
  const ACTIVITY_COL_MIN = 10;
125
+ /** Gutter marking a sub-row as belonging to the agent row above it. */
126
+ const SUB_ROW_GUTTER = "↳ ";
127
+ /**
128
+ * Most wrapped lines one failed row may spend on its error detail. A provider
129
+ * error fits well inside this at any usable width; the cap only bites on a
130
+ * pathological message, which must not swallow the whole table.
131
+ */
132
+ const ERROR_DETAIL_LINES = 8;
125
133
  const PREVIEW_LINES = 4;
126
134
  const PREVIEW_INDENT = STATUS_COL_WIDTH + 2;
127
135
  const PREVIEW_GUTTER = "│ ";
@@ -297,12 +305,20 @@ export class MoaProgressWidget implements MoaProgressView {
297
305
  }
298
306
 
299
307
  /**
300
- * Start the fan-out phase with the initial (working) proposer statuses.
308
+ * Start the fan-out phase, marking only the slots that fit the process-pool
309
+ * limit as working. The runner promotes queued slots when they acquire one.
301
310
  * `thinking[i]` tints proposer `i`'s meter; an absent entry falls to accent.
302
311
  */
303
- startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = []): void {
312
+ startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = [], maxConcurrency = refs.length): void {
304
313
  const startedAt = Date.now();
305
- this.statuses = refs.map((ref, index) => ({ ref, phase: "Plan" as const, state: "working" as const, startedAt, thinking: thinking[index] }));
314
+ const activeCount = Number.isFinite(maxConcurrency) ? Math.max(1, Math.floor(maxConcurrency)) : refs.length;
315
+ this.statuses = refs.map((ref, index) => ({
316
+ ref,
317
+ phase: "Plan" as const,
318
+ state: index < activeCount ? "working" as const : "queued" as const,
319
+ startedAt: index < activeCount ? startedAt : undefined,
320
+ thinking: thinking[index],
321
+ }));
306
322
  this.active = "Plan";
307
323
  this.mountTable();
308
324
  }
@@ -341,14 +357,17 @@ export class MoaProgressWidget implements MoaProgressView {
341
357
  const wasSettled = s.state === "done" || s.state === "error" || s.state === "cancelled";
342
358
  s.state = state;
343
359
  s.detail = detail;
344
- // Re-activation (a settled proposer being retried) un-freezes the clock
345
- // and must not show output from the failed attempt.
346
- if (state === "working" && wasSettled) {
360
+ // A settled proposer entering a queued retry/round must clear its preview
361
+ // before it can be promoted to working, so it never shows stale output.
362
+ if ((state === "working" || state === "queued") && wasSettled) {
347
363
  s.transcript = undefined;
348
364
  s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
349
365
  }
350
366
  if (!isActive(state)) s.endedAt ??= Date.now();
351
- else s.endedAt = undefined;
367
+ else {
368
+ s.startedAt ??= Date.now();
369
+ s.endedAt = undefined;
370
+ }
352
371
  }
353
372
 
354
373
  /** Update one proposer's live context, turn, tool-call, and cost readings (by index). */
@@ -386,6 +405,46 @@ export class MoaProgressWidget implements MoaProgressView {
386
405
  s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
387
406
  }
388
407
 
408
+ /**
409
+ * Point a settled proposer row at a replacement model and clear every reading
410
+ * from the abandoned attempt, so the row reads as freshly queued. The elapsed
411
+ * clock is left unstamped: `update(index, "working")` starts it when the slot
412
+ * actually runs. Remounts the table, which the replacement prompt stopped —
413
+ * `update()` alone clears only the transcript, leaving stale turns/tools/cost/
414
+ * context behind, and `stopWidget()` (needed so the select/picker are not
415
+ * fighting the sticky widget) unmounts it, so only `mountTable()` remounts.
416
+ */
417
+ replaceProposerModel(index: number, ref: ModelRef, thinking?: ThinkingLevel): void {
418
+ const s = this.statuses[index];
419
+ if (!s) return;
420
+ s.ref = ref;
421
+ s.phase = "Plan";
422
+ s.thinking = thinking;
423
+ s.state = "queued";
424
+ s.detail = undefined;
425
+ // Clear every reading from the abandoned attempt, matching the wasSettled
426
+ // branch of update(): the row must read as freshly queued, and the
427
+ // transcriptRevision bump invalidates the preview cache.
428
+ s.contextTokens = undefined;
429
+ s.activity = undefined;
430
+ s.activityHistory = [];
431
+ s.outputTokens = undefined;
432
+ s.outputRevision = undefined;
433
+ s.turns = undefined;
434
+ s.toolCalls = undefined;
435
+ s.costUsd = undefined;
436
+ s.transcript = undefined;
437
+ s.startedAt = undefined;
438
+ s.endedAt = undefined;
439
+ s.transcriptRevision = (s.transcriptRevision ?? 0) + 1;
440
+ this.mountTable();
441
+ }
442
+
443
+ /** Re-show the table after a prompt stopped it, without changing any row. Idempotent. */
444
+ resumeTable(): void {
445
+ this.mountTable();
446
+ }
447
+
389
448
  /**
390
449
  * Move a role row into `working`, starting (or keeping) its elapsed clock.
391
450
  * Each activation replaces the row's thinking level (with `undefined` when the
@@ -539,7 +598,7 @@ export class MoaProgressWidget implements MoaProgressView {
539
598
  private toRow(s: ProposerStatus, phase: MoaPhase, workingText: string, now: number, firstOfPhase: boolean): ProgressRow {
540
599
  const statusText =
541
600
  s.state === "working" ? (s.detail ?? workingText)
542
- : s.state === "cancelling" ? "cancelling…"
601
+ : s.state === "cancelling" ? "cancelling"
543
602
  : s.state === "cancelled" ? "cancelled"
544
603
  : s.state === "error" ? s.detail ?? "error"
545
604
  : s.state === "queued" ? "queued"
@@ -768,10 +827,24 @@ export class MoaProgressTableComponent implements Component {
768
827
  0,
769
828
  this.rowBudget() - TABLE_CHROME_ROWS - rows.length - phaseSectionRows(rows) - (band.length > 0 ? PHASE_BAND_ROWS : 0),
770
829
  );
830
+ // A failed row's message is the one status a reader has to act on, and it
831
+ // routinely outruns the ACTIVITY cell, so its wrapped detail claims budget
832
+ // ahead of live activity and previews. Failed rows are settled, so they
833
+ // compete for nothing else.
834
+ const subRowWidth = Math.max(0, bodyWidth - STATUS_COL_WIDTH - visibleWidth(SUB_ROW_GUTTER));
835
+ const errorDetails = new Map<number, string[]>();
836
+ let remaining = free;
837
+ for (let i = 0; i < rows.length && remaining > 0; i++) {
838
+ const detail = this.errorDetailLines(rows[i], cols.activity, subRowWidth, Math.min(ERROR_DETAIL_LINES, remaining));
839
+ if (detail.length === 0) continue;
840
+ errorDetails.set(i, detail);
841
+ remaining -= detail.length;
842
+ }
843
+
771
844
  const activeIndices = rows.flatMap((r, index) => isActive(r.state) ? [index] : []);
772
845
  const activityIndices = activeIndices.filter((index) => rows[index]?.activity);
773
- const renderedActivityIndices = new Set(activityIndices.slice(0, free));
774
- let remaining = Math.max(0, free - renderedActivityIndices.size);
846
+ const renderedActivityIndices = new Set(activityIndices.slice(0, remaining));
847
+ remaining = Math.max(0, remaining - renderedActivityIndices.size);
775
848
 
776
849
  const previewWidth = Math.max(0, leftTableWidth - PREVIEW_INDENT - visibleWidth(PREVIEW_GUTTER));
777
850
  const previewIndices = this.view.previewVisible && previewWidth >= PREVIEW_MIN_WIDTH
@@ -844,6 +917,9 @@ export class MoaProgressTableComponent implements Component {
844
917
  ),
845
918
  );
846
919
 
920
+ const errorDetail = errorDetails.get(i) ?? [];
921
+ for (const [offset, text] of errorDetail.entries()) lines.push(row(this.errorDetailRow(text, offset === 0, subRowWidth)));
922
+
847
923
  if (renderedActivityIndices.has(i)) lines.push(row(this.activitySubRow(r, bodyWidth)));
848
924
  else if (reservedActivityIndices.has(i)) lines.push(row(""));
849
925
 
@@ -881,11 +957,37 @@ export class MoaProgressTableComponent implements Component {
881
957
  /** Merged-cell tool activity line, spanning everything right of the status icon. */
882
958
  private activitySubRow(r: ProgressRow, bodyWidth: number): string {
883
959
  const room = Math.max(0, bodyWidth - STATUS_COL_WIDTH);
884
- const gutter = this.theme.fg("dim", "↳ ");
960
+ const gutter = this.theme.fg("dim", SUB_ROW_GUTTER);
885
961
  const activity = highlightActivity(this.theme, r.activity ?? "");
886
962
  return `${" ".repeat(STATUS_COL_WIDTH)}${cell(`${gutter}${activity}`, room)}`;
887
963
  }
888
964
 
965
+ /**
966
+ * A failed row's message re-laid across the table body, or nothing when the
967
+ * ACTIVITY cell already shows it whole. Provider failures arrive as one long
968
+ * JSON payload whose cause sits well past the column edge, so the text is
969
+ * wrapped — breaking the payload mid-token when it has no spaces to break on
970
+ * — rather than cut off at a width the reader cannot change.
971
+ */
972
+ private errorDetailLines(row: ProgressRow, activityWidth: number, width: number, maxLines: number): string[] {
973
+ if (row.state !== "error" || !row.statusText) return [];
974
+ if (width <= 0 || maxLines <= 0) return [];
975
+ if (visibleWidth(row.statusText) <= activityWidth) return [];
976
+ const wrapped = wrapTextWithAnsi(row.statusText, width);
977
+ if (wrapped.length <= maxLines) return wrapped;
978
+ // Mark the cut, so a clipped payload never reads as the whole message.
979
+ const shown = wrapped.slice(0, maxLines);
980
+ shown[maxLines - 1] = fitVisible(`${shown[maxLines - 1]}…`, width, { truncationMark: "…", padToWidth: false });
981
+ return shown;
982
+ }
983
+
984
+ /** One wrapped error line: the gutter carries the first, the rest hang under it. */
985
+ private errorDetailRow(text: string, first: boolean, width: number): string {
986
+ const gutter = first ? this.theme.fg("dim", SUB_ROW_GUTTER) : " ".repeat(visibleWidth(SUB_ROW_GUTTER));
987
+ const detail = fitVisible(text, width, { truncationMark: "…", padToWidth: false });
988
+ return `${" ".repeat(STATUS_COL_WIDTH)}${gutter}${this.theme.fg("error", detail)}`;
989
+ }
990
+
889
991
  private topBorder(innerWidth: number, border: (s: string) => string): string {
890
992
  const title = ` ${this.view.title} `;
891
993
  const head = `${border("══")}${this.theme.fg("accent", title)}`;
@@ -8,8 +8,8 @@
8
8
  *
9
9
  * Two kinds of slot are configured here. Planning-subagent slots persist into
10
10
  * the frontmatter of the installed agent files; MoA fan-out roles are no
11
- * longer set per role here — they live in named rosters, edited by the staged
12
- * roster manager and persisted only when this overlay's Save is chosen.
11
+ * longer set per role here — they live in named rosters whose completed edits
12
+ * are persisted immediately by the roster manager.
13
13
  */
14
14
 
15
15
  import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
@@ -17,6 +17,10 @@ import { type Component, type TUI } from "@earendil-works/pi-tui";
17
17
  import {
18
18
  defaultThinkingForModel,
19
19
  loadMoaConfig,
20
+ MAX_CONCURRENT_AGENTS,
21
+ MAX_VERIFICATION_REPAIRS_LIMIT,
22
+ MIN_CONCURRENT_AGENTS,
23
+ MIN_VERIFICATION_REPAIRS,
20
24
  saveMoaConfig,
21
25
  type MoaConfig,
22
26
  } from "../config/settings.ts";
@@ -49,8 +53,6 @@ interface SetupSlot {
49
53
  description: string;
50
54
  /** Whether the overview row appends the selected thinking level. */
51
55
  showThinkingValue: boolean;
52
- /** Where a confirmed selection is persisted. */
53
- target: { kind: "agent"; agentName: string } | { kind: "role" };
54
56
  }
55
57
 
56
58
  const SLOTS: readonly SetupSlot[] = [
@@ -59,14 +61,12 @@ const SLOTS: readonly SetupSlot[] = [
59
61
  title: agent.title,
60
62
  description: agent.description,
61
63
  showThinkingValue: true,
62
- target: { kind: "agent", agentName: agent.name },
63
64
  })),
64
65
  {
65
66
  menuLabel: "cheap/fast agent",
66
67
  title: "Cheap / fast agent — plan-file naming",
67
68
  description: "Only summarizes a plan prompt into a short file name. Always called with thinking off and falls back safely if it cannot respond, so pick the cheapest model available.",
68
69
  showThinkingValue: false,
69
- target: { kind: "role" },
70
70
  },
71
71
  ];
72
72
 
@@ -77,6 +77,8 @@ interface SetupDraft {
77
77
  thinkingOverrides: Record<string, ThinkingLevel>;
78
78
  autoResolveConflicts: boolean;
79
79
  useSummaryName: boolean;
80
+ maxConcurrentAgents: number;
81
+ maxVerificationRepairs: number;
80
82
  rosters: PlanRoster[];
81
83
  }
82
84
 
@@ -90,6 +92,8 @@ class MoaSetupComponent implements Component {
90
92
  private readonly menu: MenuComponent;
91
93
  private autoResolveConflicts: boolean;
92
94
  private useSummaryName: boolean;
95
+ private maxConcurrentAgents: number;
96
+ private maxVerificationRepairs: number;
93
97
 
94
98
  constructor(
95
99
  private readonly tui: TUI,
@@ -105,6 +109,8 @@ class MoaSetupComponent implements Component {
105
109
  this.twoPane = new TwoPaneModelThinking(tui, theme, availableRefs, config, currentThinking, ctx);
106
110
  this.autoResolveConflicts = draft.autoResolveConflicts;
107
111
  this.useSummaryName = draft.useSummaryName;
112
+ this.maxConcurrentAgents = draft.maxConcurrentAgents;
113
+ this.maxVerificationRepairs = draft.maxVerificationRepairs;
108
114
  const slotItem = (slotIndex: number): MenuItem => ({
109
115
  id: `role-${slotIndex}`,
110
116
  label: SLOTS[slotIndex]!.menuLabel,
@@ -130,6 +136,28 @@ class MoaSetupComponent implements Component {
130
136
  { title: "options", items: [
131
137
  { id: "auto-resolve", label: "auto resolve conflicts", value: this.autoResolveConflicts, onChange: (value: boolean) => { this.autoResolveConflicts = value; } },
132
138
  { id: "use-summary-name", label: "summarize plan names", value: this.useSummaryName, onChange: (value: boolean) => { this.useSummaryName = value; } },
139
+ {
140
+ id: "max-concurrent-agents",
141
+ label: "max concurrent agents",
142
+ values: Array.from(
143
+ { length: MAX_CONCURRENT_AGENTS - MIN_CONCURRENT_AGENTS + 1 },
144
+ (_unused, index) => String(MIN_CONCURRENT_AGENTS + index),
145
+ ),
146
+ valueIndex: this.maxConcurrentAgents - MIN_CONCURRENT_AGENTS,
147
+ description: "Child agents running at once in MoA, opinion, debate and plan subagent fan-outs. Keep at 1 for local agents.",
148
+ onChange: (valueIndex: number) => { this.maxConcurrentAgents = MIN_CONCURRENT_AGENTS + valueIndex; },
149
+ },
150
+ {
151
+ id: "max-verification-repairs",
152
+ label: "max verification repairs",
153
+ values: Array.from(
154
+ { length: MAX_VERIFICATION_REPAIRS_LIMIT - MIN_VERIFICATION_REPAIRS + 1 },
155
+ (_unused, index) => String(MIN_VERIFICATION_REPAIRS + index),
156
+ ),
157
+ valueIndex: this.maxVerificationRepairs - MIN_VERIFICATION_REPAIRS,
158
+ description: "Verifier-driven repair rounds after implementation (0 disables automatic repairs).",
159
+ onChange: (valueIndex: number) => { this.maxVerificationRepairs = MIN_VERIFICATION_REPAIRS + valueIndex; },
160
+ },
133
161
  ] },
134
162
  ],
135
163
  buttons: [
@@ -168,6 +196,8 @@ class MoaSetupComponent implements Component {
168
196
  thinkingOverrides: { ...this.draft.thinkingOverrides },
169
197
  autoResolveConflicts: this.autoResolveConflicts,
170
198
  useSummaryName: this.useSummaryName,
199
+ maxConcurrentAgents: this.maxConcurrentAgents,
200
+ maxVerificationRepairs: this.maxVerificationRepairs,
171
201
  rosters: [...this.draft.rosters],
172
202
  };
173
203
  }
@@ -299,6 +329,8 @@ export async function showMoaSetup(
299
329
  thinkingOverrides: {},
300
330
  autoResolveConflicts: saved.autoResolveConflicts,
301
331
  useSummaryName: saved.useSummaryName,
332
+ maxConcurrentAgents: saved.maxConcurrentAgents,
333
+ maxVerificationRepairs: saved.maxVerificationRepairs,
302
334
  rosters: [...saved.rosters],
303
335
  };
304
336
 
@@ -325,7 +357,17 @@ export async function showMoaSetup(
325
357
  );
326
358
  if (result === undefined) return false;
327
359
  if (result.action === "rosters") {
328
- draft.rosters = await showRosterManager(ctx, draft.rosters, { currentThinking });
360
+ draft.autoResolveConflicts = result.draft.autoResolveConflicts;
361
+ draft.useSummaryName = result.draft.useSummaryName;
362
+ draft.maxConcurrentAgents = result.draft.maxConcurrentAgents;
363
+ draft.maxVerificationRepairs = result.draft.maxVerificationRepairs;
364
+ draft.rosters = await showRosterManager(ctx, draft.rosters, {
365
+ currentThinking,
366
+ onChange: (rosters) => {
367
+ const current = loadMoaConfig();
368
+ saveMoaConfig({ ...current, rosters });
369
+ },
370
+ });
329
371
  continue;
330
372
  }
331
373
  break;
@@ -360,6 +402,8 @@ export async function showMoaSetup(
360
402
  agentDefaultsConfigured: true,
361
403
  autoResolveConflicts: finalDraft.autoResolveConflicts,
362
404
  useSummaryName: finalDraft.useSummaryName,
405
+ maxConcurrentAgents: finalDraft.maxConcurrentAgents,
406
+ maxVerificationRepairs: finalDraft.maxVerificationRepairs,
363
407
  cheap: finalDraft.selections[AGENT_SLOT_COUNT],
364
408
  rosters: finalDraft.rosters,
365
409
  thinkingOverrides: { ...current.thinkingOverrides, ...finalDraft.thinkingOverrides },
@@ -71,16 +71,15 @@ function formatProposersList(proposers: ModelRef[]): string {
71
71
  }
72
72
 
73
73
  function formatSplitRow(left: string, right: string | undefined, bodyWidth: number): string {
74
- const leftText = left ?? "";
75
74
  if (!right) {
76
- return padRow(leftText, bodyWidth);
75
+ return padRow(left, bodyWidth);
77
76
  }
78
77
 
79
- const minLeft = Math.min(visibleWidth(leftText), 18);
78
+ const minLeft = Math.min(visibleWidth(left), 18);
80
79
  const maxRight = Math.max(1, bodyWidth - minLeft);
81
80
  const rightPart = truncateToWidth(right, maxRight, "…", false);
82
81
  const rightVis = visibleWidth(rightPart);
83
- const leftPart = truncateToWidth(leftText, Math.max(1, bodyWidth - rightVis), "…", false);
82
+ const leftPart = truncateToWidth(left, Math.max(1, bodyWidth - rightVis), "…", false);
84
83
  const gap = bodyWidth - visibleWidth(leftPart) - visibleWidth(rightPart);
85
84
  const row = gap > 0 ? leftPart + " ".repeat(gap) + rightPart : leftPart + rightPart;
86
85
  return padRow(row, bodyWidth);
@@ -92,15 +91,23 @@ function formatRightRow(content: string, bodyWidth: number): string {
92
91
  return padRow(gap > 0 ? " ".repeat(gap) + truncated : truncated, bodyWidth);
93
92
  }
94
93
 
94
+ export interface PlanReviewLabels {
95
+ title?: string;
96
+ approveHint?: string;
97
+ keepHint?: string;
98
+ chatHint?: string;
99
+ }
100
+
95
101
  export async function showPlanReview(
96
102
  ctx: ExtensionContext,
97
103
  planMarkdown: string,
98
104
  moaInfo?: MfPlanInfo,
99
105
  planName?: string,
100
106
  allowChat = false,
107
+ labels?: PlanReviewLabels,
101
108
  ): Promise<PlanReviewDecision> {
102
109
  const result = await ctx.ui.custom<PlanReviewDecision>(
103
- (tui, theme, _keybindings, done) => new PlanReviewOverlay(tui, theme, planMarkdown, moaInfo, ctx.cwd, planName, allowChat, done),
110
+ (tui, theme, _keybindings, done) => new PlanReviewOverlay(tui, theme, planMarkdown, moaInfo, ctx.cwd, planName, allowChat, labels, done),
104
111
  {
105
112
  overlay: true,
106
113
  overlayOptions: {
@@ -136,6 +143,7 @@ class PlanReviewOverlay implements Component {
136
143
  private readonly repoCwd: string,
137
144
  private readonly planName: string | undefined,
138
145
  private readonly allowChat: boolean,
146
+ private readonly labels: PlanReviewLabels | undefined,
139
147
  private readonly done: (decision: PlanReviewDecision) => void,
140
148
  ) {
141
149
  this.markdown = new Markdown(stripTerminalSequences(planMarkdown), 0, 0, getMarkdownTheme());
@@ -234,9 +242,9 @@ class PlanReviewOverlay implements Component {
234
242
  .join(",");
235
243
  const proposalHelp = helpParts ? ` · ${helpParts} proposer plans · \`/0 synthesized` : "";
236
244
  const verdictsHelp = this.verdictsMarkdown ? " · v verdicts" : "";
237
- const chatHelp = this.allowChat ? " · c chat" : "";
245
+ const chatHelp = this.allowChat ? ` · c ${this.labels?.chatHint ?? "chat"}` : "";
238
246
  const helpLines = wrapDimLine(
239
- `↑/↓ or j/k scroll · u/d page${proposalHelp}${verdictsHelp} · e edit${chatHelp} · a/Enter approve · q/Esc keep planning`,
247
+ `↑/↓ or j/k scroll · u/d page${proposalHelp}${verdictsHelp} · e edit${chatHelp} · a/Enter ${this.labels?.approveHint ?? "approve"} · q/Esc ${this.labels?.keepHint ?? "keep planning"}`,
240
248
  bodyWidth,
241
249
  );
242
250
  this.helpLineCount = helpLines.length;
@@ -261,7 +269,7 @@ class PlanReviewOverlay implements Component {
261
269
  const planTitle = showingVerdicts
262
270
  ? "Proposer Verdicts"
263
271
  : this.activeProposerIndex === undefined
264
- ? "Proposed Plan"
272
+ ? this.labels?.title ?? "Proposed Plan"
265
273
  : `Proposer ${this.activeProposerIndex + 1} Plan`;
266
274
  const proposerModel = this.activeProposerIndex === undefined
267
275
  ? undefined
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * A staged CRUD surface over the saved `PlanRoster` list: list → name prompt →
5
5
  * slot editor → per-slot model/thinking picker, ported from persona-audit's
6
- * RosterEditor. Every mutation happens on in-memory copies; persistence stays
7
- * with the settings overlay's Save button, so closing without saving (or the
8
- * overlay's Cancel) leaves settings.json untouched.
6
+ * RosterEditor. Draft slot changes remain in memory until Save roster is
7
+ * chosen; completed roster saves and confirmed deletions are then persisted
8
+ * immediately through the manager's onChange callback.
9
9
  *
10
10
  * Each roster carries one model + thinking level for every fan-out role —
11
11
  * 2–5 proposers plus synthesizer, implementer, and verifier. A roster must be
@@ -31,8 +31,6 @@ import { ratioViewport } from "./chrome.ts";
31
31
  import { MenuComponent, showOverlayPrompt, type ActionMenuItem } from "./menu.ts";
32
32
  import { showModelThinkingPicker } from "./moaModelPicker.ts";
33
33
 
34
- const BACKSPACE = "\x7f";
35
-
36
34
  type SlotRole = "synthesizer" | "implementer" | "verifier";
37
35
  type SlotTarget = { kind: "proposer"; index: number } | { kind: "role"; role: SlotRole };
38
36
  type ListResult = { action: "create" } | { action: "edit"; index: number } | { action: "back" };
@@ -198,7 +196,7 @@ function showSlotEditor(ctx: ExtensionContext, draft: DraftRoster, focusItemId:
198
196
  { title: "roles", items: roleItems },
199
197
  ],
200
198
  onItemKey: (item, data) => {
201
- if (data !== BACKSPACE && data !== "\b" && !matchesKey(data, Key.delete)) return false;
199
+ if (!matchesKey(data, Key.backspace) && !matchesKey(data, Key.delete)) return false;
202
200
  const target = parseTargetItemId(item.id);
203
201
  if (!target) return true;
204
202
  if (target.kind === "proposer") working.proposers[target.index] = undefined;
@@ -245,7 +243,7 @@ async function editRoster(
245
243
  if (result.action === "cancel") return undefined;
246
244
  if (result.action === "save") return fromDraft(result.draft);
247
245
  if (result.action === "delete") {
248
- if (await ctx.ui.confirm("Delete agent roster?", `Delete ${draft.name}? This is staged until settings are saved.`)) return "delete";
246
+ if (await ctx.ui.confirm("Delete agent roster?", `Delete ${draft.name}? This takes effect immediately.`)) return "delete";
249
247
  continue;
250
248
  }
251
249
  if (result.action === "rename") {
@@ -272,11 +270,11 @@ async function editRoster(
272
270
  }
273
271
  }
274
272
 
275
- /** Manage a staged roster collection; persistence remains the settings overlay's Save action's responsibility. */
273
+ /** Manage roster drafts and publish completed saves immediately through onChange. */
276
274
  export async function showRosterManager(
277
275
  ctx: ExtensionContext,
278
276
  initial: PlanRoster[],
279
- deps: { currentThinking: ThinkingLevel },
277
+ deps: { currentThinking: ThinkingLevel; onChange?: (rosters: PlanRoster[]) => void },
280
278
  ): Promise<PlanRoster[]> {
281
279
  const rosters = initial.map((roster) => ({ ...roster, proposers: [...roster.proposers] }));
282
280
  for (;;) {
@@ -298,13 +296,21 @@ export async function showRosterManager(
298
296
  undefined,
299
297
  deps.currentThinking,
300
298
  );
301
- if (created && created !== "delete") rosters.push(created);
299
+ if (created && created !== "delete") {
300
+ rosters.push(created);
301
+ deps.onChange?.(rosters);
302
+ }
302
303
  continue;
303
304
  }
304
305
  const current = rosters[action.index];
305
306
  if (!current) continue;
306
307
  const edited = await editRoster(ctx, rosters, toDraft(current), action.index, deps.currentThinking);
307
- if (edited === "delete") rosters.splice(action.index, 1);
308
- else if (edited) rosters[action.index] = edited;
308
+ if (edited === "delete") {
309
+ rosters.splice(action.index, 1);
310
+ deps.onChange?.(rosters);
311
+ } else if (edited) {
312
+ rosters[action.index] = edited;
313
+ deps.onChange?.(rosters);
314
+ }
309
315
  }
310
316
  }