@phi-code-admin/phi-code 0.95.0 → 0.96.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/dist/cli/args.d.ts +1 -1
  3. package/dist/cli/args.d.ts.map +1 -1
  4. package/dist/cli/args.js +43 -1
  5. package/dist/cli/args.js.map +1 -1
  6. package/dist/core/api-key-store.d.ts +20 -4
  7. package/dist/core/api-key-store.d.ts.map +1 -1
  8. package/dist/core/api-key-store.js +37 -7
  9. package/dist/core/api-key-store.js.map +1 -1
  10. package/dist/core/compaction/compaction.d.ts +16 -0
  11. package/dist/core/compaction/compaction.d.ts.map +1 -1
  12. package/dist/core/compaction/compaction.js +43 -19
  13. package/dist/core/compaction/compaction.js.map +1 -1
  14. package/dist/core/json-utils.d.ts +11 -0
  15. package/dist/core/json-utils.d.ts.map +1 -0
  16. package/dist/core/json-utils.js +15 -0
  17. package/dist/core/json-utils.js.map +1 -0
  18. package/dist/core/model-registry.d.ts +6 -1
  19. package/dist/core/model-registry.d.ts.map +1 -1
  20. package/dist/core/model-registry.js +10 -9
  21. package/dist/core/model-registry.js.map +1 -1
  22. package/dist/migrations.d.ts.map +1 -1
  23. package/dist/migrations.js +2 -2
  24. package/dist/migrations.js.map +1 -1
  25. package/extensions/phi/benchmark.ts +32 -28
  26. package/extensions/phi/memory.ts +1 -1
  27. package/extensions/phi/models.ts +4 -3
  28. package/extensions/phi/orchestrator.ts +198 -12
  29. package/extensions/phi/providers/candidate-fanout.ts +180 -0
  30. package/extensions/phi/providers/debug-build-commands.ts +27 -0
  31. package/extensions/phi/providers/escalation.ts +16 -0
  32. package/extensions/phi/providers/explore-fanout.ts +1 -1
  33. package/extensions/phi/providers/telemetry.ts +66 -0
  34. package/extensions/phi/providers/test-discovery.ts +119 -0
  35. package/extensions/phi/providers/triage.ts +15 -0
  36. package/package.json +3 -3
@@ -23,10 +23,12 @@ 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 { runCandidateFanout } from "./providers/candidate-fanout.js";
26
27
  import { diffChangedLines } from "./providers/candidate-select.js";
27
28
  import {
28
29
  buildVerifyInstruction,
29
30
  debugPhaseInstructions,
31
+ reproAuditInstruction,
30
32
  singleShotInstruction,
31
33
  } from "./providers/debug-build-commands.js";
32
34
  import {
@@ -35,7 +37,13 @@ import {
35
37
  parseFailingState,
36
38
  type VerifiedCandidate,
37
39
  } from "./providers/debug-contract.js";
38
- import { decideEscalation, parseReproCmd, pickCandidateModels, type RoutingLike } from "./providers/escalation.js";
40
+ import {
41
+ decideEscalation,
42
+ parseReproCmd,
43
+ pickCandidateModels,
44
+ type RoutingLike,
45
+ shotBudgetMs,
46
+ } from "./providers/escalation.js";
39
47
  import { passed, runCommand, tail } from "./providers/execution.js";
40
48
  import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
41
49
  import {
@@ -46,8 +54,15 @@ import {
46
54
  type StructuredPhaseResult,
47
55
  } from "./providers/phase-machine.js";
48
56
  import { resolveSandbox, type Sandbox } from "./providers/sandbox.js";
49
- import { appendRunRecord, buildRunRecord, type PhaseRecord } from "./providers/telemetry.js";
50
- import { triage } from "./providers/triage.js";
57
+ import {
58
+ appendRunRecord,
59
+ buildRunRecord,
60
+ type PhaseRecord,
61
+ parseRunsJsonl,
62
+ summarizeRuns,
63
+ } from "./providers/telemetry.js";
64
+ import { discoverTargetedTests, fsSeamsFor } from "./providers/test-discovery.js";
65
+ import { looksLikeBugReport, triage } from "./providers/triage.js";
51
66
 
52
67
  // ─── Types ───────────────────────────────────────────────────────────────
53
68
 
@@ -333,6 +348,8 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
333
348
  reproCmd?: string;
334
349
  list: { source: string; patch: string }[];
335
350
  arbitrated: boolean;
351
+ // Experimental --parallel: FIX specs fanned out to worktrees after LOCALIZE.
352
+ parallelSpecs?: { model: string; instruction: string }[];
336
353
  } | null = null;
337
354
  // Telemetry: per-phase records for the current generic run + sandbox_run
338
355
  // invocation count (reset at orchestration start, flushed at finish).
@@ -341,6 +358,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
341
358
  // Cumulative sandbox execution time this orchestration (drift guard #2) and
342
359
  // its budget; per-call cap (#4) is tightenable for batch harnesses via env.
343
360
  let sandboxExecMs = 0;
361
+ let currentPhaseStartedAt = 0;
344
362
  const SANDBOX_BUDGET_MS = Math.max(
345
363
  1_000,
346
364
  Number(process.env.PHI_SANDBOX_BUDGET_MS ?? 20 * 60 * 1000) || 20 * 60 * 1000,
@@ -1190,6 +1208,7 @@ Tag the note with relevant keywords for vector search.
1190
1208
  const phase = phaseQueue.shift()!;
1191
1209
  phasePending = true;
1192
1210
  currentPhase = phase;
1211
+ currentPhaseStartedAt = Date.now();
1193
1212
  // New phase starts with no structured result; it is set only if this
1194
1213
  // phase's agent calls phase_result.
1195
1214
  currentPhaseResult = null;
@@ -1277,13 +1296,30 @@ Tag the note with relevant keywords for vector search.
1277
1296
  };
1278
1297
  }
1279
1298
 
1280
- // /debug: REPRODUCE → LOCALIZE → FIX → VERIFY. Each phase routes to a model
1281
- // that does NOT share the coder's blind spot (verify on the test family).
1282
- function buildDebugPhases(state: FailingState, candidates = 1): OrchestratorPhase[] {
1299
+ // /debug: REPRODUCE → [REPRO-AUDIT] → LOCALIZE → FIX → VERIFY. Each phase
1300
+ // routes to a model that does NOT share the coder's blind spot. The audit
1301
+ // phase (adversary from the review family) runs ONLY when the reproduction
1302
+ // was CONSTRUCTED from prose — a user-supplied failing test is ground truth
1303
+ // (twice-measured failure: a prose-derived repro validated the agent's
1304
+ // interpretation while the real tests failed).
1305
+ function buildDebugPhases(state: FailingState, candidates = 1, parallel = false): OrchestratorPhase[] {
1283
1306
  const ins = debugPhaseInstructions(state);
1307
+ const constructed = !state.failingTest?.trim() && !state.reproCommand?.trim();
1308
+ const audit = constructed
1309
+ ? [
1310
+ genericPhase(
1311
+ "repro-audit",
1312
+ "🕵️ Phase 1b — REPRO-AUDIT (adversary)",
1313
+ "review",
1314
+ "review",
1315
+ reproAuditInstruction(state),
1316
+ ),
1317
+ ]
1318
+ : [];
1284
1319
  if (candidates <= 1) {
1285
1320
  return [
1286
1321
  genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
1322
+ ...audit,
1287
1323
  genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
1288
1324
  genericPhase("fix", "🔧 Phase 3 — FIX", "code", "code", ins.fix),
1289
1325
  genericPhase("verify", "✅ Phase 4 — VERIFY", "test", "test", ins.verify),
@@ -1307,8 +1343,18 @@ Tag the note with relevant keywords for vector search.
1307
1343
  phase.fallback = codeRoute.fallback;
1308
1344
  return phase;
1309
1345
  });
1346
+ if (parallel) {
1347
+ // Parallel candidates: the queue stops at LOCALIZE; the driver then
1348
+ // fans the FIX out to worktrees and arbitration takes over.
1349
+ return [
1350
+ genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
1351
+ ...audit,
1352
+ genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
1353
+ ];
1354
+ }
1310
1355
  return [
1311
1356
  genericPhase("reproduce", "🔴 Phase 1 — REPRODUCE", "test", "test", ins.reproduce),
1357
+ ...audit,
1312
1358
  genericPhase("localize", "🔎 Phase 2 — LOCALIZE", "explore", "explore", ins.localize),
1313
1359
  ...fixPhases,
1314
1360
  ];
@@ -1403,13 +1449,31 @@ Tag the note with relevant keywords for vector search.
1403
1449
  "info",
1404
1450
  );
1405
1451
  }
1406
- const suiteCmd = sandbox.recipe.test?.trim();
1452
+ // Oracle upgrade (measured on flask-4992: the agent's own repro passed
1453
+ // while the module's REAL tests failed): when no suite command is known,
1454
+ // auto-discover the EXISTING test files of the modules the change touched
1455
+ // and use them as the suite leg of the oracle.
1456
+ let suiteCmd = sandbox.recipe.test?.trim();
1457
+ const inGitRepo = passed(runCommand("git rev-parse --is-inside-work-tree", { cwd, timeoutMs: 15_000 }));
1458
+ if (!suiteCmd && inGitRepo) {
1459
+ const changed = gitIn(cwd, "diff --name-only")
1460
+ .split("\n")
1461
+ .map((s) => s.trim())
1462
+ .filter(Boolean);
1463
+ const targeted = discoverTargetedTests(changed, fsSeamsFor(cwd));
1464
+ if (targeted.command) {
1465
+ suiteCmd = targeted.command;
1466
+ ctx.ui.notify(
1467
+ `\n🎯 Targeted tests discovered for the touched modules: ${targeted.files.join(", ")}`,
1468
+ "info",
1469
+ );
1470
+ }
1471
+ }
1407
1472
 
1408
1473
  // A shot that changed NOTHING has nothing to verify — "UNVERIFIED" would be
1409
1474
  // a lie of omission (measured: a text-only 91s shot ended UNVERIFIED with
1410
1475
  // zero edits). In a git repo with a clean diff, escalate to the full
1411
1476
  // pipeline instead: the work simply was not done.
1412
- const inGitRepo = passed(runCommand("git rev-parse --is-inside-work-tree", { cwd, timeoutMs: 15_000 }));
1413
1477
  if (inGitRepo && !gitIn(cwd, "diff").trim() && !gitIn(cwd, "status --porcelain").trim()) {
1414
1478
  ctx.ui.notify(
1415
1479
  `\n🔺 **/fix escalating: the single shot made NO changes** — nothing to verify, the full /debug pipeline takes over.`,
@@ -1508,9 +1572,19 @@ Tag the note with relevant keywords for vector search.
1508
1572
  verified.push({ source: cand.source, patch: cand.patch, reproAfter: null, suite: null });
1509
1573
  continue;
1510
1574
  }
1575
+ // Per-candidate suite: the recipe's, else the targeted tests of the
1576
+ // modules THIS candidate touched (flask-4992 oracle upgrade).
1577
+ let candSuite = suiteCmd;
1578
+ if (!candSuite) {
1579
+ const changed = gitIn(cwd, "diff --name-only")
1580
+ .split("\n")
1581
+ .map((s) => s.trim())
1582
+ .filter(Boolean);
1583
+ candSuite = discoverTargetedTests(changed, fsSeamsFor(cwd)).command;
1584
+ }
1511
1585
  const reproAfter =
1512
1586
  cc.reproCmd && sandbox.available() ? sandbox.exec(cc.reproCmd, { timeoutMs: 10 * 60 * 1000 }) : null;
1513
- const suite = suiteCmd && sandbox.available() ? sandbox.exec(suiteCmd, { timeoutMs: 20 * 60 * 1000 }) : null;
1587
+ const suite = candSuite && sandbox.available() ? sandbox.exec(candSuite, { timeoutMs: 20 * 60 * 1000 }) : null;
1514
1588
  verified.push({ source: cand.source, patch: cand.patch, reproAfter, suite });
1515
1589
  gitIn(cwd, "checkout -- .");
1516
1590
  const rp = reproAfter
@@ -1520,7 +1594,7 @@ Tag the note with relevant keywords for vector search.
1520
1594
  ctx.ui.notify(` ${cand.source}: ${rp}, ${st}`, "info");
1521
1595
  }
1522
1596
 
1523
- const outcome = decideVerify(verified, Boolean(suiteCmd));
1597
+ const outcome = decideVerify(verified, Boolean(suiteCmd) || verified.some((v) => v.suite !== null));
1524
1598
  if (outcome.verdict === "FIXED" && outcome.patch) {
1525
1599
  applyPatch(outcome.patch);
1526
1600
  finishGenericOrchestration(
@@ -1556,6 +1630,7 @@ Tag the note with relevant keywords for vector search.
1556
1630
  const phase = phaseQueue.shift()!;
1557
1631
  phasePending = true;
1558
1632
  currentPhase = phase;
1633
+ currentPhaseStartedAt = Date.now();
1559
1634
  currentPhaseResult = null;
1560
1635
  currentPhaseResultKey = null;
1561
1636
 
@@ -1576,6 +1651,17 @@ Tag the note with relevant keywords for vector search.
1576
1651
  } catch {
1577
1652
  /* best effort */
1578
1653
  }
1654
+ // Telemetry: a timed-out attempt is a row too (the observed
1655
+ // phases:[] bug — the swallowed agent_end never pushed one).
1656
+ runPhaseRecords.push({
1657
+ key: phase.key,
1658
+ label: phase.label,
1659
+ model: phase.useFallback ? phase.fallback : phase.model,
1660
+ verdict: "TIMEOUT",
1661
+ retried: Boolean(phase.retried),
1662
+ blockedRetried: Boolean(phase.blockedRetried),
1663
+ durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
1664
+ });
1579
1665
  if (!phase.retried) {
1580
1666
  phase.retried = true;
1581
1667
  phase.useFallback = true;
@@ -1638,6 +1724,7 @@ Tag the note with relevant keywords for vector search.
1638
1724
  verdict: outcome.verdict,
1639
1725
  retried: Boolean(currentPhase.retried),
1640
1726
  blockedRetried: Boolean(currentPhase.blockedRetried),
1727
+ durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
1641
1728
  });
1642
1729
  }
1643
1730
 
@@ -1715,12 +1802,42 @@ Tag the note with relevant keywords for vector search.
1715
1802
 
1716
1803
  if (candidateContext && currentPhase) {
1717
1804
  const cwd = ctx.cwd || process.cwd();
1805
+ if (currentPhase.key === "repro-audit") {
1806
+ // The adversary may have EXTENDED the reproduction — its REPRO-CMD
1807
+ // overrides the one REPRODUCE registered.
1808
+ const updated = parseReproCmd(outcome.handoff);
1809
+ if (updated && updated !== candidateContext.reproCmd) {
1810
+ candidateContext.reproCmd = updated;
1811
+ ctx.ui.notify(`\n🧷 Arbitration reproduction UPDATED by the audit: \`${updated}\``, "info");
1812
+ }
1813
+ }
1718
1814
  if (currentPhase.key === "reproduce" && !candidateContext.reproCmd) {
1719
1815
  candidateContext.reproCmd = parseReproCmd(outcome.handoff);
1720
1816
  if (candidateContext.reproCmd) {
1721
1817
  ctx.ui.notify(`\n🧷 Arbitration reproduction registered: \`${candidateContext.reproCmd}\``, "info");
1722
1818
  }
1723
1819
  }
1820
+ if (
1821
+ currentPhase.key === "localize" &&
1822
+ candidateContext.parallelSpecs?.length &&
1823
+ candidateContext.list.length === 0
1824
+ ) {
1825
+ ctx.ui.notify(
1826
+ `
1827
+ 🧵 **Fanning ${candidateContext.parallelSpecs.length} FIX candidates out to parallel worktrees** (experimental)…`,
1828
+ "info",
1829
+ );
1830
+ const fan = await runCandidateFanout(
1831
+ cwd,
1832
+ candidateContext.parallelSpecs.map((sp) => ({ model: sp.model, instruction: sp.instruction })),
1833
+ );
1834
+ for (const o of fan.outcomes) candidateContext.list.push({ source: o.source, patch: o.patch });
1835
+ if (fan.failures.length) ctx.ui.notify(`⚠️ Candidate failures: ${fan.failures.join("; ")}`, "warning");
1836
+ ctx.ui.notify(
1837
+ `🧵 Fan-out done — ${fan.outcomes.filter((o) => o.ok).length}/${fan.outcomes.length} candidate patch(es) collected; arbitration next.`,
1838
+ "info",
1839
+ );
1840
+ }
1724
1841
  if (currentPhase.key === "fix-cand") {
1725
1842
  const patch = gitIn(cwd, "diff").trim();
1726
1843
  candidateContext.list.push({
@@ -1777,6 +1894,7 @@ Tag the note with relevant keywords for vector search.
1777
1894
  phaseStartTime = Date.now();
1778
1895
  const first = phases[0];
1779
1896
  currentPhase = first;
1897
+ currentPhaseStartedAt = Date.now();
1780
1898
 
1781
1899
  ctx.ui.notify(headline, "info");
1782
1900
  for (const p of phases) {
@@ -1802,6 +1920,15 @@ Tag the note with relevant keywords for vector search.
1802
1920
  // Same policy as every other phase: one retry on the fallback
1803
1921
  // model before skipping (the old skip-direct lost the whole run
1804
1922
  // when the very first phase was slow).
1923
+ runPhaseRecords.push({
1924
+ key: first.key,
1925
+ label: first.label,
1926
+ model: first.useFallback ? first.fallback : first.model,
1927
+ verdict: "TIMEOUT",
1928
+ retried: Boolean(first.retried),
1929
+ blockedRetried: Boolean(first.blockedRetried),
1930
+ durationMs: currentPhaseStartedAt ? Date.now() - currentPhaseStartedAt : undefined,
1931
+ });
1805
1932
  if (!first.retried) {
1806
1933
  first.retried = true;
1807
1934
  first.useFallback = true;
@@ -2278,9 +2405,13 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
2278
2405
  // Opt-in multi-candidate FIX: `--candidates N` (2..4). Diversity
2279
2406
  // proposes (N model families patch independently), the oracle disposes
2280
2407
  // (a real run arbitrates). Cost-disciplined: default stays 1.
2408
+ const wantParallel = /(^|s)--parallel/.test(raw);
2281
2409
  const candMatch = raw.match(/(^|\s)--candidates[= ](\d)\b/);
2282
2410
  let candidates = candMatch ? Math.max(1, Math.min(4, Number(candMatch[2]))) : 1;
2283
- const cleaned = raw.replace(/(^|\s)--candidates[= ]\d\b/g, " ").trim();
2411
+ const cleaned = raw
2412
+ .replace(/(^|\s)--candidates[= ]\d\b/g, " ")
2413
+ .replace(/(^|s)--parallel/g, " ")
2414
+ .trim();
2284
2415
  const cwd = ctx.cwd || process.cwd();
2285
2416
 
2286
2417
  const state: FailingState = parseFailingState(cleaned, { cwd });
@@ -2314,13 +2445,22 @@ It runs REPRODUCE → LOCALIZE → FIX → VERIFY and only reports FIXED with a
2314
2445
  }
2315
2446
 
2316
2447
  await ensurePlansDir();
2317
- const phases = buildDebugPhases(state, candidates);
2448
+ const parallel = wantParallel && candidates > 1;
2449
+ const phases = buildDebugPhases(state, candidates, parallel);
2318
2450
  if (candidates > 1) {
2319
2451
  candidateContext = {
2320
2452
  total: candidates,
2321
2453
  reproCmd: state.failingTest?.trim() || state.reproCommand?.trim() || undefined,
2322
2454
  list: [],
2323
2455
  arbitrated: false,
2456
+ parallelSpecs: parallel
2457
+ ? pickCandidateModels(readRoutingConfig(), candidates).map((model, i) => ({
2458
+ model,
2459
+ instruction: `${debugPhaseInstructions(state).fix}
2460
+
2461
+ **Candidate protocol:** you are INDEPENDENT candidate ${i + 1} of ${candidates}, working in an isolated worktree. A REAL run arbitrates between candidates afterwards. Make your own best minimal fix.`,
2462
+ }))
2463
+ : undefined,
2324
2464
  };
2325
2465
  }
2326
2466
  startGenericOrchestration(
@@ -2378,7 +2518,16 @@ With no runnable check at all, the result is honestly labelled UNVERIFIED.`,
2378
2518
 
2379
2519
  await ensurePlansDir();
2380
2520
  fixContext = { state, oracleRan: false };
2521
+ // Tiered budget (measured: hard instances burned the whole run in the
2522
+ // shot, leaving the escalation nothing): triage decides how long the
2523
+ // single shot deserves before the pipeline takes over.
2524
+ const t = triage({ text: raw, hasFailingState: Boolean(state.failingTest || state.reproCommand) });
2381
2525
  const shot = genericPhase("shot", "🎯 Phase 1 — SINGLE SHOT", "code", "code", singleShotInstruction(state));
2526
+ shot.timeoutMs = shotBudgetMs(t.route);
2527
+ ctx.ui.notify(
2528
+ `⏱️ Shot budget: ${Math.round(shotBudgetMs(t.route) / 60000)} min (triage: ${t.reason}).`,
2529
+ "info",
2530
+ );
2382
2531
  // A real single shot legitimately runs 8–18 min (measured on the
2383
2532
  // baselines); the flat 10 min phase cap was killing it mid-work.
2384
2533
  shot.timeoutMs = 25 * 60 * 1000;
@@ -2446,6 +2595,43 @@ It reports SUCCESS only when a real run meets the acceptance criteria — otherw
2446
2595
 
2447
2596
  // ─── /sandbox Command — inspect / prepare the guaranteed environment ──
2448
2597
 
2598
+ // ─── /runs Command — exploit the telemetry (continuous measurement) ──
2599
+
2600
+ pi.registerCommand("runs", {
2601
+ description: "Aggregate .phi/runs.jsonl — green-at-shot rate, escalations, durations, slowest phases",
2602
+ handler: async (_args, ctx) => {
2603
+ const cwd = ctx.cwd || process.cwd();
2604
+ let blob = "";
2605
+ try {
2606
+ blob = readFileSync(join(cwd, ".phi", "runs.jsonl"), "utf-8");
2607
+ } catch {
2608
+ /* no file yet */
2609
+ }
2610
+ ctx.ui.notify(summarizeRuns(parseRunsJsonl(blob)), "info");
2611
+ },
2612
+ });
2613
+
2614
+ // ─── /fix suggestion — the measured-best default, surfaced once ─────
2615
+ // A message that reads like a bug report gets a one-line tip pointing at
2616
+ // /fix (never worse than a single shot, oracle-verified). Suggest once per
2617
+ // session, never during an orchestration, and NEVER intercept the input.
2618
+ let fixHintShown = false;
2619
+ pi.on("input", async (event: any, ctx: any) => {
2620
+ try {
2621
+ const text = typeof event?.text === "string" ? event.text : "";
2622
+ if (!fixHintShown && !orchestrationActive && looksLikeBugReport(text)) {
2623
+ fixHintShown = true;
2624
+ ctx.ui.notify(
2625
+ "💡 This looks like a bug report — `/fix <the same text>` runs one attempt, verifies it in the sandbox oracle, and escalates to the full /debug pipeline only if a real run stays red.",
2626
+ "info",
2627
+ );
2628
+ }
2629
+ } catch {
2630
+ /* a hint must never break input */
2631
+ }
2632
+ return undefined; // pass through untouched
2633
+ });
2634
+
2449
2635
  pi.registerCommand("sandbox", {
2450
2636
  description:
2451
2637
  "Inspect or prepare the project's execution sandbox (Docker when available) — status | prepare | run <cmd>",
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Parallel candidate generation in git WORKTREES (experimental).
3
+ *
4
+ * Sequential multi-candidate is safe (one tree, reset between attempts) but
5
+ * pays N× wall-clock. Worktrees give each candidate an isolated copy of the
6
+ * repo at HEAD, so N fixes run truly in parallel; the diffs come back to the
7
+ * main tree where the existing deterministic arbitration (real runs) picks the
8
+ * winner. Sub-candidates are separate phi processes (same pattern as
9
+ * explore-fanout), so a crash in one never corrupts another.
10
+ */
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { runCommand } from "./execution.js";
17
+ import { getPiInvocation, isRateLimited } from "./explore-fanout.js";
18
+
19
+ export interface CandidateSpec {
20
+ /** Model ref for this candidate (e.g. "alibaba-codingplan/qwen3.7-plus"). */
21
+ model: string;
22
+ /** The FIX instruction (already includes the candidate protocol note). */
23
+ instruction: string;
24
+ }
25
+
26
+ export interface CandidateOutcome {
27
+ source: string;
28
+ patch: string;
29
+ ok: boolean;
30
+ error?: string;
31
+ }
32
+
33
+ const CANDIDATE_TOOLS = ["read", "grep", "glob", "ls", "find", "edit", "write", "bash", "sandbox_run"];
34
+ const HARD_CONCURRENCY = 2;
35
+ const DEFAULT_TIMEOUT_MS = 12 * 60 * 1000;
36
+
37
+ function git(cwd: string, args: string): { ok: boolean; out: string } {
38
+ const r = runCommand(`git ${args}`, { cwd, timeoutMs: 120_000 });
39
+ return { ok: r.exitCode === 0 && !r.timedOut, out: r.stdout };
40
+ }
41
+
42
+ export interface Worktree {
43
+ path: string;
44
+ remove(): void;
45
+ }
46
+
47
+ /**
48
+ * Create a detached worktree of HEAD in a temp dir, with the main tree's
49
+ * .phi/sandbox.json copied in (the sub-candidate must target the SAME
50
+ * guaranteed environment). Throws on failure — callers fall back to the
51
+ * sequential pipeline.
52
+ */
53
+ export function createCandidateWorktree(mainCwd: string, index: number): Worktree {
54
+ const base = mkdtempSync(join(tmpdir(), `phi-cand-${index}-`));
55
+ const wt = join(base, "wt");
56
+ const add = git(mainCwd, `worktree add --detach "${wt}" HEAD`);
57
+ if (!add.ok) {
58
+ rmSync(base, { recursive: true, force: true });
59
+ throw new Error(`git worktree add failed for candidate ${index}`);
60
+ }
61
+ const sandboxCfg = join(mainCwd, ".phi", "sandbox.json");
62
+ if (existsSync(sandboxCfg)) {
63
+ mkdirSync(join(wt, ".phi"), { recursive: true });
64
+ copyFileSync(sandboxCfg, join(wt, ".phi", "sandbox.json"));
65
+ }
66
+ return {
67
+ path: wt,
68
+ remove() {
69
+ git(mainCwd, `worktree remove --force "${wt}"`);
70
+ rmSync(base, { recursive: true, force: true });
71
+ // prune bookkeeping for safety (best effort)
72
+ git(mainCwd, "worktree prune");
73
+ },
74
+ };
75
+ }
76
+
77
+ /** Diff of a worktree against its HEAD — the candidate's patch. */
78
+ export function worktreePatch(wtPath: string): string {
79
+ const d = git(wtPath, "diff");
80
+ return d.ok && d.out.trim() ? `${d.out.trim()}\n` : "";
81
+ }
82
+
83
+ /** Run one candidate as a phi sub-process inside its worktree. Never throws. */
84
+ export function runOneCandidate(
85
+ wtPath: string,
86
+ spec: CandidateSpec,
87
+ timeoutMs = DEFAULT_TIMEOUT_MS,
88
+ ): Promise<CandidateOutcome> {
89
+ return new Promise((resolve) => {
90
+ const args = [
91
+ "--mode",
92
+ "json",
93
+ "-p",
94
+ "--no-session",
95
+ "--model",
96
+ spec.model,
97
+ "--tools",
98
+ CANDIDATE_TOOLS.join(","),
99
+ ];
100
+ args.push(`Task: ${spec.instruction}`);
101
+ let proc: ReturnType<typeof spawn>;
102
+ try {
103
+ const inv = getPiInvocation(args);
104
+ proc = spawn(inv.command, inv.args, { cwd: wtPath, shell: false, stdio: ["ignore", "pipe", "pipe"] });
105
+ } catch (e) {
106
+ resolve({ source: spec.model, patch: "", ok: false, error: String(e) });
107
+ return;
108
+ }
109
+ let blob = "";
110
+ const timer = setTimeout(() => {
111
+ try {
112
+ proc.kill();
113
+ } catch {
114
+ /* best effort */
115
+ }
116
+ }, timeoutMs);
117
+ proc.stdout?.on("data", (d) => {
118
+ blob += d.toString();
119
+ });
120
+ proc.stderr?.on("data", (d) => {
121
+ blob += d.toString();
122
+ });
123
+ const finish = () => {
124
+ clearTimeout(timer);
125
+ const patch = worktreePatch(wtPath);
126
+ resolve({
127
+ source: spec.model,
128
+ patch,
129
+ ok: patch.length > 0,
130
+ error: patch ? undefined : isRateLimited(blob) ? "rate-limited" : "no changes produced",
131
+ });
132
+ };
133
+ proc.on("close", finish);
134
+ proc.on("error", () => finish());
135
+ });
136
+ }
137
+
138
+ export interface FanoutRun {
139
+ outcomes: CandidateOutcome[];
140
+ failures: string[];
141
+ }
142
+
143
+ /**
144
+ * Run N candidates in parallel worktrees (concurrency-capped), ALWAYS cleaning
145
+ * the worktrees up. Returns every outcome; the caller feeds non-empty patches
146
+ * to the deterministic arbitration in the MAIN tree.
147
+ */
148
+ export async function runCandidateFanout(
149
+ mainCwd: string,
150
+ specs: CandidateSpec[],
151
+ timeoutMs = DEFAULT_TIMEOUT_MS,
152
+ ): Promise<FanoutRun> {
153
+ const outcomes: CandidateOutcome[] = [];
154
+ const failures: string[] = [];
155
+ for (let batch = 0; batch < specs.length; batch += HARD_CONCURRENCY) {
156
+ const slice = specs.slice(batch, batch + HARD_CONCURRENCY);
157
+ const settled = await Promise.all(
158
+ slice.map(async (spec, j) => {
159
+ let wt: Worktree | null = null;
160
+ try {
161
+ wt = createCandidateWorktree(mainCwd, batch + j);
162
+ return await runOneCandidate(wt.path, spec, timeoutMs);
163
+ } catch (e) {
164
+ return { source: spec.model, patch: "", ok: false, error: String(e) } as CandidateOutcome;
165
+ } finally {
166
+ try {
167
+ wt?.remove();
168
+ } catch {
169
+ /* best effort */
170
+ }
171
+ }
172
+ }),
173
+ );
174
+ for (const o of settled) {
175
+ outcomes.push(o);
176
+ if (!o.ok) failures.push(`${o.source}: ${o.error ?? "failed"}`);
177
+ }
178
+ }
179
+ return { outcomes, failures };
180
+ }
@@ -111,6 +111,33 @@ Reason: <only when BLOCKED>
111
111
  };
112
112
  }
113
113
 
114
+ /**
115
+ * The REPRO-AUDIT phase — red-team the reproduction ITSELF (the twice-measured
116
+ * failure: requests-2148 and flask-4992 both had a reproduction that validated
117
+ * the agent's interpretation while the project's real tests failed). A
118
+ * DIFFERENT model family answers one question — "which case stated in the
119
+ * issue is NOT covered by this reproduction?" — and extends it. Only used when
120
+ * the reproduction was CONSTRUCTED from prose; a user-supplied failing test is
121
+ * already ground truth.
122
+ */
123
+ export function reproAuditInstruction(state: FailingState): string {
124
+ const failing = formatFailingState(state);
125
+ return `You are the REPRO-AUDIT agent (adversary). The previous phase CONSTRUCTED a reproduction from the issue text. Your single question: **which case stated in the issue is NOT covered by that reproduction?**
126
+
127
+ **The issue / failing state:**
128
+ ${failing}
129
+
130
+ **Do exactly this:**
131
+ 1. Read the reproduction script/command the previous phase reported (see its handoff and the repro file in the working tree).
132
+ 2. Compare it against the issue LITERALLY: every quoted snippet, input, expected value, and edge case named in the issue text. List what the reproduction does NOT exercise.
133
+ 3. If gaps exist: EXTEND the reproduction (edit the repro file — this is the one file you may edit) to cover them, then run it with \`sandbox_run\` — it must still FAIL on the current code for the same root cause.
134
+ 4. If the reproduction only passes because it mirrors an interpretation, not the issue: rewrite it from the issue's own examples and re-run.
135
+ 5. **Last action:** call \`phase_result\` with \`verdict: PASS\` (audited — gaps closed or none found) or \`verdict: BLOCKED\` (the issue cannot be reproduced as stated), and a handoff that MUST restate the final command on a machine-readable line:
136
+ \`\`\`
137
+ REPRO-CMD: <the exact, possibly updated, command>
138
+ \`\`\`${DEBUG_RULES}`;
139
+ }
140
+
114
141
  /**
115
142
  * The /fix single-shot phase — the measured-cheapest first attempt. One agent
116
143
  * fixes directly (baseline cost); the DRIVER then oracle-checks the result
@@ -85,6 +85,22 @@ export function parseReproCmd(handoff: string | null | undefined): string | unde
85
85
  return cmd && cmd.length > 1 ? cmd : undefined;
86
86
  }
87
87
 
88
+ /**
89
+ * Tiered shot budget (drift guard, measured on sympy/sphinx): a hard instance
90
+ * must fail FAST at the shot so the escalation inherits real budget; an easy
91
+ * one deserves a longer single attempt since escalation is unlikely.
92
+ */
93
+ export function shotBudgetMs(triageRoute: "single-shot" | "debug" | "build" | "plan"): number {
94
+ switch (triageRoute) {
95
+ case "single-shot":
96
+ return 12 * 60 * 1000; // likely to finish here — give it room
97
+ case "debug":
98
+ return 8 * 60 * 1000;
99
+ default:
100
+ return 6 * 60 * 1000; // build-scale/hard: fail fast, escalate with budget left
101
+ }
102
+ }
103
+
88
104
  /** Minimal shape of routing.json this module needs (kept structural). */
89
105
  export interface RoutingLike {
90
106
  routes?: Record<string, { preferredModel?: string; fallback?: string } | undefined>;
@@ -49,7 +49,7 @@ const HARD_CONCURRENCY_CAP = 2;
49
49
  const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
50
50
 
51
51
  /** Re-invoke the current phi binary (so the sub-explorer is the same build). */
52
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
52
+ export function getPiInvocation(args: string[]): { command: string; args: string[] } {
53
53
  const currentScript = process.argv[1];
54
54
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
55
55
  if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {