@phi-code-admin/phi-code 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/docs/adr/0001-phase-contract.md +57 -0
  3. package/docs/adr/0002-independent-review.md +47 -0
  4. package/docs/fork-policy.md +18 -0
  5. package/extensions/phi/agents.ts +7 -4
  6. package/extensions/phi/benchmark.ts +129 -49
  7. package/extensions/phi/browser.ts +10 -37
  8. package/extensions/phi/btw/btw.ts +1 -7
  9. package/extensions/phi/chrome/index.ts +1283 -741
  10. package/extensions/phi/commit.ts +9 -14
  11. package/extensions/phi/goal/index.ts +10 -33
  12. package/extensions/phi/init.ts +37 -47
  13. package/extensions/phi/keys.ts +2 -7
  14. package/extensions/phi/mcp/callback-server.ts +162 -165
  15. package/extensions/phi/mcp/config.ts +122 -136
  16. package/extensions/phi/mcp/errors.ts +18 -23
  17. package/extensions/phi/mcp/index.ts +322 -355
  18. package/extensions/phi/mcp/oauth-provider.ts +289 -289
  19. package/extensions/phi/mcp/server-manager.ts +390 -413
  20. package/extensions/phi/mcp/tool-bridge.ts +381 -415
  21. package/extensions/phi/memory.ts +2 -2
  22. package/extensions/phi/models.ts +27 -26
  23. package/extensions/phi/orchestrator.ts +451 -237
  24. package/extensions/phi/productivity.ts +4 -2
  25. package/extensions/phi/providers/agent-def.ts +1 -1
  26. package/extensions/phi/providers/alibaba.ts +56 -7
  27. package/extensions/phi/providers/context-window.ts +4 -1
  28. package/extensions/phi/providers/live-models.ts +5 -20
  29. package/extensions/phi/providers/orchestrator-helpers.ts +31 -5
  30. package/extensions/phi/providers/phase-machine.ts +283 -0
  31. package/extensions/phi/setup.ts +196 -169
  32. package/extensions/phi/skill-loader.ts +14 -17
  33. package/extensions/phi/smart-router.ts +6 -6
  34. package/extensions/phi/web-search.ts +90 -50
  35. package/package.json +1 -1
@@ -16,20 +16,21 @@
16
16
  * /plans — List plans and their execution status
17
17
  */
18
18
 
19
- import { Type } from "@sinclair/typebox";
20
- import type { ExtensionAPI } from "phi-code";
21
- import { writeFile, mkdir, readdir, readFile } from "node:fs/promises";
22
- import { join } from "node:path";
23
19
  import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
24
21
  import { homedir } from "node:os";
25
- import {
26
- extractBlockingFindings,
27
- extractHandoff,
28
- isTransientError,
29
- parsePhaseVerdict,
30
- } from "./providers/orchestrator-helpers.js";
31
- import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
22
+ import { join } from "node:path";
23
+ import { Type } from "@sinclair/typebox";
24
+ import type { ExtensionAPI } from "phi-code";
32
25
  import { type AgentDef, loadAgentDef } from "./providers/agent-def.js";
26
+ import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
27
+ import {
28
+ analyzePhaseMessages,
29
+ buildNextBrief,
30
+ decidePhaseTransition,
31
+ resolvePhaseOutcome,
32
+ type StructuredPhaseResult,
33
+ } from "./providers/phase-machine.js";
33
34
 
34
35
  // ─── Types ───────────────────────────────────────────────────────────────
35
36
 
@@ -58,35 +59,51 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
58
59
  // ─── Plan File Generators ────────────────────────────────────────
59
60
 
60
61
  function generateSpec(p: {
61
- title: string; description: string; goals: string[]; requirements: string[];
62
- architecture?: string[]; constraints?: string[]; successCriteria?: string[]; tasks: TaskDef[];
62
+ title: string;
63
+ description: string;
64
+ goals: string[];
65
+ requirements: string[];
66
+ architecture?: string[];
67
+ constraints?: string[];
68
+ successCriteria?: string[];
69
+ tasks: TaskDef[];
63
70
  }): string {
64
71
  let spec = `# ${p.title}\n\n`;
65
72
  spec += `**Created:** ${new Date().toLocaleString()}\n\n`;
66
73
  spec += `## Description\n\n${p.description}\n\n`;
67
74
  spec += `## Goals\n\n`;
68
- p.goals.forEach((g, i) => { spec += `${i + 1}. ${g}\n`; });
75
+ p.goals.forEach((g, i) => {
76
+ spec += `${i + 1}. ${g}\n`;
77
+ });
69
78
  spec += "\n## Requirements\n\n";
70
- p.requirements.forEach(r => { spec += `- ${r}\n`; });
79
+ p.requirements.forEach((r) => {
80
+ spec += `- ${r}\n`;
81
+ });
71
82
  spec += "\n";
72
83
  if (p.architecture?.length) {
73
84
  spec += `## Architecture\n\n`;
74
- p.architecture.forEach(a => { spec += `- ${a}\n`; });
85
+ p.architecture.forEach((a) => {
86
+ spec += `- ${a}\n`;
87
+ });
75
88
  spec += "\n";
76
89
  }
77
90
  if (p.constraints?.length) {
78
91
  spec += `## Constraints\n\n`;
79
- p.constraints.forEach(c => { spec += `- ${c}\n`; });
92
+ p.constraints.forEach((c) => {
93
+ spec += `- ${c}\n`;
94
+ });
80
95
  spec += "\n";
81
96
  }
82
97
  if (p.successCriteria?.length) {
83
98
  spec += `## Success Criteria\n\n`;
84
- p.successCriteria.forEach(s => { spec += `- [ ] ${s}\n`; });
99
+ p.successCriteria.forEach((s) => {
100
+ spec += `- [ ] ${s}\n`;
101
+ });
85
102
  spec += "\n";
86
103
  }
87
104
  spec += `## Task Overview\n\n| # | Task | Agent | Priority | Dependencies |\n|---|------|-------|----------|-------------|\n`;
88
105
  p.tasks.forEach((t, i) => {
89
- const deps = t.dependencies?.map(d => `#${d}`).join(", ") || "—";
106
+ const deps = t.dependencies?.map((d) => `#${d}`).join(", ") || "—";
90
107
  spec += `| ${i + 1} | ${t.title} | ${t.agent || "code"} | ${t.priority || "medium"} | ${deps} |\n`;
91
108
  });
92
109
  spec += `\n---\n*Generated by Phi Code Orchestrator*\n`;
@@ -102,12 +119,15 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
102
119
  const prioTag = t.priority === "high" ? " 🔴" : t.priority === "low" ? " 🟢" : " 🟡";
103
120
  const depsTag = t.dependencies?.length ? ` (after #${t.dependencies.join(", #")})` : "";
104
121
  todo += `## Task ${i + 1}: ${t.title}${prioTag}${agentTag}${depsTag}\n\n- [ ] ${t.description}\n`;
105
- if (t.subtasks) t.subtasks.forEach(st => { todo += ` - [ ] ${st}\n`; });
122
+ if (t.subtasks)
123
+ t.subtasks.forEach((st) => {
124
+ todo += ` - [ ] ${st}\n`;
125
+ });
106
126
  todo += "\n";
107
127
  });
108
128
  todo += `---\n\n## Progress\n\n- Total: ${tasks.length} tasks\n`;
109
- todo += `- High priority: ${tasks.filter(t => t.priority === "high").length}\n`;
110
- todo += `- Agents: ${[...new Set(tasks.map(t => t.agent || "code"))].join(", ")}\n`;
129
+ todo += `- High priority: ${tasks.filter((t) => t.priority === "high").length}\n`;
130
+ todo += `- Agents: ${[...new Set(tasks.map((t) => t.agent || "code"))].join(", ")}\n`;
111
131
  return todo;
112
132
  }
113
133
 
@@ -116,8 +136,10 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
116
136
  pi.registerTool({
117
137
  name: "orchestrate",
118
138
  label: "Project Orchestrator",
119
- description: "Create a structured project plan with spec, todo, and task breakdown. Forces the model to decompose the project into goals, requirements, architecture, constraints, and success criteria before execution.",
120
- promptSnippet: "Plan + structure projects using the prompt-architect pattern. Each task gets [CONTEXT] [TASK] [FORMAT] [CONSTRAINTS].",
139
+ description:
140
+ "Create a structured project plan with spec, todo, and task breakdown. Forces the model to decompose the project into goals, requirements, architecture, constraints, and success criteria before execution.",
141
+ promptSnippet:
142
+ "Plan + structure projects using the prompt-architect pattern. Each task gets [CONTEXT] → [TASK] → [FORMAT] → [CONSTRAINTS].",
121
143
  promptGuidelines: [
122
144
  // NOTE: The old guideline "When asked to plan or build a project, call the orchestrate tool"
123
145
  // was REMOVED because it hijacked the /plan command flow. The orchestrate tool is now
@@ -130,30 +152,59 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
130
152
  ],
131
153
  parameters: Type.Object({
132
154
  title: Type.String({ description: "Concise project title" }),
133
- description: Type.String({ description: "Full project description: what to build, why, and any relevant context" }),
134
- goals: Type.Union([Type.Array(Type.String()), Type.String()], { description: "Measurable project goals (what success looks like)" }),
135
- requirements: Type.Union([Type.Array(Type.String()), Type.String()], { description: "Technical and functional requirements" }),
136
- architecture: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Architecture decisions, tech stack choices, trade-offs" })),
155
+ description: Type.String({
156
+ description: "Full project description: what to build, why, and any relevant context",
157
+ }),
158
+ goals: Type.Union([Type.Array(Type.String()), Type.String()], {
159
+ description: "Measurable project goals (what success looks like)",
160
+ }),
161
+ requirements: Type.Union([Type.Array(Type.String()), Type.String()], {
162
+ description: "Technical and functional requirements",
163
+ }),
164
+ architecture: Type.Optional(
165
+ Type.Union([Type.Array(Type.String()), Type.String()], {
166
+ description: "Architecture decisions, tech stack choices, trade-offs",
167
+ }),
168
+ ),
137
169
  tasks: Type.Array(
138
170
  Type.Object({
139
171
  title: Type.String({ description: "Clear, action-oriented task title" }),
140
- description: Type.String({ description: "SELF-CONTAINED task description. Include ALL context the sub-agent needs." }),
172
+ description: Type.String({
173
+ description: "SELF-CONTAINED task description. Include ALL context the sub-agent needs.",
174
+ }),
141
175
  agent: Type.Optional(Type.String({ description: "Agent type: explore, plan, code, test, review" })),
142
176
  priority: Type.Optional(Type.String({ description: "high, medium, low" })),
143
- dependencies: Type.Optional(Type.Array(Type.Number(), { description: "Task numbers this depends on (1-indexed)" })),
144
- subtasks: Type.Optional(Type.Array(Type.String(), { description: "Specific sub-steps within this task" })),
177
+ dependencies: Type.Optional(
178
+ Type.Array(Type.Number(), { description: "Task numbers this depends on (1-indexed)" }),
179
+ ),
180
+ subtasks: Type.Optional(
181
+ Type.Array(Type.String(), { description: "Specific sub-steps within this task" }),
182
+ ),
183
+ }),
184
+ { description: "Ordered list of tasks" },
185
+ ),
186
+ constraints: Type.Optional(
187
+ Type.Union([Type.Array(Type.String()), Type.String()], {
188
+ description: "Hard constraints: things to avoid, rules",
189
+ }),
190
+ ),
191
+ successCriteria: Type.Optional(
192
+ Type.Union([Type.Array(Type.String()), Type.String()], {
193
+ description: "How to verify the project is complete",
145
194
  }),
146
- { description: "Ordered list of tasks" }
147
195
  ),
148
- constraints: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "Hard constraints: things to avoid, rules" })),
149
- successCriteria: Type.Optional(Type.Union([Type.Array(Type.String()), Type.String()], { description: "How to verify the project is complete" })),
150
196
  }),
151
197
 
152
198
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
153
199
  // Block if /plan orchestration is active — prevent hijacking
154
200
  if (orchestrationActive) {
155
201
  return {
156
- content: [{ type: "text", text: "⚠️ Orchestration is already active via /plan. Do NOT call orchestrate during /plan phases. Follow the phase instructions instead." }],
202
+ content: [
203
+ {
204
+ type: "text",
205
+ text: "⚠️ Orchestration is already active via /plan. Do NOT call orchestrate during /plan phases. Follow the phase instructions instead.",
206
+ },
207
+ ],
157
208
  details: undefined,
158
209
  };
159
210
  }
@@ -164,7 +215,11 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
164
215
  const toArray = (v: any): string[] => {
165
216
  if (!v) return [];
166
217
  if (Array.isArray(v)) return v;
167
- if (typeof v === "string") return v.split("\n").map((s: string) => s.replace(/^[-•*]\s*/, "").trim()).filter(Boolean);
218
+ if (typeof v === "string")
219
+ return v
220
+ .split("\n")
221
+ .map((s: string) => s.replace(/^[-•*]\s*/, "").trim())
222
+ .filter(Boolean);
168
223
  return [];
169
224
  };
170
225
 
@@ -191,7 +246,8 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
191
246
  await writeFile(join(plansDir, specFile), spec, "utf-8");
192
247
  await writeFile(join(plansDir, todoFile), todo, "utf-8");
193
248
 
194
- const summary = `📋 **Project "${p.title}" — Structured plan created!**\n\n` +
249
+ const summary =
250
+ `📋 **Project "${p.title}" — Structured plan created!**\n\n` +
195
251
  `**Spec:** \`${specFile}\` | **Todo:** \`${todoFile}\`\n\n` +
196
252
  `**Goals:** ${p.goals.length} | **Requirements:** ${p.requirements.length} | **Tasks:** ${p.tasks.length}\n\n` +
197
253
  `The spec and todo files are ready in \`.phi/plans/\`. ` +
@@ -234,7 +290,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
234
290
  let phaseQueue: OrchestratorPhase[] = [];
235
291
  let orchestrationActive = false;
236
292
  let activeAgentPrompt: string | null = null;
237
- let activeAgentTools: string[] | null = null;
293
+ let _activeAgentTools: string[] | null = null;
238
294
  let savedTools: string[] | null = null;
239
295
  let phasePending = false; // true while waiting for a phase to complete
240
296
  let phaseTimeoutId: ReturnType<typeof setTimeout> | null = null;
@@ -258,13 +314,89 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
258
314
  // Set true right before an INTERNAL ctx.abort() (phase timeout) so the
259
315
  // resulting agent_end is not mistaken for a user Ctrl+C cancellation.
260
316
  let internalAbort = false;
317
+ // Structured result emitted by the current phase via the `phase_result` tool
318
+ // (robust primary path). Reset at each phase start; read in agent_end and
319
+ // merged with the parsed markdown report (fallback) by resolvePhaseOutcome.
320
+ // currentPhaseResultKey stamps which phase produced it, so a stale result
321
+ // from a previous run/phase (or a tool call that lands after the phase
322
+ // transition) is never mistaken for the current phase's output.
323
+ let currentPhaseResult: StructuredPhaseResult | null = null;
324
+ let currentPhaseResultKey: string | null = null;
325
+
326
+ // ─── phase_result Tool — structured phase outcome (robust primary path) ───
327
+ // A phase agent CALLS this to emit its verdict/handoff/blocking as data
328
+ // instead of relying on the orchestrator regex-scraping its markdown report.
329
+ // It is only meaningful during /plan; outside orchestration it is a no-op so
330
+ // a stray call never errors. The markdown report is still written (it is the
331
+ // human-readable artifact); this just makes the machine-read path exact.
332
+ pi.registerTool({
333
+ name: "phase_result",
334
+ label: "Phase Result",
335
+ description:
336
+ "Report THIS /plan phase's structured outcome so the orchestrator reads it exactly instead of parsing your markdown. Call it once, at the end, IN ADDITION to writing your report file. TEST and REVIEW phases MUST call it with a verdict.",
337
+ promptGuidelines: [
338
+ "During a /plan phase, after writing your report file, call phase_result once with your verdict (TEST/REVIEW) and a concise handoff for the next phase.",
339
+ "verdict: PASS only if you OBSERVED success; FAIL if any blocking issue; BLOCKED if you could not run; SKIP if not applicable.",
340
+ "blocking: the must-fix findings (REVIEW), one per line. handoff: what is done, open risks, and the single most important next action.",
341
+ ],
342
+ parameters: Type.Object({
343
+ verdict: Type.Optional(
344
+ Type.Union([Type.Literal("PASS"), Type.Literal("FAIL"), Type.Literal("BLOCKED"), Type.Literal("SKIP")], {
345
+ description: "Phase verdict (required for TEST and REVIEW phases)",
346
+ }),
347
+ ),
348
+ blocking: Type.Optional(
349
+ Type.String({ description: "Must-fix findings, one per line (REVIEW). Empty when none." }),
350
+ ),
351
+ handoff: Type.Optional(
352
+ Type.String({ description: "Concise handoff for the next phase: state, open risks, next action." }),
353
+ ),
354
+ }),
355
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
356
+ if (!orchestrationActive) {
357
+ return {
358
+ content: [{ type: "text", text: "phase_result is only used during /plan orchestration; ignored here." }],
359
+ details: undefined,
360
+ };
361
+ }
362
+ const raw = params as { verdict?: StructuredPhaseResult["verdict"]; blocking?: string; handoff?: string };
363
+ // Merge only the fields this call actually set, so a phase that emits
364
+ // its result across several calls (verdict first, handoff later — a
365
+ // common LLM pattern) does not erase earlier fields. Start fresh when
366
+ // the previous result belonged to a different phase.
367
+ const defined: StructuredPhaseResult = {};
368
+ if (raw.verdict !== undefined) defined.verdict = raw.verdict;
369
+ if (typeof raw.blocking === "string") defined.blocking = raw.blocking;
370
+ if (typeof raw.handoff === "string") defined.handoff = raw.handoff;
371
+ const samePhase = currentPhaseResultKey === (currentPhase?.key ?? null);
372
+ currentPhaseResult = { ...(samePhase && currentPhaseResult ? currentPhaseResult : {}), ...defined };
373
+ currentPhaseResultKey = currentPhase?.key ?? null;
374
+ const parts = [
375
+ raw.verdict ? `verdict ${raw.verdict}` : null,
376
+ raw.blocking?.trim() ? "blocking findings" : null,
377
+ raw.handoff?.trim() ? "handoff" : null,
378
+ ].filter(Boolean);
379
+ return {
380
+ content: [{ type: "text", text: `Phase result recorded${parts.length ? ` (${parts.join(", ")})` : ""}.` }],
381
+ details: undefined,
382
+ };
383
+ },
384
+ });
385
+
386
+ // A phase's report file is not always named <key>-<ts>.md: PLAN writes its
387
+ // handoff into todo-<ts>.md and CODE into progress-<ts>.md. Map key -> file
388
+ // stem so the text fallback for HANDOFF/BLOCKING actually finds them.
389
+ const REPORT_FILE_STEM: Record<string, string> = { plan: "todo", code: "progress" };
261
390
 
262
- /** Read a phase's report file (.phi/plans/<key>-<ts>.md). Null-safe. */
391
+ /** Read a phase's report file (.phi/plans/<stem>-<ts>.md). Null-safe. */
263
392
  function readPhaseReport(key: string, ts: string): string | null {
264
393
  try {
265
- const f = join(process.cwd(), ".phi", "plans", `${key}-${ts}.md`);
394
+ const stem = REPORT_FILE_STEM[key] ?? key;
395
+ const f = join(process.cwd(), ".phi", "plans", `${stem}-${ts}.md`);
266
396
  if (existsSync(f)) return readFileSync(f, "utf-8");
267
- } catch { /* ignore */ }
397
+ } catch {
398
+ /* ignore */
399
+ }
268
400
  return null;
269
401
  }
270
402
 
@@ -277,13 +409,18 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
277
409
  }
278
410
  function writeCheckpoint(nextBaseIndex: number): void {
279
411
  try {
280
- if (nextBaseIndex >= 5) { clearCheckpoint(); return; }
412
+ if (nextBaseIndex >= 5) {
413
+ clearCheckpoint();
414
+ return;
415
+ }
281
416
  writeFileSync(
282
417
  checkpointPath(),
283
418
  JSON.stringify({ description: currentDescription, ts: currentRunTs, nextBaseIndex }, null, 2),
284
419
  "utf-8",
285
420
  );
286
- } catch { /* best effort */ }
421
+ } catch {
422
+ /* best effort */
423
+ }
287
424
  }
288
425
  function readCheckpoint(): { description: string; ts: string; nextBaseIndex: number } | null {
289
426
  try {
@@ -293,14 +430,18 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
293
430
  if (typeof c?.description === "string" && typeof c?.ts === "string" && typeof c?.nextBaseIndex === "number") {
294
431
  return c;
295
432
  }
296
- } catch { /* ignore */ }
433
+ } catch {
434
+ /* ignore */
435
+ }
297
436
  return null;
298
437
  }
299
438
  function clearCheckpoint(): void {
300
439
  try {
301
440
  const p = checkpointPath();
302
441
  if (existsSync(p)) unlinkSync(p);
303
- } catch { /* best effort */ }
442
+ } catch {
443
+ /* best effort */
444
+ }
304
445
  }
305
446
 
306
447
  /**
@@ -344,7 +485,9 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
344
485
  const fmMatch = content.match(/^---\s*\n[\s\S]*?\n---\s*\n([\s\S]*)$/);
345
486
  const body = (fmMatch ? fmMatch[1] : content).trim();
346
487
  if (body) return body;
347
- } catch { /* try next dir */ }
488
+ } catch {
489
+ /* try next dir */
490
+ }
348
491
  }
349
492
  return null;
350
493
  }
@@ -358,7 +501,9 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
358
501
  let routing: any = { routes: {}, default: { model: "default" } };
359
502
  try {
360
503
  routing = JSON.parse(readFileSync(routingPath, "utf-8"));
361
- } catch { /* no routing config */ }
504
+ } catch {
505
+ /* no routing config */
506
+ }
362
507
 
363
508
  function getModel(routeKey: string): { preferred: string; fallback: string } {
364
509
  const route = routing.routes?.[routeKey];
@@ -377,9 +522,10 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
377
522
  const ts = tsOverride || timestamp();
378
523
  currentRunTs = ts; // shared by all phase report file names this run (or resumed)
379
524
  // Inject runtime info so agents can adapt to the host OS
380
- const shellNote = process.platform === 'win32'
381
- ? `\nShell: bash (Git Bash), NOT cmd.exe. Always use Unix syntax: rm not del, test -f not if exist, / not \\\\`
382
- : '';
525
+ const shellNote =
526
+ process.platform === "win32"
527
+ ? `\nShell: bash (Git Bash), NOT cmd.exe. Always use Unix syntax: rm not del, test -f not if exist, / not \\\\`
528
+ : "";
383
529
  const runtimeInfo = `\n\nRuntime: ${process.platform} (${process.arch})${shellNote}`;
384
530
 
385
531
  // Embed the prompt-architect skill so the PLAN phase applies it to every
@@ -393,9 +539,13 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
393
539
 
394
540
  const phases: OrchestratorPhase[] = [
395
541
  {
396
- key: "explore", label: "🔍 Phase 1 — EXPLORE", model: explore.preferred, fallback: explore.fallback,
542
+ key: "explore",
543
+ label: "🔍 Phase 1 — EXPLORE",
544
+ model: explore.preferred,
545
+ fallback: explore.fallback,
397
546
  agent: loadAgentDef("explore"),
398
- instruction: `You are the EXPLORE agent. Analyze the project requirements and existing codebase.
547
+ instruction:
548
+ `You are the EXPLORE agent. Analyze the project requirements and existing codebase.
399
549
 
400
550
  **FIRST ACTION (MANDATORY):** Call \`memory_search\` with project-relevant keywords to check for prior context.
401
551
 
@@ -448,12 +598,18 @@ After your analysis, use \`ontology_add\` to save key project entities AND their
448
598
  - Every function fully implemented
449
599
  - Follow existing patterns if codebase exists
450
600
  - [Any other specific constraints]
451
- \`\`\`` + runtimeInfo + COMMON_PHASE_RULES,
601
+ \`\`\`` +
602
+ runtimeInfo +
603
+ COMMON_PHASE_RULES,
452
604
  },
453
605
  {
454
- key: "plan", label: "📐 Phase 2 — PLAN", model: plan.preferred, fallback: plan.fallback,
606
+ key: "plan",
607
+ label: "📐 Phase 2 — PLAN",
608
+ model: plan.preferred,
609
+ fallback: plan.fallback,
455
610
  agent: loadAgentDef("plan"),
456
- instruction: `You are the PLAN agent. Design the architecture and create a detailed task list.
611
+ instruction:
612
+ `You are the PLAN agent. Design the architecture and create a detailed task list.
457
613
 
458
614
  **Context Retrieval:**
459
615
  1. Use \`ontology_query\` to retrieve all entities and relations from Phase 1
@@ -496,12 +652,18 @@ After your analysis, use \`ontology_add\` to save key project entities AND their
496
652
  - Dependencies: Task 1
497
653
  \`\`\`
498
654
  ${promptArchitectSection}
499
- Before finishing, use \`memory_write\` to save your plan summary with relevant tags for future reference.` + runtimeInfo + COMMON_PHASE_RULES,
655
+ Before finishing, use \`memory_write\` to save your plan summary with relevant tags for future reference.` +
656
+ runtimeInfo +
657
+ COMMON_PHASE_RULES,
500
658
  },
501
659
  {
502
- key: "code", label: "💻 Phase 3 — CODE", model: code.preferred, fallback: code.fallback,
660
+ key: "code",
661
+ label: "💻 Phase 3 — CODE",
662
+ model: code.preferred,
663
+ fallback: code.fallback,
503
664
  agent: loadAgentDef("code"),
504
- instruction: `You are the CODE agent. Implement the complete project.
665
+ instruction:
666
+ `You are the CODE agent. Implement the complete project.
505
667
 
506
668
  **Context Retrieval:**
507
669
  1. Use \`memory_search\` with project keywords to find notes from previous phases
@@ -545,12 +707,18 @@ After implementation, use \`memory_write\` to save a summary of what was built,
545
707
  **CRITICAL RULES:**
546
708
  - Write ONE file per tool call — NEVER combine multiple files in a single response
547
709
  - Keep each file under 500 lines. If longer, split into modules
548
- - After writing ALL files, verify they exist with a single \`find . -name '*.ts' -o -name '*.js' -o -name '*.html' | sort\`` + runtimeInfo + COMMON_PHASE_RULES,
710
+ - After writing ALL files, verify they exist with a single \`find . -name '*.ts' -o -name '*.js' -o -name '*.html' | sort\`` +
711
+ runtimeInfo +
712
+ COMMON_PHASE_RULES,
549
713
  },
550
714
  {
551
- key: "test", label: "🧪 Phase 4 — TEST", model: test.preferred, fallback: test.fallback,
715
+ key: "test",
716
+ label: "🧪 Phase 4 — TEST",
717
+ model: test.preferred,
718
+ fallback: test.fallback,
552
719
  agent: loadAgentDef("test"),
553
- instruction: `You are the TEST agent. Verify the implementation.
720
+ instruction:
721
+ `You are the TEST agent. Verify the implementation.
554
722
 
555
723
  **Context Retrieval:**
556
724
  1. Use \`memory_search\` to find implementation notes from the CODE phase
@@ -569,6 +737,7 @@ After implementation, use \`memory_write\` to save a summary of what was built,
569
737
  - Running \`npm test\` alone is NOT sufficient proof for a feature.
570
738
  **Step 4:** Fix any real errors you find (root cause, not a workaround).
571
739
  **Step 5:** Write test results to \`.phi/plans/test-${ts}.md\`, starting the file with a VERDICT line.
740
+ **Step 6 (MANDATORY):** Call the \`phase_result\` tool with your \`verdict\` (PASS/FAIL/BLOCKED) and a concise \`handoff\`. This is how the orchestrator reads your outcome exactly; the report file above is the human-readable copy.
572
741
 
573
742
  **Test report format (the FIRST line MUST be the verdict):**
574
743
  \`\`\`markdown
@@ -609,12 +778,18 @@ $ <command actually executed>
609
778
 
610
779
  After testing, use \`memory_write\` to save test results, bugs found, and lessons learned.
611
780
 
612
- **Ontology update:** Use \`ontology_add\` to update the project status (e.g., entity "test-results" type "Phase" with properties {passed: "N", failed: "M", coverage: "X%"}) and add a relation to the project entity.` + runtimeInfo + COMMON_PHASE_RULES,
781
+ **Ontology update:** Use \`ontology_add\` to update the project status (e.g., entity "test-results" type "Phase" with properties {passed: "N", failed: "M", coverage: "X%"}) and add a relation to the project entity.` +
782
+ runtimeInfo +
783
+ COMMON_PHASE_RULES,
613
784
  },
614
785
  {
615
- key: "review", label: "🔍 Phase 5 — REVIEW", model: review.preferred, fallback: review.fallback,
786
+ key: "review",
787
+ label: "🔍 Phase 5 — REVIEW",
788
+ model: review.preferred,
789
+ fallback: review.fallback,
616
790
  agent: loadAgentDef("review"),
617
- instruction: `You are the REVIEW agent. Final quality review.
791
+ instruction:
792
+ `You are the REVIEW agent. Final quality review.
618
793
 
619
794
  **FIRST ACTIONS (MANDATORY — do these before anything else):**
620
795
  1. Call \`memory_search\` to find all notes from previous phases (explore, plan, code, test)
@@ -636,6 +811,7 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
636
811
  - **REFUTED** - drop it. Only refute if you can quote the line that proves it cannot happen (a type, guard, or constant). When unsure, keep it PLAUSIBLE.
637
812
  Keep only CONFIRMED + PLAUSIBLE. A finding with no concrete failure scenario is noise: drop it.
638
813
  **Step 4:** Write \`.phi/plans/review-${ts}.md\`, starting with the VERDICT line.
814
+ **Step 5 (MANDATORY):** Call the \`phase_result\` tool with your \`verdict\` (PASS/FAIL), the \`blocking\` findings (one per line, empty when none), and a short \`handoff\`. A FAIL here drives the one bounded fix→re-review cycle, so the orchestrator must read it exactly — do not rely on markdown parsing alone.
639
815
 
640
816
  **Final report format (the FIRST line MUST be the verdict):**
641
817
  \`\`\`markdown
@@ -666,10 +842,14 @@ Tag the note with relevant keywords for vector search.
666
842
  **Ontology enrichment:** After your review, use \`ontology_add\` to save your key findings:
667
843
  - Add a "review-report" entity with type "Document"
668
844
  - Add relations to the project: "reviews" → project, quality score as entity property
669
- - Save any new architectural decisions or patterns discovered` + runtimeInfo + COMMON_PHASE_RULES,
845
+ - Save any new architectural decisions or patterns discovered` +
846
+ runtimeInfo +
847
+ COMMON_PHASE_RULES,
670
848
  },
671
849
  ];
672
- phases.forEach((p, i) => { p.baseIndex = i; });
850
+ phases.forEach((p, i) => {
851
+ p.baseIndex = i;
852
+ });
673
853
  return phases;
674
854
  }
675
855
 
@@ -733,7 +913,7 @@ Tag the note with relevant keywords for vector search.
733
913
  /**
734
914
  * Activate agent for a phase: set system prompt + restrict tools.
735
915
  */
736
- function activateAgent(phase: OrchestratorPhase, ctx: any) {
916
+ function activateAgent(phase: OrchestratorPhase, _ctx: any) {
737
917
  // Fallback capture: savedTools is normally taken in the /plan handler at
738
918
  // orchestration start, but capture it here too so restoration is always defined.
739
919
  if (!savedTools) {
@@ -745,22 +925,29 @@ Tag the note with relevant keywords for vector search.
745
925
  activeAgentPrompt = agentDef.systemPrompt;
746
926
  // Restrict tools to agent's allowed tools
747
927
  if (agentDef.tools.length > 0) {
748
- // Always include memory tools in orchestration phases
749
- const memoryTools = ['memory_search', 'memory_write', 'memory_read', 'ontology_add', 'ontology_query'];
750
- const agentTools = [...agentDef.tools, ...memoryTools.filter(t => !agentDef.tools.includes(t))];
751
- activeAgentTools = agentTools;
928
+ // Always include the memory tools and phase_result in orchestration phases.
929
+ const forcedTools = [
930
+ "memory_search",
931
+ "memory_write",
932
+ "memory_read",
933
+ "ontology_add",
934
+ "ontology_query",
935
+ "phase_result",
936
+ ];
937
+ const agentTools = [...agentDef.tools, ...forcedTools.filter((t) => !agentDef.tools.includes(t))];
938
+ _activeAgentTools = agentTools;
752
939
  pi.setActiveTools(agentTools);
753
940
  } else if (savedTools) {
754
941
  // Agent with no tool restriction: reset to the full toolset so the
755
942
  // previous phase's narrower restriction does not leak into this one.
756
- activeAgentTools = null;
943
+ _activeAgentTools = null;
757
944
  pi.setActiveTools(savedTools);
758
945
  }
759
946
  } else {
760
947
  // No agent for this phase: restore the full toolset, otherwise the prior
761
948
  // phase's restriction would persist.
762
949
  activeAgentPrompt = null;
763
- activeAgentTools = null;
950
+ _activeAgentTools = null;
764
951
  if (savedTools) {
765
952
  pi.setActiveTools(savedTools);
766
953
  }
@@ -772,7 +959,7 @@ Tag the note with relevant keywords for vector search.
772
959
  */
773
960
  function deactivateAgent() {
774
961
  activeAgentPrompt = null;
775
- activeAgentTools = null;
962
+ _activeAgentTools = null;
776
963
  if (savedTools) {
777
964
  pi.setActiveTools(savedTools);
778
965
  savedTools = null;
@@ -796,11 +983,16 @@ Tag the note with relevant keywords for vector search.
796
983
  setOrchestrationActive(false);
797
984
  phasePending = false;
798
985
  deactivateAgent();
799
- if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
986
+ if (phaseTimeoutId) {
987
+ clearTimeout(phaseTimeoutId);
988
+ phaseTimeoutId = null;
989
+ }
800
990
  if (originalModel) {
801
991
  const toRestore = originalModel;
802
992
  originalModel = null;
803
- Promise.resolve(pi.setModel(toRestore)).catch(() => { /* best effort */ });
993
+ Promise.resolve(pi.setModel(toRestore)).catch(() => {
994
+ /* best effort */
995
+ });
804
996
  }
805
997
  }
806
998
 
@@ -815,18 +1007,26 @@ Tag the note with relevant keywords for vector search.
815
1007
  if (originalModel) {
816
1008
  const toRestore = originalModel;
817
1009
  originalModel = null;
818
- Promise.resolve(pi.setModel(toRestore)).catch(() => { /* best effort */ });
1010
+ Promise.resolve(pi.setModel(toRestore)).catch(() => {
1011
+ /* best effort */
1012
+ });
1013
+ }
1014
+ if (phaseTimeoutId) {
1015
+ clearTimeout(phaseTimeoutId);
1016
+ phaseTimeoutId = null;
819
1017
  }
820
- if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
821
1018
  // Generate global final summary using the real phase counters
822
1019
  const elapsed = phaseStartTime ? Math.round((Date.now() - phaseStartTime) / 1000) : 0;
823
1020
  const minutes = Math.floor(elapsed / 60);
824
1021
  const seconds = elapsed % 60;
825
1022
  const skippedNote = skippedPhases ? ` (${skippedPhases} skipped on timeout)` : "";
826
- ctx.ui.notify(`\n📊 **Orchestration Summary**\n` +
827
- ` Phases: ${completedPhases}/5 completed${skippedNote}\n` +
828
- ` Duration: ${minutes}m ${seconds}s\n` +
829
- ` Check \`.phi/plans/\` for all reports`, "info");
1023
+ ctx.ui.notify(
1024
+ `\n📊 **Orchestration Summary**\n` +
1025
+ ` Phases: ${completedPhases}/5 completed${skippedNote}\n` +
1026
+ ` Duration: ${minutes}m ${seconds}s\n` +
1027
+ ` Check \`.phi/plans/\` for all reports`,
1028
+ "info",
1029
+ );
830
1030
  const allDone = completedPhases >= 5 && skippedPhases === 0;
831
1031
  const doneMsg = allDone
832
1032
  ? `\n✅ **All 5 phases complete!**`
@@ -835,7 +1035,11 @@ Tag the note with relevant keywords for vector search.
835
1035
  ctx.ui.notify(doneMsg, "info");
836
1036
  } catch {
837
1037
  // Fallback: inject completion message into conversation stream
838
- try { pi.sendUserMessage(`Orchestration finished: ${completedPhases}/5 phases completed${skippedNote}.`); } catch { /* best effort */ }
1038
+ try {
1039
+ pi.sendUserMessage(`Orchestration finished: ${completedPhases}/5 phases completed${skippedNote}.`);
1040
+ } catch {
1041
+ /* best effort */
1042
+ }
839
1043
  }
840
1044
  return;
841
1045
  }
@@ -843,6 +1047,10 @@ Tag the note with relevant keywords for vector search.
843
1047
  const phase = phaseQueue.shift()!;
844
1048
  phasePending = true;
845
1049
  currentPhase = phase;
1050
+ // New phase starts with no structured result; it is set only if this
1051
+ // phase's agent calls phase_result.
1052
+ currentPhaseResult = null;
1053
+ currentPhaseResultKey = null;
846
1054
 
847
1055
  switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
848
1056
  activateAgent(phase, ctx);
@@ -862,7 +1070,11 @@ Tag the note with relevant keywords for vector search.
862
1070
  // race a still-streaming phase. Mark it internal so the resulting
863
1071
  // agent_end is not treated as a user cancellation.
864
1072
  internalAbort = true;
865
- try { ctx.abort(); } catch { /* best effort */ }
1073
+ try {
1074
+ ctx.abort();
1075
+ } catch {
1076
+ /* best effort */
1077
+ }
866
1078
  // Retry the SAME phase once on the fallback model before skipping:
867
1079
  // a timed-out phase usually means the model/route stalled, and a
868
1080
  // different family often gets through (cap = one retry per phase).
@@ -870,7 +1082,10 @@ Tag the note with relevant keywords for vector search.
870
1082
  phase.retried = true;
871
1083
  phase.useFallback = true;
872
1084
  phaseQueue.unshift(phase);
873
- ctx.ui.notify(`\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min) — retrying once on the fallback model.`, "warning");
1085
+ ctx.ui.notify(
1086
+ `\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min) — retrying once on the fallback model.`,
1087
+ "warning",
1088
+ );
874
1089
  } else {
875
1090
  skippedPhases++;
876
1091
  ctx.ui.notify(`\n⏰ **Phase timed out again** — skipping to next phase.`, "warning");
@@ -883,9 +1098,9 @@ Tag the note with relevant keywords for vector search.
883
1098
 
884
1099
  // ─── System Prompt Injection — Agent personas ────────────────────
885
1100
 
886
- pi.on("before_agent_start", async (event, _ctx) => {
1101
+ pi.on("before_agent_start", async (_event, _ctx) => {
887
1102
  if (!orchestrationActive || !activeAgentPrompt) {
888
- return { };
1103
+ return {};
889
1104
  }
890
1105
  // Replace system prompt with the active agent's prompt
891
1106
  return { systemPrompt: activeAgentPrompt };
@@ -905,15 +1120,21 @@ Tag the note with relevant keywords for vector search.
905
1120
  // An internal abort (phase timeout) re-emits agent_end; that transition was
906
1121
  // already handled by the timeout. Consume the flag and ignore this event so
907
1122
  // it is not mistaken for a user cancellation below.
908
- if (internalAbort) { internalAbort = false; return; }
1123
+ if (internalAbort) {
1124
+ internalAbort = false;
1125
+ return;
1126
+ }
1127
+
1128
+ const messages = event.messages || [];
1129
+ // Analyze what happened, read the phase's report, and let the pure state
1130
+ // machine (providers/phase-machine.ts, unit tested) decide the transition.
1131
+ // This hook only interprets the decision: notifications, queue edits,
1132
+ // checkpoints, model switches.
1133
+ const analysis = analyzePhaseMessages(messages);
909
1134
 
910
1135
  // User abort (Ctrl+C / ESC): the aborted run still emits agent_end, but it
911
- // must NOT be treated as phase completion. Detect the aborted assistant
912
- // message and stop the whole workflow instead of chaining the next phase.
913
- const userAborted = (event.messages || []).some(
914
- (m: any) => m.role === 'assistant' && m.stopReason === 'aborted',
915
- );
916
- if (userAborted) {
1136
+ // must NOT be treated as phase completion.
1137
+ if (analysis.userAborted) {
917
1138
  ctx.ui.notify(`\n🛑 **Orchestration cancelled** by user. Remaining phases skipped.`, "warning");
918
1139
  stopOrchestration();
919
1140
  return;
@@ -929,163 +1150,123 @@ Tag the note with relevant keywords for vector search.
929
1150
  }
930
1151
 
931
1152
  // Clear phase timeout on normal completion
932
- if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
933
-
934
- // Build a structured summary of what happened in this phase
935
- // Instead of raw LLM text, extract concrete actions: files created/modified,
936
- // errors encountered, test results. This gives the next phase actionable context.
937
- const messages = event.messages || [];
938
- const filesWritten: string[] = [];
939
- const filesEdited: string[] = [];
940
- const errorsHit: string[] = [];
941
- const testResults: string[] = [];
942
- let toolCallCount = 0;
943
-
944
- for (const rawMsg of messages) {
945
- // Pi message variants are scanned dynamically; cast to a loose shape once.
946
- const msg = rawMsg as { role?: string; content?: unknown; name?: string; toolName?: string; isError?: boolean };
947
- // Pi uses role: "toolResult" instead of "tool"
948
- if (msg.role === 'tool' || msg.role === 'function' || msg.role === 'toolResult') {
949
- toolCallCount++;
950
- const content = Array.isArray(msg.content)
951
- ? msg.content.map((c: any) => c.text || '').join('')
952
- : String(msg.content || '');
953
- const name = msg.name || msg.toolName || '';
954
- // Track writes
955
- if (name === 'write' && content.includes('Successfully wrote')) {
956
- const match = content.match(/wrote \d+ bytes to (.+)/);
957
- if (match) filesWritten.push(match[1]);
958
- }
959
- // Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
960
- // Anchor the path capture so it does not over-capture unrelated text.
961
- if (name === 'edit' && !content.includes('ERR') && !msg.isError) {
962
- const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
963
- if (match) filesEdited.push(match[1]);
964
- }
965
- // Track errors — but filter out edit retries (old_text mismatch = normal retry, not error)
966
- if ((content.includes('ERR:') || content.includes('Error:') || content.includes('FAIL'))
967
- && !content.includes('old text must match')
968
- && !content.includes('The old text')
969
- && !content.includes('oldText not found')
970
- && !content.includes('old_text not found')) {
971
- const preview = content.slice(0, 150).replace(/\n/g, ' ');
972
- errorsHit.push(`${name}: ${preview}`);
973
- }
974
- // Track test results
975
- if (content.includes('PASS') || content.includes('✅') || content.includes('✗') || content.includes('❌')) {
976
- const lines = content.split('\n').filter((l: string) => /PASS|FAIL|✅|❌|✗/.test(l));
977
- testResults.push(...lines.slice(0, 10));
978
- }
979
- }
1153
+ if (phaseTimeoutId) {
1154
+ clearTimeout(phaseTimeoutId);
1155
+ phaseTimeoutId = null;
980
1156
  }
981
1157
 
982
- // Detect API errors (401, auth failures) abort workflow if found
983
- const hasAuthError = messages.some((msg: any) => {
984
- const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content || '');
985
- return content.includes('401') && (content.includes('invalid access token') || content.includes('token expired') || content.includes('Unauthorized'));
1158
+ // Resolve the phase outcome: prefer the structured phase_result emission
1159
+ // (exact) and fall back per-field to the regex-scraped markdown report.
1160
+ // Only trust a structured result stamped with THIS phase's key a stale
1161
+ // result from a previous run or a late tool call is ignored.
1162
+ const reportContent = currentPhase ? readPhaseReport(currentPhase.key, currentRunTs) : null;
1163
+ const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
1164
+ const outcome = resolvePhaseOutcome(structuredForThisPhase, reportContent);
1165
+ const verdict = outcome.verdict;
1166
+ const decision = decidePhaseTransition({
1167
+ analysis,
1168
+ phase: currentPhase ? { key: currentPhase.key, retried: currentPhase.retried } : null,
1169
+ verdict,
1170
+ reviewFixRounds,
1171
+ maxToolCallsPerPhase: MAX_TOOL_CALLS_PER_PHASE,
986
1172
  });
987
- // Only a genuine auth failure (401) is fatal — abort the whole workflow.
988
- if (hasAuthError) {
989
- ctx.ui.notify(`\n❌ **Orchestrator aborted:** API authentication error (401)\nCheck your API key and model configuration.`, "error");
1173
+
1174
+ if (decision.action === "stop") {
1175
+ // Only a genuine auth failure (401) is fatal abort the whole workflow.
1176
+ ctx.ui.notify(
1177
+ `\n❌ **Orchestrator aborted:** API authentication error (401)\nCheck your API key and model configuration.`,
1178
+ "error",
1179
+ );
990
1180
  stopOrchestration();
991
1181
  return;
992
1182
  }
993
1183
 
994
- // Transient provider/proxy failure (timeout text / 5xx / 429 / broken JSON
995
- // tool call) that is NOT a 401: retry the SAME phase once on the fallback
996
- // model (a different family often gets through) before chaining onward.
997
- if (currentPhase && !currentPhase.retried && isTransientError(messages)) {
998
- if (phaseTimeoutId) { clearTimeout(phaseTimeoutId); phaseTimeoutId = null; }
1184
+ if (decision.action === "retry-fallback" && currentPhase) {
1185
+ // Transient provider/proxy failure (timeout text / 5xx / 429 / broken
1186
+ // JSON tool call) that is NOT a 401: retry the SAME phase once on the
1187
+ // fallback model (a different family often gets through).
999
1188
  currentPhase.retried = true;
1000
1189
  currentPhase.useFallback = true;
1001
1190
  phaseQueue.unshift(currentPhase);
1002
1191
  phasePending = false;
1003
- ctx.ui.notify(`\n🔁 **Transient provider error** in ${currentPhase.label} — retrying once on the fallback model.`, "warning");
1192
+ ctx.ui.notify(
1193
+ `\n🔁 **Transient provider error** in ${currentPhase.label} — retrying once on the fallback model.`,
1194
+ "warning",
1195
+ );
1004
1196
  sendNextPhase(ctx);
1005
1197
  return;
1006
1198
  }
1007
1199
 
1008
- // A phase that made 0 tool calls is NOT fatal: a model may legitimately
1009
- // answer with text only (e.g. an inline plan). Warn and continue rather
1010
- // than killing phases 2-5, mirroring the graceful phase-timeout path.
1011
- if (toolCallCount === 0 && messages.length > 0) {
1012
- ctx.ui.notify(`\n⚠️ **Phase made 0 tool calls** — it may have responded with text only. Continuing to the next phase.`, "warning");
1200
+ if (decision.action === "pause-blocked" && currentPhase) {
1201
+ ctx.ui.notify(
1202
+ `\n⏸️ **${currentPhase.label} reported BLOCKED.** Pausing /plan — see \`.phi/plans/${currentPhase.key}-${currentRunTs}.md\`. Fix the blocker and re-run \`/plan\` to continue.`,
1203
+ "warning",
1204
+ );
1205
+ stopOrchestration();
1206
+ return;
1207
+ }
1208
+
1209
+ if (decision.action === "continue" && decision.zeroToolCalls && messages.length > 0) {
1210
+ // A phase that made 0 tool calls is NOT fatal: a model may legitimately
1211
+ // answer with text only (e.g. an inline plan). Warn and continue rather
1212
+ // than killing phases 2-5, mirroring the graceful phase-timeout path.
1213
+ ctx.ui.notify(
1214
+ `\n⚠️ **Phase made 0 tool calls** — it may have responded with text only. Continuing to the next phase.`,
1215
+ "warning",
1216
+ );
1013
1217
  if (phaseQueue.length > 0) {
1014
1218
  phaseQueue[0].instruction += `\n\n**Note:** The previous phase made 0 tool calls and may have responded with text only. Make sure you actually call the required tools.`;
1015
1219
  }
1016
1220
  }
1017
1221
 
1018
- // Build the summary
1019
- const summaryParts: string[] = [];
1020
- summaryParts.push(`Tool calls: ${toolCallCount}`);
1021
- // Anti-loop guard: warn if tool calls are excessive
1022
- if (toolCallCount > MAX_TOOL_CALLS_PER_PHASE) {
1023
- summaryParts.push(`⚠️ WARNING: Phase used ${toolCallCount} tool calls (limit: ${MAX_TOOL_CALLS_PER_PHASE}). Possible loop detected.`);
1222
+ if (decision.action === "continue" && decision.missingVerdict && currentPhase) {
1223
+ // The model deviated from BOTH contract paths: it neither called
1224
+ // phase_result with a verdict nor wrote a parseable "VERDICT:" line.
1225
+ // Surface it instead of silently treating the phase as passed.
1226
+ ctx.ui.notify(
1227
+ `\n⚠️ **${currentPhase.label} reported no verdict** (no phase_result call and no VERDICT line in \`.phi/plans/${currentPhase.key}-${currentRunTs}.md\`) — treating it as passed. Check the report manually.`,
1228
+ "warning",
1229
+ );
1024
1230
  }
1025
- if (filesWritten.length > 0) summaryParts.push(`Files created/written: ${filesWritten.join(', ')}`);
1026
- if (filesEdited.length > 0) summaryParts.push(`Files edited: ${filesEdited.join(', ')}`);
1027
- if (testResults.length > 0) summaryParts.push(`Test results:\n${testResults.join('\n')}`);
1028
- if (errorsHit.length > 0) summaryParts.push(`Errors encountered: ${errorsHit.length}\n${errorsHit.slice(0, 5).join('\n')}`);
1029
-
1030
- // Verify mandatory tool usage
1031
- const toolNames = messages
1032
- .filter((m: any) => m.role === 'tool' || m.role === 'function' || m.role === 'toolResult')
1033
- .map((m: any) => (m as any).name || (m as any).toolName || '');
1034
- const hasMemorySearch = toolNames.includes('memory_search');
1035
- const hasMemoryWrite = toolNames.includes('memory_write');
1036
- if (!hasMemorySearch) summaryParts.push(`⚠️ Phase did NOT call memory_search (mandatory)`);
1037
- if (!hasMemoryWrite) summaryParts.push(`⚠️ Phase did NOT call memory_write (mandatory)`);
1038
-
1039
- const phaseSummary = summaryParts.join('\n');
1040
-
1041
- // Prefer the phase's canonical "## HANDOFF" block (a deterministic text
1042
- // contract written to its report file) over the heuristic summary; fall back
1043
- // to the heuristic when the model did not write one.
1044
- const reportContent = currentPhase ? readPhaseReport(currentPhase.key, currentRunTs) : null;
1045
- const handoff = reportContent ? extractHandoff(reportContent) : "";
1046
- const nextBrief = handoff
1047
- ? `Tool calls: ${toolCallCount}\n## HANDOFF (from ${currentPhase?.label || "previous phase"})\n${handoff}`
1048
- : phaseSummary;
1231
+
1232
+ // Prefer the resolved HANDOFF (structured phase_result, else the report's
1233
+ // "## HANDOFF" block) over the heuristic summary; fall back to the
1234
+ // heuristic when neither was provided.
1235
+ const nextBrief = buildNextBrief(
1236
+ analysis,
1237
+ outcome.handoff,
1238
+ currentPhase?.label || "previous phase",
1239
+ MAX_TOOL_CALLS_PER_PHASE,
1240
+ );
1049
1241
  if (nextBrief && phaseQueue.length > 0) {
1050
1242
  phaseQueue[0].instruction += `\n\n**Previous phase summary:**\n${nextBrief}`;
1051
1243
  }
1052
1244
 
1053
- // Verdict-driven control (best-effort, never breaks the chain):
1054
- // - BLOCKED: the phase could not run; pause the run for the user.
1055
- // - REVIEW FAIL: open ONE bounded fix -> re-review cycle.
1056
- try {
1057
- const verdict = reportContent ? parsePhaseVerdict(reportContent) : null;
1058
- const looped = toolCallCount > MAX_TOOL_CALLS_PER_PHASE;
1059
-
1060
- if (verdict === "BLOCKED" && currentPhase) {
1061
- ctx.ui.notify(`\n⏸️ **${currentPhase.label} reported BLOCKED.** Pausing /plan — see \`.phi/plans/${currentPhase.key}-${currentRunTs}.md\`. Fix the blocker and re-run \`/plan\` to continue.`, "warning");
1062
- stopOrchestration();
1063
- return;
1064
- }
1065
-
1066
- if (currentPhase?.key === "review" && verdict === "FAIL" && reviewFixRounds < 1 && !looped) {
1067
- reviewFixRounds++;
1068
- const blocking = (reportContent ? extractBlockingFindings(reportContent) : "").slice(0, 4000);
1069
- const fixPhase: OrchestratorPhase = {
1070
- key: "code",
1071
- label: "🔧 Fix — CODE (review remediation)",
1072
- model: currentPhase.model,
1073
- fallback: currentPhase.fallback,
1074
- agent: loadAgentDef("code"),
1075
- instruction: `You are the CODE agent. REVIEW found BLOCKING issues. Fix ONLY these, root cause not workaround, then update \`.phi/plans/progress-${currentRunTs}.md\`.\n\n## BLOCKING findings to fix:\n${blocking || "(see the review report in .phi/plans/)"}\n` + COMMON_PHASE_RULES,
1076
- };
1077
- const reReviewPhase: OrchestratorPhase = {
1078
- ...currentPhase,
1079
- retried: false,
1080
- useFallback: false,
1081
- label: "🔍 Re-REVIEW (after fix)",
1082
- };
1083
- // Run next: fix, then re-review.
1084
- phaseQueue.unshift(reReviewPhase);
1085
- phaseQueue.unshift(fixPhase);
1086
- ctx.ui.notify(`\n🔁 **REVIEW verdict: FAIL** — running one targeted fix + re-review cycle.`, "warning");
1087
- }
1088
- } catch { /* verdict logic is best-effort */ }
1245
+ if (decision.action === "review-fix-cycle" && currentPhase) {
1246
+ // REVIEW FAIL: open ONE bounded fix -> re-review cycle.
1247
+ reviewFixRounds++;
1248
+ const blocking = outcome.blocking.slice(0, 4000);
1249
+ const fixPhase: OrchestratorPhase = {
1250
+ key: "code",
1251
+ label: "🔧 Fix — CODE (review remediation)",
1252
+ model: currentPhase.model,
1253
+ fallback: currentPhase.fallback,
1254
+ agent: loadAgentDef("code"),
1255
+ instruction:
1256
+ `You are the CODE agent. REVIEW found BLOCKING issues. Fix ONLY these, root cause not workaround, then update \`.phi/plans/progress-${currentRunTs}.md\`.\n\n## BLOCKING findings to fix:\n${blocking || "(see the review report in .phi/plans/)"}\n` +
1257
+ COMMON_PHASE_RULES,
1258
+ };
1259
+ const reReviewPhase: OrchestratorPhase = {
1260
+ ...currentPhase,
1261
+ retried: false,
1262
+ useFallback: false,
1263
+ label: "🔍 Re-REVIEW (after fix)",
1264
+ };
1265
+ // Run next: fix, then re-review.
1266
+ phaseQueue.unshift(reReviewPhase);
1267
+ phaseQueue.unshift(fixPhase);
1268
+ ctx.ui.notify(`\n🔁 **REVIEW verdict: FAIL** — running one targeted fix + re-review cycle.`, "warning");
1269
+ }
1089
1270
 
1090
1271
  // Checkpoint resume position after each base phase (synthetic fix/re-review
1091
1272
  // phases have no baseIndex and do not advance it).
@@ -1132,11 +1313,18 @@ Tag the note with relevant keywords for vector search.
1132
1313
  skippedPhases = 0;
1133
1314
  reviewFixRounds = 0;
1134
1315
  internalAbort = false;
1316
+ // Clear any structured result left over from a prior run so this
1317
+ // resumed phase's outcome is read fresh (see phase-result leak guard).
1318
+ currentPhaseResult = null;
1319
+ currentPhaseResultKey = null;
1135
1320
  phaseStartTime = Date.now();
1136
1321
  const firstResume = remaining[0];
1137
1322
  currentPhase = firstResume;
1138
1323
  phaseQueue = remaining.slice(1);
1139
- ctx.ui.notify(`📋 **Resuming orchestration** at ${firstResume.label} (${remaining.length} phase(s) left)\n`, "info");
1324
+ ctx.ui.notify(
1325
+ `📋 **Resuming orchestration** at ${firstResume.label} (${remaining.length} phase(s) left)\n`,
1326
+ "info",
1327
+ );
1140
1328
  const { modelId, warning } = await switchModelForPhase(firstResume, ctx);
1141
1329
  activateAgent(firstResume, ctx);
1142
1330
  ctx.ui.notify(`${firstResume.label} → \`${modelId}\``, "info");
@@ -1151,7 +1339,8 @@ Tag the note with relevant keywords for vector search.
1151
1339
  const description = rawArgs.replace(/(^|\s)--fanout\b/g, " ").trim();
1152
1340
 
1153
1341
  if (!description) {
1154
- ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run, \`/plan --fanout <desc>\` for parallel exploration)
1342
+ ctx.ui.notify(
1343
+ `**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run, \`/plan --fanout <desc>\` for parallel exploration)
1155
1344
 
1156
1345
  **Examples:**
1157
1346
  /plan Build a REST API for user authentication with JWT
@@ -1159,7 +1348,9 @@ Tag the note with relevant keywords for vector search.
1159
1348
  /plan Add test coverage to the payment module
1160
1349
 
1161
1350
  **Other commands:**
1162
- /plans — List all plans`, "info");
1351
+ /plans — List all plans`,
1352
+ "info",
1353
+ );
1163
1354
  return;
1164
1355
  }
1165
1356
 
@@ -1167,7 +1358,11 @@ Tag the note with relevant keywords for vector search.
1167
1358
  await ensurePlansDir();
1168
1359
  const ts = timestamp();
1169
1360
  const specFile = `spec-${ts}.md`;
1170
- await writeFile(join(plansDir, specFile), `# ${description}\n\n**Created:** ${new Date().toLocaleString()}\n`, "utf-8");
1361
+ await writeFile(
1362
+ join(plansDir, specFile),
1363
+ `# ${description}\n\n**Created:** ${new Date().toLocaleString()}\n`,
1364
+ "utf-8",
1365
+ );
1171
1366
 
1172
1367
  // Build phases with model assignments + agent definitions
1173
1368
  currentDescription = description;
@@ -1184,6 +1379,11 @@ Tag the note with relevant keywords for vector search.
1184
1379
  skippedPhases = 0;
1185
1380
  reviewFixRounds = 0;
1186
1381
  internalAbort = false;
1382
+ // Clear any structured result left over from a previous /plan run in
1383
+ // this session; the first phase is launched directly (not via
1384
+ // sendNextPhase), so without this a stale verdict/handoff would leak.
1385
+ currentPhaseResult = null;
1386
+ currentPhaseResultKey = null;
1187
1387
  const firstPhase = phases[0];
1188
1388
  currentPhase = firstPhase;
1189
1389
 
@@ -1207,7 +1407,10 @@ Tag the note with relevant keywords for vector search.
1207
1407
  try {
1208
1408
  const available = ctx.modelRegistry?.getAvailable?.() || [];
1209
1409
  const exploreModelId = resolveModelRef(available, firstPhase.model)?.id || firstPhase.model;
1210
- ctx.ui.notify(`\n🔭 **Parallel exploration** (read-only, up to 2 concurrent) — building a fuller map before phase 1...`, "info");
1410
+ ctx.ui.notify(
1411
+ `\n🔭 **Parallel exploration** (read-only, up to 2 concurrent) — building a fuller map before phase 1...`,
1412
+ "info",
1413
+ );
1211
1414
  const fan = await runExploreFanout(defaultExplorerSpecs(description), {
1212
1415
  model: exploreModelId,
1213
1416
  tools: READONLY_EXPLORER_TOOLS,
@@ -1217,14 +1420,22 @@ Tag the note with relevant keywords for vector search.
1217
1420
  });
1218
1421
  const okCount = fan.results.filter((r) => r.ok).length;
1219
1422
  if (fan.merged.trim()) {
1220
- firstPhase.instruction =
1221
- `## Parallel exploration findings (${okCount} read-only sub-explorer(s))\nUse these as a starting map; verify and synthesize them into your brief, do not trust them blindly.\n\n${fan.merged}\n\n---\n${firstPhase.instruction}`;
1222
- ctx.ui.notify(`\n🔭 ${okCount} sub-exploration(s) merged into EXPLORE${fan.rateLimited ? " (rate limit hit — ran the rest sequentially)" : ""}.`, "info");
1423
+ firstPhase.instruction = `## Parallel exploration findings (${okCount} read-only sub-explorer(s))\nUse these as a starting map; verify and synthesize them into your brief, do not trust them blindly.\n\n${fan.merged}\n\n---\n${firstPhase.instruction}`;
1424
+ ctx.ui.notify(
1425
+ `\n🔭 ${okCount} sub-exploration(s) merged into EXPLORE${fan.rateLimited ? " (rate limit hit — ran the rest sequentially)" : ""}.`,
1426
+ "info",
1427
+ );
1223
1428
  } else {
1224
- ctx.ui.notify(`\n🔭 Parallel exploration returned nothing usable${fan.rateLimited ? " (rate-limited)" : ""} — running the normal single-agent EXPLORE.`, "warning");
1429
+ ctx.ui.notify(
1430
+ `\n🔭 Parallel exploration returned nothing usable${fan.rateLimited ? " (rate-limited)" : ""} — running the normal single-agent EXPLORE.`,
1431
+ "warning",
1432
+ );
1225
1433
  }
1226
1434
  } catch {
1227
- ctx.ui.notify(`\n🔭 Parallel exploration skipped (error) — running the normal single-agent EXPLORE.`, "warning");
1435
+ ctx.ui.notify(
1436
+ `\n🔭 Parallel exploration skipped (error) — running the normal single-agent EXPLORE.`,
1437
+ "warning",
1438
+ );
1228
1439
  }
1229
1440
  }
1230
1441
 
@@ -1261,7 +1472,10 @@ Tag the note with relevant keywords for vector search.
1261
1472
  }
1262
1473
 
1263
1474
  const files = await readdir(plansDir);
1264
- const specs = files.filter(f => f.startsWith("spec-") && f.endsWith(".md")).sort().reverse();
1475
+ const specs = files
1476
+ .filter((f) => f.startsWith("spec-") && f.endsWith(".md"))
1477
+ .sort()
1478
+ .reverse();
1265
1479
 
1266
1480
  if (specs.length === 0) {
1267
1481
  ctx.ui.notify("No plans found.", "info");