@phi-code-admin/phi-code 0.80.0 → 0.82.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,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.82.0] - 2026-06-14
4
+
5
+ ### Added
6
+
7
+ - **Opt-in parallel EXPLORE fan-out** (`/plan --fanout <description>`). Before
8
+ phase 1, the orchestrator runs up to **2 concurrent read-only** sub-explorers,
9
+ each with a narrow mandate (architecture & reusable patterns, impacted files,
10
+ risks & constraints), and merges their findings into the EXPLORE context. This
11
+ applies the perspective-diverse pattern to the front of the pipeline so the
12
+ downstream phases start from a fuller, more accurate map.
13
+
14
+ Guardrails (the fan-out is best-effort and never breaks a run): default OFF
15
+ (single-agent EXPLORE unchanged); read-only tool set only (no write/edit/bash);
16
+ hard concurrency cap of 2 against the single rate-limited key; **adaptive
17
+ fallback to sequential** the moment a sub-explorer reports a 429 / rate limit;
18
+ a per-explorer timeout that kills the process; and full graceful degradation to
19
+ the normal EXPLORE on any error, empty result, or rate limit. Experimental: not
20
+ yet validated against a live proxy at scale, hence opt-in.
21
+
22
+ ## [0.81.0] - 2026-06-14
23
+
24
+ Marathon increment 4 (prompt-only, low risk).
25
+
26
+ ### Changed
27
+
28
+ - **Memory discipline.** The per-turn reminder now tells the model to (1) save only
29
+ non-obvious, cross-session facts (not what git already records) and (2)
30
+ verify-before-trust: a recalled note describes a PAST state, so confirm it against
31
+ the live code before acting, and the current observation wins on conflict.
32
+ - **REVIEW final sweep.** On large diffs, the reviewer re-reads the diff once more
33
+ as if fresh and adds only genuinely new bugs (no padding).
34
+ - **Run Recipe.** EXPLORE records a `## Run Recipe` (build/run command, port, ready
35
+ signal) in the brief; TEST uses it to actually run the code and to tell a real
36
+ failure (FAIL) from a stale launch recipe (BLOCKED).
37
+
3
38
  ## [0.80.0] - 2026-06-14
4
39
 
5
40
  Marathon increment 3: context protection, productivity commands, and the
@@ -665,9 +665,15 @@ REMINDER (project rule, applies every turn):
665
665
  project context, prior decisions, and saved learnings are accessible
666
666
  ONLY via this tool.
667
667
  2. AFTER completing significant work, call \`memory_write\` to save what
668
- you did and learned.
669
-
670
- These two tool calls are not optional. Skipping them violates project rules.
668
+ you did and learned. Save only NON-OBVIOUS, cross-session facts and user
669
+ preferences; do NOT save what the repo or git history already records.
670
+ 3. Saved notes describe a PAST state. Before you act on a recalled fact,
671
+ verify it against the CURRENT code (read/grep that the path or symbol still
672
+ exists). If it conflicts, the live observation wins. Do not delete a stale
673
+ note; note the divergence.
674
+
675
+ The memory_search and memory_write calls are not optional. Skipping them
676
+ violates project rules.
671
677
  </system-reminder>
672
678
 
673
679
  `;
@@ -28,6 +28,7 @@ import {
28
28
  isTransientError,
29
29
  parsePhaseVerdict,
30
30
  } from "./providers/orchestrator-helpers.js";
31
+ import { defaultExplorerSpecs, READONLY_EXPLORER_TOOLS, runExploreFanout } from "./providers/explore-fanout.js";
31
32
 
32
33
  // ─── Types ───────────────────────────────────────────────────────────────
33
34
 
@@ -414,6 +415,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
414
415
  - Requirements: specific features needed
415
416
  - Tech decisions: frameworks, patterns to use
416
417
  - Constraints: what to NOT break
418
+ - Run Recipe: from package.json scripts / Makefile / Dockerfile / README, a \`## Run Recipe\` with the build command, run command, port/URL, and ready signal (the TEST phase uses this to actually run the code)
417
419
  5. **MANDATORY:** Write your findings to \`.phi/plans/explore-${ts}.md\` using the \`write\` tool. This file is READ by Phase 2 PLAN — if you skip it, the plan agent has no context. Do NOT skip this step.
418
420
 
419
421
  **LAST ACTION (MANDATORY):** Call \`memory_write\` to save your exploration findings for downstream agents.
@@ -580,6 +582,8 @@ $ <command actually executed>
580
582
  - Error: ... -> Fix: ...
581
583
  \`\`\`
582
584
 
585
+ **Launch:** if the brief contains a "## Run Recipe" (build command, run command, port/URL, ready signal), use it. Distinguish a real failure (FAIL) from a stale launch recipe (BLOCKED) and report the recipe that actually works.
586
+
583
587
  **Verdict rules:** PASS only if you OBSERVED every feature working at runtime. When in doubt, FAIL. No partial pass (3/4 working = FAIL). Use BLOCKED only when you could not run anything (broken launch recipe, missing env, provider down) and say what is needed.
584
588
 
585
589
  **CRITICAL RULES:**
@@ -640,6 +644,8 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
640
644
  - path/file.ts:123 - <what must change and why>
641
645
  \`\`\`
642
646
 
647
+ **Final sweep (large diffs only):** before writing the verdict, re-read the diff once more as if you had never seen it; add ONLY bugs not already in your list. If there are none, do not pad.
648
+
643
649
  **Verdict rules:** VERDICT: FAIL if there is ANY CONFIRMED correctness/security finding (it goes under BLOCKING). VERDICT: PASS only if BLOCKING is empty. Do NOT pad: if the code is clean, return PASS with no findings.
644
650
 
645
651
  After your review, use \`memory_write\` ONCE to save:
@@ -1132,10 +1138,13 @@ Tag the note with relevant keywords for vector search.
1132
1138
  return;
1133
1139
  }
1134
1140
 
1135
- const description = rawArgs;
1141
+ // Opt-in: `--fanout` runs a small read-only parallel exploration before
1142
+ // phase 1 (default OFF — single-agent EXPLORE, the safe behaviour).
1143
+ const wantFanout = /(^|\s)--fanout\b/.test(rawArgs);
1144
+ const description = rawArgs.replace(/(^|\s)--fanout\b/g, " ").trim();
1136
1145
 
1137
1146
  if (!description) {
1138
- ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run)
1147
+ ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run, \`/plan --fanout <desc>\` for parallel exploration)
1139
1148
 
1140
1149
  **Examples:**
1141
1150
  /plan Build a REST API for user authentication with JWT
@@ -1183,6 +1192,35 @@ Tag the note with relevant keywords for vector search.
1183
1192
 
1184
1193
  // Record orchestration start time for final summary
1185
1194
  phaseStartTime = Date.now();
1195
+
1196
+ // Opt-in parallel EXPLORE fan-out (read-only, <=2 concurrent, sequential
1197
+ // fallback on rate limit). Best-effort: any failure degrades to the normal
1198
+ // single-agent EXPLORE below by simply not enriching the instruction.
1199
+ if (wantFanout) {
1200
+ try {
1201
+ const available = ctx.modelRegistry?.getAvailable?.() || [];
1202
+ const exploreModelId = resolveModelRef(available, firstPhase.model)?.id || firstPhase.model;
1203
+ ctx.ui.notify(`\n🔭 **Parallel exploration** (read-only, up to 2 concurrent) — building a fuller map before phase 1...`, "info");
1204
+ const fan = await runExploreFanout(defaultExplorerSpecs(description), {
1205
+ model: exploreModelId,
1206
+ tools: READONLY_EXPLORER_TOOLS,
1207
+ cwd: ctx.cwd || process.cwd(),
1208
+ concurrency: 2,
1209
+ timeoutMs: 4 * 60 * 1000,
1210
+ });
1211
+ const okCount = fan.results.filter((r) => r.ok).length;
1212
+ if (fan.merged.trim()) {
1213
+ firstPhase.instruction =
1214
+ `## Parallel exploration findings (${okCount} read-only sub-explorer(s))\nUse these as a starting map; verify and synthesize them into your brief, do not trust them blindly.\n\n${fan.merged}\n\n---\n${firstPhase.instruction}`;
1215
+ ctx.ui.notify(`\n🔭 ${okCount} sub-exploration(s) merged into EXPLORE${fan.rateLimited ? " (rate limit hit — ran the rest sequentially)" : ""}.`, "info");
1216
+ } else {
1217
+ ctx.ui.notify(`\n🔭 Parallel exploration returned nothing usable${fan.rateLimited ? " (rate-limited)" : ""} — running the normal single-agent EXPLORE.`, "warning");
1218
+ }
1219
+ } catch {
1220
+ ctx.ui.notify(`\n🔭 Parallel exploration skipped (error) — running the normal single-agent EXPLORE.`, "warning");
1221
+ }
1222
+ }
1223
+
1186
1224
  // Switch model and activate agent for first phase
1187
1225
  const { modelId, warning } = await switchModelForPhase(firstPhase, ctx);
1188
1226
  activateAgent(firstPhase, ctx);
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Opt-in parallel EXPLORE fan-out for the /plan orchestrator.
3
+ *
4
+ * Spawns a small number of read-only `pi` sub-explorers, each with a narrow focus
5
+ * (architecture / impacted files / risks), and merges their findings into the
6
+ * EXPLORE phase context. This is the perspective-diverse pattern applied to the
7
+ * front of the pipeline: a more complete map of the codebase means better
8
+ * downstream plan/code/test phases.
9
+ *
10
+ * GUARDRAILS (this whole module is best-effort and NEVER throws):
11
+ * - Read-only only: sub-explorers get a read-only tool set, no write/edit/bash.
12
+ * - Hard concurrency cap of 2 (a single rate-limited proxy key is the constraint).
13
+ * - Adaptive fallback: if any sub-explorer reports a 429 / rate limit, the
14
+ * remaining ones run sequentially (concurrency drops to 1).
15
+ * - Per-explorer timeout, with the process killed on expiry.
16
+ * - Any failure (spawn error, timeout, no output, rate limit) degrades to "no
17
+ * findings" so the orchestrator simply runs the normal single-agent EXPLORE.
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { existsSync } from "node:fs";
22
+ import { basename } from "node:path";
23
+
24
+ export interface ExplorerSpec {
25
+ focus: string;
26
+ prompt: string;
27
+ }
28
+
29
+ export interface ExplorerResult {
30
+ focus: string;
31
+ text: string;
32
+ ok: boolean;
33
+ rateLimited: boolean;
34
+ error?: string;
35
+ }
36
+
37
+ export interface FanoutOptions {
38
+ model?: string;
39
+ tools?: string[];
40
+ cwd?: string;
41
+ concurrency?: number;
42
+ timeoutMs?: number;
43
+ }
44
+
45
+ /** Read-only tools handed to every sub-explorer. */
46
+ export const READONLY_EXPLORER_TOOLS = ["read", "grep", "glob", "ls", "find", "memory_search", "memory_read"];
47
+
48
+ const HARD_CONCURRENCY_CAP = 2;
49
+ const DEFAULT_TIMEOUT_MS = 4 * 60 * 1000;
50
+
51
+ /** Re-invoke the current phi binary (so the sub-explorer is the same build). */
52
+ function getPiInvocation(args: string[]): { command: string; args: string[] } {
53
+ const currentScript = process.argv[1];
54
+ const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
55
+ if (currentScript && !isBunVirtualScript && existsSync(currentScript)) {
56
+ return { command: process.execPath, args: [currentScript, ...args] };
57
+ }
58
+ const execName = basename(process.execPath).toLowerCase();
59
+ const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName);
60
+ if (!isGenericRuntime) return { command: process.execPath, args };
61
+ return { command: "pi", args };
62
+ }
63
+
64
+ export function isRateLimited(text: string): boolean {
65
+ return /\b429\b|rate.?limit(ed)?|too many requests|overloaded|quota exceeded|resource exhausted/i.test(text);
66
+ }
67
+
68
+ /** Run one sub-explorer. Never throws; always resolves to an ExplorerResult. */
69
+ function runOneExplorer(spec: ExplorerSpec, opts: FanoutOptions): Promise<ExplorerResult> {
70
+ return new Promise((resolve) => {
71
+ const args = ["--mode", "json", "-p", "--no-session"];
72
+ if (opts.model) args.push("--model", opts.model);
73
+ const tools = opts.tools && opts.tools.length > 0 ? opts.tools : READONLY_EXPLORER_TOOLS;
74
+ args.push("--tools", tools.join(","));
75
+ args.push(`Task: ${spec.prompt}`);
76
+
77
+ let proc: ReturnType<typeof spawn>;
78
+ try {
79
+ const inv = getPiInvocation(args);
80
+ proc = spawn(inv.command, inv.args, { cwd: opts.cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
81
+ } catch (e) {
82
+ resolve({ focus: spec.focus, text: "", ok: false, rateLimited: false, error: String(e) });
83
+ return;
84
+ }
85
+
86
+ let buffer = "";
87
+ let finalText = "";
88
+ let stderr = "";
89
+ let errorMessage = "";
90
+ let settled = false;
91
+
92
+ const timer = setTimeout(() => {
93
+ try {
94
+ proc.kill();
95
+ } catch {
96
+ /* best effort */
97
+ }
98
+ }, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
99
+
100
+ const processLine = (line: string) => {
101
+ if (!line.trim()) return;
102
+ let event: any;
103
+ try {
104
+ event = JSON.parse(line);
105
+ } catch {
106
+ return;
107
+ }
108
+ if (event?.type === "message_end" && event.message?.role === "assistant") {
109
+ const parts = event.message.content;
110
+ if (Array.isArray(parts)) {
111
+ for (const p of parts) {
112
+ if (p?.type === "text" && typeof p.text === "string") finalText = p.text;
113
+ }
114
+ }
115
+ if (event.message.errorMessage) errorMessage = String(event.message.errorMessage);
116
+ }
117
+ };
118
+
119
+ const finish = (timedOut: boolean) => {
120
+ if (settled) return;
121
+ settled = true;
122
+ clearTimeout(timer);
123
+ if (buffer.trim()) processLine(buffer);
124
+ const blob = `${finalText}\n${errorMessage}\n${stderr}`;
125
+ const rateLimited = isRateLimited(blob);
126
+ const ok = !timedOut && finalText.trim().length > 0 && !rateLimited;
127
+ resolve({
128
+ focus: spec.focus,
129
+ text: finalText,
130
+ ok,
131
+ rateLimited,
132
+ error: errorMessage || (ok ? undefined : timedOut ? "timeout" : "no output"),
133
+ });
134
+ };
135
+
136
+ proc.stdout?.on("data", (data) => {
137
+ buffer += data.toString();
138
+ const lines = buffer.split("\n");
139
+ buffer = lines.pop() || "";
140
+ for (const line of lines) processLine(line);
141
+ });
142
+ proc.stderr?.on("data", (data) => {
143
+ stderr += data.toString();
144
+ });
145
+ proc.on("error", (e) => {
146
+ if (settled) return;
147
+ settled = true;
148
+ clearTimeout(timer);
149
+ resolve({ focus: spec.focus, text: "", ok: false, rateLimited: false, error: String(e) });
150
+ });
151
+ proc.on("close", () => finish(false));
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Run the sub-explorers with a hard concurrency cap of 2; if a batch hits a rate
157
+ * limit, drop to sequential for the rest. Returns the merged findings (markdown)
158
+ * plus the raw results. Best-effort: callers should fall back to the normal
159
+ * single-agent EXPLORE when `merged` is empty.
160
+ */
161
+ export async function runExploreFanout(
162
+ specs: ExplorerSpec[],
163
+ opts: FanoutOptions,
164
+ ): Promise<{ results: ExplorerResult[]; merged: string; rateLimited: boolean }> {
165
+ let concurrency = Math.max(1, Math.min(opts.concurrency ?? HARD_CONCURRENCY_CAP, HARD_CONCURRENCY_CAP));
166
+ const results: ExplorerResult[] = [];
167
+ let i = 0;
168
+ while (i < specs.length) {
169
+ const batch = specs.slice(i, i + concurrency);
170
+ const batchResults = await Promise.all(batch.map((s) => runOneExplorer(s, opts)));
171
+ results.push(...batchResults);
172
+ i += batch.length;
173
+ // Adaptive backoff: a rate limit means the single key is saturated, so
174
+ // serialize whatever is left rather than push harder.
175
+ if (batchResults.some((r) => r.rateLimited)) concurrency = 1;
176
+ }
177
+ const ok = results.filter((r) => r.ok && r.text.trim());
178
+ const merged = ok.map((r) => `### Exploration — ${r.focus}\n${r.text.trim()}`).join("\n\n");
179
+ return { results, merged, rateLimited: results.some((r) => r.rateLimited) };
180
+ }
181
+
182
+ /** The default narrow-mandate explorer specs for a /plan request. */
183
+ export function defaultExplorerSpecs(description: string): ExplorerSpec[] {
184
+ const base = `Read-only exploration. Do NOT modify any file. Be concise and cite file:line.`;
185
+ return [
186
+ {
187
+ focus: "architecture & reusable patterns",
188
+ prompt: `${base}\nProject request: ${description}\nExplore the codebase architecture: tech stack, key modules/directories, the conventions and existing utilities/functions to REUSE, and how the pieces fit together.`,
189
+ },
190
+ {
191
+ focus: "impacted files",
192
+ prompt: `${base}\nProject request: ${description}\nIdentify the files and code that will be IMPACTED: grep the relevant symbols, find callers and callees, and list the exact files (file:line) that will likely need changes and why.`,
193
+ },
194
+ {
195
+ focus: "risks & constraints",
196
+ prompt: `${base}\nProject request: ${description}\nIdentify risks, edge cases, and constraints to NOT break: behaviour that must be preserved, error handling, security, and tests that cover the area.`,
197
+ },
198
+ ];
199
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phi-code-admin/phi-code",
3
- "version": "0.80.0",
3
+ "version": "0.82.0",
4
4
  "description": "Coding agent CLI with persistent memory, sub-agents, intelligent routing, and orchestration",
5
5
  "type": "module",
6
6
  "piConfig": {