@phi-code-admin/phi-code 0.93.2 → 0.95.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,52 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.95.0] - 2026-07-12
4
+
5
+ ### Fixed — the 6h-drift class of failures (measured on seaborn-2848)
6
+
7
+ sandbox_run executed commands with spawnSync, which blocks Node's event loop:
8
+ no JS timer (session timeouts, phase timeouts, harness watchdogs) can fire while
9
+ a command runs. Back-to-back long executions drifted a 25-minute cap to 6h18.
10
+ Four layered defences, per the post-mortem:
11
+
12
+ - **Root fix:** execution.ts gains runCommandAsync/runArgvAsync (spawn-based,
13
+ event loop stays alive, the timeout kills the whole process TREE via
14
+ taskkill /T on Windows). Sandbox gains execAsync (all three backends);
15
+ sandbox_run now awaits execAsync. Regression test proves a timer fires DURING
16
+ a child run.
17
+ - **Cumulative execution budget per orchestration** (default 20 min, env
18
+ PHI_SANDBOX_BUDGET_MS): beyond it, sandbox_run refuses with BUDGET_EXHAUSTED
19
+ and instructs the agent to conclude honestly with the evidence it has.
20
+ - **Per-call cap tightenable for batch harnesses** (env PHI_SANDBOX_MAX_TIMEOUT_S,
21
+ default 1800s; the SWE-bench harness now sets 180s).
22
+ - **External watchdog in the harness driver** (timeout -k 30 4500): an internal
23
+ guard can never protect against itself; a hard kill from another process can.
24
+
25
+ +8 tests (async twins incl. event-loop-liveness regression, budget refusal).
26
+
27
+ ## [0.94.0] - 2026-07-12
28
+
29
+ ### Fixed — three defects the run telemetry caught live
30
+
31
+ The .phi/runs.jsonl telemetry (0.93.0) paid for itself on its first outing: it
32
+ showed /fix shots being killed mid-work by the flat 10-min phase cap (601s run,
33
+ empty phases, lost REPRO-CMD handoff), oracles going blind after those aborts,
34
+ and escalations burning ~10 min re-confirming a reproduction the driver itself
35
+ had just executed.
36
+
37
+ - **Per-phase timeout** (OrchestratorPhase.timeoutMs): the /fix single shot now
38
+ gets 25 min (measured: real shots run 8-18 min). The FIRST phase of a generic
39
+ run also gains the standard retry-on-fallback before being skipped (it was
40
+ skip-direct).
41
+ - **Oracle fallback reproduction**: when the shot's REPRO-CMD handoff was lost
42
+ but the conventional repro_issue.py exists, the oracle runs it instead of
43
+ finishing UNVERIFIED blind.
44
+ - **Escalation skips REPRODUCE**: the oracle just executed the red reproduction;
45
+ /fix now goes straight to LOCALIZE -> FIX -> VERIFY with the red run seeded,
46
+ saving ~10 min under tight budgets.
47
+
48
+ Full suite green (1472).
49
+
3
50
  ## [0.93.2] - 2026-07-12
4
51
 
5
52
  ### Fixed
@@ -307,6 +307,11 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
307
307
  // 0..4 for the five base phases (explore..review); undefined for synthetic
308
308
  // phases (the review fix + re-review). Used to checkpoint resume position.
309
309
  baseIndex?: number;
310
+ // Per-phase timeout override (generic driver only). Defaults to
311
+ // MAX_PHASE_DURATION_MS. Measured need: a /fix single shot legitimately
312
+ // runs 8–18 min — the flat 10 min cap killed it mid-work and lost its
313
+ // REPRO-CMD handoff (telemetry: 601s run, phases empty, UNVERIFIED).
314
+ timeoutMs?: number;
310
315
  }
311
316
 
312
317
  let phaseQueue: OrchestratorPhase[] = [];
@@ -333,6 +338,14 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
333
338
  // invocation count (reset at orchestration start, flushed at finish).
334
339
  let runPhaseRecords: PhaseRecord[] = [];
335
340
  let sandboxExecCount = 0;
341
+ // Cumulative sandbox execution time this orchestration (drift guard #2) and
342
+ // its budget; per-call cap (#4) is tightenable for batch harnesses via env.
343
+ let sandboxExecMs = 0;
344
+ const SANDBOX_BUDGET_MS = Math.max(
345
+ 1_000,
346
+ Number(process.env.PHI_SANDBOX_BUDGET_MS ?? 20 * 60 * 1000) || 20 * 60 * 1000,
347
+ );
348
+ const SANDBOX_MAX_TIMEOUT_S = Math.max(30, Number(process.env.PHI_SANDBOX_MAX_TIMEOUT_S ?? 1800) || 1800);
336
349
 
337
350
  /** Run a git command in cwd; returns stdout ("" on any failure). */
338
351
  function gitIn(cwd: string, args: string): string {
@@ -466,11 +479,29 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
466
479
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
467
480
  const p = params as { command: string; timeoutSeconds?: number };
468
481
  const cwd = ctx?.cwd || process.cwd();
482
+ // Guard #2 against runtime drift: a cumulative execution budget per
483
+ // orchestration. Even with async runs, an agent chaining long commands
484
+ // can eat a whole session — beyond the budget it must conclude with
485
+ // what it has. (Measured: back-to-back long runs drifted a 25-min cap
486
+ // to 6h18 before the async fix.)
487
+ if (orchestrationActive && sandboxExecMs >= SANDBOX_BUDGET_MS) {
488
+ return {
489
+ content: [
490
+ {
491
+ type: "text",
492
+ text: `SANDBOX BUDGET EXHAUSTED — ${Math.round(sandboxExecMs / 60000)} min of cumulative execution used this run (budget ${Math.round(SANDBOX_BUDGET_MS / 60000)} min). No further runs this orchestration: conclude with the evidence you already have, honestly (BLOCKED/PARTIAL if unverified).`,
493
+ },
494
+ ],
495
+ details: { verdict: "BUDGET_EXHAUSTED", budgetMs: SANDBOX_BUDGET_MS, usedMs: sandboxExecMs },
496
+ };
497
+ }
469
498
  if (orchestrationActive) sandboxExecCount++;
470
499
  const sandbox = getSessionSandbox(cwd);
471
- const result = sandbox.exec(p.command, {
472
- timeoutMs: Math.max(1, Math.min(1800, p.timeoutSeconds ?? 300)) * 1000,
500
+ // Guard #4: per-call cap, tightenable for batch harnesses via env.
501
+ const result = await sandbox.execAsync(p.command, {
502
+ timeoutMs: Math.max(1, Math.min(SANDBOX_MAX_TIMEOUT_S, p.timeoutSeconds ?? 300)) * 1000,
473
503
  });
504
+ if (orchestrationActive) sandboxExecMs += result.durationMs;
474
505
  const verdict = !sandbox.available()
475
506
  ? "UNAVAILABLE"
476
507
  : result.timedOut
@@ -1360,8 +1391,18 @@ Tag the note with relevant keywords for vector search.
1360
1391
  fixContext.oracleRan = true;
1361
1392
  const cwd = ctx.cwd || process.cwd();
1362
1393
  const sandbox = getSessionSandbox(cwd);
1363
- const reproCmd =
1394
+ let reproCmd =
1364
1395
  fixContext.state.failingTest?.trim() || fixContext.state.reproCommand?.trim() || fixContext.reproFromShot;
1396
+ // Conventional fallback: the shot is instructed to write `repro_issue.py`.
1397
+ // If its handoff was lost (phase timeout/abort) but the file exists, the
1398
+ // oracle can still run it instead of going blind (telemetry-measured gap).
1399
+ if (!reproCmd && existsSync(join(cwd, "repro_issue.py"))) {
1400
+ reproCmd = "python repro_issue.py";
1401
+ ctx.ui.notify(
1402
+ `\n🧷 Oracle fallback: found \`repro_issue.py\` — using \`${reproCmd}\` as the reproduction.`,
1403
+ "info",
1404
+ );
1405
+ }
1365
1406
  const suiteCmd = sandbox.recipe.test?.trim();
1366
1407
 
1367
1408
  // A shot that changed NOTHING has nothing to verify — "UNVERIFIED" would be
@@ -1409,13 +1450,16 @@ Tag the note with relevant keywords for vector search.
1409
1450
  );
1410
1451
  return true;
1411
1452
  }
1412
- // escalate: the pipeline inherits the RED run as its concrete failing state.
1453
+ // escalate: the pipeline inherits the RED run as its concrete failing
1454
+ // state. REPRODUCE is SKIPPED — the oracle just executed the reproduction
1455
+ // and it is red; re-confirming it would burn ~10 min re-running what the
1456
+ // driver already ran (measured: escalations were dying on outer budgets).
1413
1457
  ctx.ui.notify(
1414
- `\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.`,
1458
+ `\n🔺 **/fix escalating** — ${decision.diagnostic}. The reproduction is already established (the oracle just ran it); going straight to LOCALIZE FIX VERIFY.`,
1415
1459
  "warning",
1416
1460
  );
1417
- phaseQueue.push(...buildDebugPhases(decision.failing));
1418
- return false; // fall through: sendNextGenericPhase will dispatch REPRODUCE
1461
+ phaseQueue.push(...buildDebugPhases(decision.failing).slice(1));
1462
+ return false; // fall through: sendNextGenericPhase dispatches LOCALIZE
1419
1463
  }
1420
1464
 
1421
1465
  // Multi-candidate arbitration — the oracle disposes. Apply each captured
@@ -1522,6 +1566,7 @@ Tag the note with relevant keywords for vector search.
1522
1566
  if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
1523
1567
  setTimeout(() => pi.sendUserMessage(phase.instruction, { deliverAs: "followUp" }), 500);
1524
1568
  if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
1569
+ const phaseBudget = phase.timeoutMs ?? MAX_PHASE_DURATION_MS;
1525
1570
  phaseTimeoutId = setTimeout(() => {
1526
1571
  if (orchestrationActive && phasePending) {
1527
1572
  phasePending = false;
@@ -1536,7 +1581,7 @@ Tag the note with relevant keywords for vector search.
1536
1581
  phase.useFallback = true;
1537
1582
  phaseQueue.unshift(phase);
1538
1583
  ctx.ui.notify(
1539
- `\n⏰ **Phase timed out** (${MAX_PHASE_DURATION_MS / 60000} min) — retrying once on the fallback model.`,
1584
+ `\n⏰ **Phase timed out** (${Math.round(phaseBudget / 60000)} min) — retrying once on the fallback model.`,
1540
1585
  "warning",
1541
1586
  );
1542
1587
  } else {
@@ -1545,7 +1590,7 @@ Tag the note with relevant keywords for vector search.
1545
1590
  }
1546
1591
  sendNextGenericPhase(ctx);
1547
1592
  }
1548
- }, MAX_PHASE_DURATION_MS);
1593
+ }, phaseBudget);
1549
1594
  });
1550
1595
  }
1551
1596
 
@@ -1719,6 +1764,7 @@ Tag the note with relevant keywords for vector search.
1719
1764
  if (mode !== "debug") candidateContext = null;
1720
1765
  runPhaseRecords = [];
1721
1766
  sandboxExecCount = 0;
1767
+ sandboxExecMs = 0;
1722
1768
  setOrchestrationActive(true);
1723
1769
  phasePending = true;
1724
1770
  originalModel = ctx.model || null;
@@ -1743,6 +1789,7 @@ Tag the note with relevant keywords for vector search.
1743
1789
  ctx.ui.notify(`\n${first.label} → \`${modelId}\``, "info");
1744
1790
  if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
1745
1791
  if (phaseTimeoutId) clearTimeout(phaseTimeoutId);
1792
+ const firstBudget = first.timeoutMs ?? MAX_PHASE_DURATION_MS;
1746
1793
  phaseTimeoutId = setTimeout(() => {
1747
1794
  if (orchestrationActive && phasePending) {
1748
1795
  phasePending = false;
@@ -1752,11 +1799,24 @@ Tag the note with relevant keywords for vector search.
1752
1799
  } catch {
1753
1800
  /* best effort */
1754
1801
  }
1755
- skippedPhases++;
1756
- ctx.ui.notify(`\n⏰ **First phase timed out** skipping.`, "warning");
1802
+ // Same policy as every other phase: one retry on the fallback
1803
+ // model before skipping (the old skip-direct lost the whole run
1804
+ // when the very first phase was slow).
1805
+ if (!first.retried) {
1806
+ first.retried = true;
1807
+ first.useFallback = true;
1808
+ phaseQueue.unshift(first);
1809
+ ctx.ui.notify(
1810
+ `\n⏰ **First phase timed out** (${Math.round(firstBudget / 60000)} min) — retrying once on the fallback model.`,
1811
+ "warning",
1812
+ );
1813
+ } else {
1814
+ skippedPhases++;
1815
+ ctx.ui.notify(`\n⏰ **First phase timed out again** — skipping.`, "warning");
1816
+ }
1757
1817
  sendNextGenericPhase(ctx);
1758
1818
  }
1759
- }, MAX_PHASE_DURATION_MS);
1819
+ }, firstBudget);
1760
1820
  setTimeout(() => pi.sendUserMessage(first.instruction, { deliverAs: "followUp" }), 200);
1761
1821
  });
1762
1822
  }
@@ -2319,6 +2379,9 @@ With no runnable check at all, the result is honestly labelled UNVERIFIED.`,
2319
2379
  await ensurePlansDir();
2320
2380
  fixContext = { state, oracleRan: false };
2321
2381
  const shot = genericPhase("shot", "🎯 Phase 1 — SINGLE SHOT", "code", "code", singleShotInstruction(state));
2382
+ // A real single shot legitimately runs 8–18 min (measured on the
2383
+ // baselines); the flat 10 min phase cap was killing it mid-work.
2384
+ shot.timeoutMs = 25 * 60 * 1000;
2322
2385
  startGenericOrchestration(
2323
2386
  "fix",
2324
2387
  [shot],
@@ -8,10 +8,102 @@
8
8
  * itself is exercised with trivial real commands.
9
9
  */
10
10
 
11
- import { spawnSync } from "node:child_process";
11
+ import { spawn, spawnSync } from "node:child_process";
12
12
 
13
13
  const DEFAULT_MAX_BUFFER = 32 * 1024 * 1024;
14
14
 
15
+ /**
16
+ * Kill a spawned process AND its children. On Windows, child.kill() only hits
17
+ * the direct child (a shell) — the actual workload (docker, pytest…) survives;
18
+ * taskkill /T fells the whole tree.
19
+ */
20
+ function killTree(pid: number | undefined): void {
21
+ if (!pid) return;
22
+ try {
23
+ if (process.platform === "win32") {
24
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true });
25
+ } else {
26
+ process.kill(-pid, "SIGKILL");
27
+ }
28
+ } catch {
29
+ /* best effort */
30
+ }
31
+ }
32
+
33
+ /**
34
+ * ASYNC command run — the drift fix. spawnSync blocks Node's event loop, so no
35
+ * JS timer (session timeouts, phase timeouts, a harness Promise.race) can fire
36
+ * while a command runs; long back-to-back runs measured a 25-minute cap
37
+ * drifting to 6h18. spawn keeps the loop alive: every watchdog fires on time,
38
+ * and the timeout here kills the whole process tree itself. Same contract as
39
+ * runCommand: never throws, everything comes back as data.
40
+ */
41
+ export function runCommandAsync(command: string, options: RunOptions = {}): Promise<CommandResult> {
42
+ return spawnAsync(command, undefined, options, command);
43
+ }
44
+
45
+ /** ASYNC no-shell argv run — twin of runArgv (used for docker invocations). */
46
+ export function runArgvAsync(
47
+ file: string,
48
+ args: string[],
49
+ options: RunOptions & { label?: string } = {},
50
+ ): Promise<CommandResult> {
51
+ return spawnAsync(file, args, options, options.label ?? `${file} ${args.join(" ")}`.trim());
52
+ }
53
+
54
+ function spawnAsync(
55
+ fileOrCommand: string,
56
+ args: string[] | undefined,
57
+ options: RunOptions,
58
+ label: string,
59
+ ): Promise<CommandResult> {
60
+ const start = Date.now();
61
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
62
+ return new Promise((resolve) => {
63
+ let stdout = "";
64
+ let stderr = "";
65
+ let timedOut = false;
66
+ let settled = false;
67
+ const child = args
68
+ ? spawn(fileOrCommand, args, {
69
+ cwd: options.cwd,
70
+ env: options.env ?? process.env,
71
+ shell: false,
72
+ windowsHide: true,
73
+ })
74
+ : spawn(fileOrCommand, { cwd: options.cwd, env: options.env ?? process.env, shell: true, windowsHide: true });
75
+ const timer = setTimeout(() => {
76
+ timedOut = true;
77
+ killTree(child.pid);
78
+ }, timeoutMs);
79
+ const cap = (s: string, d: unknown) => {
80
+ const next = s + String(d);
81
+ return next.length > DEFAULT_MAX_BUFFER ? next.slice(-DEFAULT_MAX_BUFFER) : next;
82
+ };
83
+ child.stdout?.on("data", (d) => {
84
+ stdout = cap(stdout, d);
85
+ });
86
+ child.stderr?.on("data", (d) => {
87
+ stderr = cap(stderr, d);
88
+ });
89
+ const finish = (exitCode: number | null, extraErr?: string) => {
90
+ if (settled) return;
91
+ settled = true;
92
+ clearTimeout(timer);
93
+ resolve({
94
+ command: label,
95
+ exitCode: timedOut ? null : exitCode,
96
+ stdout,
97
+ stderr: extraErr ? `${extraErr}\n${stderr}` : stderr,
98
+ durationMs: Date.now() - start,
99
+ timedOut,
100
+ });
101
+ };
102
+ child.on("error", (err) => finish(null, err.message));
103
+ child.on("close", (code) => finish(code));
104
+ });
105
+ }
106
+
15
107
  export interface CommandResult {
16
108
  command: string;
17
109
  exitCode: number | null;
@@ -18,7 +18,9 @@ import {
18
18
  passed,
19
19
  type RunOptions,
20
20
  runArgv as realRunArgv,
21
+ runArgvAsync as realRunArgvAsync,
21
22
  runCommand as realRunCommand,
23
+ runCommandAsync as realRunCommandAsync,
22
24
  } from "./execution.js";
23
25
  import {
24
26
  applyConfig,
@@ -45,19 +47,34 @@ export interface Sandbox {
45
47
  readonly reason: string;
46
48
  describe(): string;
47
49
  available(): boolean;
48
- /** Run a command in the environment. Never throws (see execution.ts). */
50
+ /** Run a command in the environment. Never throws (see execution.js). */
49
51
  exec(command: string, options?: RunOptions): CommandResult;
52
+ /**
53
+ * ASYNC twin of exec — non-blocking (the drift fix): the event loop stays
54
+ * alive during the run, so session/phase timers fire on time and the run's
55
+ * own timeout kills the process tree. Agent-facing paths (sandbox_run) MUST
56
+ * use this; sync exec remains for bounded driver-internal steps.
57
+ */
58
+ execAsync(command: string, options?: RunOptions): Promise<CommandResult>;
50
59
  /** Build the image / install deps. Idempotent, best-effort. */
51
60
  prepare(): PrepareResult;
52
61
  }
53
62
 
54
63
  type RunCommandFn = (command: string, options?: RunOptions) => CommandResult;
55
64
  type RunArgvFn = (file: string, args: string[], options?: RunOptions & { label?: string }) => CommandResult;
65
+ type RunCommandAsyncFn = (command: string, options?: RunOptions) => Promise<CommandResult>;
66
+ type RunArgvAsyncFn = (
67
+ file: string,
68
+ args: string[],
69
+ options?: RunOptions & { label?: string },
70
+ ) => Promise<CommandResult>;
56
71
 
57
72
  /** Injectable seams — real fs/spawn by default, fakes in tests. */
58
73
  export interface SandboxDeps {
59
74
  runCommand?: RunCommandFn;
60
75
  runArgv?: RunArgvFn;
76
+ runCommandAsync?: RunCommandAsyncFn;
77
+ runArgvAsync?: RunArgvAsyncFn;
61
78
  listFiles?: (cwd: string) => string[];
62
79
  readConfig?: (cwd: string) => SandboxConfig | undefined;
63
80
  /** Force docker availability in tests; otherwise probed via `docker version`. */
@@ -112,6 +129,8 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
112
129
  const deps = opts.deps ?? {};
113
130
  const rc = deps.runCommand ?? realRunCommand;
114
131
  const ra = deps.runArgv ?? realRunArgv;
132
+ const rcAsync = deps.runCommandAsync ?? realRunCommandAsync;
133
+ const raAsync = deps.runArgvAsync ?? realRunArgvAsync;
115
134
  const files = (deps.listFiles ?? listProjectFiles)(opts.cwd);
116
135
  const config = (deps.readConfig ?? readSandboxConfig)(opts.cwd);
117
136
  const tc = detectToolchain(files);
@@ -128,8 +147,8 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
128
147
  // Effective image can change after prepare() builds a project Dockerfile.
129
148
  const state = { image: recipe.image };
130
149
 
131
- const dockerExec = (command: string, options?: RunOptions): CommandResult => {
132
- const args = buildDockerRunArgs({
150
+ const dockerArgsFor = (command: string) =>
151
+ buildDockerRunArgs({
133
152
  image: state.image,
134
153
  command,
135
154
  mountSource: opts.cwd,
@@ -139,12 +158,18 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
139
158
  memory: recipe.memory,
140
159
  cpus: recipe.cpus,
141
160
  });
142
- return ra("docker", args, {
161
+ const dockerExec = (command: string, options?: RunOptions): CommandResult =>
162
+ ra("docker", dockerArgsFor(command), {
163
+ cwd: opts.cwd,
164
+ timeoutMs: options?.timeoutMs,
165
+ label: `docker[${state.image}] ${command}`,
166
+ });
167
+ const dockerExecAsync = (command: string, options?: RunOptions): Promise<CommandResult> =>
168
+ raAsync("docker", dockerArgsFor(command), {
143
169
  cwd: opts.cwd,
144
170
  timeoutMs: options?.timeoutMs,
145
171
  label: `docker[${state.image}] ${command}`,
146
172
  });
147
- };
148
173
 
149
174
  const base = { backend: decision.backend, recipe, reason: decision.reason };
150
175
 
@@ -154,6 +179,7 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
154
179
  describe: () => `unavailable — ${decision.reason}`,
155
180
  available: () => false,
156
181
  exec: (command) => unavailableResult(command),
182
+ execAsync: async (command) => unavailableResult(command),
157
183
  prepare: () => ({ ok: false, backend: "unavailable", detail: decision.reason }),
158
184
  };
159
185
  }
@@ -164,6 +190,7 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
164
190
  describe: () => `local host (${opts.cwd})`,
165
191
  available: () => true,
166
192
  exec: (command, options) => rc(command, { cwd: opts.cwd, ...options }),
193
+ execAsync: (command, options) => rcAsync(command, { cwd: opts.cwd, ...options }),
167
194
  // Deliberately no host-side dependency install — running `npm install`
168
195
  // etc. on the user's host is intrusive; local is best-effort as-is.
169
196
  prepare: () => ({ ok: true, backend: "local", detail: "local host — no preparation performed" }),
@@ -176,6 +203,7 @@ export function resolveSandbox(opts: ResolveOptions): Sandbox {
176
203
  describe: () => `docker (${state.image})`,
177
204
  available: () => true,
178
205
  exec: dockerExec,
206
+ execAsync: dockerExecAsync,
179
207
  prepare: () => {
180
208
  if (recipe.source === "dockerfile") {
181
209
  const tag = imageTagFor(recipe);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.93.2",
3
+ "version": "0.95.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {