@underactive/pi-topping-moa-fusion 0.2.0 → 0.4.1

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,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.1]
6
+
7
+ ### Added
8
+
9
+ - `write_plan`, `enter_plan_mode`, `exit_plan_mode`, and `mf_plan_subagent` now declare a pi 0.99 `outputSchema` and return matching `structuredContent` for RPC/JSON consumers, `tool_result` handlers, and codemode scripts: per-agent status, exit code, stop reason, usage, and capped output for subagent runs, and the approval outcome and kickoff route for plan exit. Model-facing text is unchanged.
10
+ - The same four tools now declare MCP-style `annotations`, so permission extensions see accurate destructive and idempotent hints. None claims to be read-only.
11
+
12
+ ### Changed
13
+
14
+ - Updated all four pi development dependencies from 0.87.0 to 0.99.2.
15
+ - Plan-only tools stay out of normal mode by being removed from the active tool set, not through pi 0.99's `prepareLoadout`, which leaves hidden tools active and callable.
16
+
17
+ ### Fixed
18
+
19
+ - Plan-only tools are now also removed on the first turn after leaving plan mode. Previously the exit-reminder turn skipped the leak repair, so a leaked `write_plan`, `exit_plan_mode`, or `mf_plan_subagent` stayed declared for that turn.
20
+
21
+ ## [0.3.0]
22
+
23
+ ### Added
24
+
25
+ - 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`.
26
+ - 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.
27
+
28
+ ### Changed
29
+
30
+ - Updated all four pi development dependencies from 0.86.0 to 0.87.0.
31
+ - 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.
32
+
33
+ ### Fixed
34
+
35
+ - 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.
36
+ - 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.
37
+ - 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.
38
+ - Saving or deleting an agent roster now takes effect immediately, without requiring a second save from the main settings overlay.
39
+ - 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.
40
+
5
41
  ## [0.2.0]
6
42
 
7
43
  ### 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 |
@@ -215,6 +230,8 @@ Outside plan mode, one additional tool is exposed:
215
230
  |------|-------------|
216
231
  | `enter_plan_mode` | Lets the agent enter plan mode itself when asked for a design or plan. Takes the single-model path on the current session model — no pickers, no MoA. Exit and approval work exactly as if the user had invoked `/mf-plan`. |
217
232
 
233
+ All four tools declare pi 0.99 `annotations`, and none claims to be read-only: `write_plan` is `destructiveHint: true, idempotentHint: true`, `exit_plan_mode` is `destructiveHint: true, idempotentHint: false`, `mf_plan_subagent` is `idempotentHint: false`, and `enter_plan_mode` sets only `readOnlyHint: false`. Under the example permission policy in pi's extension docs they are confirmed exactly as before. Each also declares an `outputSchema` and returns matching `structuredContent` for programmatic consumers (RPC/JSON `tool_execution_end` events, `tool_result` handlers, and codemode scripts); for `mf_plan_subagent` that is each agent's status, exit code, stop reason, usage, and output capped at 50 KB. The model-facing text is unchanged, and pi does not save structured values in the session.
234
+
218
235
  ## Subagents
219
236
 
220
237
  Seven agent definitions are auto-installed to `~/.pi/agent/agents/` on first run (won't overwrite existing files). Two belong to the single-model workflow, three to MoA planning, one to the independent opinion flow, and one to the debate flow:
@@ -272,7 +289,7 @@ Rosters persist in `~/.pi/agent/mf-plan/settings.json`:
272
289
  }
273
290
  ```
274
291
 
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**.
292
+ 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
293
 
277
294
  #### Max concurrent agents
278
295
 
@@ -328,7 +345,7 @@ The slug is a generated adjective-adjective-noun triple (e.g. `happy-mellifluous
328
345
 
329
346
  ## Non-Interactive Behavior
330
347
 
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.
348
+ 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
349
 
333
350
  ## How It Works (Architecture)
334
351
 
@@ -370,6 +387,9 @@ src/
370
387
  │ ├── debateRounds.ts # Survivor/early-stop/next-round bookkeeping
371
388
  │ ├── debateResults.ts # Outcome collection and debate transcript markdown
372
389
  │ └── debateFile.ts # Repo-local debate artifacts
390
+ ├── spec/
391
+ │ ├── specContract.ts # Clarification/brief contracts, parsing, and handoff helpers
392
+ │ └── runSpec.ts # Interactive/headless /mf-spec state machine
373
393
  ├── planning/
374
394
  │ ├── planMode.ts # Plan-mode state transitions and lifecycle handlers
375
395
  │ ├── modeState.ts # Persisted state shape, byte cap, append deduplication
@@ -437,7 +457,7 @@ agents/ # Shipped agent definitions
437
457
  - **Custom `write_plan` tool** instead of path-guarded `edit`/`write` — guarantees the single-writable-file invariant.
438
458
  - **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
459
  - **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.
460
+ - **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
461
  - **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
462
 
443
463
  ## Porting Notes
@@ -453,6 +473,7 @@ Ported from Claude Code's plan mode, adapted to pi's extension primitives:
453
473
  | `FileEditTool`/`FileWriteTool` guarded | Custom `write_plan` tool | Safer single-writable-file invariant |
454
474
  | Subscription-tier agent counts | Hardcoded constants (3/1) | Bumpable via code change |
455
475
  | Plan-length A/B experiment | Standard Phase 4 | Experiment noise skipped |
476
+ | Spec Kit / brainstorming | `/mf-spec` | Requirements clarification only |
456
477
  | Interview phase variant | Standard 5-phase workflow | Variant skipped |
457
478
 
458
479
  ## 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.4.1",
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.99.2",
54
+ "@earendil-works/pi-ai": "0.99.2",
55
+ "@earendil-works/pi-coding-agent": "0.99.2",
56
+ "@earendil-works/pi-tui": "0.99.2",
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,