@underactive/pi-topping-moa-fusion 0.2.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 CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0]
6
+
7
+ ### Added
8
+
9
+ - Added `/mf-spec`, an in-process requirements-clarification flow that asks up to five focused questions, reviews and saves approved `__spec.md` planning briefs, and prepares a `/mf-plan` handoff without exposing specs to `/mf-plan-implement`.
10
+ - A hidden `mf-plan-verification-pending` session marker now records when an approved implementation is expected to enter independent verification. Resuming before a verifier result is recorded warns that the implementation is still unverified.
11
+
12
+ ### Changed
13
+
14
+ - Updated all four pi development dependencies from 0.86.0 to 0.87.0.
15
+ - The pi 0.87 settle-boundary context-edit and verification-marker integrations are capability-gated and remain inert on older hosts, which retain the existing request-time filtering and verification dispatch behavior.
16
+
17
+ ### Fixed
18
+
19
+ - An assigned slot in the MoA Fusion Pre-flight overview can now be cleared back to `(none)` with Backspace or Delete. Previously a slot could only be reassigned to another model, never emptied.
20
+ - Reusable `[PLAN MODE ACTIVE]` and `[PLAN MODE RE-ENTRY]` instruction entries are now durably omitted with settle-boundary null context edits after plan-mode exit, preventing later compaction from carrying stale plan instructions into implementation.
21
+ - Custom OpenAI-compatible endpoints used by proposers and verifiers now benefit from pi 0.87's upstream strict-schema capability fix: unknown endpoints no longer receive strict tool schemas unless they explicitly advertise support.
22
+ - Saving or deleting an agent roster now takes effect immediately, without requiring a second save from the main settings overlay.
23
+ - A failed agent's error is now readable in full without widening the terminal. Provider failures were cut to 80 characters before reaching the progress table and then truncated again at the ACTIVITY column edge, hiding the part naming the cause. The message is now kept whole and word-wrapped beneath its row — breaking the JSON payload mid-token when it has no spaces to break on — while a short error stays inline as before.
24
+
5
25
  ## [0.2.0]
6
26
 
7
27
  ### Added
package/README.md CHANGED
@@ -16,6 +16,7 @@ A Pi extension built around **Mixture of Agents (MoA)** planning: several models
16
16
  - [The MoA Fusion table](#the-moa-fusion-table)
17
17
  - [Opinions (`/mf-opinion`)](#opinions-mf-opinion)
18
18
  - [Debates (`/mf-debate`)](#debates-mf-debate)
19
+ - [Specs (`/mf-spec`)](#specs-mf-spec)
19
20
  - [Commands](#commands)
20
21
  - [Cancelling running subagents](#cancelling-running-subagents)
21
22
  - [Plan Mode](#plan-mode)
@@ -74,7 +75,7 @@ Fanning out a coding task and merging the resulting token streams does not work
74
75
  ### How a run works
75
76
 
76
77
  1. After you submit your plan prompt, a picker opens on a mode screen (**Mixture of Agents** or **Single model**). Single model — or Esc — runs the 5-phase workflow on the current model.
77
- 2. MoA opens the **MoA Fusion Pre-flight** overview with all eight slots — five proposer slots plus a synthesizer, an implementer, and a verifier — starting empty at `(none)`, plus a **Load Roster** row above **Start fan-out** that applies a saved agent roster to every slot at once (see [Agent rosters](#agent-rosters)). Selecting any slot row opens a two-pane model/thinking picker for that one slot and returns to the overview; cancelling leaves the slot unchanged. **Start fan-out** stays disabled until you assign at least two proposer slots and all three required roles (the minimum roster; the same model may fill several slots, and the extra proposer slots are optional). Thinking levels come from the model's pi registry metadata, which is authoritative for every model the registry lists; the generic `off`/`low`/`medium`/`high` list applies only to models it doesn't. Your saved choices and active model only pre-highlight a slot's picker — they never count as assignments until you confirm them — and confirmed selections are remembered in `~/.pi/agent/mf-plan/settings.json`.
78
+ 2. MoA opens the **MoA Fusion Pre-flight** overview with all eight slots — five proposer slots plus a synthesizer, an implementer, and a verifier — starting empty at `(none)`, plus a **Load Roster** row above **Start fan-out** that applies a saved agent roster to every slot at once (see [Agent rosters](#agent-rosters)). Selecting any slot row opens a two-pane model/thinking picker for that one slot and returns to the overview; cancelling leaves the slot unchanged, and Backspace (or Delete) on a slot row clears it back to `(none)`. **Start fan-out** stays disabled until you assign at least two proposer slots and all three required roles (the minimum roster; the same model may fill several slots, and the extra proposer slots are optional). Thinking levels come from the model's pi registry metadata, which is authoritative for every model the registry lists; the generic `off`/`low`/`medium`/`high` list applies only to models it doesn't. Your saved choices and active model only pre-highlight a slot's picker — they never count as assignments until you confirm them — and confirmed selections are remembered in `~/.pi/agent/mf-plan/settings.json`.
78
79
  3. Proposers run in parallel as isolated `moa-proposer` subprocesses. Each ends with an "Open Questions / Assumptions" section rather than pausing — proposers never block waiting on input.
79
80
  4. `moa-synthesizer` reads every successful proposal (at least 1 must succeed) plus the original request, and writes one merged plan.
80
81
  5. If the proposers substantively disagreed, a conflict-review overlay opens first: one tab per conflict, plain-language options, the synthesized pick tagged **(Recommended)**, and a **Chat about this** box for free-text feedback. Your resolutions are fed back for re-synthesis. Esc cancels the whole run.
@@ -152,6 +153,19 @@ During the run, `F3` opens the live observer and `Esc` or `F4` opens cancellatio
152
153
 
153
154
  During the run, `F3` opens the live observer and `Esc` or `F4` opens cancellation controls. The prompt and combined transcript are saved under `.pi/mf-debate/` as `<slug>__debate-prompt.md` and `<slug>__debate.md`.
154
155
 
156
+ ### Specs (`/mf-spec`)
157
+
158
+ `/mf-spec` follows a separate requirements workflow: rough request → clarification → approved planning brief → a user-started `/mf-plan`.
159
+
160
+ 1. Enter a request directly (`/mf-spec add dark mode`) or use the prompt editor.
161
+ 2. It asks up to five adaptive clarifying questions, stopping early when it has enough context. Each question names the area it affects: scope, UX, architecture, acceptance criteria, or testing.
162
+ 3. Press **Esc** during clarification to choose whether to draft from the answers so far or cancel; cancelling saves nothing.
163
+ 4. Review the draft and approve and save it, edit it directly, or revise it with feedback. Nothing is written before approval.
164
+ 5. The approved brief is saved as `.pi/mf-plan/<slug>__spec.md`. `/mf-plan-implement` ignores these files because they are specifications, not approved `__plan.md` implementation plans.
165
+ 6. After saving, a ready-to-run `/mf-plan …` handoff is placed in the input box when it is empty; review it and press Enter to start planning. Existing input is never overwritten, and `/mf-spec` refuses to run while plan mode is active.
166
+
167
+ Clarification and drafting run in-process on the active session model with no tools or repository access. Repo-grounded planning begins only when you run the prepared `/mf-plan` command.
168
+
155
169
  ## Commands
156
170
 
157
171
  | Command | Shortcut | Description |
@@ -159,6 +173,7 @@ During the run, `F3` opens the live observer and `Esc` or `F4` opens cancellatio
159
173
  | `/mf-plan` | — | Toggle plan mode on/off |
160
174
  | `/mf-opinion` | — | Ask 1–5 models for independent read-only opinions about the repository |
161
175
  | `/mf-debate` | — | Run a read-only multi-round debate between up to 5 models |
176
+ | `/mf-spec` | — | Clarify a rough request into an approved planning brief and prepare a `/mf-plan` handoff |
162
177
  | `/mf-preview` | — | Toggle the inline/live preview of streamed agent output |
163
178
  | `/mf-plan-settings` | — | Configure the explore and cheap/fast agents, named agent rosters for the MoA roles, plan options, and max concurrent agents |
164
179
  | `/mf-plan-implement` | — | Retry implementation of the last approved MoA plan, optionally with a different model |
@@ -272,7 +287,7 @@ Rosters persist in `~/.pi/agent/mf-plan/settings.json`:
272
287
  }
273
288
  ```
274
289
 
275
- Names are 1–24 alphanumeric characters, unique case-insensitively, up to 20 rosters. The roster manager stages every edit in memory — nothing is written until the settings overlay's **Save and Close**.
290
+ Names are 1–24 alphanumeric characters, unique case-insensitively, up to 20 rosters. Slot edits remain drafts until **Save roster** is selected; that action persists the completed roster immediately, without requiring **Save and Close** in the parent settings overlay.
276
291
 
277
292
  #### Max concurrent agents
278
293
 
@@ -328,7 +343,7 @@ The slug is a generated adjective-adjective-noun triple (e.g. `happy-mellifluous
328
343
 
329
344
  ## Non-Interactive Behavior
330
345
 
331
- In non-interactive modes (`pi -p`, `--mode json`), `exit_plan_mode` is rejected unless `MOA_PLAN_AUTO_APPROVE=1` is set. With that explicit opt-in, it exits plan mode and hands the plan back without prompting. The plan file is still written to disk.
346
+ In non-interactive modes (`pi -p`, `--mode json`), `exit_plan_mode` is rejected unless `MOA_PLAN_AUTO_APPROVE=1` is set. With that explicit opt-in, it exits plan mode and hands the plan back without prompting. The plan file is still written to disk. `/mf-spec <request>` likewise requires `MOA_PLAN_AUTO_APPROVE=1`; with it, the command skips questions and review, generates and saves the brief, and prints the `/mf-plan` handoff, while without it no model call or file write occurs.
332
347
 
333
348
  ## How It Works (Architecture)
334
349
 
@@ -370,6 +385,9 @@ src/
370
385
  │ ├── debateRounds.ts # Survivor/early-stop/next-round bookkeeping
371
386
  │ ├── debateResults.ts # Outcome collection and debate transcript markdown
372
387
  │ └── debateFile.ts # Repo-local debate artifacts
388
+ ├── spec/
389
+ │ ├── specContract.ts # Clarification/brief contracts, parsing, and handoff helpers
390
+ │ └── runSpec.ts # Interactive/headless /mf-spec state machine
373
391
  ├── planning/
374
392
  │ ├── planMode.ts # Plan-mode state transitions and lifecycle handlers
375
393
  │ ├── modeState.ts # Persisted state shape, byte cap, append deduplication
@@ -437,7 +455,7 @@ agents/ # Shipped agent definitions
437
455
  - **Custom `write_plan` tool** instead of path-guarded `edit`/`write` — guarantees the single-writable-file invariant.
438
456
  - **Subprocess subagents** (not in-process) — each agent runs in an isolated `pi --mode json -p --no-session` subprocess with an enforced read-only tool allowlist.
439
457
  - **State via `appendEntry`** — plan mode state (enabled, slug, tools snapshot) persists in the session for `/resume` and `/reload`.
440
- - **Context injection via `before_agent_start`** — the 5-phase instructions are injected as a user message every turn while plan mode is active; the `context` hook strips stale plan-mode messages once it is off.
458
+ - **Context injection and lifecycle cleanup via `before_agent_start` + `context`** — the 5-phase instructions are injected as a user message every turn while plan mode is active, and the `context` hook strips stale plan-mode messages once it is off. On pi 0.87+, the settle boundary also persists null context edits for reusable plan-instruction entries once plan mode is off, so later compaction cannot carry those instructions forward. When an approved implementation is expected to enter verification, the same boundary persists a hidden `mf-plan-verification-pending` marker so a resumed session can warn that verification is still outstanding.
441
459
  - **MoA proposers never pause mid-run** — subprocess subagents are one-shot, so a proposer that hits an ambiguity records it as an assumption and keeps going. Only the synthesizer, which sees every proposal, asks the user anything — keeping fan-out fully parallel.
442
460
 
443
461
  ## Porting Notes
@@ -453,6 +471,7 @@ Ported from Claude Code's plan mode, adapted to pi's extension primitives:
453
471
  | `FileEditTool`/`FileWriteTool` guarded | Custom `write_plan` tool | Safer single-writable-file invariant |
454
472
  | Subscription-tier agent counts | Hardcoded constants (3/1) | Bumpable via code change |
455
473
  | Plan-length A/B experiment | Standard Phase 4 | Experiment noise skipped |
474
+ | Spec Kit / brainstorming | `/mf-spec` | Requirements clarification only |
456
475
  | Interview phase variant | Standard 5-phase workflow | Variant skipped |
457
476
 
458
477
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-moa-fusion",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension implementing /mf-plan — Claude Code-style 5-phase plan mode with a Mixture-of-Agents proposer/synthesizer pipeline.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -50,10 +50,10 @@
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "0.86.0",
54
- "@earendil-works/pi-ai": "0.86.0",
55
- "@earendil-works/pi-coding-agent": "0.86.0",
56
- "@earendil-works/pi-tui": "0.86.0",
53
+ "@earendil-works/pi-agent-core": "0.87.0",
54
+ "@earendil-works/pi-ai": "0.87.0",
55
+ "@earendil-works/pi-coding-agent": "0.87.0",
56
+ "@earendil-works/pi-tui": "0.87.0",
57
57
  "@types/node": "^24.0.0",
58
58
  "typebox": "^1.3.3",
59
59
  "typescript": "^5.8.0"
@@ -4,7 +4,7 @@ import type { AgentConfig } from "../agents/discovery.ts";
4
4
  import { modelExtensionOptions, resolveContextWindow, resolveModelCost } from "../moa/modelRuntime.ts";
5
5
  import type { CancelSession } from "../runtime/cancelRun.ts";
6
6
  import { CancelRun } from "../runtime/cancelRun.ts";
7
- import { getFinalOutput, getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
7
+ import { getFinalOutput, getResultOutput, isFailedResult, statusDetail, type SingleResult } from "../runtime/results.ts";
8
8
  import { runParallelAgentsWithModels, type ModelParallelAgentTask } from "../runtime/runner.ts";
9
9
  import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
10
10
  import { activityLoopCount } from "../ui/agentStatus.ts";
@@ -117,7 +117,7 @@ export async function runDebateRounds(options: DebateFanoutOptions): Promise<Deb
117
117
  if (observed) observed.state = "cancelled";
118
118
  } else if (isFailedResult(result)) {
119
119
  currentRun.settle(batchIndex, "error");
120
- widget.update(debateIndex, "error", getResultOutput(result).slice(0, 80));
120
+ widget.update(debateIndex, "error", statusDetail(getResultOutput(result)));
121
121
  if (observed) observed.state = "error";
122
122
  } else {
123
123
  currentRun.settle(batchIndex, "done");
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  } from "./moa/implementationRetry.ts";
13
13
  import { modelRefLabel, TRIGGER_TURN } from "./shared/modelRefs.ts";
14
14
  import { loadMoaConfig } from "./config/settings.ts";
15
- import { getPlanFilePath, getRepoPlanDirectory, isValidPlanSlug, readRepoPlanFile, saveRepoPlanFile } from "./planning/planFile.ts";
15
+ import { getPlanFilePath, getRepoPlanDirectory, isApprovedRepoPlanFilename, isValidPlanSlug, readRepoPlanFile, saveRepoPlanFile } from "./planning/planFile.ts";
16
16
  import { showImplementingModelPicker } from "./ui/moaModelPicker.ts";
17
17
  import { showPlanReview } from "./ui/planReviewOverlay.ts";
18
18
  import { showMoaSetup } from "./ui/moaSetupOverlay.ts";
@@ -20,6 +20,7 @@ import { runCriteriaGeneration } from "./moa/verificationCriteria.ts";
20
20
  import { stripSynthSections } from "./moa/verdicts.ts";
21
21
  import { runInteractiveOpinion, type OpinionHost } from "./opinion/runOpinion.ts";
22
22
  import { runInteractiveDebate } from "./debate/runDebate.ts";
23
+ import { createSpecRunner } from "./spec/runSpec.ts";
23
24
  import { createPlanModeController } from "./planning/planMode.ts";
24
25
  import { registerEnterPlanModeTool, runInteractivePlanMode } from "./planning/tools/enterPlanMode.ts";
25
26
  import { registerExitPlanModeTool } from "./planning/tools/exitPlanMode.ts";
@@ -38,6 +39,7 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
38
39
  setRunningProgressWidget: controller.moaRunHost.setRunningProgressWidget,
39
40
  };
40
41
  const debateHost = opinionHost;
42
+ const runSpec = createSpecRunner(controller);
41
43
  const togglePlanMode = async (ctx: Parameters<typeof controller.exitPlanMode>[0], prompt?: string): Promise<void> => {
42
44
  if (controller.questionnaireBusy(ctx)) return;
43
45
  if (controller.getActiveCancelSession()?.run) {
@@ -69,6 +71,10 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
69
71
  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
72
  handler: async (args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await runInteractiveDebate(pi, debateHost, ctx, args.trim() || undefined); },
71
73
  });
74
+ pi.registerCommand("mf-spec", {
75
+ description: "Clarify a rough request into an approved planning brief for /mf-plan — append the request (e.g. /mf-spec add dark mode) to skip the editor",
76
+ handler: async (args, ctx) => runSpec(ctx, args.trim() || undefined),
77
+ });
72
78
  pi.registerCommand("mf-plan-clear", {
73
79
  description: "Clear the completed plan state so /mf-plan starts a fresh planning round (approved plans stay in .pi/mf-plan/)",
74
80
  handler: async (_args, ctx) => { if (controller.questionnaireBusy(ctx)) return; await controller.clearCompletedPlan(ctx); },
@@ -104,7 +110,7 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
104
110
  if (fs.existsSync(repoDir)) {
105
111
  const entries = fs.readdirSync(repoDir);
106
112
  planFiles = entries
107
- .filter((file) => file.endsWith("__plan.md") && !file.endsWith("__plan-prompt.md"))
113
+ .filter(isApprovedRepoPlanFilename)
108
114
  .map((file) => {
109
115
  const slug = file.slice(0, -"__plan.md".length);
110
116
  const fullPath = path.join(repoDir, file);
@@ -270,6 +276,10 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
270
276
  registerMfPlanSubagentTool(pi, controller);
271
277
  registerExitPlanModeTool(pi, controller);
272
278
 
279
+ // Deliberately not context_with_system (pi 0.87.0): its result is sent
280
+ // verbatim, so a filtering mistake could drop pi's prompt and tool declarations.
281
+ // Keep injection in before_agent_start, filtering in context, and durable
282
+ // omissions in agent_before_settle so pi continues to own prompt/tool state.
273
283
  pi.on("context", controller.onContext);
274
284
  pi.on("before_agent_start", controller.onBeforeAgentStart);
275
285
  pi.on("agent_start", controller.onAgentStart);
@@ -277,6 +287,7 @@ export default function mfPlanExtension(pi: ExtensionAPI): void {
277
287
  pi.on("message_update", controller.onMessageUpdate);
278
288
  pi.on("tool_execution_start", controller.onToolExecutionStart);
279
289
  pi.on("message_end", controller.onMessageEnd);
290
+ pi.on("agent_before_settle", controller.onAgentBeforeSettle);
280
291
  pi.on("agent_settled", controller.onAgentSettled);
281
292
  pi.on("session_start", controller.onSessionStart);
282
293
  pi.on("session_shutdown", controller.onSessionShutdown);
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { AgentConfig } from "../agents/discovery.ts";
4
4
  import type { CancelRun, CancelSession } from "../runtime/cancelRun.ts";
5
- import { getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
5
+ import { getResultOutput, isFailedResult, statusDetail, type SingleResult } from "../runtime/results.ts";
6
6
  import { runParallelAgentsWithModels, type ModelParallelAgentTask } from "../runtime/runner.ts";
7
7
  import type { ModelRef } from "../shared/modelRefs.ts";
8
8
  import type { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
@@ -55,7 +55,7 @@ export async function runWidgetFanout(options: WidgetFanoutOptions): Promise<Sin
55
55
  if (observed) observed.state = "cancelled";
56
56
  } else if (isFailedResult(result)) {
57
57
  run.settle(index, "error");
58
- widget.update(widgetIndex, "error", getResultOutput(result).slice(0, 80));
58
+ widget.update(widgetIndex, "error", statusDetail(getResultOutput(result)));
59
59
  if (observed) observed.state = "error";
60
60
  } else {
61
61
  run.settle(index, "done");
@@ -24,13 +24,13 @@
24
24
  * by hand.
25
25
  */
26
26
 
27
- import { CONFIG_DIR_NAME, type ExtensionContext } from "@earendil-works/pi-coding-agent";
27
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
28
28
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
29
29
 
30
30
  import { loadMoaConfig } from "../config/settings.ts";
31
31
  import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
32
32
  import { discoverAgents } from "../agents/discovery.ts";
33
- import { readRepoPlanFile, saveRepoPlanFile } from "../planning/planFile.ts";
33
+ import { readRepoPlanFile, repoPlanDisplayPath, saveRepoPlanFile } from "../planning/planFile.ts";
34
34
  import { CancelRun, type CancelSession } from "../runtime/cancelRun.ts";
35
35
  import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
36
36
  import { getFinalOutput, getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
@@ -762,7 +762,7 @@ export async function runImplementationVerification(
762
762
  : outcome.output;
763
763
  try {
764
764
  saveRepoPlanFile(verificationOutput, ctx.cwd, host.getPlanRepoSlug()!, "verification");
765
- verificationReportPath = `${CONFIG_DIR_NAME}/mf-plan/${host.getPlanRepoSlug()!}__verification.md`;
765
+ verificationReportPath = repoPlanDisplayPath(host.getPlanRepoSlug()!, "verification");
766
766
  } catch {
767
767
  // Persisting the report is best-effort; verification proceeds regardless.
768
768
  }
@@ -861,14 +861,14 @@ export async function runImplementationVerification(
861
861
  // Only point at the checklist file when one is actually on disk;
862
862
  // criteria can come from the handoff alone.
863
863
  criteriaFilePath: readRepoPlanFile(ctx.cwd, repoPlanSlug, "criteria")
864
- ? `${CONFIG_DIR_NAME}/mf-plan/${repoPlanSlug}__criteria.md`
864
+ ? repoPlanDisplayPath(repoPlanSlug, "criteria")
865
865
  : undefined,
866
866
  }),
867
867
  ctx.cwd,
868
868
  repoPlanSlug,
869
869
  "verification-handoff",
870
870
  );
871
- handoffPath = `${CONFIG_DIR_NAME}/mf-plan/${repoPlanSlug}__verification-handoff.md`;
871
+ handoffPath = repoPlanDisplayPath(repoPlanSlug, "verification-handoff");
872
872
  } catch {
873
873
  // Persisting the handoff is best-effort; the exhaustion notification still fires.
874
874
  }
@@ -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
+ }
@@ -131,21 +131,38 @@ export function slugifyPlanName(text: string): string {
131
131
  return trySlugifyPlanName(text) ?? generateWordSlug();
132
132
  }
133
133
 
134
- /** Fallback slug when LLM summarization is unavailable. */
135
- export function fallbackPlanName(): string {
136
- return generateWordSlug();
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 type RepoPlanFileKind = "plan-prompt" | "plan" | "verification" | "criteria" | "verification-handoff";
140
+ export function isApprovedRepoPlanFilename(file: string): boolean {
141
+ return file.endsWith("__plan.md");
142
+ }
140
143
 
141
- function repoPlanSuffix(kind: RepoPlanFileKind): string {
142
- // `__verification` and `__criteria` never collide with the `/mf-plan-implement`
143
- // scan, which matches `__plan.md` while excluding `__plan-prompt.md`.
144
- if (kind === "plan-prompt") return "__plan-prompt";
145
- if (kind === "verification") return "__verification";
146
- if (kind === "criteria") return "__criteria";
147
- if (kind === "verification-handoff") return "__verification-handoff";
148
- return "__plan";
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();
149
166
  }
150
167
 
151
168
  /** Ensure `<CONFIG_DIR_NAME>/mf-plan/` exists in the given repo directory. */
@@ -159,7 +176,7 @@ export function getRepoPlanDirectory(repoCwd: string): string {
159
176
  return planDir;
160
177
  }
161
178
 
162
- /** Save content to `<CONFIG_DIR_NAME>/mf-plan/<slug>__plan-prompt.md` or `<slug>__plan.md`. Returns the file path. */
179
+ /** Save content to `<CONFIG_DIR_NAME>/mf-plan/<slug>__<kind>.md` for the given kind. Returns the file path. */
163
180
  export function saveRepoPlanFile(
164
181
  content: string,
165
182
  repoCwd: string,
@@ -1,7 +1,8 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { Message, TextContent } from "@earendil-works/pi-ai";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
3
  import { Key } from "@earendil-works/pi-tui";
4
4
  import type {
5
+ AgentBeforeSettleEvent,
5
6
  AgentSettledEvent,
6
7
  AgentStartEvent,
7
8
  ExtensionAPI,
@@ -9,6 +10,7 @@ import type {
9
10
  MessageEndEvent,
10
11
  MessageStartEvent,
11
12
  MessageUpdateEvent,
13
+ SessionBoundaryDraft,
12
14
  ThemeColor,
13
15
  ToolExecutionStartEvent,
14
16
  } from "@earendil-works/pi-coding-agent";
@@ -39,10 +41,18 @@ import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
39
41
  import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
40
42
  import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
41
43
  import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
44
+ import {
45
+ buildVerificationPendingMessage,
46
+ collectOmissionDrafts,
47
+ isPlanInstructionEntry,
48
+ isPlanModeInstructionText,
49
+ isVerificationPendingEntry,
50
+ type ScannedEntry,
51
+ } from "./contextEdits.ts";
42
52
  import { buildPlanModeExitInstructions, buildPlanModeInstructions, buildPlanModeReentryInstructions } from "./instructions.ts";
43
53
  import { latestPlanModeStateEntry, PlanModeStatePersistence, validateToolLoadoutSnapshot } from "./modeState.ts";
44
54
  import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug, slugifyPlanName } from "./planFile.ts";
45
- import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
55
+ import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS, VERIFICATION_PENDING_CONTEXT_TYPE } from "./tools/shared.ts";
46
56
 
47
57
  /** Trailing chars of the implementer report kept for the verifier's evidence. */
48
58
  const IMPLEMENTATION_REPORT_MAX_CHARS = 8000;
@@ -86,6 +96,17 @@ function extractAssistantText(content: unknown): string {
86
96
  return "";
87
97
  }
88
98
 
99
+ // Existing tests and stale post-reload contexts may not expose getBranch;
100
+ // boundary handlers should become inert rather than throw in that case.
101
+ function readBranchEntries(ctx: ExtensionContext): readonly ScannedEntry[] {
102
+ try {
103
+ const manager = ctx.sessionManager as { getBranch?: () => readonly ScannedEntry[] };
104
+ return typeof manager.getBranch === "function" ? manager.getBranch() : [];
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
89
110
  export function createPlanModeController(pi: ExtensionAPI) {
90
111
  let planModeEnabled = false;
91
112
  let toolsBeforePlanMode: string[] | undefined;
@@ -99,6 +120,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
99
120
  let activeRunMoaInfo: MfPlanInfo | undefined;
100
121
  let implementationHandoff: ImplementationHandoff | undefined;
101
122
  let implementationPending = false;
123
+ let verificationInFlight = false;
124
+ const omittedEntryIds = new Set<string>();
102
125
  let lastImplementationStopReason: string | undefined;
103
126
  // The implementer's final assistant text, captured while pending so the
104
127
  // verifier can be handed the implementer's own (untrusted) report. Capped to
@@ -548,6 +571,63 @@ export function createPlanModeController(pi: ExtensionAPI) {
548
571
  activeProgressWidget?.updateRoleTranscript("Implement", implementationMessages);
549
572
  }
550
573
  },
574
+ onAgentBeforeSettle: (event: AgentBeforeSettleEvent, ctx: ExtensionContext) => {
575
+ try {
576
+ const branch = readBranchEntries(ctx);
577
+ const persistedOmissions = new Set(
578
+ branch
579
+ .filter((entry) => entry.type === "context_edit" && entry.replacement === null && typeof entry.targetId === "string")
580
+ .map((entry) => entry.targetId as string),
581
+ );
582
+ // A different extension can invalidate the combined boundary proposal.
583
+ // Forget locally proposed edits that did not reach the branch so they are
584
+ // retried rather than suppressed for the rest of this process.
585
+ for (const id of omittedEntryIds) {
586
+ if (!persistedOmissions.has(id)) omittedEntryIds.delete(id);
587
+ }
588
+ const drafts: SessionBoundaryDraft[] = [];
589
+
590
+ // Persist plan-instruction omissions after plan mode exits. This is
591
+ // additive to onContext's per-request filter and repairs older sessions.
592
+ if (!planModeEnabled) {
593
+ drafts.push(...collectOmissionDrafts(branch, isPlanInstructionEntry, omittedEntryIds));
594
+ }
595
+ if (!verificationInFlight) {
596
+ drafts.push(...collectOmissionDrafts(branch, isVerificationPendingEntry, omittedEntryIds));
597
+ }
598
+
599
+ const stopReason = lastImplementationStopReason;
600
+ // KEEP IN LOCKSTEP with onAgentSettled's verification branch below.
601
+ // exit_plan_mode marks implementation pending before the follow-up has
602
+ // run, so implementationTurns distinguishes that planning boundary.
603
+ const verificationWillDispatch = implementationPending
604
+ && implementationTurns > 0
605
+ && event.context.pendingMessages.length === 0
606
+ && event.outcome !== "aborted"
607
+ && event.outcome !== "error"
608
+ && !implementationTurnFailed(stopReason)
609
+ && stopReason !== "aborted"
610
+ && implementationHandoff !== undefined
611
+ && (implementationHandoff.verifier ?? loadMoaConfig().verifier) !== undefined;
612
+ if (verificationWillDispatch) {
613
+ drafts.push({
614
+ type: "custom_message",
615
+ customType: VERIFICATION_PENDING_CONTEXT_TYPE,
616
+ content: buildVerificationPendingMessage(implementationHandoff?.planFilePath),
617
+ display: false,
618
+ });
619
+ }
620
+
621
+ if (drafts.length === 0) return undefined;
622
+ for (const draft of drafts) {
623
+ if (draft.type === "context_edit") omittedEntryIds.add(draft.targetId);
624
+ }
625
+ return { entries: [...event.entries, ...drafts] };
626
+ } catch {
627
+ // A thrown boundary handler could reject every proposed entry.
628
+ return undefined;
629
+ }
630
+ },
551
631
  onAgentSettled: (_event: AgentSettledEvent, ctx: ExtensionContext) => {
552
632
  updateStatus(ctx);
553
633
  if (!implementationPending) return;
@@ -595,10 +675,13 @@ export function createPlanModeController(pi: ExtensionAPI) {
595
675
  // explicit guard is required before the verification branch.
596
676
  if (stopReason === "aborted") return;
597
677
  if (!implementationHandoff) return;
678
+ // KEEP IN LOCKSTEP with verificationWillDispatch in onAgentBeforeSettle.
598
679
  const verifier = implementationHandoff.verifier ?? loadMoaConfig().verifier;
599
680
  if (verifier) {
681
+ verificationInFlight = true;
600
682
  void runImplementationVerification(ctx, moaRunHost, { ...implementationHandoff, verifier }, report)
601
- .catch(reportFlowFailure("Verification"));
683
+ .catch(reportFlowFailure("Verification"))
684
+ .finally(() => { verificationInFlight = false; });
602
685
  }
603
686
  },
604
687
  onContext: async (event: { messages: AgentMessage[] }) => {
@@ -607,9 +690,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
607
690
  const msg = message as AgentMessage & { customType?: string };
608
691
  if (msg.customType === PLAN_MODE_CONTEXT_TYPE || msg.customType === PLAN_EXIT_CONTEXT_TYPE) return false;
609
692
  if (msg.role !== "user") return true;
610
- if (typeof msg.content === "string") return !msg.content.startsWith("[PLAN MODE ACTIVE]") && !msg.content.startsWith("[PLAN MODE RE-ENTRY]");
611
- if (Array.isArray(msg.content)) return !msg.content.some((part) => part.type === "text" && ((part as TextContent).text?.startsWith("[PLAN MODE ACTIVE]") || (part as TextContent).text?.startsWith("[PLAN MODE RE-ENTRY]")));
612
- return true;
693
+ return !isPlanModeInstructionText(msg.content);
613
694
  }) };
614
695
  },
615
696
  onBeforeAgentStart: async () => {
@@ -641,6 +722,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
641
722
  activeCancelSession = undefined;
642
723
  activeObserveSession = undefined;
643
724
  implementationHandoff = undefined;
725
+ verificationInFlight = false;
726
+ omittedEntryIds.clear();
644
727
  footerError = undefined;
645
728
  lastRenderedFooter = undefined;
646
729
  lastFooterCtx = undefined;
@@ -680,6 +763,13 @@ export function createPlanModeController(pi: ExtensionAPI) {
680
763
  } else {
681
764
  deactivatePlanOnlyTools();
682
765
  }
766
+ try {
767
+ if (collectOmissionDrafts(readBranchEntries(ctx), isVerificationPendingEntry, new Set()).length > 0) {
768
+ ctx.ui.notify("The previous session ended before plan verification finished — the last implementation is unverified. Run /mf-plan-implement to verify it.", "warning");
769
+ }
770
+ } catch {
771
+ // A stale session-start context cannot be notified safely.
772
+ }
683
773
  updateStatus(ctx);
684
774
  startFooterTicker(ctx);
685
775
  if (ctx.mode === "tui" && typeof ctx.ui.onTerminalInput === "function") {
@@ -694,6 +784,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
694
784
  },
695
785
  onSessionShutdown: (_event: { reason: string }) => {
696
786
  implementationPending = false;
787
+ verificationInFlight = false;
788
+ omittedEntryIds.clear();
697
789
  lastImplementationStopReason = undefined;
698
790
  lastImplementationReport = undefined;
699
791
  resetImplementationTranscript();
@@ -17,3 +17,4 @@ export const PLAN_ONLY_REGISTERED_TOOLS = ["write_plan", "exit_plan_mode", "mf_p
17
17
  export const PLAN_SUBAGENT_NAMES = new Set(["moa-explore", "mf-plan"]);
18
18
  export const PLAN_MODE_CONTEXT_TYPE = "mf-plan-context";
19
19
  export const PLAN_EXIT_CONTEXT_TYPE = "mf-plan-exit";
20
+ export const VERIFICATION_PENDING_CONTEXT_TYPE = "mf-plan-verification-pending";
@@ -92,6 +92,24 @@ export function getResultOutput(result: { exitCode: number; stopReason?: string;
92
92
  return getFinalOutput(result.messages) || "(no output)";
93
93
  }
94
94
 
95
+ /**
96
+ * Longest failure message kept for a status row. Generous enough that a
97
+ * provider's JSON error payload survives whole for a progress table to wrap
98
+ * across several lines, bounded so a multi-KB stderr dump cannot crowd out the
99
+ * table it renders in.
100
+ */
101
+ const MAX_STATUS_DETAIL_CHARS = 500;
102
+
103
+ /**
104
+ * Collapse an agent failure into one bounded line for a progress row. Runs of
105
+ * whitespace — newlines included — are flattened because the row is laid out as
106
+ * a single line before its surface wraps it to the available width.
107
+ */
108
+ export function statusDetail(output: string): string {
109
+ const collapsed = output.replace(/\s+/g, " ").trim();
110
+ return collapsed.length <= MAX_STATUS_DETAIL_CHARS ? collapsed : `${collapsed.slice(0, MAX_STATUS_DETAIL_CHARS - 1)}…`;
111
+ }
112
+
95
113
  export function truncateOutput(output: string): string {
96
114
  const maxBytes = 50 * 1024;
97
115
  const bytes = Buffer.from(output, "utf8");