@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.
- package/CHANGELOG.md +54 -0
- package/README.md +54 -12
- package/agents/moa-synthesizer.md +9 -6
- package/package.json +5 -5
- package/src/config/planName.ts +29 -24
- package/src/config/settings.ts +38 -1
- package/src/debate/debateFanout.ts +16 -6
- package/src/debate/runDebate.ts +13 -4
- package/src/index.ts +14 -3
- package/src/moa/conflictContract.ts +2 -1
- package/src/moa/contextContract.ts +1 -1
- package/src/moa/fanout.ts +115 -4
- package/src/moa/fanoutWiring.ts +13 -3
- package/src/moa/implementationRetry.ts +2 -2
- package/src/moa/orchestration.ts +3 -0
- package/src/moa/panelDependence.ts +87 -0
- package/src/moa/reviewLoop.ts +2 -2
- package/src/moa/synthesis.ts +12 -8
- package/src/moa/verification.ts +117 -20
- package/src/moa/verificationCriteria.ts +1 -1
- package/src/opinion/opinionFanout.ts +5 -4
- package/src/opinion/runOpinion.ts +14 -13
- package/src/planning/contextEdits.ts +62 -0
- package/src/planning/modeState.ts +17 -0
- package/src/planning/planFile.ts +30 -12
- package/src/planning/planMode.ts +144 -11
- package/src/planning/tools/exitPlanMode.ts +7 -2
- package/src/planning/tools/mfPlanSubagent.ts +3 -0
- package/src/planning/tools/shared.ts +1 -0
- package/src/runtime/activityTracking.ts +28 -12
- package/src/runtime/processPool.ts +2 -0
- package/src/runtime/results.ts +18 -0
- package/src/runtime/runner.ts +45 -9
- package/src/runtime/wire.ts +11 -0
- package/src/spec/runSpec.ts +343 -0
- package/src/spec/specContract.ts +324 -0
- package/src/ui/cancelOverlay.ts +1 -1
- package/src/ui/moaModelPicker.ts +32 -4
- package/src/ui/moaProgressWidget.ts +114 -12
- package/src/ui/moaSetupOverlay.ts +51 -7
- package/src/ui/planReviewOverlay.ts +16 -8
- package/src/ui/rosterEditor.ts +18 -12
- package/src/ui/workingOverlay.ts +265 -0
package/src/moa/verification.ts
CHANGED
|
@@ -11,20 +11,26 @@
|
|
|
11
11
|
* evidence — the implementer's own report is treated as an untrusted claim.
|
|
12
12
|
*
|
|
13
13
|
* A `complete` verdict with a clean script gate ends the phase. Anything less
|
|
14
|
-
* offers the user a bounded repair round (up to
|
|
15
|
-
*
|
|
16
|
-
* dialog and sent back to the same
|
|
17
|
-
* kickoff machinery. The result is then
|
|
18
|
-
* as-is" is always available.
|
|
14
|
+
* offers the user a bounded repair round (up to the `maxVerificationRepairs`
|
|
15
|
+
* setting, default 2; 0 disables the offer): the verifier's gaps and failing
|
|
16
|
+
* checks are shown inline in the repair/accept dialog and sent back to the same
|
|
17
|
+
* in-session implementer via the existing kickoff machinery. The result is then
|
|
18
|
+
* verified again. "Accept implementation as-is" is always available.
|
|
19
|
+
*
|
|
20
|
+
* Once that budget is spent (or when it is zero, or no UI can offer the choice),
|
|
21
|
+
* the phase writes a `<slug>__verification-handoff.md` next to the plan holding
|
|
22
|
+
* every criterion's status plus the whole frozen checklist, and names that file
|
|
23
|
+
* in the exhaustion notification so the fix/verification loop can be continued
|
|
24
|
+
* by hand.
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
|
-
import {
|
|
27
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
28
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
23
29
|
|
|
24
30
|
import { loadMoaConfig } from "../config/settings.ts";
|
|
25
31
|
import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
|
|
26
32
|
import { discoverAgents } from "../agents/discovery.ts";
|
|
27
|
-
import { readRepoPlanFile, saveRepoPlanFile } from "../planning/planFile.ts";
|
|
33
|
+
import { readRepoPlanFile, repoPlanDisplayPath, saveRepoPlanFile } from "../planning/planFile.ts";
|
|
28
34
|
import { CancelRun, type CancelSession } from "../runtime/cancelRun.ts";
|
|
29
35
|
import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
|
|
30
36
|
import { getFinalOutput, getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
|
|
@@ -42,9 +48,6 @@ import { parseVerificationCriteria, sectionBullets, type VerificationCriterion }
|
|
|
42
48
|
import type { MoaRunHost } from "./runContext.ts";
|
|
43
49
|
import { captureImplementationDiff, discoverVerifyScripts, runVerifyScript, type VerifyResult } from "./verifyGate.ts";
|
|
44
50
|
|
|
45
|
-
/** How many verifier-driven repair rounds a single implementation may consume. */
|
|
46
|
-
export const MAX_VERIFICATION_REPAIRS = 2;
|
|
47
|
-
|
|
48
51
|
export type VerificationVerdictValue = "complete" | "partial" | "incomplete" | "cannot-verify";
|
|
49
52
|
|
|
50
53
|
export interface CriterionVerdict {
|
|
@@ -349,6 +352,58 @@ export function formatVerificationReport(parsed: VerificationVerdict, failedScri
|
|
|
349
352
|
return sections.join("\n\n");
|
|
350
353
|
}
|
|
351
354
|
|
|
355
|
+
/** Markdown handoff written when automatic verifier repairs are exhausted or disabled. */
|
|
356
|
+
export function buildVerificationExhaustionHandoff(input: {
|
|
357
|
+
repairsUsed: number;
|
|
358
|
+
maxRepairs: number;
|
|
359
|
+
parsed: VerificationVerdict;
|
|
360
|
+
criteria: VerificationCriterion[];
|
|
361
|
+
failedScripts: VerifyResult[];
|
|
362
|
+
planFilePath: string;
|
|
363
|
+
verificationReportPath?: string;
|
|
364
|
+
/** Frozen checklist on disk, referenced alongside the inlined criteria. */
|
|
365
|
+
criteriaFilePath?: string;
|
|
366
|
+
}): string {
|
|
367
|
+
const { repairsUsed, maxRepairs, parsed, criteria, failedScripts, planFilePath, verificationReportPath, criteriaFilePath } = input;
|
|
368
|
+
const sections: string[] = [
|
|
369
|
+
"# Verification repair handoff",
|
|
370
|
+
"",
|
|
371
|
+
maxRepairs === 0
|
|
372
|
+
? "Automatic verifier repair rounds are disabled in settings (`maxVerificationRepairs: 0`)."
|
|
373
|
+
: `Verifier repair rounds are exhausted (${repairsUsed} of ${maxRepairs} used).`,
|
|
374
|
+
"",
|
|
375
|
+
"Use this file to continue the fix/verification loop manually: send the criteria status and gaps below to the implementer, apply fixes, then re-run verification (for example via `/mf-plan-implement` or by approving a follow-up turn).",
|
|
376
|
+
"",
|
|
377
|
+
`Approved plan: ${planFilePath}`,
|
|
378
|
+
];
|
|
379
|
+
if (verificationReportPath) sections.push(`Full verifier report: ${verificationReportPath}`);
|
|
380
|
+
if (criteriaFilePath) sections.push(`Frozen verification criteria: ${criteriaFilePath}`);
|
|
381
|
+
sections.push(
|
|
382
|
+
"",
|
|
383
|
+
"## Verification result",
|
|
384
|
+
`**Verdict:** ${parsed.verdict ?? "unknown"}`,
|
|
385
|
+
parsed.summary ? `**Summary:** ${parsed.summary}` : "**Summary:** (none)",
|
|
386
|
+
);
|
|
387
|
+
if (criteria.length > 0) {
|
|
388
|
+
const byId = new Map(parsed.criteria.map((criterion) => [criterion.id, criterion]));
|
|
389
|
+
const statusLines = criteria.map((criterion) => {
|
|
390
|
+
const verdict = byId.get(criterion.id);
|
|
391
|
+
if (!verdict) return `- **${criterion.id}:** not judged — ${criterion.text}`;
|
|
392
|
+
return `- **${criterion.id}:** ${verdict.status} — ${verdict.evidence || criterion.text}`;
|
|
393
|
+
});
|
|
394
|
+
sections.push("", "## Criteria status", ...statusLines, "", "## Full verification criteria", ...criteria.map((criterion) => `- **${criterion.id}:** ${criterion.text}`));
|
|
395
|
+
} else if (parsed.steps.length > 0) {
|
|
396
|
+
sections.push("", "## Step verdicts", ...parsed.steps.map((step) => `- ${step}`));
|
|
397
|
+
}
|
|
398
|
+
if (parsed.gaps.length > 0) {
|
|
399
|
+
sections.push("", "## Gaps", ...parsed.gaps.map((gap) => `- ${gap}`));
|
|
400
|
+
}
|
|
401
|
+
if (failedScripts.length > 0) {
|
|
402
|
+
sections.push("", "## Failing project checks", ...failedScripts.map((result) => `- \`${result.command}\` failed (exit ${result.exitCode})`));
|
|
403
|
+
}
|
|
404
|
+
return sections.join("\n");
|
|
405
|
+
}
|
|
406
|
+
|
|
352
407
|
function buildRepairNote(parsed: VerificationVerdict, failedScripts: VerifyResult[]): string {
|
|
353
408
|
const parts: string[] = [
|
|
354
409
|
"The verifier reviewed your implementation against the approved plan and found it did not fully land.",
|
|
@@ -434,7 +489,7 @@ export async function runVerificationDecision(deps: {
|
|
|
434
489
|
// Reactivate the Implement row for the repair round and leave the table
|
|
435
490
|
// mounted; the next settled turn re-enters this verification flow.
|
|
436
491
|
widget?.settleRoleRow("Verify", "done");
|
|
437
|
-
if (handoff.model) widget?.switchToImplementing(handoff.model, "addressing verifier findings
|
|
492
|
+
if (handoff.model) widget?.switchToImplementing(handoff.model, "addressing verifier findings", handoff.thinking);
|
|
438
493
|
host.markImplementationPending(ctx);
|
|
439
494
|
const repairNote = buildRepairNote(parsed, failedScripts);
|
|
440
495
|
await host.pi.sendUserMessage(
|
|
@@ -492,7 +547,7 @@ export async function runImplementationVerification(
|
|
|
492
547
|
widget?.setActivePhase("Verify");
|
|
493
548
|
// `activeVerifierThinking` is not declared yet; the initial project-checks
|
|
494
549
|
// activation carries the handoff's verifier level, which it is seeded from.
|
|
495
|
-
widget?.switchToVerifying(verifier, "running project checks
|
|
550
|
+
widget?.switchToVerifying(verifier, "running project checks", handoff.verifierThinking);
|
|
496
551
|
|
|
497
552
|
// Diff and script gate run in the parent, BEFORE the verifier's mutation
|
|
498
553
|
// tripwire is armed: `npm test` may legitimately write snapshots/coverage,
|
|
@@ -532,7 +587,7 @@ export async function runImplementationVerification(
|
|
|
532
587
|
|
|
533
588
|
const runVerifierPreflight = async (): Promise<VerifierRunOutcome> => {
|
|
534
589
|
const verifier = activeVerifier;
|
|
535
|
-
widget?.switchToVerifying(verifier, "checking verifier
|
|
590
|
+
widget?.switchToVerifying(verifier, "checking verifier", activeVerifierThinking);
|
|
536
591
|
const result = await runVerifier(
|
|
537
592
|
ctx.cwd, agents, "moa-verifier", "Reply with the single word ok", undefined, undefined, undefined,
|
|
538
593
|
modelRefLabel(verifier), activeVerifierThinking, {
|
|
@@ -548,7 +603,7 @@ export async function runImplementationVerification(
|
|
|
548
603
|
const tripwire = new MutationTripwire();
|
|
549
604
|
await tripwire.arm(ctx.cwd);
|
|
550
605
|
|
|
551
|
-
widget?.switchToVerifying(verifier, "verifying implementation
|
|
606
|
+
widget?.switchToVerifying(verifier, "verifying implementation", activeVerifierThinking);
|
|
552
607
|
widget?.updateRoleTranscript("Verify", []);
|
|
553
608
|
|
|
554
609
|
const session: CancelSession = { title: "MoA verification", run: undefined, overlayOpen: false };
|
|
@@ -707,7 +762,7 @@ export async function runImplementationVerification(
|
|
|
707
762
|
: outcome.output;
|
|
708
763
|
try {
|
|
709
764
|
saveRepoPlanFile(verificationOutput, ctx.cwd, host.getPlanRepoSlug()!, "verification");
|
|
710
|
-
verificationReportPath =
|
|
765
|
+
verificationReportPath = repoPlanDisplayPath(host.getPlanRepoSlug()!, "verification");
|
|
711
766
|
} catch {
|
|
712
767
|
// Persisting the report is best-effort; verification proceeds regardless.
|
|
713
768
|
}
|
|
@@ -756,15 +811,17 @@ export async function runImplementationVerification(
|
|
|
756
811
|
const failedScripts = scriptResults.filter((r) => r.status === "fail");
|
|
757
812
|
if (verificationPassed(parsed.verdict, scriptResults)) {
|
|
758
813
|
const checksNote = scriptResults.length > 0 ? " and all project checks passed" : "";
|
|
759
|
-
|
|
814
|
+
const message = criteria.length > 0
|
|
760
815
|
? `Verification passed — all ${criteria.length} criteria met${checksNote}.`
|
|
761
|
-
: `Verification passed — the implementation matches the approved plan${checksNote}
|
|
816
|
+
: `Verification passed — the implementation matches the approved plan${checksNote}.`;
|
|
817
|
+
ctx.ui.notify(ctx.ui.theme.bg("toolSuccessBg", ctx.ui.theme.fg("success", message)));
|
|
762
818
|
finish("done");
|
|
763
819
|
return;
|
|
764
820
|
}
|
|
765
821
|
|
|
766
822
|
const repairsUsed = activeHandoff.verificationRepairs ?? 0;
|
|
767
|
-
|
|
823
|
+
const maxRepairs = config.maxVerificationRepairs;
|
|
824
|
+
if (ctx.hasUI && maxRepairs > 0 && repairsUsed < maxRepairs) {
|
|
768
825
|
await runVerificationDecision({
|
|
769
826
|
ctx,
|
|
770
827
|
host,
|
|
@@ -785,7 +842,47 @@ export async function runImplementationVerification(
|
|
|
785
842
|
|
|
786
843
|
const summary = buildVerificationSummary(parsed, scriptResults).join("\n");
|
|
787
844
|
const report = formatVerificationReport(parsed, failedScripts);
|
|
788
|
-
|
|
789
|
-
|
|
845
|
+
let handoffPath: string | undefined;
|
|
846
|
+
// The run's own slug is authoritative, but a resumed implementation may have
|
|
847
|
+
// verified without one (e.g. /mf-plan-implement after a restart); the handoff
|
|
848
|
+
// carries the slug its plan was saved under, so fall back to it.
|
|
849
|
+
const repoPlanSlug = host.getPlanRepoSlug() ?? activeHandoff.repoPlanSlug;
|
|
850
|
+
if (repoPlanSlug) {
|
|
851
|
+
try {
|
|
852
|
+
saveRepoPlanFile(
|
|
853
|
+
buildVerificationExhaustionHandoff({
|
|
854
|
+
repairsUsed,
|
|
855
|
+
maxRepairs,
|
|
856
|
+
parsed,
|
|
857
|
+
criteria,
|
|
858
|
+
failedScripts,
|
|
859
|
+
planFilePath: activeHandoff.planFilePath,
|
|
860
|
+
verificationReportPath,
|
|
861
|
+
// Only point at the checklist file when one is actually on disk;
|
|
862
|
+
// criteria can come from the handoff alone.
|
|
863
|
+
criteriaFilePath: readRepoPlanFile(ctx.cwd, repoPlanSlug, "criteria")
|
|
864
|
+
? repoPlanDisplayPath(repoPlanSlug, "criteria")
|
|
865
|
+
: undefined,
|
|
866
|
+
}),
|
|
867
|
+
ctx.cwd,
|
|
868
|
+
repoPlanSlug,
|
|
869
|
+
"verification-handoff",
|
|
870
|
+
);
|
|
871
|
+
handoffPath = repoPlanDisplayPath(repoPlanSlug, "verification-handoff");
|
|
872
|
+
} catch {
|
|
873
|
+
// Persisting the handoff is best-effort; the exhaustion notification still fires.
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// The notification is the only place the handoff is announced, so say plainly
|
|
877
|
+
// when it could not be written rather than silently dropping the location.
|
|
878
|
+
const handoffNote = handoffPath
|
|
879
|
+
? `\n\nContinue fix/verification using the handoff file:\n${handoffPath}`
|
|
880
|
+
: "\n\nThe verification handoff file could not be saved — the findings above are the only record.";
|
|
881
|
+
const exhaustedNote = maxRepairs === 0
|
|
882
|
+
? "\n\nAutomatic verifier repair rounds are disabled in settings."
|
|
883
|
+
: repairsUsed >= maxRepairs
|
|
884
|
+
? "\n\nRepair rounds are exhausted."
|
|
885
|
+
: "";
|
|
886
|
+
ctx.ui.notify(`${summary}\n\n${report}${exhaustedNote}${handoffNote}`, "warning");
|
|
790
887
|
finish("done");
|
|
791
888
|
}
|
|
@@ -84,7 +84,7 @@ export async function runCriteriaGeneration(input: {
|
|
|
84
84
|
observe?: ObserveSession;
|
|
85
85
|
}): Promise<{ criteria: VerificationCriterion[]; markdown: string } | undefined> {
|
|
86
86
|
const { ctx, agents, synthesizer, thinking, plan, session, widget } = input;
|
|
87
|
-
widget?.switchToSynthesizing(synthesizer, "writing verification criteria
|
|
87
|
+
widget?.switchToSynthesizing(synthesizer, "writing verification criteria", thinking);
|
|
88
88
|
const run = new CancelRun();
|
|
89
89
|
const slot = run.add(modelRefLabel(synthesizer));
|
|
90
90
|
if (session) {
|
|
@@ -27,6 +27,7 @@ export interface OpinionFanoutOptions {
|
|
|
27
27
|
session: CancelSession;
|
|
28
28
|
widget: MoaProgressWidget;
|
|
29
29
|
agents: AgentConfig[];
|
|
30
|
+
maxConcurrency: number;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export type OpinionFanoutResult =
|
|
@@ -34,9 +35,9 @@ export type OpinionFanoutResult =
|
|
|
34
35
|
| { status: "cancelled" };
|
|
35
36
|
|
|
36
37
|
export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<OpinionFanoutResult> {
|
|
37
|
-
const { host, ctx, question, models, thinking, session, widget, agents } = options;
|
|
38
|
+
const { host, ctx, question, models, thinking, session, widget, agents, maxConcurrency } = options;
|
|
38
39
|
const originalTask = buildOpinionTask(question);
|
|
39
|
-
widget.startFanout(models, thinking);
|
|
40
|
+
widget.startFanout(models, thinking, maxConcurrency);
|
|
40
41
|
const observe: ObserveSession = {
|
|
41
42
|
title: "MoA opinions",
|
|
42
43
|
phase: "fanout",
|
|
@@ -57,7 +58,7 @@ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<O
|
|
|
57
58
|
tasks: ModelParallelAgentTask[],
|
|
58
59
|
indexMap: (index: number) => number,
|
|
59
60
|
currentRun: CancelRun,
|
|
60
|
-
): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap });
|
|
61
|
+
): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap, maxConcurrency });
|
|
61
62
|
|
|
62
63
|
const run = new CancelRun();
|
|
63
64
|
session.title = "Opinion agents";
|
|
@@ -107,7 +108,7 @@ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<O
|
|
|
107
108
|
const ref = models[opinionIndex];
|
|
108
109
|
const label = modelRefLabel(ref);
|
|
109
110
|
const retryTask = buildOpinionRetryTask(originalTask, getFinalOutput(results[opinionIndex].messages));
|
|
110
|
-
widget.update(opinionIndex, "
|
|
111
|
+
widget.update(opinionIndex, "queued", "retrying: no opinion produced");
|
|
111
112
|
const observed = observe.agents[opinionIndex];
|
|
112
113
|
if (observed) {
|
|
113
114
|
observed.state = "working";
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
3
2
|
|
|
4
3
|
import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
|
|
5
4
|
import { discoverAgents } from "../agents/discovery.ts";
|
|
6
5
|
import { summarizePlanPromptName } from "../config/planName.ts";
|
|
6
|
+
import { slugifyPlanName } from "../planning/planFile.ts";
|
|
7
7
|
import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
|
|
8
8
|
import { resolveContextWindow } from "../moa/modelRuntime.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { type CancelSession, subscribeCancelOverlayOnEsc } from "../runtime/cancelRun.ts";
|
|
10
10
|
import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
|
|
11
11
|
import { type SendUserMessageOptions, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
12
12
|
import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
|
|
13
13
|
import type { ObserveSession } from "../ui/observeOverlay.ts";
|
|
14
14
|
import { showOpinionModelPicker } from "../ui/opinionModelPicker.ts";
|
|
15
15
|
import { showPromptEditor } from "../ui/promptEditor.ts";
|
|
16
|
+
import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
|
|
16
17
|
import { runOpinionFanout } from "./opinionFanout.ts";
|
|
17
18
|
import { repoOpinionDisplayPath, saveRepoOpinionFile } from "./opinionFile.ts";
|
|
18
19
|
import { collectOpinionOutcomes, formatOpinionsMarkdown } from "./opinionResults.ts";
|
|
@@ -45,15 +46,7 @@ export async function runInteractiveOpinion(
|
|
|
45
46
|
installShippedAgents();
|
|
46
47
|
const session: CancelSession = { title: "Opinion agents", run: undefined, overlayOpen: false };
|
|
47
48
|
host.setActiveCancelSession(session);
|
|
48
|
-
const unsubscribeEsc = ctx
|
|
49
|
-
? ctx.ui.onTerminalInput((data) => {
|
|
50
|
-
if (isKeyRelease(data) || !matchesKey(data, Key.escape)) return undefined;
|
|
51
|
-
if (host.getActiveObserveSession()?.overlayOpen) return undefined;
|
|
52
|
-
if (session.overlayOpen) return undefined;
|
|
53
|
-
if (!session.run) return undefined;
|
|
54
|
-
return host.openCancelOverlayIfActive(ctx) ? { consume: true } : undefined;
|
|
55
|
-
})
|
|
56
|
-
: undefined;
|
|
49
|
+
const unsubscribeEsc = subscribeCancelOverlayOnEsc(ctx, host, session);
|
|
57
50
|
|
|
58
51
|
try {
|
|
59
52
|
let prefill = initialPrompt?.trim() ?? "";
|
|
@@ -83,7 +76,11 @@ export async function runInteractiveOpinion(
|
|
|
83
76
|
thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections },
|
|
84
77
|
});
|
|
85
78
|
|
|
86
|
-
const slug = await
|
|
79
|
+
const slug = await withWorkingOverlay(
|
|
80
|
+
ctx,
|
|
81
|
+
planNamingOverlay("opinion", () => slugifyPlanName(question)),
|
|
82
|
+
(signal) => summarizePlanPromptName(ctx, question, { signal }),
|
|
83
|
+
);
|
|
87
84
|
try {
|
|
88
85
|
saveRepoOpinionFile(question, ctx.cwd, slug, "opinion-prompt");
|
|
89
86
|
} catch {
|
|
@@ -114,7 +111,7 @@ export async function runInteractiveOpinion(
|
|
|
114
111
|
host.setRunningProgressWidget(widget);
|
|
115
112
|
let outcome;
|
|
116
113
|
try {
|
|
117
|
-
outcome = await runOpinionFanout({ host, ctx, question, models, thinking, session, widget, agents });
|
|
114
|
+
outcome = await runOpinionFanout({ host, ctx, question, models, thinking, session, widget, agents, maxConcurrency: settings.maxConcurrentAgents });
|
|
118
115
|
} finally {
|
|
119
116
|
host.setRunningProgressWidget(undefined);
|
|
120
117
|
widget.stopWidget();
|
|
@@ -139,6 +136,10 @@ export async function runInteractiveOpinion(
|
|
|
139
136
|
} catch {
|
|
140
137
|
saved = false;
|
|
141
138
|
}
|
|
139
|
+
// `sendUserMessage` routes to prompt() and ignores triggerTurn (only
|
|
140
|
+
// sendCustomMessage honors it), so this report does start a model turn.
|
|
141
|
+
// Genuinely append-only delivery needs pi.sendMessage({ customType, … },
|
|
142
|
+
// { triggerTurn: false }) plus a message renderer — a UX change, not made here.
|
|
142
143
|
const appendOnly: SendUserMessageOptions = { triggerTurn: false };
|
|
143
144
|
await pi.sendUserMessage(markdown, appendOnly);
|
|
144
145
|
const completed = outcomes.filter((item) => item.status === "done").length;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ContextEditEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { PLAN_MODE_CONTEXT_TYPE, VERIFICATION_PENDING_CONTEXT_TYPE } from "./tools/shared.ts";
|
|
4
|
+
|
|
5
|
+
/** Structural entry shape containing only fields read while scanning a branch. */
|
|
6
|
+
export interface ScannedEntry {
|
|
7
|
+
id?: string;
|
|
8
|
+
type: string;
|
|
9
|
+
customType?: string;
|
|
10
|
+
message?: { role?: string; content?: unknown };
|
|
11
|
+
targetId?: string;
|
|
12
|
+
replacement?: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isPlanModeInstructionText(content: unknown): boolean {
|
|
16
|
+
const isInstruction = (text: string): boolean =>
|
|
17
|
+
text.startsWith("[PLAN MODE ACTIVE]") || text.startsWith("[PLAN MODE RE-ENTRY]");
|
|
18
|
+
if (typeof content === "string") return isInstruction(content);
|
|
19
|
+
if (!Array.isArray(content)) return false;
|
|
20
|
+
return content.some((part) =>
|
|
21
|
+
part !== null
|
|
22
|
+
&& typeof part === "object"
|
|
23
|
+
&& "type" in part
|
|
24
|
+
&& part.type === "text"
|
|
25
|
+
&& "text" in part
|
|
26
|
+
&& typeof part.text === "string"
|
|
27
|
+
&& isInstruction(part.text));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function isPlanInstructionEntry(entry: ScannedEntry): boolean {
|
|
31
|
+
if (entry.type === "custom_message") return entry.customType === PLAN_MODE_CONTEXT_TYPE;
|
|
32
|
+
if (entry.type !== "message" || entry.message?.role !== "user") return false;
|
|
33
|
+
// The one-shot mf-plan-exit reminder remains governed by the per-request
|
|
34
|
+
// context filter; only reusable plan-mode instructions are omitted durably.
|
|
35
|
+
return isPlanModeInstructionText(entry.message.content);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isVerificationPendingEntry(entry: ScannedEntry): boolean {
|
|
39
|
+
return entry.type === "custom_message" && entry.customType === VERIFICATION_PENDING_CONTEXT_TYPE;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function collectOmissionDrafts(
|
|
43
|
+
entries: readonly ScannedEntry[],
|
|
44
|
+
predicate: (entry: ScannedEntry) => boolean,
|
|
45
|
+
alreadyOmitted: ReadonlySet<string>,
|
|
46
|
+
): ContextEditEntryDraft[] {
|
|
47
|
+
const omitted = new Set(alreadyOmitted);
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (entry.type === "context_edit" && entry.replacement === null && typeof entry.targetId === "string") {
|
|
50
|
+
omitted.add(entry.targetId);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return entries.flatMap((entry): ContextEditEntryDraft[] => {
|
|
54
|
+
if (typeof entry.id !== "string" || omitted.has(entry.id) || !predicate(entry)) return [];
|
|
55
|
+
return [{ type: "context_edit", targetId: entry.id, replacement: null }];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildVerificationPendingMessage(planFilePath: string | undefined): string {
|
|
60
|
+
const target = planFilePath ? ` for ${planFilePath}` : "";
|
|
61
|
+
return `The implementation turn for the approved plan${target} has finished. Independent read-only verification is pending and runs out of band. Treat the implementation as unverified until the verifier reports.`;
|
|
62
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
import type { MfPlanInfo } from "../moa/planInfo.ts";
|
|
4
4
|
import type { ImplementationHandoff } from "../moa/implementationRetry.ts";
|
|
5
|
+
import { PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
|
|
5
6
|
|
|
6
7
|
export interface PlanModeState {
|
|
7
8
|
enabled: boolean;
|
|
@@ -10,10 +11,26 @@ export interface PlanModeState {
|
|
|
10
11
|
needsExitReminder?: boolean;
|
|
11
12
|
moaInfo?: MfPlanInfo;
|
|
12
13
|
implementationHandoff?: ImplementationHandoff;
|
|
14
|
+
/** Active loadout captured when plan mode was entered, so exit restores it. */
|
|
15
|
+
toolsBeforePlanMode?: string[];
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export const MAX_PERSISTED_STATE_BYTES = 512 * 1024;
|
|
16
19
|
|
|
20
|
+
/**
|
|
21
|
+
* A snapshot containing plan-only tools was taken while plan mode was already
|
|
22
|
+
* live (pre-fix sessions, or a transcript-restored loadout). Restoring it would
|
|
23
|
+
* re-apply the read-only set after approval, so reject it and let the caller
|
|
24
|
+
* fall back to the registered loadout.
|
|
25
|
+
*/
|
|
26
|
+
export function validateToolLoadoutSnapshot(value: unknown): string[] | undefined {
|
|
27
|
+
if (!Array.isArray(value)) return undefined;
|
|
28
|
+
const names = value.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
29
|
+
if (names.length === 0) return undefined;
|
|
30
|
+
if (names.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))) return undefined;
|
|
31
|
+
return [...new Set(names)];
|
|
32
|
+
}
|
|
33
|
+
|
|
17
34
|
export function serializePlanModeState(state: PlanModeState): { state: PlanModeState; serialized: string } {
|
|
18
35
|
let persisted = state.moaInfo?.proposerPlans
|
|
19
36
|
? { ...state, moaInfo: { ...state.moaInfo, proposerPlans: undefined } }
|
package/src/planning/planFile.ts
CHANGED
|
@@ -131,20 +131,38 @@ export function slugifyPlanName(text: string): string {
|
|
|
131
131
|
return trySlugifyPlanName(text) ?? generateWordSlug();
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
134
|
+
export type RepoPlanFileKind = "plan-prompt" | "plan" | "spec" | "verification" | "criteria" | "verification-handoff";
|
|
135
|
+
|
|
136
|
+
function repoPlanSuffix(kind: RepoPlanFileKind): string {
|
|
137
|
+
return `__${kind}`;
|
|
137
138
|
}
|
|
138
139
|
|
|
139
|
-
export
|
|
140
|
+
export function isApprovedRepoPlanFilename(file: string): boolean {
|
|
141
|
+
return file.endsWith("__plan.md");
|
|
142
|
+
}
|
|
140
143
|
|
|
141
|
-
function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
export function repoPlanDisplayPath(baseSlug: string, kind: RepoPlanFileKind): string {
|
|
145
|
+
if (!isValidPlanSlug(baseSlug)) throw new Error("Invalid repository plan slug");
|
|
146
|
+
return `${CONFIG_DIR_NAME}/mf-plan/${baseSlug}${repoPlanSuffix(kind)}.md`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function nextFreeRepoPlanSlug(repoCwd: string, baseSlug: string, kind: RepoPlanFileKind): string {
|
|
150
|
+
if (!isValidPlanSlug(baseSlug)) throw new Error("Invalid repository plan slug");
|
|
151
|
+
const planDir = getRepoPlanDirectory(repoCwd);
|
|
152
|
+
if (!fs.existsSync(path.join(planDir, `${baseSlug}${repoPlanSuffix(kind)}.md`))) return baseSlug;
|
|
153
|
+
|
|
154
|
+
for (let suffix = 2; suffix <= 20; suffix++) {
|
|
155
|
+
const suffixText = `-${suffix}`;
|
|
156
|
+
const trimmedBase = baseSlug
|
|
157
|
+
.slice(0, MAX_PLAN_SLUG_LENGTH - suffixText.length)
|
|
158
|
+
.replace(/-+$/g, "");
|
|
159
|
+
const candidate = `${trimmedBase}${suffixText}`;
|
|
160
|
+
if (
|
|
161
|
+
isValidPlanSlug(candidate)
|
|
162
|
+
&& !fs.existsSync(path.join(planDir, `${candidate}${repoPlanSuffix(kind)}.md`))
|
|
163
|
+
) return candidate;
|
|
164
|
+
}
|
|
165
|
+
return generateWordSlug();
|
|
148
166
|
}
|
|
149
167
|
|
|
150
168
|
/** Ensure `<CONFIG_DIR_NAME>/mf-plan/` exists in the given repo directory. */
|
|
@@ -158,7 +176,7 @@ export function getRepoPlanDirectory(repoCwd: string): string {
|
|
|
158
176
|
return planDir;
|
|
159
177
|
}
|
|
160
178
|
|
|
161
|
-
/** Save content to `<CONFIG_DIR_NAME>/mf-plan/<slug>
|
|
179
|
+
/** Save content to `<CONFIG_DIR_NAME>/mf-plan/<slug>__<kind>.md` for the given kind. Returns the file path. */
|
|
162
180
|
export function saveRepoPlanFile(
|
|
163
181
|
content: string,
|
|
164
182
|
repoCwd: string,
|