@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
@@ -332,7 +332,8 @@ export default function productivityExtension(pi: ExtensionAPI) {
332
332
  // /title
333
333
  // ------------------------------------------------------------------------
334
334
  pi.registerCommand("title", {
335
- description: "Derive a session title + kebab-case branch name from the first user message (deterministic, no LLM).",
335
+ description:
336
+ "Derive a session title + kebab-case branch name from the first user message (deterministic, no LLM).",
336
337
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
337
338
  try {
338
339
  const source = firstUserMessageText(ctx);
@@ -387,7 +388,8 @@ export default function productivityExtension(pi: ExtensionAPI) {
387
388
  // /dream
388
389
  // ------------------------------------------------------------------------
389
390
  pi.registerCommand("dream", {
390
- description: "Deterministic memory consolidation: report exact-duplicate notes that could be merged (no deletion).",
391
+ description:
392
+ "Deterministic memory consolidation: report exact-duplicate notes that could be merged (no deletion).",
391
393
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
392
394
  try {
393
395
  const files = memory.notes.list();
@@ -15,7 +15,7 @@
15
15
  * resolves to a real agents/ directory in both.
16
16
  */
17
17
 
18
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
18
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
19
19
  import { homedir } from "node:os";
20
20
  import { basename, join } from "node:path";
21
21
 
@@ -51,16 +51,65 @@ export interface AlibabaModelSpec {
51
51
  * are multimodal); the qwen3-coder / qwen3-max / glm / MiniMax entries are text.
52
52
  */
53
53
  export const ALIBABA_MODELS: readonly AlibabaModelSpec[] = [
54
- { id: "qwen3.7-plus", name: "Qwen 3.7 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
55
- { id: "qwen3.6-plus", name: "Qwen 3.6 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
56
- { id: "qwen3.5-plus", name: "Qwen 3.5 Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: true },
57
- { id: "qwen3-max-2026-01-23", name: "Qwen 3 Max", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: false },
58
- { id: "qwen3-coder-plus", name: "Qwen 3 Coder Plus", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
59
- { id: "qwen3-coder-next", name: "Qwen 3 Coder Next", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
54
+ {
55
+ id: "qwen3.7-plus",
56
+ name: "Qwen 3.7 Plus",
57
+ contextWindow: 1_000_000,
58
+ maxTokens: 16_384,
59
+ reasoning: true,
60
+ vision: true,
61
+ },
62
+ {
63
+ id: "qwen3.6-plus",
64
+ name: "Qwen 3.6 Plus",
65
+ contextWindow: 1_000_000,
66
+ maxTokens: 16_384,
67
+ reasoning: true,
68
+ vision: true,
69
+ },
70
+ {
71
+ id: "qwen3.5-plus",
72
+ name: "Qwen 3.5 Plus",
73
+ contextWindow: 1_000_000,
74
+ maxTokens: 16_384,
75
+ reasoning: true,
76
+ vision: true,
77
+ },
78
+ {
79
+ id: "qwen3-max-2026-01-23",
80
+ name: "Qwen 3 Max",
81
+ contextWindow: 262_144,
82
+ maxTokens: 16_384,
83
+ reasoning: true,
84
+ vision: false,
85
+ },
86
+ {
87
+ id: "qwen3-coder-plus",
88
+ name: "Qwen 3 Coder Plus",
89
+ contextWindow: 1_000_000,
90
+ maxTokens: 16_384,
91
+ reasoning: true,
92
+ vision: false,
93
+ },
94
+ {
95
+ id: "qwen3-coder-next",
96
+ name: "Qwen 3 Coder Next",
97
+ contextWindow: 1_000_000,
98
+ maxTokens: 16_384,
99
+ reasoning: true,
100
+ vision: false,
101
+ },
60
102
  { id: "kimi-k2.5", name: "Kimi K2.5", contextWindow: 262_144, maxTokens: 16_384, reasoning: true, vision: true },
61
103
  { id: "glm-5", name: "GLM 5", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
62
104
  { id: "glm-4.7", name: "GLM 4.7", contextWindow: 200_000, maxTokens: 128_000, reasoning: true, vision: false },
63
- { id: "MiniMax-M2.5", name: "MiniMax M2.5", contextWindow: 1_000_000, maxTokens: 16_384, reasoning: true, vision: false },
105
+ {
106
+ id: "MiniMax-M2.5",
107
+ name: "MiniMax M2.5",
108
+ contextWindow: 1_000_000,
109
+ maxTokens: 16_384,
110
+ reasoning: true,
111
+ vision: false,
112
+ },
64
113
  ] as const;
65
114
 
66
115
  export const ALIBABA_RATE_LIMITS = {
@@ -44,7 +44,10 @@ export function inferContextWindow(modelId: string, apiValue?: number, providerI
44
44
 
45
45
  /** Parse a context-window value like "256k", "1M", "1.5m", or "200000". */
46
46
  export function parseContextWindow(input: string): number | undefined {
47
- const m = input.trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*([km])?$/);
47
+ const m = input
48
+ .trim()
49
+ .toLowerCase()
50
+ .match(/^(\d+(?:\.\d+)?)\s*([km])?$/);
48
51
  if (!m) return undefined;
49
52
  const n = Number.parseFloat(m[1]);
50
53
  if (!Number.isFinite(n) || n <= 0) return undefined;
@@ -19,13 +19,9 @@
19
19
  * are exposed so the wizard can show *something* before the first live fetch.
20
20
  */
21
21
 
22
- import {
23
- OPENCODE_GO_FALLBACK_MODELS,
24
- getOpenCodeGoModels,
25
- pingOpenCodeGo,
26
- } from "./opencode-go.js";
27
22
  import { ALIBABA_MODELS, ALIBABA_PROVIDERS, pingAlibaba } from "./alibaba.js";
28
23
  import { inferContextWindow } from "./context-window.js";
24
+ import { getOpenCodeGoModels, OPENCODE_GO_FALLBACK_MODELS, pingOpenCodeGo } from "./opencode-go.js";
29
25
 
30
26
  export const LAST_VERIFIED = "2026-07-10";
31
27
 
@@ -66,11 +62,7 @@ function isCacheValid(entry: CacheEntry | undefined, now: number): boolean {
66
62
  return entry !== undefined && now - entry.fetchedAt < CACHE_TTL_MS;
67
63
  }
68
64
 
69
- async function fetchJson(
70
- url: string,
71
- headers: Record<string, string>,
72
- timeoutMs: number,
73
- ): Promise<unknown> {
65
+ async function fetchJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<unknown> {
74
66
  const controller = new AbortController();
75
67
  const timer = setTimeout(() => controller.abort(), timeoutMs);
76
68
  try {
@@ -401,10 +393,7 @@ async function dispatchFetch(providerId: string, options: FetchOptions): Promise
401
393
  * 3. Previous cache entry, if any (even if stale).
402
394
  * 4. Static fallback (versioned).
403
395
  */
404
- export async function fetchLiveModels(
405
- providerId: string,
406
- options: FetchOptions = {},
407
- ): Promise<LiveModelsResult> {
396
+ export async function fetchLiveModels(providerId: string, options: FetchOptions = {}): Promise<LiveModelsResult> {
408
397
  const now = Date.now();
409
398
  const force = options.forceRefresh === true;
410
399
 
@@ -425,9 +414,7 @@ export async function fetchLiveModels(
425
414
  const models = await promise;
426
415
  if (models.length === 0) {
427
416
  const fallback = staticFallbackFor(providerId);
428
- return fallback.length > 0
429
- ? { models: fallback, source: "fallback" }
430
- : { models: [], source: "unsupported" };
417
+ return fallback.length > 0 ? { models: fallback, source: "fallback" } : { models: [], source: "unsupported" };
431
418
  }
432
419
  cache.set(providerId, { models, fetchedAt: now });
433
420
  return { models, source: "live" };
@@ -466,9 +453,7 @@ export async function pingProvider(
466
453
  default: {
467
454
  try {
468
455
  const models = await dispatchFetch(providerId, { apiKey, timeoutMs, forceRefresh: true });
469
- return models.length > 0
470
- ? { ok: true }
471
- : { ok: false, error: "no models returned" };
456
+ return models.length > 0 ? { ok: true } : { ok: false, error: "no models returned" };
472
457
  } catch (err) {
473
458
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
474
459
  }
@@ -21,14 +21,40 @@ export function parsePhaseVerdict(content: string): PhaseVerdict | null {
21
21
  }
22
22
 
23
23
  /**
24
- * Extract the body of a markdown section by heading name (e.g. "BLOCKING",
25
- * "HANDOFF"), up to the next heading or end of file. Returns "" if absent.
24
+ * Extract the body of a section by name (e.g. "BLOCKING", "HANDOFF"), up to the
25
+ * next section header or end of file. Returns "" if absent.
26
+ *
27
+ * Tolerant of the three header shapes models actually emit for the same
28
+ * section: a markdown heading (`## HANDOFF`), a standalone bold label
29
+ * (`**HANDOFF**`), or a plain label line (`HANDOFF:`). Termination stops at the
30
+ * next markdown heading or the next standalone bold/plain label line, so a
31
+ * report written entirely with bold labels still splits into sections instead
32
+ * of the first one swallowing the rest.
26
33
  */
27
34
  export function extractSection(content: string, heading: string): string {
28
35
  if (!content) return "";
29
- const re = new RegExp(`#{1,6}\\s*\\**\\s*${heading}\\b[^\\n]*\\n([\\s\\S]*?)(?:\\n#{1,6}\\s|$)`, "i");
30
- const m = content.match(re);
31
- return m ? m[1].trim() : "";
36
+ // Header line, in one of three shapes, anchored to line start:
37
+ // `## HANDOFF ...` (markdown heading — trailing text allowed)
38
+ // `**HANDOFF**` (bold label closing stars, then only trailing space)
39
+ // `HANDOFF:` / `HANDOFF` (plain label — must be the whole line or end in ":")
40
+ // The plain form requires ":" or end-of-line after the name so a prose line
41
+ // like "Blocking issues remain: 2" is not mistaken for a "BLOCKING" header.
42
+ const start = new RegExp(
43
+ `(?:^|\\n)[ \\t]{0,3}(?:` +
44
+ `#{1,6}[ \\t]*\\*{0,2}[ \\t]*${heading}\\b[^\\n]*` + // heading
45
+ `|\\*{2}[ \\t]*${heading}[ \\t]*\\*{2}[ \\t]*` + // bold label
46
+ `|${heading}[ \\t]*:?[ \\t]*` + // plain label (bare or "name:")
47
+ `)\\n`,
48
+ "i",
49
+ );
50
+ const m = start.exec(content);
51
+ if (!m) return "";
52
+ const rest = content.slice(m.index + m[0].length);
53
+ // Next section boundary: a markdown heading line, or a line that is ONLY a
54
+ // bold label (`**LABEL**`) — not inline bold inside a bullet, which would
55
+ // truncate the body.
56
+ const end = rest.search(/\n[ \t]{0,3}(?:#{1,6}[ \t]|\*{2}[A-Za-z][^\n]*\*{2}[ \t]*(?:\n|$))/);
57
+ return (end === -1 ? rest : rest.slice(0, end)).trim();
32
58
  }
33
59
 
34
60
  export function extractBlockingFindings(content: string): string {
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Pure decision core of the /plan orchestrator's phase state machine.
3
+ *
4
+ * orchestrator.ts owns the I/O (UI notifications, queue mutation, checkpoint
5
+ * files, model switching); everything DECIDABLE from a finished phase's
6
+ * messages and report lives here so the exact transition behavior is unit
7
+ * tested — including what happens when a model deviates from the text
8
+ * contracts (missing report, unparseable verdict, zero tool calls).
9
+ *
10
+ * Behavior contract (mirrors the agent_end hook, in priority order):
11
+ * user abort > auth error (401) > transient retry (once, fallback model)
12
+ * > BLOCKED pause > review-FAIL fix cycle (once, unless looping)
13
+ * > continue (with diagnostics).
14
+ */
15
+
16
+ import {
17
+ extractBlockingFindings,
18
+ extractHandoff,
19
+ isTransientError,
20
+ type PhaseVerdict,
21
+ parsePhaseVerdict,
22
+ } from "./orchestrator-helpers.js";
23
+
24
+ export interface PhaseEndAnalysis {
25
+ userAborted: boolean;
26
+ hasAuthError: boolean;
27
+ transient: boolean;
28
+ toolCallCount: number;
29
+ filesWritten: string[];
30
+ filesEdited: string[];
31
+ errorsHit: string[];
32
+ testResults: string[];
33
+ calledMemorySearch: boolean;
34
+ calledMemoryWrite: boolean;
35
+ }
36
+
37
+ interface LooseMessage {
38
+ role?: string;
39
+ content?: unknown;
40
+ name?: string;
41
+ toolName?: string;
42
+ isError?: boolean;
43
+ stopReason?: string;
44
+ }
45
+
46
+ function messageText(msg: LooseMessage): string {
47
+ if (typeof msg.content === "string") return msg.content;
48
+ if (Array.isArray(msg.content)) {
49
+ return msg.content.map((c: { text?: string }) => c?.text || "").join("");
50
+ }
51
+ return JSON.stringify(msg.content ?? "");
52
+ }
53
+
54
+ /**
55
+ * Analyze a finished phase's message list into the facts the transition
56
+ * decision needs. Pure: no I/O, no state.
57
+ */
58
+ export function analyzePhaseMessages(rawMessages: readonly unknown[]): PhaseEndAnalysis {
59
+ const messages = (rawMessages || []) as LooseMessage[];
60
+
61
+ const userAborted = messages.some((m) => m.role === "assistant" && m.stopReason === "aborted");
62
+
63
+ const hasAuthError = messages.some((m) => {
64
+ const content = typeof m.content === "string" ? m.content : JSON.stringify(m.content || "");
65
+ return (
66
+ content.includes("401") &&
67
+ (content.includes("invalid access token") ||
68
+ content.includes("token expired") ||
69
+ content.includes("Unauthorized"))
70
+ );
71
+ });
72
+
73
+ const filesWritten: string[] = [];
74
+ const filesEdited: string[] = [];
75
+ const errorsHit: string[] = [];
76
+ const testResults: string[] = [];
77
+ let toolCallCount = 0;
78
+ const toolNames: string[] = [];
79
+
80
+ for (const msg of messages) {
81
+ // Pi uses role: "toolResult" instead of "tool"
82
+ if (msg.role !== "tool" && msg.role !== "function" && msg.role !== "toolResult") continue;
83
+ toolCallCount++;
84
+ const content = messageText(msg);
85
+ const name = msg.name || msg.toolName || "";
86
+ toolNames.push(name);
87
+
88
+ // Track writes
89
+ if (name === "write" && content.includes("Successfully wrote")) {
90
+ const match = content.match(/wrote \d+ bytes to (.+)/);
91
+ if (match) filesWritten.push(match[1]);
92
+ }
93
+ // Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
94
+ // Anchor the path capture so it does not over-capture unrelated text.
95
+ if (name === "edit" && !content.includes("ERR") && !msg.isError) {
96
+ const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
97
+ if (match) filesEdited.push(match[1]);
98
+ }
99
+ // Track errors — but filter out edit retries (old_text mismatch = normal retry, not error)
100
+ if (
101
+ (content.includes("ERR:") || content.includes("Error:") || content.includes("FAIL")) &&
102
+ !content.includes("old text must match") &&
103
+ !content.includes("The old text") &&
104
+ !content.includes("oldText not found") &&
105
+ !content.includes("old_text not found")
106
+ ) {
107
+ const preview = content.slice(0, 150).replace(/\n/g, " ");
108
+ errorsHit.push(`${name}: ${preview}`);
109
+ }
110
+ // Track test results
111
+ if (content.includes("PASS") || content.includes("✅") || content.includes("✗") || content.includes("❌")) {
112
+ const lines = content.split("\n").filter((l: string) => /PASS|FAIL|✅|❌|✗/.test(l));
113
+ testResults.push(...lines.slice(0, 10));
114
+ }
115
+ }
116
+
117
+ return {
118
+ userAborted,
119
+ hasAuthError,
120
+ transient: isTransientError(rawMessages),
121
+ toolCallCount,
122
+ filesWritten,
123
+ filesEdited,
124
+ errorsHit,
125
+ testResults,
126
+ calledMemorySearch: toolNames.includes("memory_search"),
127
+ calledMemoryWrite: toolNames.includes("memory_write"),
128
+ };
129
+ }
130
+
131
+ /** Build the heuristic summary injected into the next phase's instruction. */
132
+ export function buildPhaseSummary(analysis: PhaseEndAnalysis, maxToolCallsPerPhase: number): string {
133
+ const parts: string[] = [];
134
+ parts.push(`Tool calls: ${analysis.toolCallCount}`);
135
+ if (analysis.toolCallCount > maxToolCallsPerPhase) {
136
+ parts.push(
137
+ `⚠️ WARNING: Phase used ${analysis.toolCallCount} tool calls (limit: ${maxToolCallsPerPhase}). Possible loop detected.`,
138
+ );
139
+ }
140
+ if (analysis.filesWritten.length > 0) parts.push(`Files created/written: ${analysis.filesWritten.join(", ")}`);
141
+ if (analysis.filesEdited.length > 0) parts.push(`Files edited: ${analysis.filesEdited.join(", ")}`);
142
+ if (analysis.testResults.length > 0) parts.push(`Test results:\n${analysis.testResults.join("\n")}`);
143
+ if (analysis.errorsHit.length > 0)
144
+ parts.push(`Errors encountered: ${analysis.errorsHit.length}\n${analysis.errorsHit.slice(0, 5).join("\n")}`);
145
+ if (!analysis.calledMemorySearch) parts.push(`⚠️ Phase did NOT call memory_search (mandatory)`);
146
+ if (!analysis.calledMemoryWrite) parts.push(`⚠️ Phase did NOT call memory_write (mandatory)`);
147
+ return parts.join("\n");
148
+ }
149
+
150
+ /**
151
+ * Prefer the phase's canonical "## HANDOFF" block over the heuristic summary;
152
+ * fall back to the heuristic when the model did not write one.
153
+ */
154
+ export function buildNextBrief(
155
+ analysis: PhaseEndAnalysis,
156
+ handoff: string,
157
+ phaseLabel: string,
158
+ maxToolCallsPerPhase: number,
159
+ ): string {
160
+ return handoff
161
+ ? `Tool calls: ${analysis.toolCallCount}\n## HANDOFF (from ${phaseLabel})\n${handoff}`
162
+ : buildPhaseSummary(analysis, maxToolCallsPerPhase);
163
+ }
164
+
165
+ export type PhaseDecision =
166
+ | { action: "stop"; reason: "user-abort" | "auth-error" }
167
+ | { action: "retry-fallback" }
168
+ | { action: "pause-blocked" }
169
+ | { action: "review-fix-cycle" }
170
+ | {
171
+ action: "continue";
172
+ /** The phase answered with prose only — warn and nudge the next phase. */
173
+ zeroToolCalls: boolean;
174
+ /**
175
+ * The phase was expected to write a "VERDICT:" line but none parsed —
176
+ * a model deviated from the text contract. Surface it instead of
177
+ * silently treating the phase as passed.
178
+ */
179
+ missingVerdict: boolean;
180
+ };
181
+
182
+ export interface PhaseDecisionInput {
183
+ analysis: PhaseEndAnalysis;
184
+ /** Currently executing phase, or null when the queue raced. */
185
+ phase: { key: string; retried?: boolean } | null;
186
+ /** Resolved verdict (structured phase_result or parsed report; null = none). */
187
+ verdict: PhaseVerdict | null;
188
+ /** Completed review-fix cycles so far (bounded to one). */
189
+ reviewFixRounds: number;
190
+ maxToolCallsPerPhase: number;
191
+ }
192
+
193
+ /**
194
+ * Structured phase result: what a phase agent emits by CALLING the
195
+ * `phase_result` tool (robust primary path), instead of only writing markdown
196
+ * that has to be regex-scraped (fragile fallback). All fields optional — a
197
+ * partial structured emission is merged field-by-field with the parsed report.
198
+ */
199
+ export interface StructuredPhaseResult {
200
+ verdict?: PhaseVerdict;
201
+ blocking?: string;
202
+ handoff?: string;
203
+ }
204
+
205
+ export interface EffectivePhaseOutcome {
206
+ verdict: PhaseVerdict | null;
207
+ blocking: string;
208
+ handoff: string;
209
+ /** Where each resolved field came from — for diagnostics and tests. */
210
+ source: "structured" | "text" | "mixed" | "none";
211
+ }
212
+
213
+ /**
214
+ * Resolve a phase's effective outcome, preferring the structured tool emission
215
+ * and falling back to the regex-scraped markdown report per field. This is the
216
+ * heart of the "structured primary, text fallback" contract: when the model
217
+ * calls phase_result the outcome is exact; when it doesn't, behavior is
218
+ * identical to the pure-text path that shipped before.
219
+ */
220
+ export function resolvePhaseOutcome(
221
+ structured: StructuredPhaseResult | null,
222
+ reportText: string | null,
223
+ ): EffectivePhaseOutcome {
224
+ const textVerdict = reportText ? parsePhaseVerdict(reportText) : null;
225
+ const textBlocking = reportText ? extractBlockingFindings(reportText) : "";
226
+ const textHandoff = reportText ? extractHandoff(reportText) : "";
227
+
228
+ const verdict = structured?.verdict ?? textVerdict;
229
+ const blocking = structured?.blocking?.trim() || textBlocking;
230
+ const handoff = structured?.handoff?.trim() || textHandoff;
231
+
232
+ const usedStructured = Boolean(
233
+ structured && (structured.verdict || structured.blocking?.trim() || structured.handoff?.trim()),
234
+ );
235
+ const usedText = Boolean(textVerdict || textBlocking || textHandoff);
236
+ let source: EffectivePhaseOutcome["source"] = "none";
237
+ if (usedStructured && usedText) source = "mixed";
238
+ else if (usedStructured) source = "structured";
239
+ else if (usedText) source = "text";
240
+
241
+ return { verdict, blocking, handoff, source };
242
+ }
243
+
244
+ /** Phases whose contract REQUIRES a verdict (structured or a leading VERDICT: line). */
245
+ const VERDICT_PHASES = new Set(["test", "review"]);
246
+
247
+ /**
248
+ * Decide the transition after a phase's agent loop completed. Pure — the
249
+ * caller interprets the decision (notifications, queue edits, checkpoints).
250
+ */
251
+ export function decidePhaseTransition(input: PhaseDecisionInput): PhaseDecision {
252
+ const { analysis, phase, verdict, reviewFixRounds, maxToolCallsPerPhase } = input;
253
+
254
+ if (analysis.userAborted) return { action: "stop", reason: "user-abort" };
255
+ if (analysis.hasAuthError) return { action: "stop", reason: "auth-error" };
256
+
257
+ // Transient provider/proxy failure: retry the SAME phase once on the
258
+ // fallback model (a different family often gets through).
259
+ if (phase && !phase.retried && analysis.transient) {
260
+ return { action: "retry-fallback" };
261
+ }
262
+
263
+ if (verdict === "BLOCKED" && phase) return { action: "pause-blocked" };
264
+
265
+ const looped = analysis.toolCallCount > maxToolCallsPerPhase;
266
+ if (phase?.key === "review" && verdict === "FAIL" && reviewFixRounds < 1 && !looped) {
267
+ return { action: "review-fix-cycle" };
268
+ }
269
+
270
+ // A TEST/REVIEW phase must produce a verdict (structured phase_result is
271
+ // mandatory for them, with the report's VERDICT line as fallback). If it
272
+ // completed with tool work but no verdict either way, that is a contract
273
+ // deviation worth surfacing — not silently passing. The zero-tool-call case
274
+ // has its own warning, so exclude it here to avoid a double warning.
275
+ const missingVerdict =
276
+ phase !== null && VERDICT_PHASES.has(phase.key) && verdict === null && analysis.toolCallCount > 0;
277
+
278
+ return {
279
+ action: "continue",
280
+ zeroToolCalls: analysis.toolCallCount === 0,
281
+ missingVerdict,
282
+ };
283
+ }