@phi-code-admin/phi-code 0.87.0 → 0.89.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.
@@ -23,9 +23,17 @@ import { join } from "node:path";
23
23
  import { Type } from "@sinclair/typebox";
24
24
  import type { ExtensionAPI } from "phi-code";
25
25
  import { type AgentDef, loadAgentDef } from "./providers/agent-def.js";
26
+ import { buildVerifyInstruction, debugPhaseInstructions } from "./providers/debug-build-commands.js";
27
+ import { type FailingState, parseFailingState } from "./providers/debug-contract.js";
26
28
  import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
27
- import { extractBlockingFindings, extractHandoff, parsePhaseVerdict } from "./providers/orchestrator-helpers.js";
28
- import { analyzePhaseMessages, buildNextBrief, decidePhaseTransition } from "./providers/phase-machine.js";
29
+ import {
30
+ analyzePhaseMessages,
31
+ buildNextBrief,
32
+ decidePhaseTransition,
33
+ resolvePhaseOutcome,
34
+ type StructuredPhaseResult,
35
+ } from "./providers/phase-machine.js";
36
+ import { triage } from "./providers/triage.js";
29
37
 
30
38
  // ─── Types ───────────────────────────────────────────────────────────────
31
39
 
@@ -284,6 +292,10 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
284
292
 
285
293
  let phaseQueue: OrchestratorPhase[] = [];
286
294
  let orchestrationActive = false;
295
+ // Which pipeline is running. "plan" keeps the existing 5-phase agent_end path
296
+ // untouched; "debug"/"build" are driven by the separate linear generic driver
297
+ // so /plan's fragile review-fix + "/5" logic is never engaged for them.
298
+ let orchestrationMode: "plan" | "debug" | "build" = "plan";
287
299
  let activeAgentPrompt: string | null = null;
288
300
  let _activeAgentTools: string[] | null = null;
289
301
  let savedTools: string[] | null = null;
@@ -309,11 +321,85 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
309
321
  // Set true right before an INTERNAL ctx.abort() (phase timeout) so the
310
322
  // resulting agent_end is not mistaken for a user Ctrl+C cancellation.
311
323
  let internalAbort = false;
324
+ // Structured result emitted by the current phase via the `phase_result` tool
325
+ // (robust primary path). Reset at each phase start; read in agent_end and
326
+ // merged with the parsed markdown report (fallback) by resolvePhaseOutcome.
327
+ // currentPhaseResultKey stamps which phase produced it, so a stale result
328
+ // from a previous run/phase (or a tool call that lands after the phase
329
+ // transition) is never mistaken for the current phase's output.
330
+ let currentPhaseResult: StructuredPhaseResult | null = null;
331
+ let currentPhaseResultKey: string | null = null;
332
+
333
+ // ─── phase_result Tool — structured phase outcome (robust primary path) ───
334
+ // A phase agent CALLS this to emit its verdict/handoff/blocking as data
335
+ // instead of relying on the orchestrator regex-scraping its markdown report.
336
+ // It is only meaningful during /plan; outside orchestration it is a no-op so
337
+ // a stray call never errors. The markdown report is still written (it is the
338
+ // human-readable artifact); this just makes the machine-read path exact.
339
+ pi.registerTool({
340
+ name: "phase_result",
341
+ label: "Phase Result",
342
+ description:
343
+ "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.",
344
+ promptGuidelines: [
345
+ "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.",
346
+ "verdict: PASS only if you OBSERVED success; FAIL if any blocking issue; BLOCKED if you could not run; SKIP if not applicable.",
347
+ "blocking: the must-fix findings (REVIEW), one per line. handoff: what is done, open risks, and the single most important next action.",
348
+ ],
349
+ parameters: Type.Object({
350
+ verdict: Type.Optional(
351
+ Type.Union([Type.Literal("PASS"), Type.Literal("FAIL"), Type.Literal("BLOCKED"), Type.Literal("SKIP")], {
352
+ description: "Phase verdict (required for TEST and REVIEW phases)",
353
+ }),
354
+ ),
355
+ blocking: Type.Optional(
356
+ Type.String({ description: "Must-fix findings, one per line (REVIEW). Empty when none." }),
357
+ ),
358
+ handoff: Type.Optional(
359
+ Type.String({ description: "Concise handoff for the next phase: state, open risks, next action." }),
360
+ ),
361
+ }),
362
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
363
+ if (!orchestrationActive) {
364
+ return {
365
+ content: [{ type: "text", text: "phase_result is only used during /plan orchestration; ignored here." }],
366
+ details: undefined,
367
+ };
368
+ }
369
+ const raw = params as { verdict?: StructuredPhaseResult["verdict"]; blocking?: string; handoff?: string };
370
+ // Merge only the fields this call actually set, so a phase that emits
371
+ // its result across several calls (verdict first, handoff later — a
372
+ // common LLM pattern) does not erase earlier fields. Start fresh when
373
+ // the previous result belonged to a different phase.
374
+ const defined: StructuredPhaseResult = {};
375
+ if (raw.verdict !== undefined) defined.verdict = raw.verdict;
376
+ if (typeof raw.blocking === "string") defined.blocking = raw.blocking;
377
+ if (typeof raw.handoff === "string") defined.handoff = raw.handoff;
378
+ const samePhase = currentPhaseResultKey === (currentPhase?.key ?? null);
379
+ currentPhaseResult = { ...(samePhase && currentPhaseResult ? currentPhaseResult : {}), ...defined };
380
+ currentPhaseResultKey = currentPhase?.key ?? null;
381
+ const parts = [
382
+ raw.verdict ? `verdict ${raw.verdict}` : null,
383
+ raw.blocking?.trim() ? "blocking findings" : null,
384
+ raw.handoff?.trim() ? "handoff" : null,
385
+ ].filter(Boolean);
386
+ return {
387
+ content: [{ type: "text", text: `Phase result recorded${parts.length ? ` (${parts.join(", ")})` : ""}.` }],
388
+ details: undefined,
389
+ };
390
+ },
391
+ });
392
+
393
+ // A phase's report file is not always named <key>-<ts>.md: PLAN writes its
394
+ // handoff into todo-<ts>.md and CODE into progress-<ts>.md. Map key -> file
395
+ // stem so the text fallback for HANDOFF/BLOCKING actually finds them.
396
+ const REPORT_FILE_STEM: Record<string, string> = { plan: "todo", code: "progress" };
312
397
 
313
- /** Read a phase's report file (.phi/plans/<key>-<ts>.md). Null-safe. */
398
+ /** Read a phase's report file (.phi/plans/<stem>-<ts>.md). Null-safe. */
314
399
  function readPhaseReport(key: string, ts: string): string | null {
315
400
  try {
316
- const f = join(process.cwd(), ".phi", "plans", `${key}-${ts}.md`);
401
+ const stem = REPORT_FILE_STEM[key] ?? key;
402
+ const f = join(process.cwd(), ".phi", "plans", `${stem}-${ts}.md`);
317
403
  if (existsSync(f)) return readFileSync(f, "utf-8");
318
404
  } catch {
319
405
  /* ignore */
@@ -626,6 +712,7 @@ After implementation, use \`memory_write\` to save a summary of what was built,
626
712
  **Ontology update:** Use \`ontology_add\` to update the project status (e.g., entity "implementation" type "Phase" with properties {status: "complete", files: "N"}) and add a relation to the project entity.
627
713
 
628
714
  **CRITICAL RULES:**
715
+ - **Minimal diff, root cause.** Prefer the SMALLEST change that fixes the underlying cause. Every extra condition, guard, or early-return you add is a liability — it can silently make the fix a no-op in the real scenario (a guard that skips the fix on the exact input the bug is about). When in doubt, do LESS and match the existing code's pattern rather than adding cleverness. A fix more elaborate than the problem is a red flag, not thoroughness.
629
716
  - Write ONE file per tool call — NEVER combine multiple files in a single response
630
717
  - Keep each file under 500 lines. If longer, split into modules
631
718
  - After writing ALL files, verify they exist with a single \`find . -name '*.ts' -o -name '*.js' -o -name '*.html' | sort\`` +
@@ -650,14 +737,13 @@ After implementation, use \`memory_write\` to save a summary of what was built,
650
737
 
651
738
  **Step 1:** Read \`.phi/plans/todo-*.md\` to know what was planned
652
739
  **Step 2:** Read \`.phi/plans/progress-*.md\` to see what was done
653
- **Step 3:** ACTUALLY RUN the code and observe it (proof, not a declarative checkbox). Verification = runtime evidence, by surface type:
654
- - **CLI:** run the real command, capture stdout AND the exit code, paste them.
655
- - **HTTP/API:** start the server in the background with a readiness wait, then \`curl\` the route that changed; paste the response + status.
656
- - **Library/package:** import the public entry and call it; paste the output.
657
- - At least one adversarial probe per feature (empty input, wrong type, missing arg).
658
- - Running \`npm test\` alone is NOT sufficient proof for a feature.
659
- **Step 4:** Fix any real errors you find (root cause, not a workaround).
740
+ **Step 3 (GROUND TRUTH — the single most important step):** Derive the expected behaviour from the **Project Request above**, NEVER from the code that was written. Write a reproduction of the EXACT scenario the request describes — same conditions, same inputs. If the request is about a streaming response with no charset, stream a chunked response with no charset; if it is about an empty list, pass an empty list. Assert the behaviour the **request** says is correct, and make each assertion trace to a specific phrase in the request.
741
+ - **CRITICAL ANTI-PATTERN:** do NOT write a test that merely re-states what the CODE now does that only confirms the code's own assumptions, which is exactly how a wrong fix passes its own tests. The test must be written as if you had NOT seen the fix.
742
+ - **FALSIFY FIRST:** your reproduction MUST FAIL against the ORIGINAL (pre-fix) code — \`git stash\` the fix, run it, confirm it fails, then \`git stash pop\` and confirm it now passes. If it passes on the original code, it does not reproduce the bug: rewrite it. Paste both runs.
743
+ **Step 3b:** ACTUALLY RUN it and paste runtime evidence (stdout + exit code). Add at least one adversarial probe per feature (empty input, wrong type, missing arg). Running \`npm test\`/the existing suite alone is NOT sufficient — it does not cover the new scenario.
744
+ **Step 4:** Fix any real errors you find (root cause, not a workaround). If the fix is a no-op in the request's own scenario, that is a FAIL, not a pass.
660
745
  **Step 5:** Write test results to \`.phi/plans/test-${ts}.md\`, starting the file with a VERDICT line.
746
+ **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.
661
747
 
662
748
  **Test report format (the FIRST line MUST be the verdict):**
663
749
  \`\`\`markdown
@@ -725,12 +811,14 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
725
811
  - **Angle 1 - Correctness:** read the diff line by line. What behaviour did it change or remove? Off-by-one, null/undefined, wrong condition, broken invariant.
726
812
  - **Angle 2 - Cross-file:** grep the callers and callees of changed symbols. Did a signature/return/contract change break a caller elsewhere?
727
813
  - **Angle 3 - Security & language pitfalls:** input validation, injection, secrets, auth, error handling; plus language traps (async not awaited, shadowed scope, mutation of shared state).
814
+ - **Angle 4 - Over-engineering & dead guards (the failure mode that ships confident-wrong fixes — check this FIRST):** for EVERY new condition, guard, or early-return the diff added, name the concrete input where it takes the SKIP / else branch, and check the behaviour is still correct THERE — *especially against the exact scenario in the Project Request*. A guard that makes the fix a no-op in the request's own scenario (e.g. an \`if buffered\` guard on a bug that is about the streaming path) is a **CONFIRMED** blocking bug. Then compare the diff's size to the problem: if the fix is more elaborate than the request requires, or does not match the surrounding code's existing pattern, that itself is a finding — the minimal change is usually the correct one.
728
815
  **Step 3 - VERIFY (3-state, cite the line):** for EACH candidate, quote the exact line and classify:
729
816
  - **CONFIRMED** - you can name the input/state that triggers it and the wrong output. Quote the line.
730
817
  - **PLAUSIBLE** - the mechanism is real but the trigger is uncertain. Say what would confirm it.
731
818
  - **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.
732
819
  Keep only CONFIRMED + PLAUSIBLE. A finding with no concrete failure scenario is noise: drop it.
733
820
  **Step 4:** Write \`.phi/plans/review-${ts}.md\`, starting with the VERDICT line.
821
+ **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.
734
822
 
735
823
  **Final report format (the FIRST line MUST be the verdict):**
736
824
  \`\`\`markdown
@@ -844,9 +932,16 @@ Tag the note with relevant keywords for vector search.
844
932
  activeAgentPrompt = agentDef.systemPrompt;
845
933
  // Restrict tools to agent's allowed tools
846
934
  if (agentDef.tools.length > 0) {
847
- // Always include memory tools in orchestration phases
848
- const memoryTools = ["memory_search", "memory_write", "memory_read", "ontology_add", "ontology_query"];
849
- const agentTools = [...agentDef.tools, ...memoryTools.filter((t) => !agentDef.tools.includes(t))];
935
+ // Always include the memory tools and phase_result in orchestration phases.
936
+ const forcedTools = [
937
+ "memory_search",
938
+ "memory_write",
939
+ "memory_read",
940
+ "ontology_add",
941
+ "ontology_query",
942
+ "phase_result",
943
+ ];
944
+ const agentTools = [...agentDef.tools, ...forcedTools.filter((t) => !agentDef.tools.includes(t))];
850
945
  _activeAgentTools = agentTools;
851
946
  pi.setActiveTools(agentTools);
852
947
  } else if (savedTools) {
@@ -959,6 +1054,10 @@ Tag the note with relevant keywords for vector search.
959
1054
  const phase = phaseQueue.shift()!;
960
1055
  phasePending = true;
961
1056
  currentPhase = phase;
1057
+ // New phase starts with no structured result; it is set only if this
1058
+ // phase's agent calls phase_result.
1059
+ currentPhaseResult = null;
1060
+ currentPhaseResultKey = null;
962
1061
 
963
1062
  switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
964
1063
  activateAgent(phase, ctx);
@@ -1004,6 +1103,242 @@ Tag the note with relevant keywords for vector search.
1004
1103
  });
1005
1104
  }
1006
1105
 
1106
+ // ─── Generic linear driver (/debug, /build) ─────────────────────
1107
+ // A deliberately simpler engine than /plan's: phases run in order, a BLOCKED
1108
+ // verdict halts, and there is no review-fix cycle or "/5" bookkeeping. It
1109
+ // reuses the tested primitives (switchModelForPhase, activateAgent,
1110
+ // resolvePhaseOutcome) but never the /plan-specific agent_end path.
1111
+
1112
+ function routeFor(routeKey: string): { preferred: string; fallback: string } {
1113
+ const routingPath = join(homedir(), ".phi", "agent", "routing.json");
1114
+ try {
1115
+ const routing = JSON.parse(readFileSync(routingPath, "utf-8"));
1116
+ const route = routing.routes?.[routeKey];
1117
+ return {
1118
+ preferred: route?.preferredModel || routing.default?.model || "default",
1119
+ fallback: route?.fallback || routing.default?.model || "default",
1120
+ };
1121
+ } catch {
1122
+ return { preferred: "default", fallback: "default" };
1123
+ }
1124
+ }
1125
+
1126
+ function genericPhase(
1127
+ key: string,
1128
+ label: string,
1129
+ routeKey: string,
1130
+ agentKey: string,
1131
+ instruction: string,
1132
+ ): OrchestratorPhase {
1133
+ const route = routeFor(routeKey);
1134
+ return {
1135
+ key,
1136
+ label,
1137
+ model: route.preferred,
1138
+ fallback: route.fallback,
1139
+ agent: loadAgentDef(agentKey),
1140
+ instruction: instruction + COMMON_PHASE_RULES,
1141
+ };
1142
+ }
1143
+
1144
+ // /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase routes to a model
1145
+ // that does NOT share the coder's blind spot (verify on the test family).
1146
+ function buildDebugPhases(state: FailingState): OrchestratorPhase[] {
1147
+ const ins = debugPhaseInstructions(state);
1148
+ return [
1149
+ genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
1150
+ genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
1151
+ genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
1152
+ genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
1153
+ ];
1154
+ }
1155
+
1156
+ // /build: reuse /plan's EXPLORE→PLAN→CODE, then an execution-grounded
1157
+ // BUILD-VERIFY that runs the recipe, checks acceptance, red-teams the
1158
+ // boundary, and routes real failures to the /debug protocol.
1159
+ function buildBuildPhases(spec: string, ts: string): OrchestratorPhase[] {
1160
+ const planLike = buildPhases(spec, ts).slice(0, 3); // explore, plan, code
1161
+ const verify = genericPhase(
1162
+ "verify",
1163
+ "🧪 Phase 4 — BUILD-VERIFY",
1164
+ "review",
1165
+ "review",
1166
+ buildVerifyInstruction(spec),
1167
+ );
1168
+ return [...planLike, verify];
1169
+ }
1170
+
1171
+ function finishGenericOrchestration(ctx: any, headline: string) {
1172
+ const elapsed = phaseStartTime ? Math.round((Date.now() - phaseStartTime) / 1000) : 0;
1173
+ const minutes = Math.floor(elapsed / 60);
1174
+ const seconds = elapsed % 60;
1175
+ const mode = orchestrationMode;
1176
+ setOrchestrationActive(false);
1177
+ phasePending = false;
1178
+ orchestrationMode = "plan";
1179
+ deactivateAgent();
1180
+ if (phaseTimeoutId) {
1181
+ clearTimeout(phaseTimeoutId);
1182
+ phaseTimeoutId = null;
1183
+ }
1184
+ if (originalModel) {
1185
+ const toRestore = originalModel;
1186
+ originalModel = null;
1187
+ Promise.resolve(pi.setModel(toRestore)).catch(() => {
1188
+ /* best effort */
1189
+ });
1190
+ }
1191
+ ctx.ui.notify(
1192
+ `\n📊 **/${mode} summary** — ${completedPhases} phase(s) in ${minutes}m ${seconds}s${
1193
+ skippedPhases ? ` (${skippedPhases} skipped on timeout)` : ""
1194
+ }\n${headline}`,
1195
+ "info",
1196
+ );
1197
+ }
1198
+
1199
+ function sendNextGenericPhase(ctx: any) {
1200
+ if (phaseQueue.length === 0) {
1201
+ finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
1202
+ return;
1203
+ }
1204
+ const phase = phaseQueue.shift()!;
1205
+ phasePending = true;
1206
+ currentPhase = phase;
1207
+ currentPhaseResult = null;
1208
+ currentPhaseResultKey = null;
1209
+
1210
+ switchModelForPhase(phase, ctx).then(({ modelId, warning }) => {
1211
+ activateAgent(phase, ctx);
1212
+ const agentName = phase.agent?.name || phase.key;
1213
+ ctx.ui.notify(`\n${phase.label} → \`${modelId}\` (agent: ${agentName})`, "info");
1214
+ if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
1215
+ setTimeout(() => pi.sendUserMessage(phase.instruction, { deliverAs: "followUp" }), 500);
1216
+ if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
1217
+ phaseTimeoutId = setTimeout(() => {
1218
+ if (orchestrationActive && phasePending) {
1219
+ phasePending = false;
1220
+ internalAbort = true;
1221
+ try {
1222
+ ctx.abort();
1223
+ } catch {
1224
+ /* best effort */
1225
+ }
1226
+ if (!phase.retried) {
1227
+ phase.retried = true;
1228
+ phase.useFallback = true;
1229
+ phaseQueue.unshift(phase);
1230
+ ctx.ui.notify(
1231
+ `\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min) — retrying once on the fallback model.`,
1232
+ "warning",
1233
+ );
1234
+ } else {
1235
+ skippedPhases++;
1236
+ ctx.ui.notify(`\n⏰ **Phase timed out again** — skipping to next phase.`, "warning");
1237
+ }
1238
+ sendNextGenericPhase(ctx);
1239
+ }
1240
+ }, MAX_PHASE_DURATION_MS);
1241
+ });
1242
+ }
1243
+
1244
+ async function handleGenericPhaseEnd(event: any, ctx: any) {
1245
+ const messages = event.messages || [];
1246
+ const analysis = analyzePhaseMessages(messages);
1247
+
1248
+ if (analysis.userAborted) {
1249
+ ctx.ui.notify(`\n🛑 **/${orchestrationMode} cancelled** by user. Remaining phases skipped.`, "warning");
1250
+ finishGenericOrchestration(ctx, "🛑 Cancelled.");
1251
+ return;
1252
+ }
1253
+
1254
+ if (!phasePending) {
1255
+ if (phaseQueue.length === 0) finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
1256
+ return;
1257
+ }
1258
+ if (phaseTimeoutId) {
1259
+ clearTimeout(phaseTimeoutId);
1260
+ phaseTimeoutId = null;
1261
+ }
1262
+
1263
+ // A BLOCKED verdict (REPRODUCE cannot reproduce / VERIFY could not confirm)
1264
+ // halts the pipeline honestly — no fabricated success.
1265
+ const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
1266
+ const outcome = resolvePhaseOutcome(structuredForThisPhase, null);
1267
+ if (outcome.verdict === "BLOCKED" && currentPhase) {
1268
+ ctx.ui.notify(
1269
+ `\n⏸️ **${currentPhase.label} reported BLOCKED.** ${outcome.handoff || "No reproducible/verifiable result."}`,
1270
+ "warning",
1271
+ );
1272
+ finishGenericOrchestration(ctx, `⏸️ **/${orchestrationMode} stopped: BLOCKED at ${currentPhase.label}.**`);
1273
+ return;
1274
+ }
1275
+
1276
+ // Propagate a concise handoff to the next phase, like /plan does.
1277
+ const nextBrief = buildNextBrief(
1278
+ analysis,
1279
+ outcome.handoff,
1280
+ currentPhase?.label || "previous phase",
1281
+ MAX_TOOL_CALLS_PER_PHASE,
1282
+ );
1283
+ if (nextBrief && phaseQueue.length > 0) {
1284
+ phaseQueue[0].instruction += `\n\n**Previous phase summary:**\n${nextBrief}`;
1285
+ }
1286
+
1287
+ completedPhases++;
1288
+ phasePending = false;
1289
+ sendNextGenericPhase(ctx);
1290
+ }
1291
+
1292
+ function startGenericOrchestration(
1293
+ mode: "debug" | "build",
1294
+ phases: OrchestratorPhase[],
1295
+ ctx: any,
1296
+ headline: string,
1297
+ ) {
1298
+ phaseQueue = phases.slice(1);
1299
+ orchestrationMode = mode;
1300
+ setOrchestrationActive(true);
1301
+ phasePending = true;
1302
+ originalModel = ctx.model || null;
1303
+ savedTools = pi.getActiveTools();
1304
+ completedPhases = 0;
1305
+ skippedPhases = 0;
1306
+ internalAbort = false;
1307
+ currentPhaseResult = null;
1308
+ currentPhaseResultKey = null;
1309
+ phaseStartTime = Date.now();
1310
+ const first = phases[0];
1311
+ currentPhase = first;
1312
+
1313
+ ctx.ui.notify(headline, "info");
1314
+ for (const p of phases) {
1315
+ const agentName = p.agent?.name || p.key;
1316
+ ctx.ui.notify(` ${p.label} → \`${p.model}\` (agent: ${agentName})`, "info");
1317
+ }
1318
+
1319
+ switchModelForPhase(first, ctx).then(({ modelId, warning }) => {
1320
+ activateAgent(first, ctx);
1321
+ ctx.ui.notify(`\n${first.label} → \`${modelId}\``, "info");
1322
+ if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
1323
+ if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
1324
+ phaseTimeoutId = setTimeout(() => {
1325
+ if (orchestrationActive && phasePending) {
1326
+ phasePending = false;
1327
+ internalAbort = true;
1328
+ try {
1329
+ ctx.abort();
1330
+ } catch {
1331
+ /* best effort */
1332
+ }
1333
+ skippedPhases++;
1334
+ ctx.ui.notify(`\n⏰ **First phase timed out** — skipping.`, "warning");
1335
+ sendNextGenericPhase(ctx);
1336
+ }
1337
+ }, MAX_PHASE_DURATION_MS);
1338
+ setTimeout(() => pi.sendUserMessage(first.instruction, { deliverAs: "followUp" }), 200);
1339
+ });
1340
+ }
1341
+
1007
1342
  // ─── System Prompt Injection — Agent personas ────────────────────
1008
1343
 
1009
1344
  pi.on("before_agent_start", async (_event, _ctx) => {
@@ -1033,6 +1368,13 @@ Tag the note with relevant keywords for vector search.
1033
1368
  return;
1034
1369
  }
1035
1370
 
1371
+ // /debug and /build run on the separate linear driver — never through the
1372
+ // /plan-specific review-fix + "/5" transition logic below.
1373
+ if (orchestrationMode !== "plan") {
1374
+ await handleGenericPhaseEnd(event, ctx);
1375
+ return;
1376
+ }
1377
+
1036
1378
  const messages = event.messages || [];
1037
1379
  // Analyze what happened, read the phase's report, and let the pure state
1038
1380
  // machine (providers/phase-machine.ts, unit tested) decide the transition.
@@ -1063,13 +1405,18 @@ Tag the note with relevant keywords for vector search.
1063
1405
  phaseTimeoutId = null;
1064
1406
  }
1065
1407
 
1408
+ // Resolve the phase outcome: prefer the structured phase_result emission
1409
+ // (exact) and fall back per-field to the regex-scraped markdown report.
1410
+ // Only trust a structured result stamped with THIS phase's key — a stale
1411
+ // result from a previous run or a late tool call is ignored.
1066
1412
  const reportContent = currentPhase ? readPhaseReport(currentPhase.key, currentRunTs) : null;
1067
- const verdict = reportContent ? parsePhaseVerdict(reportContent) : null;
1413
+ const structuredForThisPhase = currentPhaseResultKey === (currentPhase?.key ?? null) ? currentPhaseResult : null;
1414
+ const outcome = resolvePhaseOutcome(structuredForThisPhase, reportContent);
1415
+ const verdict = outcome.verdict;
1068
1416
  const decision = decidePhaseTransition({
1069
1417
  analysis,
1070
1418
  phase: currentPhase ? { key: currentPhase.key, retried: currentPhase.retried } : null,
1071
1419
  verdict,
1072
- hasReport: reportContent !== null,
1073
1420
  reviewFixRounds,
1074
1421
  maxToolCallsPerPhase: MAX_TOOL_CALLS_PER_PHASE,
1075
1422
  });
@@ -1123,22 +1470,21 @@ Tag the note with relevant keywords for vector search.
1123
1470
  }
1124
1471
 
1125
1472
  if (decision.action === "continue" && decision.missingVerdict && currentPhase) {
1126
- // The model deviated from the text contract: this phase must start its
1127
- // report with "VERDICT: ..." and nothing parseable was found. Surface it
1128
- // instead of silently treating the phase as passed.
1473
+ // The model deviated from BOTH contract paths: it neither called
1474
+ // phase_result with a verdict nor wrote a parseable "VERDICT:" line.
1475
+ // Surface it instead of silently treating the phase as passed.
1129
1476
  ctx.ui.notify(
1130
- `\n⚠️ **${currentPhase.label} wrote no parseable VERDICT line** in \`.phi/plans/${currentPhase.key}-${currentRunTs}.md\` — treating it as passed. Check the report manually.`,
1477
+ `\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.`,
1131
1478
  "warning",
1132
1479
  );
1133
1480
  }
1134
1481
 
1135
- // Prefer the phase's canonical "## HANDOFF" block (a deterministic text
1136
- // contract written to its report file) over the heuristic summary; fall back
1137
- // to the heuristic when the model did not write one.
1138
- const handoff = reportContent ? extractHandoff(reportContent) : "";
1482
+ // Prefer the resolved HANDOFF (structured phase_result, else the report's
1483
+ // "## HANDOFF" block) over the heuristic summary; fall back to the
1484
+ // heuristic when neither was provided.
1139
1485
  const nextBrief = buildNextBrief(
1140
1486
  analysis,
1141
- handoff,
1487
+ outcome.handoff,
1142
1488
  currentPhase?.label || "previous phase",
1143
1489
  MAX_TOOL_CALLS_PER_PHASE,
1144
1490
  );
@@ -1149,7 +1495,7 @@ Tag the note with relevant keywords for vector search.
1149
1495
  if (decision.action === "review-fix-cycle" && currentPhase) {
1150
1496
  // REVIEW FAIL: open ONE bounded fix -> re-review cycle.
1151
1497
  reviewFixRounds++;
1152
- const blocking = (reportContent ? extractBlockingFindings(reportContent) : "").slice(0, 4000);
1498
+ const blocking = outcome.blocking.slice(0, 4000);
1153
1499
  const fixPhase: OrchestratorPhase = {
1154
1500
  key: "code",
1155
1501
  label: "🔧 Fix — CODE (review remediation)",
@@ -1217,6 +1563,10 @@ Tag the note with relevant keywords for vector search.
1217
1563
  skippedPhases = 0;
1218
1564
  reviewFixRounds = 0;
1219
1565
  internalAbort = false;
1566
+ // Clear any structured result left over from a prior run so this
1567
+ // resumed phase's outcome is read fresh (see phase-result leak guard).
1568
+ currentPhaseResult = null;
1569
+ currentPhaseResultKey = null;
1220
1570
  phaseStartTime = Date.now();
1221
1571
  const firstResume = remaining[0];
1222
1572
  currentPhase = firstResume;
@@ -1279,6 +1629,11 @@ Tag the note with relevant keywords for vector search.
1279
1629
  skippedPhases = 0;
1280
1630
  reviewFixRounds = 0;
1281
1631
  internalAbort = false;
1632
+ // Clear any structured result left over from a previous /plan run in
1633
+ // this session; the first phase is launched directly (not via
1634
+ // sendNextPhase), so without this a stale verdict/handoff would leak.
1635
+ currentPhaseResult = null;
1636
+ currentPhaseResultKey = null;
1282
1637
  const firstPhase = phases[0];
1283
1638
  currentPhase = firstPhase;
1284
1639
 
@@ -1411,4 +1766,105 @@ Tag the note with relevant keywords for vector search.
1411
1766
  ctx.ui.notify(output, "info");
1412
1767
  },
1413
1768
  });
1769
+
1770
+ // ─── /debug Command — turn a real failure green (execution-grounded) ──
1771
+
1772
+ pi.registerCommand("debug", {
1773
+ description: "Fix a REAL failing state — REPRODUCE → LOCALIZE → FIX → VERIFY, verdict backed by a real run",
1774
+ handler: async (args, ctx) => {
1775
+ if (orchestrationActive) {
1776
+ ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
1777
+ return;
1778
+ }
1779
+ const raw = args.trim();
1780
+ if (!raw) {
1781
+ ctx.ui.notify(
1782
+ `**Usage:** \`/debug <failing test | repro command | description>\`
1783
+
1784
+ /debug never guesses — it needs something reproducible.
1785
+ **Examples:**
1786
+ /debug pytest tests/test_auth.py::test_login_returns_jwt
1787
+ /debug node repro.js (segfaults on empty input, should return [])
1788
+ /debug the /login route 500s when the body is missing
1789
+
1790
+ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a real before/after run — otherwise BLOCKED.`,
1791
+ "info",
1792
+ );
1793
+ return;
1794
+ }
1795
+
1796
+ const state: FailingState = parseFailingState(raw, { cwd: ctx.cwd || process.cwd() });
1797
+ // Honest gate at the boundary: /debug needs a reproducible failure. A bare
1798
+ // prose description with no test/command still runs (REPRODUCE will try to
1799
+ // derive one), but we tell the user the oracle depends on a real run.
1800
+ if (!state.failingTest && !state.reproCommand) {
1801
+ ctx.ui.notify(
1802
+ `⚠️ No failing test or repro command detected — /debug works best with one (e.g. \`pytest …\` or \`node repro.js\`). Proceeding: REPRODUCE will try to construct a reproduction from your description, and will emit **BLOCKED** if it cannot run one (no fabricated PASS).`,
1803
+ "warning",
1804
+ );
1805
+ }
1806
+
1807
+ await ensurePlansDir();
1808
+ const phases = buildDebugPhases(state);
1809
+ startGenericOrchestration(
1810
+ "debug",
1811
+ phases,
1812
+ ctx,
1813
+ `🐛 **/debug started** — 4 execution-grounded phases (verdict requires a real run)\n`,
1814
+ );
1815
+ },
1816
+ });
1817
+
1818
+ // ─── /build Command — build until it runs (plan + execution loop) ────
1819
+
1820
+ pi.registerCommand("build", {
1821
+ description:
1822
+ "Build from a spec AND prove it runs — EXPLORE → PLAN → CODE → BUILD-VERIFY (run + red-team + debug)",
1823
+ handler: async (args, ctx) => {
1824
+ if (orchestrationActive) {
1825
+ ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
1826
+ return;
1827
+ }
1828
+ const spec = args.trim();
1829
+ if (!spec) {
1830
+ ctx.ui.notify(
1831
+ `**Usage:** \`/build <what to build>\`
1832
+
1833
+ /build = /plan then an execution-grounded verify loop (run the recipe, check acceptance, executable red-team, fix real failures via /debug).
1834
+ **Examples:**
1835
+ /build a REST API for user auth with JWT and refresh tokens
1836
+ /build a CLI that converts CSV to JSON with a --pretty flag
1837
+
1838
+ It reports SUCCESS only when a real run meets the acceptance criteria — otherwise an honest PARTIAL listing what still fails. For a pure decomposition use \`/plan\`; to fix a known failure use \`/debug\`.`,
1839
+ "info",
1840
+ );
1841
+ return;
1842
+ }
1843
+
1844
+ // Cheap triage note (transparency): confirm /build is the right depth.
1845
+ const decision = triage({ text: spec });
1846
+ if (decision.route === "single-shot") {
1847
+ ctx.ui.notify(
1848
+ `ℹ️ This looks small and self-contained (${decision.reason}). /build will still run, but a single request may be cheaper.`,
1849
+ "info",
1850
+ );
1851
+ }
1852
+
1853
+ await ensurePlansDir();
1854
+ const ts = timestamp();
1855
+ await writeFile(
1856
+ join(plansDir, `spec-${ts}.md`),
1857
+ `# ${spec}\n\n**Created:** ${new Date().toLocaleString()}\n`,
1858
+ "utf-8",
1859
+ );
1860
+ currentDescription = spec;
1861
+ const phases = buildBuildPhases(spec, ts);
1862
+ startGenericOrchestration(
1863
+ "build",
1864
+ phases,
1865
+ ctx,
1866
+ `🏗️ **/build started** — plan + execution loop (SUCCESS requires a real run, else honest PARTIAL)\n`,
1867
+ );
1868
+ },
1869
+ });
1414
1870
  }