@phi-code-admin/phi-code 0.92.0 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,51 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.93.0] - 2026-07-12
4
+
5
+ ### Added — the escalation architecture the n=13 measurement demanded
6
+
7
+ - **`/fix` — single-shot first, oracle next, escalate only on red.** One direct
8
+ attempt at baseline cost, then the DRIVER runs the reproduction and the
9
+ project suite in the real sandbox (deterministic, zero model tokens): green →
10
+ done; red → the full /debug pipeline takes over, seeded with the exact red
11
+ run (command + trace, never a paraphrase). Nothing runnable → honestly
12
+ labelled UNVERIFIED. By construction never worse than the single shot
13
+ (measured: baseline 7/13 vs pipeline 6/13 at ~2.5×).
14
+ - **`/debug --candidates N` — diversity proposes, the oracle disposes.** N
15
+ independent FIX attempts on DIVERSE model families (routing-derived), each
16
+ patch captured and the tree reset between attempts; then deterministic
17
+ arbitration applies each candidate and runs the reproduction (from the input
18
+ or the REPRODUCE handoff's machine-readable REPRO-CMD line) + suite in the
19
+ sandbox; the minimal passing candidate wins (candidate-select). This is the
20
+ adversarial review that works — the one we measured (opinion panels rubber-
21
+ stamp shared misconceptions; a red run does not). Refuses to run over a dirty
22
+ tree (non-deletion policy) and falls back to single-candidate.
23
+ - **Never a blank page (lesson sympy-11870).** When no candidate passes
24
+ arbitration, the smallest non-empty candidate stays applied, clearly labelled
25
+ UNVERIFIED; a confirmed-BLOCKED halt now reports any draft left in the tree
26
+ (git diff --stat) instead of silently discarding work.
27
+ - **Literal reproductions (lesson requests-2148).** REPRODUCE must build the
28
+ repro from the EXACT snippets/values quoted in the issue (a reproduction of
29
+ your interpretation validates your interpretation, not the bug) and must
30
+ emit a REPRO-CMD: line for deterministic re-runs. LOCALIZE now consults and
31
+ feeds project memory (memory_search/memory_write).
32
+ - **Run telemetry.** Every /fix //debug //build run appends one JSONL line to
33
+ .phi/runs.jsonl (mode, phases with model+verdict+retries, sandbox exec count,
34
+ duration, outcome) — continuous measurement instead of campaigns.
35
+
36
+ 30 new tests (escalation core 13, telemetry 6, instructions 5, integration 6 —
37
+ including a real-git multi-candidate arbitration that applies the minimal
38
+ passing patch and a real-run /fix escalation); full suite green (1469).
39
+
40
+ ## [0.92.1] - 2026-07-12
41
+
42
+ ### Docs
43
+
44
+ - README: document the execution-grounded modes (/plan, /debug, /build, /sandbox,
45
+ the sandbox_run oracle, per-phase model routing, the honest n=13 measurement)
46
+ and the one-command Alibaba Cloud Coding Plan setup — three releases of
47
+ features were undocumented.
48
+
3
49
  ## [0.92.0] - 2026-07-11
4
50
 
5
51
  ### Fixed — the two defects the n=13 SWE-bench measurement exposed
package/README.md CHANGED
@@ -16,6 +16,7 @@ phi-code runs in four modes: interactive, print or JSON, RPC for process integra
16
16
 
17
17
  - [Quick Start](#quick-start)
18
18
  - [Providers & Models](#providers--models)
19
+ - [Execution-Grounded Modes (/plan, /debug, /build, /sandbox)](#execution-grounded-modes)
19
20
  - [Interactive Mode](#interactive-mode)
20
21
  - [Editor](#editor)
21
22
  - [Commands](#commands)
@@ -103,13 +104,62 @@ For each built-in provider, phi maintains a list of tool-capable models, updated
103
104
  - Xiaomi MiMo Token Plan (China)
104
105
  - Xiaomi MiMo Token Plan (Amsterdam)
105
106
  - Xiaomi MiMo Token Plan (Singapore)
107
+ - Alibaba Cloud Coding Plan (`qwen3.7-plus`, GLM, Kimi, MiniMax — subscription quota)
106
108
 
107
109
  See [docs/providers.md](docs/providers.md) for detailed setup instructions.
108
110
 
111
+ **One-command setup for known providers:** `/keys set alibaba-codingplan sk-sp-…` (or `opencode-go <key>`) bootstraps the full provider entry — endpoint, protocol, model list — from just the API key, then validates it with `/keys test <id>`. The live catalog refreshes on session start.
112
+
109
113
  **Custom providers & models:** Add providers via `~/.phi/agent/models.json` if they speak a supported API (OpenAI, Anthropic, Google). For custom APIs or OAuth, use extensions. See [docs/models.md](docs/models.md) and [docs/custom-provider.md](docs/custom-provider.md).
110
114
 
111
115
  ---
112
116
 
117
+ ## Execution-Grounded Modes
118
+
119
+ Phi's multi-phase modes share one principle, learned the hard way on SWE-bench:
120
+ **execution is the only oracle**. A verdict must come from running real code —
121
+ never from a model reviewing its own output (which we measured approving wrong
122
+ code, even across model families). Full design:
123
+ [docs/design/plan-debug-build.md](docs/design/plan-debug-build.md).
124
+
125
+ | Command | Job | Pipeline |
126
+ |---|---|---|
127
+ | `/fix <failing test \| repro \| description>` | Fix at single-shot cost, verified | SINGLE SHOT → sandbox oracle → green: done / red: escalate to /debug |
128
+ | `/plan <spec>` | Plan AND build a project | EXPLORE → PLAN → CODE → TEST → REVIEW |
129
+ | `/debug <…> [--candidates N]` | Turn a REAL failure green | REPRODUCE → LOCALIZE → FIX (×N diverse models) → real-run arbitration / VERIFY |
130
+ | `/build <spec>` | Build until it runs | EXPLORE → PLAN → CODE → BUILD-VERIFY (run recipe + acceptance + executable red-team) |
131
+ | `/sandbox [status\|prepare\|run <cmd>]` | Inspect the guaranteed environment | — |
132
+
133
+ **The execution sandbox.** Every oracle run goes through the `sandbox_run` tool:
134
+ a per-project Docker container (toolchain auto-detected — node/python/go/rust/ruby
135
+ or your `Dockerfile`; override anything in `.phi/sandbox.json`) that returns the
136
+ REAL exit code. No Docker and nothing to containerize → runs fall back to the
137
+ host; if an environment is demanded but unavailable, phases report `BLOCKED`
138
+ rather than fabricate a pass.
139
+
140
+ **Honesty guarantees** (each one exists because we measured its absence losing):
141
+ - `/debug` refuses to "fix" a failure it cannot reproduce — and a BLOCKED verdict
142
+ gets one second chance on a different model family before the halt.
143
+ - VERIFY requires the reproduction to pass AND the suite not to regress; a
144
+ TIMEOUT counts as a regression (a fix that hangs the suite is worse than none).
145
+ - `/build` reports `SUCCESS` only when a real run meets the acceptance criteria,
146
+ otherwise an honest `PARTIAL` listing what still fails.
147
+
148
+ **Per-phase model routing.** Each phase can run on its own model via
149
+ `~/.phi/agent/routing.json` (e.g. code on `alibaba-codingplan/qwen3.7-plus`,
150
+ verification on a different family so it does not share the coder's blind spot),
151
+ with automatic fallback on transient provider errors.
152
+
153
+ **Measured, honestly:** on a 13-instance SWE-bench-lite slice (official harness),
154
+ a strong single-shot baseline resolved 7/13 vs `/debug` 6/13 at ~2.5× the time —
155
+ with `/debug` producing smaller patches and zero toxic ones. `/fix` is the
156
+ composition that measurement demanded: by construction never worse than the
157
+ single shot, escalating exactly where a real red run proves the shot failed.
158
+ Every run appends a telemetry line to `.phi/runs.jsonl` (phases, models,
159
+ verdicts, sandbox executions, duration) so behaviour is measured continuously.
160
+
161
+ ---
162
+
113
163
  ## Interactive Mode
114
164
 
115
165
  <p align="center"><img src="docs/images/interactive-mode.png" alt="Interactive Mode" width="600"></p>
@@ -23,9 +23,20 @@ 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";
28
- import { passed, tail } from "./providers/execution.js";
26
+ import { diffChangedLines } from "./providers/candidate-select.js";
27
+ import {
28
+ buildVerifyInstruction,
29
+ debugPhaseInstructions,
30
+ singleShotInstruction,
31
+ } from "./providers/debug-build-commands.js";
32
+ import {
33
+ decideVerify,
34
+ type FailingState,
35
+ parseFailingState,
36
+ type VerifiedCandidate,
37
+ } from "./providers/debug-contract.js";
38
+ import { decideEscalation, parseReproCmd, pickCandidateModels, type RoutingLike } from "./providers/escalation.js";
39
+ import { passed, runCommand, tail } from "./providers/execution.js";
29
40
  import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
30
41
  import {
31
42
  analyzePhaseMessages,
@@ -35,6 +46,7 @@ import {
35
46
  type StructuredPhaseResult,
36
47
  } from "./providers/phase-machine.js";
37
48
  import { resolveSandbox, type Sandbox } from "./providers/sandbox.js";
49
+ import { appendRunRecord, buildRunRecord, type PhaseRecord } from "./providers/telemetry.js";
38
50
  import { triage } from "./providers/triage.js";
39
51
 
40
52
  // ─── Types ───────────────────────────────────────────────────────────────
@@ -300,9 +312,32 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
300
312
  let phaseQueue: OrchestratorPhase[] = [];
301
313
  let orchestrationActive = false;
302
314
  // Which pipeline is running. "plan" keeps the existing 5-phase agent_end path
303
- // untouched; "debug"/"build" are driven by the separate linear generic driver
304
- // so /plan's fragile review-fix + "/5" logic is never engaged for them.
305
- let orchestrationMode: "plan" | "debug" | "build" = "plan";
315
+ // untouched; "debug"/"build"/"fix" are driven by the separate linear generic
316
+ // driver so /plan's fragile review-fix + "/5" logic is never engaged for them.
317
+ let orchestrationMode: "plan" | "debug" | "build" | "fix" = "plan";
318
+ // /fix escalation state: the failing state the command started from, and
319
+ // whether the driver-level oracle already ran (it must run exactly once,
320
+ // after the single-shot phase, before the run can finish).
321
+ let fixContext: { state: FailingState; oracleRan: boolean } | null = null;
322
+ // /debug --candidates N state: each FIX candidate's captured patch, the
323
+ // reproduction command used for deterministic arbitration (from the input or
324
+ // the REPRODUCE handoff's REPRO-CMD line), and whether arbitration ran.
325
+ let candidateContext: {
326
+ total: number;
327
+ reproCmd?: string;
328
+ list: { source: string; patch: string }[];
329
+ arbitrated: boolean;
330
+ } | null = null;
331
+ // Telemetry: per-phase records for the current generic run + sandbox_run
332
+ // invocation count (reset at orchestration start, flushed at finish).
333
+ let runPhaseRecords: PhaseRecord[] = [];
334
+ let sandboxExecCount = 0;
335
+
336
+ /** Run a git command in cwd; returns stdout ("" on any failure). */
337
+ function gitIn(cwd: string, args: string): string {
338
+ const r = runCommand(`git ${args}`, { cwd, timeoutMs: 60_000 });
339
+ return passed(r) ? r.stdout : "";
340
+ }
306
341
  let activeAgentPrompt: string | null = null;
307
342
  let _activeAgentTools: string[] | null = null;
308
343
  let savedTools: string[] | null = null;
@@ -430,6 +465,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
430
465
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
431
466
  const p = params as { command: string; timeoutSeconds?: number };
432
467
  const cwd = ctx?.cwd || process.cwd();
468
+ if (orchestrationActive) sandboxExecCount++;
433
469
  const sandbox = getSessionSandbox(cwd);
434
470
  const result = sandbox.exec(p.command, {
435
471
  timeoutMs: Math.max(1, Math.min(1800, p.timeoutSeconds ?? 300)) * 1000,
@@ -1211,16 +1247,50 @@ Tag the note with relevant keywords for vector search.
1211
1247
 
1212
1248
  // /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase routes to a model
1213
1249
  // that does NOT share the coder's blind spot (verify on the test family).
1214
- function buildDebugPhases(state: FailingState): OrchestratorPhase[] {
1250
+ function buildDebugPhases(state: FailingState, candidates = 1): OrchestratorPhase[] {
1215
1251
  const ins = debugPhaseInstructions(state);
1252
+ if (candidates <= 1) {
1253
+ return [
1254
+ genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
1255
+ genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
1256
+ genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
1257
+ genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
1258
+ ];
1259
+ }
1260
+ // Multi-candidate: N independent FIX attempts on DIVERSE model families
1261
+ // (diversity proposes), then the driver arbitrates each candidate with a
1262
+ // REAL run (the oracle disposes) — no VERIFY agent phase, no vote.
1263
+ const models = pickCandidateModels(readRoutingConfig(), candidates);
1264
+ const codeRoute = routeFor("code");
1265
+ const fixPhases = Array.from({ length: candidates }, (_, i) => {
1266
+ const ref = models[i % Math.max(1, models.length)] ?? codeRoute.preferred;
1267
+ const phase = genericPhase(
1268
+ "fix-cand",
1269
+ `🔧 Phase ${3 + i} — FIX candidate ${i + 1}/${candidates}`,
1270
+ "code",
1271
+ "code",
1272
+ `${ins.fix}\n\n**Candidate protocol:** you are INDEPENDENT candidate ${i + 1} of ${candidates}. Other candidates attempt the same fault separately; a REAL run of the reproduction and the suite arbitrates between the resulting patches. Make your own best minimal fix — do not hedge for the others.`,
1273
+ );
1274
+ phase.model = ref;
1275
+ phase.fallback = codeRoute.fallback;
1276
+ return phase;
1277
+ });
1216
1278
  return [
1217
1279
  genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
1218
1280
  genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
1219
- genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
1220
- genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
1281
+ ...fixPhases,
1221
1282
  ];
1222
1283
  }
1223
1284
 
1285
+ /** Read ~/.phi/agent/routing.json (shape shared with routeFor). */
1286
+ function readRoutingConfig(): RoutingLike {
1287
+ try {
1288
+ return JSON.parse(readFileSync(join(homedir(), ".phi", "agent", "routing.json"), "utf-8")) as RoutingLike;
1289
+ } catch {
1290
+ return {};
1291
+ }
1292
+ }
1293
+
1224
1294
  // /build: reuse /plan's EXPLORE→PLAN→CODE, then an execution-grounded
1225
1295
  // BUILD-VERIFY that runs the recipe, checks acceptance, red-teams the
1226
1296
  // boundary, and routes real failures to the /debug protocol.
@@ -1241,9 +1311,25 @@ Tag the note with relevant keywords for vector search.
1241
1311
  const minutes = Math.floor(elapsed / 60);
1242
1312
  const seconds = elapsed % 60;
1243
1313
  const mode = orchestrationMode;
1314
+ // Telemetry: one JSONL line per run — continuous measurement for free.
1315
+ appendRunRecord(
1316
+ ctx.cwd || process.cwd(),
1317
+ buildRunRecord({
1318
+ mode,
1319
+ startedAtMs: phaseStartTime,
1320
+ endedAtMs: Date.now(),
1321
+ phases: runPhaseRecords,
1322
+ completedPhases,
1323
+ skippedPhases,
1324
+ sandboxExecs: sandboxExecCount,
1325
+ outcome: headline,
1326
+ }),
1327
+ );
1244
1328
  setOrchestrationActive(false);
1245
1329
  phasePending = false;
1246
1330
  orchestrationMode = "plan";
1331
+ fixContext = null;
1332
+ candidateContext = null;
1247
1333
  deactivateAgent();
1248
1334
  if (phaseTimeoutId) {
1249
1335
  clearTimeout(phaseTimeoutId);
@@ -1264,10 +1350,148 @@ Tag the note with relevant keywords for vector search.
1264
1350
  );
1265
1351
  }
1266
1352
 
1353
+ // /fix driver-level oracle: after the single shot, run the reproduction and
1354
+ // the project suite in the REAL sandbox. Green → finish at baseline cost.
1355
+ // Red → escalate: enqueue the full /debug pipeline seeded with the red run.
1356
+ // Deterministic — zero model tokens; the verdict comes from exit codes.
1357
+ function runFixOracleAndMaybeEscalate(ctx: any): boolean {
1358
+ if (orchestrationMode !== "fix" || !fixContext || fixContext.oracleRan) return false;
1359
+ fixContext.oracleRan = true;
1360
+ const cwd = ctx.cwd || process.cwd();
1361
+ const sandbox = getSessionSandbox(cwd);
1362
+ const reproCmd = fixContext.state.failingTest?.trim() || fixContext.state.reproCommand?.trim();
1363
+ const suiteCmd = sandbox.recipe.test?.trim();
1364
+
1365
+ if (!sandbox.available() && (reproCmd || suiteCmd)) {
1366
+ ctx.ui.notify(
1367
+ `\n⚠️ **/fix oracle: sandbox unavailable** (${sandbox.reason}) — the single shot cannot be verified. Treating it as UNVERIFIED.`,
1368
+ "warning",
1369
+ );
1370
+ finishGenericOrchestration(ctx, `⚠️ **/fix finished UNVERIFIED — no executable environment for the oracle.**`);
1371
+ return true;
1372
+ }
1373
+
1374
+ ctx.ui.notify(
1375
+ `\n🧪 **/fix oracle** — running ${[reproCmd && "the reproduction", suiteCmd && "the suite"].filter(Boolean).join(" and ") || "nothing (no runnable check)"} in ${sandbox.describe()}…`,
1376
+ "info",
1377
+ );
1378
+ const repro = reproCmd ? sandbox.exec(reproCmd, { timeoutMs: 10 * 60 * 1000 }) : null;
1379
+ const suite = suiteCmd ? sandbox.exec(suiteCmd, { timeoutMs: 20 * 60 * 1000 }) : null;
1380
+ const decision = decideEscalation(fixContext.state, { repro, suite });
1381
+
1382
+ if (decision.action === "done-green") {
1383
+ finishGenericOrchestration(
1384
+ ctx,
1385
+ `✅ **/fix finished GREEN at single-shot cost** — oracle evidence: ${decision.evidence}.`,
1386
+ );
1387
+ return true;
1388
+ }
1389
+ if (decision.action === "done-unverified") {
1390
+ finishGenericOrchestration(
1391
+ ctx,
1392
+ `⚠️ **/fix finished UNVERIFIED** — ${decision.reason}. Provide a failing test or a repro command for a guaranteed verdict.`,
1393
+ );
1394
+ return true;
1395
+ }
1396
+ // escalate: the pipeline inherits the RED run as its concrete failing state.
1397
+ ctx.ui.notify(
1398
+ `\n🔺 **/fix escalating to the full /debug pipeline** — ${decision.diagnostic}. The single shot was not enough; the pipeline inherits the red run as its reproduction.`,
1399
+ "warning",
1400
+ );
1401
+ phaseQueue.push(...buildDebugPhases(decision.failing));
1402
+ return false; // fall through: sendNextGenericPhase will dispatch REPRODUCE
1403
+ }
1404
+
1405
+ // Multi-candidate arbitration — the oracle disposes. Apply each captured
1406
+ // candidate in turn, run the reproduction (and the suite when known) in the
1407
+ // REAL sandbox, select the minimal passing candidate (candidate-select), and
1408
+ // leave the WINNER applied. If none passes: never a blank page — the smallest
1409
+ // non-empty candidate is left applied, clearly labelled UNVERIFIED.
1410
+ function runCandidateArbitration(ctx: any): boolean {
1411
+ if (!candidateContext || candidateContext.arbitrated) return false;
1412
+ candidateContext.arbitrated = true;
1413
+ const cc = candidateContext;
1414
+ const cwd = ctx.cwd || process.cwd();
1415
+ const sandbox = getSessionSandbox(cwd);
1416
+ const suiteCmd = sandbox.recipe.test?.trim();
1417
+ const nonEmpty = cc.list.filter((c) => c.patch.trim());
1418
+
1419
+ if (nonEmpty.length === 0) {
1420
+ finishGenericOrchestration(ctx, `⏸️ **/${orchestrationMode} BLOCKED — no candidate produced a patch.**`);
1421
+ return true;
1422
+ }
1423
+
1424
+ const applyPatch = (patch: string): boolean => {
1425
+ try {
1426
+ const f = join(cwd, ".phi", "candidate.patch");
1427
+ writeFileSync(f, patch, "utf-8");
1428
+ const r = runCommand(`git apply --whitespace=nowarn "${f}"`, { cwd, timeoutMs: 60_000 });
1429
+ try {
1430
+ unlinkSync(f);
1431
+ } catch {
1432
+ /* best effort */
1433
+ }
1434
+ return passed(r);
1435
+ } catch {
1436
+ return false;
1437
+ }
1438
+ };
1439
+
1440
+ ctx.ui.notify(
1441
+ `\n⚖️ **Arbitrating ${nonEmpty.length} candidate(s)** with real runs${cc.reproCmd ? ` — repro \`${cc.reproCmd}\`` : ""}${suiteCmd ? `, suite \`${suiteCmd}\`` : ""} in ${sandbox.describe()}…`,
1442
+ "info",
1443
+ );
1444
+
1445
+ const verified: VerifiedCandidate[] = [];
1446
+ for (const cand of nonEmpty) {
1447
+ if (!applyPatch(cand.patch)) {
1448
+ verified.push({ source: cand.source, patch: cand.patch, reproAfter: null, suite: null });
1449
+ continue;
1450
+ }
1451
+ const reproAfter =
1452
+ cc.reproCmd && sandbox.available() ? sandbox.exec(cc.reproCmd, { timeoutMs: 10 * 60 * 1000 }) : null;
1453
+ const suite = suiteCmd && sandbox.available() ? sandbox.exec(suiteCmd, { timeoutMs: 20 * 60 * 1000 }) : null;
1454
+ verified.push({ source: cand.source, patch: cand.patch, reproAfter, suite });
1455
+ gitIn(cwd, "checkout -- .");
1456
+ const rp = reproAfter
1457
+ ? `repro exit ${reproAfter.exitCode}${reproAfter.timedOut ? " TIMEOUT" : ""}`
1458
+ : "repro n/a";
1459
+ const st = suite ? `suite exit ${suite.exitCode}${suite.timedOut ? " TIMEOUT" : ""}` : "suite n/a";
1460
+ ctx.ui.notify(` ${cand.source}: ${rp}, ${st}`, "info");
1461
+ }
1462
+
1463
+ const outcome = decideVerify(verified, Boolean(suiteCmd));
1464
+ if (outcome.verdict === "FIXED" && outcome.patch) {
1465
+ applyPatch(outcome.patch);
1466
+ finishGenericOrchestration(
1467
+ ctx,
1468
+ `✅ **/${orchestrationMode} FIXED by candidate arbitration** — ${outcome.reason}. Evidence: repro passes${suiteCmd ? ", suite green" : ""} (real runs above). The winning patch is applied.`,
1469
+ );
1470
+ return true;
1471
+ }
1472
+
1473
+ // Honest failure — but never a blank page: leave the smallest non-empty
1474
+ // candidate applied, clearly labelled unverified (lesson: sympy-11870).
1475
+ const smallest = [...nonEmpty].sort((a, b) => diffChangedLines(a.patch) - diffChangedLines(b.patch))[0];
1476
+ const draftApplied = applyPatch(smallest.patch);
1477
+ finishGenericOrchestration(
1478
+ ctx,
1479
+ `⏸️ **/${orchestrationMode} BLOCKED — no candidate passed arbitration** (${outcome.reason ?? "see runs above"}).` +
1480
+ (draftApplied
1481
+ ? ` An **UNVERIFIED draft** (smallest candidate, ${smallest.source}) is left in the working tree — \`git diff\` to inspect, \`git checkout -- .\` to discard.`
1482
+ : ""),
1483
+ );
1484
+ return true;
1485
+ }
1486
+
1267
1487
  function sendNextGenericPhase(ctx: any) {
1268
1488
  if (phaseQueue.length === 0) {
1269
- finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
1270
- return;
1489
+ if (runCandidateArbitration(ctx)) return;
1490
+ if (runFixOracleAndMaybeEscalate(ctx)) return;
1491
+ if (phaseQueue.length === 0) {
1492
+ finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
1493
+ return;
1494
+ }
1271
1495
  }
1272
1496
  const phase = phaseQueue.shift()!;
1273
1497
  phasePending = true;
@@ -1320,7 +1544,9 @@ Tag the note with relevant keywords for vector search.
1320
1544
  }
1321
1545
 
1322
1546
  if (!phasePending) {
1323
- if (phaseQueue.length === 0) finishGenericOrchestration(ctx, `✅ **/${orchestrationMode} finished.**`);
1547
+ // Route through sendNextGenericPhase so the /fix oracle still runs on
1548
+ // this (duplicate-event) completion path instead of being bypassed.
1549
+ if (phaseQueue.length === 0) sendNextGenericPhase(ctx);
1324
1550
  return;
1325
1551
  }
1326
1552
  if (phaseTimeoutId) {
@@ -1342,6 +1568,18 @@ Tag the note with relevant keywords for vector search.
1342
1568
  maxToolCallsPerPhase: MAX_TOOL_CALLS_PER_PHASE,
1343
1569
  });
1344
1570
 
1571
+ // Telemetry: one record per phase attempt (retries appear as extra rows).
1572
+ if (currentPhase) {
1573
+ runPhaseRecords.push({
1574
+ key: currentPhase.key,
1575
+ label: currentPhase.label,
1576
+ model: currentPhase.useFallback ? currentPhase.fallback : currentPhase.model,
1577
+ verdict: outcome.verdict,
1578
+ retried: Boolean(currentPhase.retried),
1579
+ blockedRetried: Boolean(currentPhase.blockedRetried),
1580
+ });
1581
+ }
1582
+
1345
1583
  if (decision.action === "stop") {
1346
1584
  ctx.ui.notify(
1347
1585
  `\n❌ **/${orchestrationMode} aborted:** API authentication error (401). Check your API key.`,
@@ -1387,10 +1625,46 @@ Tag the note with relevant keywords for vector search.
1387
1625
  `\n⏸️ **${currentPhase.label} reported BLOCKED (confirmed on retry).** ${outcome.handoff || "No reproducible/verifiable result."}`,
1388
1626
  "warning",
1389
1627
  );
1390
- finishGenericOrchestration(ctx, `⏸️ **/${orchestrationMode} stopped: BLOCKED at ${currentPhase.label}.**`);
1628
+ // Never a silent blank page: if the run left changes in the tree (an
1629
+ // unverified FIX attempt), say so instead of pretending nothing exists.
1630
+ const draft = gitIn(ctx.cwd || process.cwd(), "diff --stat").trim();
1631
+ finishGenericOrchestration(
1632
+ ctx,
1633
+ `⏸️ **/${orchestrationMode} stopped: BLOCKED at ${currentPhase.label}.**` +
1634
+ (draft
1635
+ ? `\n📝 An **UNVERIFIED draft** remains in the working tree (\`git diff\` to inspect, \`git checkout -- .\` to discard):\n\`\`\`\n${draft.slice(0, 600)}\n\`\`\``
1636
+ : ""),
1637
+ );
1391
1638
  return;
1392
1639
  }
1393
1640
 
1641
+ // Multi-candidate bookkeeping (mode debug, --candidates N):
1642
+ // - after REPRODUCE: learn the exact repro command from the REPRO-CMD
1643
+ // handoff line (used later for deterministic arbitration);
1644
+ // - after each FIX candidate: capture its patch (git diff of tracked
1645
+ // files) and RESET the tree so the next candidate starts clean.
1646
+ if (candidateContext && currentPhase) {
1647
+ const cwd = ctx.cwd || process.cwd();
1648
+ if (currentPhase.key === "reproduce" && !candidateContext.reproCmd) {
1649
+ candidateContext.reproCmd = parseReproCmd(outcome.handoff);
1650
+ if (candidateContext.reproCmd) {
1651
+ ctx.ui.notify(`\n🧷 Arbitration reproduction registered: \`${candidateContext.reproCmd}\``, "info");
1652
+ }
1653
+ }
1654
+ if (currentPhase.key === "fix-cand") {
1655
+ const patch = gitIn(cwd, "diff").trim();
1656
+ candidateContext.list.push({
1657
+ source: `${currentPhase.label} (${currentPhase.useFallback ? currentPhase.fallback : currentPhase.model})`,
1658
+ patch: patch ? `${patch}\n` : "",
1659
+ });
1660
+ gitIn(cwd, "checkout -- .");
1661
+ ctx.ui.notify(
1662
+ `\n📦 Candidate ${candidateContext.list.length}/${candidateContext.total} captured (${patch ? `${patch.length} bytes` : "EMPTY"}) — tree reset for the next candidate.`,
1663
+ "info",
1664
+ );
1665
+ }
1666
+ }
1667
+
1394
1668
  // Propagate a concise handoff to the next phase, like /plan does.
1395
1669
  const nextBrief = buildNextBrief(
1396
1670
  analysis,
@@ -1408,13 +1682,18 @@ Tag the note with relevant keywords for vector search.
1408
1682
  }
1409
1683
 
1410
1684
  function startGenericOrchestration(
1411
- mode: "debug" | "build",
1685
+ mode: "debug" | "build" | "fix",
1412
1686
  phases: OrchestratorPhase[],
1413
1687
  ctx: any,
1414
1688
  headline: string,
1415
1689
  ) {
1416
1690
  phaseQueue = phases.slice(1);
1417
1691
  orchestrationMode = mode;
1692
+ // Defensive: stale /fix or candidate state must never leak across runs.
1693
+ if (mode !== "fix") fixContext = null;
1694
+ if (mode !== "debug") candidateContext = null;
1695
+ runPhaseRecords = [];
1696
+ sandboxExecCount = 0;
1418
1697
  setOrchestrationActive(true);
1419
1698
  phasePending = true;
1420
1699
  originalModel = ctx.model || null;
@@ -1911,7 +2190,15 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
1911
2190
  return;
1912
2191
  }
1913
2192
 
1914
- const state: FailingState = parseFailingState(raw, { cwd: ctx.cwd || process.cwd() });
2193
+ // Opt-in multi-candidate FIX: `--candidates N` (2..4). Diversity
2194
+ // proposes (N model families patch independently), the oracle disposes
2195
+ // (a real run arbitrates). Cost-disciplined: default stays 1.
2196
+ const candMatch = raw.match(/(^|\s)--candidates[= ](\d)\b/);
2197
+ let candidates = candMatch ? Math.max(1, Math.min(4, Number(candMatch[2]))) : 1;
2198
+ const cleaned = raw.replace(/(^|\s)--candidates[= ]\d\b/g, " ").trim();
2199
+ const cwd = ctx.cwd || process.cwd();
2200
+
2201
+ const state: FailingState = parseFailingState(cleaned, { cwd });
1915
2202
  // Honest gate at the boundary: /debug needs a reproducible failure. A bare
1916
2203
  // prose description with no test/command still runs (REPRODUCE will try to
1917
2204
  // derive one), but we tell the user the oracle depends on a real run.
@@ -1922,13 +2209,96 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
1922
2209
  );
1923
2210
  }
1924
2211
 
2212
+ if (candidates > 1) {
2213
+ // Candidate capture RESETS the tree between attempts — refuse to do
2214
+ // that over a user's uncommitted work (non-deletion policy).
2215
+ const porcelain = runCommand("git status --porcelain", { cwd, timeoutMs: 30_000 });
2216
+ if (!passed(porcelain)) {
2217
+ ctx.ui.notify(
2218
+ "⚠️ `--candidates` needs a git repository (candidate patches are captured and arbitrated via git). Falling back to the single-candidate pipeline.",
2219
+ "warning",
2220
+ );
2221
+ candidates = 1;
2222
+ } else if (porcelain.stdout.trim()) {
2223
+ ctx.ui.notify(
2224
+ "⚠️ Your working tree has uncommitted changes — candidate arbitration resets the tree between attempts and will NOT run over them. Commit/stash first for multi-candidate. Falling back to the single-candidate pipeline.",
2225
+ "warning",
2226
+ );
2227
+ candidates = 1;
2228
+ }
2229
+ }
2230
+
1925
2231
  await ensurePlansDir();
1926
- const phases = buildDebugPhases(state);
2232
+ const phases = buildDebugPhases(state, candidates);
2233
+ if (candidates > 1) {
2234
+ candidateContext = {
2235
+ total: candidates,
2236
+ reproCmd: state.failingTest?.trim() || state.reproCommand?.trim() || undefined,
2237
+ list: [],
2238
+ arbitrated: false,
2239
+ };
2240
+ }
1927
2241
  startGenericOrchestration(
1928
2242
  "debug",
1929
2243
  phases,
1930
2244
  ctx,
1931
- `🐛 **/debug started** — 4 execution-grounded phases (verdict requires a real run)\n`,
2245
+ candidates > 1
2246
+ ? `🐛 **/debug started (multi-candidate ×${candidates})** — REPRODUCE → LOCALIZE → ${candidates} independent FIXes → real-run arbitration\n`
2247
+ : `🐛 **/debug started** — 4 execution-grounded phases (verdict requires a real run)\n`,
2248
+ );
2249
+ },
2250
+ });
2251
+
2252
+ // ─── /fix Command — single-shot first, oracle next, escalate on red ──
2253
+ // Measured (n=13, official SWE-bench harness): the single shot resolved 7/13
2254
+ // vs the full pipeline's 6/13 at ~2.5× the time, and both arms converged on
2255
+ // 11/13. /fix is the composition: by construction never worse than the
2256
+ // single shot, and it inherits the pipeline exactly where a REAL red run
2257
+ // proves the shot failed.
2258
+
2259
+ pi.registerCommand("fix", {
2260
+ description:
2261
+ "Fix at single-shot cost, verified by the sandbox oracle — escalates to the full /debug pipeline only if a real run stays red",
2262
+ handler: async (args, ctx) => {
2263
+ if (orchestrationActive) {
2264
+ ctx.ui.notify("An orchestration is already running. Let it finish, or restart the session.", "warning");
2265
+ return;
2266
+ }
2267
+ const raw = args.trim();
2268
+ if (!raw) {
2269
+ ctx.ui.notify(
2270
+ `**Usage:** \`/fix <failing test | repro command | description>\`
2271
+
2272
+ /fix = one direct attempt, then the ORACLE (reproduction + suite in the sandbox):
2273
+ green → done at single-shot cost;
2274
+ red → the full /debug pipeline takes over, seeded with the red run.
2275
+ **Examples:**
2276
+ /fix pytest tests/test_auth.py::test_login_returns_jwt
2277
+ /fix node repro.js (crashes on empty input, should return [])
2278
+ /fix the /login route 500s when the body is missing
2279
+
2280
+ With no runnable check at all, the result is honestly labelled UNVERIFIED.`,
2281
+ "info",
2282
+ );
2283
+ return;
2284
+ }
2285
+
2286
+ const state: FailingState = parseFailingState(raw, { cwd: ctx.cwd || process.cwd() });
2287
+ if (!state.failingTest && !state.reproCommand) {
2288
+ ctx.ui.notify(
2289
+ `⚠️ No failing test or repro command detected — the /fix oracle will rely on the project suite alone (or report UNVERIFIED if none is known). A concrete repro gives a guaranteed verdict.`,
2290
+ "warning",
2291
+ );
2292
+ }
2293
+
2294
+ await ensurePlansDir();
2295
+ fixContext = { state, oracleRan: false };
2296
+ const shot = genericPhase("shot", "🎯 Phase 1 — SINGLE SHOT", "code", "code", singleShotInstruction(state));
2297
+ startGenericOrchestration(
2298
+ "fix",
2299
+ [shot],
2300
+ ctx,
2301
+ `🎯 **/fix started** — single shot, then the sandbox oracle decides (green = done, red = full pipeline)\n`,
1932
2302
  );
1933
2303
  },
1934
2304
  });
@@ -53,6 +53,7 @@ ${failing}
53
53
 
54
54
  **Do exactly this:**
55
55
  1. Run the reproduction on the CURRENT, unmodified code with the \`sandbox_run\` tool: \`sandbox_run ${repro}\`.
56
+ - If you must CONSTRUCT the reproduction (only a description was given): build it **literally from the issue** — copy the exact code snippets, inputs and expected values QUOTED in the issue text into a runnable script/test. Do NOT paraphrase the issue into your own interpretation; a reproduction of your interpretation validates your interpretation, not the bug (measured failure mode).
56
57
  2. Paste the exact command and its full output.
57
58
  3. Decide from what \`sandbox_run\` returned:
58
59
  - If it FAILS as reported → capture the precise symptom (assertion, exception, exit code) and hand off to LOCALIZE.
@@ -60,8 +61,10 @@ ${failing}
60
61
  - If \`sandbox_run\` reports \`SANDBOX UNAVAILABLE\` → STOP. Write \`BLOCKED: no executable environment\`.
61
62
  4. Do NOT edit any source yet. This phase only observes.
62
63
  5. **BLOCKED is a last resort, not a first reaction.** If a run fails for an infrastructure-looking reason (tool error, missing file you guessed wrong, transient failure), adjust and retry at least once — e.g. locate the real test paths, try an alternative reproduction — before concluding. Only report BLOCKED after a genuine attempt showed the state is not reproducible or not runnable.
63
- 6. **Last action:** call \`phase_result\` — \`verdict: PASS\` if it reproduced (proceed to LOCALIZE), or \`verdict: BLOCKED\` with the reason if you stopped.` +
64
- DEBUG_RULES,
64
+ 6. **Last action:** call \`phase_result\` — \`verdict: PASS\` if it reproduced (proceed to LOCALIZE), or \`verdict: BLOCKED\` with the reason if you stopped. Your handoff MUST contain a line with the EXACT command that reproduces the failure, in this machine-readable form (the orchestrator re-runs it to arbitrate candidate fixes):
65
+ \`\`\`
66
+ REPRO-CMD: <the exact command, e.g. python /testbed/repro_issue.py>
67
+ \`\`\`` + DEBUG_RULES,
65
68
 
66
69
  localize: `You are the LOCALIZE agent (phase 2 of /debug). Drive from the REAL symptom the previous phase captured.
67
70
 
@@ -69,10 +72,11 @@ ${failing}
69
72
  ${failing}
70
73
 
71
74
  **Do exactly this:**
72
- 1. Read the traceback / error from the reproduction. Follow it to the exact frame.
73
- 2. Read the implicated source and the symbols it touches (grep the nearby/changed identifiers).
74
- 3. Name the fault site precisely — file:line and the wrong assumption. Localization, not more review, is the lever here.
75
- 4. Hand off a crisp fault description; do NOT fix yet.${DEBUG_RULES}`,
75
+ 1. Call \`memory_search\` with the failing symbol/module names a previous run may have already localized this area or a related failure.
76
+ 2. Read the traceback / error from the reproduction. Follow it to the exact frame.
77
+ 3. Read the implicated source and the symbols it touches (grep the nearby/changed identifiers).
78
+ 4. Name the fault site precisely file:line and the wrong assumption. Localization, not more review, is the lever here.
79
+ 5. Hand off a crisp fault description; do NOT fix yet. Call \`memory_write\` with the fault site so future runs on this project start ahead.${DEBUG_RULES}`,
76
80
 
77
81
  fix:
78
82
  `You are the FIX agent (phase 3 of /debug). Produce the MINIMAL change that addresses the located root cause.
@@ -107,6 +111,29 @@ Reason: <only when BLOCKED>
107
111
  };
108
112
  }
109
113
 
114
+ /**
115
+ * The /fix single-shot phase — the measured-cheapest first attempt. One agent
116
+ * fixes directly (baseline cost); the DRIVER then oracle-checks the result
117
+ * deterministically (sandbox repro + suite) and escalates to the full /debug
118
+ * pipeline only if a real run is red. Measured rationale: the single shot
119
+ * resolved 7/13 vs the full pipeline's 6/13 at ~2.5× the time — so pay the
120
+ * pipeline only when a real run proves the shot failed.
121
+ */
122
+ export function singleShotInstruction(state: FailingState): string {
123
+ const failing = formatFailingState(state);
124
+ return `You are the FIX agent (single shot — /fix phase 1). Fix the problem directly, with the MINIMAL change.
125
+
126
+ **The problem:**
127
+ ${failing}
128
+
129
+ **Do exactly this:**
130
+ 1. Read the relevant code and locate the root cause.
131
+ 2. Make the smallest change that addresses it. Do NOT edit tests. Every added guard/branch is a liability.
132
+ 3. You MAY use \`sandbox_run\` to check your work (the project's real environment) — recommended when a command is available.
133
+ 4. After your change, the orchestrator will run the reproduction and the project suite in the sandbox itself: your work is judged by those REAL runs, not by your confidence. If they are red, a full diagnostic pipeline takes over from your change.
134
+ 5. **Last action:** call \`phase_result\` with \`verdict: PASS\` and a one-line handoff describing what you changed and why.${DEBUG_RULES}`;
135
+ }
136
+
110
137
  /**
111
138
  * The /build execution-grounded verify phase: run the recipe, check acceptance,
112
139
  * red-team the boundary the change touched, and route real failures to /debug.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Escalation core — the pure decisions behind /fix (single-shot first, oracle
3
+ * next, escalate to /debug only on red).
4
+ *
5
+ * Measured rationale (n=13, official SWE-bench harness): the single-shot
6
+ * baseline resolved 7/13 while the full /debug pipeline resolved 6/13 at ~2.5×
7
+ * the time — and on 11/13 instances both arms converged to the same verdict.
8
+ * So the right architecture is neither: pay the single-shot price always, pay
9
+ * the pipeline price only when a REAL run says the single shot failed. By
10
+ * construction /fix is never worse than the baseline, and inherits /debug's
11
+ * upside exactly where it can matter.
12
+ */
13
+
14
+ import type { FailingState } from "./debug-contract.js";
15
+ import { type CommandResult, passed, tail } from "./execution.js";
16
+
17
+ export type EscalationDecision =
18
+ | { action: "done-green"; evidence: string }
19
+ | { action: "escalate"; failing: FailingState; diagnostic: string }
20
+ | { action: "done-unverified"; reason: string };
21
+
22
+ export interface OracleRuns {
23
+ /** The reproduction run (null = no runnable reproduction was available). */
24
+ repro: CommandResult | null;
25
+ /** The project suite run (null = no suite command known). */
26
+ suite: CommandResult | null;
27
+ }
28
+
29
+ /**
30
+ * Decide after the single shot: green oracle → done at baseline cost; any red
31
+ * run → escalate to /debug with the red run as the concrete failing state
32
+ * (never a paraphrase — the exact command and its tail). Nothing runnable →
33
+ * done, honestly labelled unverified.
34
+ */
35
+ export function decideEscalation(state: FailingState, runs: OracleRuns): EscalationDecision {
36
+ const { repro, suite } = runs;
37
+
38
+ if (repro !== null && !passed(repro)) {
39
+ return {
40
+ action: "escalate",
41
+ failing: {
42
+ ...state,
43
+ reproCommand: state.failingTest?.trim() || state.reproCommand?.trim() || repro.command,
44
+ trace: tail(repro, 30),
45
+ },
46
+ diagnostic: `reproduction still red after the single shot: \`${repro.command}\` → exit ${repro.exitCode ?? "?"}${repro.timedOut ? " (TIMEOUT)" : ""}`,
47
+ };
48
+ }
49
+
50
+ if (suite !== null && !passed(suite)) {
51
+ return {
52
+ action: "escalate",
53
+ failing: {
54
+ ...state,
55
+ failingTest: suite.command,
56
+ trace: tail(suite, 30),
57
+ },
58
+ diagnostic: `the suite is red after the single shot: \`${suite.command}\` → exit ${suite.exitCode ?? "?"}${suite.timedOut ? " (TIMEOUT)" : ""}`,
59
+ };
60
+ }
61
+
62
+ if (repro === null && suite === null) {
63
+ return {
64
+ action: "done-unverified",
65
+ reason: "no runnable reproduction and no known suite command — the single shot could not be oracle-checked",
66
+ };
67
+ }
68
+
69
+ const parts: string[] = [];
70
+ if (repro) parts.push(`repro \`${repro.command}\` → exit 0`);
71
+ if (suite) parts.push(`suite \`${suite.command}\` → exit 0`);
72
+ return { action: "done-green", evidence: parts.join("; ") };
73
+ }
74
+
75
+ /**
76
+ * Parse the `REPRO-CMD: <command>` line the REPRODUCE phase is instructed to
77
+ * put in its handoff, so the driver can re-run the exact reproduction
78
+ * deterministically (multi-candidate arbitration, /fix oracle). Returns
79
+ * undefined when absent/malformed — callers must degrade gracefully.
80
+ */
81
+ export function parseReproCmd(handoff: string | null | undefined): string | undefined {
82
+ if (!handoff) return undefined;
83
+ const m = handoff.match(/^\s*REPRO-CMD:\s*(.+)\s*$/im);
84
+ const cmd = m?.[1]?.trim();
85
+ return cmd && cmd.length > 1 ? cmd : undefined;
86
+ }
87
+
88
+ /** Minimal shape of routing.json this module needs (kept structural). */
89
+ export interface RoutingLike {
90
+ routes?: Record<string, { preferredModel?: string; fallback?: string } | undefined>;
91
+ default?: { model?: string };
92
+ }
93
+
94
+ /**
95
+ * Pick up to `n` DISTINCT model refs for candidate generation, favouring family
96
+ * diversity: the coder first (it knows the task), then reviewer/test/explore
97
+ * families, then fallbacks. Diversity proposes — the oracle disposes.
98
+ */
99
+ export function pickCandidateModels(routing: RoutingLike, n: number): string[] {
100
+ const routes = routing.routes ?? {};
101
+ const ordered = [
102
+ routes.code?.preferredModel,
103
+ routes.review?.preferredModel,
104
+ routes.test?.preferredModel,
105
+ routes.explore?.preferredModel,
106
+ routes.code?.fallback,
107
+ routes.review?.fallback,
108
+ routes.test?.fallback,
109
+ routing.default?.model,
110
+ ];
111
+ const out: string[] = [];
112
+ for (const ref of ordered) {
113
+ if (typeof ref === "string" && ref.trim() && !out.includes(ref)) out.push(ref);
114
+ if (out.length >= n) break;
115
+ }
116
+ return out.slice(0, Math.max(1, n));
117
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Run telemetry — one JSONL line per orchestration run (.phi/runs.jsonl), so
3
+ * "how do /fix //debug //build actually behave over time?" is answered by
4
+ * appending to a file on every run instead of mounting a measurement campaign.
5
+ * Pure record building here; the single append call-site lives in the driver.
6
+ */
7
+
8
+ import { appendFileSync, mkdirSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
+
11
+ export interface PhaseRecord {
12
+ key: string;
13
+ label: string;
14
+ model: string;
15
+ verdict: string | null;
16
+ retried: boolean;
17
+ blockedRetried: boolean;
18
+ }
19
+
20
+ export interface RunRecord {
21
+ mode: string;
22
+ startedAt: string;
23
+ durationMs: number;
24
+ phases: PhaseRecord[];
25
+ completedPhases: number;
26
+ skippedPhases: number;
27
+ sandboxExecs: number;
28
+ outcome: string;
29
+ }
30
+
31
+ /** Build the run record (pure — timestamps/durations are supplied). */
32
+ export function buildRunRecord(input: {
33
+ mode: string;
34
+ startedAtMs: number | null;
35
+ endedAtMs: number;
36
+ phases: PhaseRecord[];
37
+ completedPhases: number;
38
+ skippedPhases: number;
39
+ sandboxExecs: number;
40
+ outcome: string;
41
+ }): RunRecord {
42
+ const started = input.startedAtMs ?? input.endedAtMs;
43
+ return {
44
+ mode: input.mode,
45
+ startedAt: new Date(started).toISOString(),
46
+ durationMs: Math.max(0, input.endedAtMs - started),
47
+ phases: input.phases,
48
+ completedPhases: input.completedPhases,
49
+ skippedPhases: input.skippedPhases,
50
+ sandboxExecs: input.sandboxExecs,
51
+ // Strip markdown noise; keep the headline single-line and bounded.
52
+ outcome: input.outcome
53
+ .replace(/\*\*|`/g, "")
54
+ .replace(/\s+/g, " ")
55
+ .trim()
56
+ .slice(0, 300),
57
+ };
58
+ }
59
+
60
+ /** Serialize for JSONL (single line, newline-terminated). */
61
+ export function toJsonlLine(record: RunRecord): string {
62
+ return `${JSON.stringify(record)}\n`;
63
+ }
64
+
65
+ /** Append a run record to <cwd>/.phi/runs.jsonl. Best-effort — never throws. */
66
+ export function appendRunRecord(cwd: string, record: RunRecord): boolean {
67
+ try {
68
+ const file = join(cwd, ".phi", "runs.jsonl");
69
+ mkdirSync(dirname(file), { recursive: true });
70
+ appendFileSync(file, toJsonlLine(record), "utf-8");
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.92.0",
3
+ "version": "0.93.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {