@phi-code-admin/phi-code 0.78.0 → 0.80.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.
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Commit Extension - /commit command for deterministic, LLM-free git commits.
3
+ *
4
+ * Inspired by examples/extensions/auto-commit-on-exit.ts, but exposed as an
5
+ * explicit user command and hardened with a strict safety protocol. There is
6
+ * NO model call anywhere in this file: the commit message is derived purely
7
+ * from git state and the local session, so the result is fully reproducible
8
+ * and never depends on a flaky structured-output proxy.
9
+ *
10
+ * Usage:
11
+ * /commit : commit what is already staged (no auto-staging)
12
+ * /commit <message> : commit already-staged changes with that message
13
+ * /commit --all : stage everything (git add -A) then commit
14
+ * /commit --all <message> : stage everything then commit with that message
15
+ *
16
+ * Message format: always prefixed with "[phi] ". When the user passes a
17
+ * message it is used verbatim after the prefix. Otherwise the message is
18
+ * derived deterministically from the staged files (git diff --staged --stat /
19
+ * git status) plus the first line of the last assistant message when present.
20
+ *
21
+ * Safety protocol (strict):
22
+ * - Refuses to commit if a sensitive file is staged (.env, *.pem, *secret*,
23
+ * id_rsa). The user is warned and nothing is committed.
24
+ * - Never uses --amend.
25
+ * - Only stages with `git add -A` when --all is passed; otherwise it commits
26
+ * strictly what is already in the index.
27
+ * - The message is passed via a single pi.exec("git", ["commit", "-m", msg])
28
+ * call: no shell string interpolation, robust cross-OS.
29
+ */
30
+
31
+ import type { ExtensionAPI, ExtensionCommandContext } from "phi-code";
32
+
33
+ /** Max length of the derived subject line (without the "[phi] " prefix). */
34
+ const MAX_SUBJECT_LENGTH = 60;
35
+
36
+ /** Max number of file names listed in an auto-derived commit message. */
37
+ const MAX_LISTED_FILES = 3;
38
+
39
+ /**
40
+ * Patterns for files that must never be committed by this command.
41
+ * Matched against the path of each staged file (case-insensitive on basename).
42
+ */
43
+ const SENSITIVE_PATTERNS: { label: string; test: (path: string) => boolean }[] = [
44
+ {
45
+ label: ".env file",
46
+ test: (p) => {
47
+ const base = basename(p).toLowerCase();
48
+ return base === ".env" || base.startsWith(".env.");
49
+ },
50
+ },
51
+ { label: "PEM/key file", test: (p) => p.toLowerCase().endsWith(".pem") },
52
+ { label: "secret-named file", test: (p) => basename(p).toLowerCase().includes("secret") },
53
+ { label: "SSH private key", test: (p) => basename(p).toLowerCase() === "id_rsa" },
54
+ ];
55
+
56
+ /** Return the last path segment, tolerating both / and \ separators. */
57
+ function basename(path: string): string {
58
+ const normalized = path.replace(/\\/g, "/");
59
+ const idx = normalized.lastIndexOf("/");
60
+ return idx === -1 ? normalized : normalized.slice(idx + 1);
61
+ }
62
+
63
+ /**
64
+ * Parse `git status --porcelain` output into staged file paths.
65
+ *
66
+ * Porcelain v1 lines are "XY <path>" where X is the index (staged) status.
67
+ * A file is considered staged when X is not a space and not "?" (untracked).
68
+ * Rename entries use "orig -> dest"; we keep the destination path.
69
+ */
70
+ function parseStagedFiles(porcelain: string): string[] {
71
+ const files: string[] = [];
72
+ for (const rawLine of porcelain.split("\n")) {
73
+ const line = rawLine.replace(/\r$/, "");
74
+ if (line.length < 4) {
75
+ continue;
76
+ }
77
+ const indexStatus = line[0];
78
+ if (indexStatus === " " || indexStatus === "?") {
79
+ continue;
80
+ }
81
+ let pathPart = line.slice(3).trim();
82
+ const renameSep = pathPart.indexOf(" -> ");
83
+ if (renameSep !== -1) {
84
+ pathPart = pathPart.slice(renameSep + 4).trim();
85
+ }
86
+ // Porcelain quotes paths containing special chars; strip surrounding quotes.
87
+ if (pathPart.startsWith('"') && pathPart.endsWith('"')) {
88
+ pathPart = pathPart.slice(1, -1);
89
+ }
90
+ if (pathPart.length > 0) {
91
+ files.push(pathPart);
92
+ }
93
+ }
94
+ return files;
95
+ }
96
+
97
+ /** Find the first sensitive file among the staged paths, if any. */
98
+ function findSensitiveFile(stagedFiles: string[]): { path: string; label: string } | undefined {
99
+ for (const path of stagedFiles) {
100
+ for (const pattern of SENSITIVE_PATTERNS) {
101
+ if (pattern.test(path)) {
102
+ return { path, label: pattern.label };
103
+ }
104
+ }
105
+ }
106
+ return undefined;
107
+ }
108
+
109
+ /** Extract the first line of the last assistant message from the session. */
110
+ function lastAssistantFirstLine(ctx: ExtensionCommandContext): string {
111
+ const entries = ctx.sessionManager.getEntries();
112
+ for (let i = entries.length - 1; i >= 0; i--) {
113
+ const entry = entries[i];
114
+ if (entry.type !== "message" || entry.message.role !== "assistant") {
115
+ continue;
116
+ }
117
+ const content = entry.message.content;
118
+ let text = "";
119
+ if (typeof content === "string") {
120
+ text = content;
121
+ } else if (Array.isArray(content)) {
122
+ text = content
123
+ .filter((c): c is { type: "text"; text: string } => c?.type === "text" && typeof c.text === "string")
124
+ .map((c) => c.text)
125
+ .join("\n");
126
+ }
127
+ const firstLine = text.split("\n").find((l) => l.trim().length > 0);
128
+ return firstLine ? firstLine.trim() : "";
129
+ }
130
+ return "";
131
+ }
132
+
133
+ /** Collapse whitespace and clamp to MAX_SUBJECT_LENGTH with an ellipsis. */
134
+ function clampSubject(text: string): string {
135
+ const collapsed = text.replace(/\s+/g, " ").trim();
136
+ if (collapsed.length <= MAX_SUBJECT_LENGTH) {
137
+ return collapsed;
138
+ }
139
+ return `${collapsed.slice(0, MAX_SUBJECT_LENGTH - 3)}...`;
140
+ }
141
+
142
+ /**
143
+ * Build a deterministic subject derived from the staged files plus the first
144
+ * line of the last assistant message. No LLM involved.
145
+ */
146
+ function deriveSubject(stagedFiles: string[], assistantHint: string): string {
147
+ const names = stagedFiles.map(basename);
148
+ let filesPart: string;
149
+ if (names.length === 0) {
150
+ filesPart = "changes";
151
+ } else if (names.length <= MAX_LISTED_FILES) {
152
+ filesPart = names.join(", ");
153
+ } else {
154
+ const shown = names.slice(0, MAX_LISTED_FILES).join(", ");
155
+ filesPart = `${shown} (+${names.length - MAX_LISTED_FILES} more)`;
156
+ }
157
+
158
+ const hint = assistantHint.trim();
159
+ const subject = hint.length > 0 ? `update ${filesPart}: ${hint}` : `update ${filesPart}`;
160
+ return clampSubject(subject);
161
+ }
162
+
163
+ export default function commitExtension(pi: ExtensionAPI) {
164
+ pi.registerCommand("commit", {
165
+ description: "Deterministically commit staged changes ([phi] prefix, no LLM). Use --all to stage everything first.",
166
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
167
+ try {
168
+ // 1. Parse args: extract the --all flag, keep the rest as the message.
169
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
170
+ const stageAll = tokens.includes("--all");
171
+ const userMessage = tokens.filter((t) => t !== "--all").join(" ").trim();
172
+
173
+ // 2. Confirm we are inside a git repo with changes.
174
+ const status = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
175
+ if (status.code !== 0) {
176
+ ctx.ui.notify(
177
+ "Not a git repository (or git unavailable). Nothing to commit.",
178
+ "warning",
179
+ );
180
+ return;
181
+ }
182
+ if (status.stdout.trim().length === 0) {
183
+ ctx.ui.notify("Working tree clean: nothing to commit.", "info");
184
+ return;
185
+ }
186
+
187
+ // 3. Optionally stage everything (only when explicitly requested).
188
+ if (stageAll) {
189
+ const add = await pi.exec("git", ["add", "-A"], { cwd: ctx.cwd });
190
+ if (add.code !== 0) {
191
+ ctx.ui.notify(`git add -A failed: ${add.stderr.trim() || "unknown error"}`, "error");
192
+ return;
193
+ }
194
+ }
195
+
196
+ // 4. Re-read status to know exactly what is staged after any add.
197
+ const afterStatus = await pi.exec("git", ["status", "--porcelain"], { cwd: ctx.cwd });
198
+ const stagedFiles = parseStagedFiles(afterStatus.stdout);
199
+ if (stagedFiles.length === 0) {
200
+ ctx.ui.notify(
201
+ "No staged changes to commit. Stage files first, or run `/commit --all`.",
202
+ "warning",
203
+ );
204
+ return;
205
+ }
206
+
207
+ // 5. Safety protocol: refuse if a sensitive file is staged.
208
+ const sensitive = findSensitiveFile(stagedFiles);
209
+ if (sensitive) {
210
+ ctx.ui.notify(
211
+ `Refusing to commit: sensitive ${sensitive.label} is staged (\`${sensitive.path}\`). ` +
212
+ `Unstage it with \`git restore --staged ${sensitive.path}\` before committing.`,
213
+ "error",
214
+ );
215
+ return;
216
+ }
217
+
218
+ // 6. Build the deterministic message.
219
+ let subject: string;
220
+ if (userMessage.length > 0) {
221
+ subject = userMessage;
222
+ } else {
223
+ const hint = lastAssistantFirstLine(ctx);
224
+ subject = deriveSubject(stagedFiles, hint);
225
+ }
226
+ const message = `[phi] ${subject}`;
227
+
228
+ // 7. Commit via a single exec call (no shell, no --amend).
229
+ const commit = await pi.exec("git", ["commit", "-m", message], { cwd: ctx.cwd });
230
+ if (commit.code !== 0) {
231
+ ctx.ui.notify(
232
+ `git commit failed: ${commit.stderr.trim() || commit.stdout.trim() || "unknown error"}`,
233
+ "error",
234
+ );
235
+ return;
236
+ }
237
+
238
+ // 8. Report the result with the short hash.
239
+ const rev = await pi.exec("git", ["rev-parse", "--short", "HEAD"], { cwd: ctx.cwd });
240
+ const shortHash = rev.code === 0 ? rev.stdout.trim() : "(unknown)";
241
+ ctx.ui.notify(
242
+ `Committed ${shortHash}: ${message} (${stagedFiles.length} file${stagedFiles.length === 1 ? "" : "s"}).`,
243
+ "info",
244
+ );
245
+ } catch (err) {
246
+ ctx.ui.notify(
247
+ `/commit error: ${err instanceof Error ? err.message : String(err)}`,
248
+ "error",
249
+ );
250
+ }
251
+ },
252
+ });
253
+ }
@@ -64,6 +64,113 @@ function withFrontmatter(content: string, name: string): string {
64
64
  return `---\nname: "${name}"\ndescription: "${description}"\n---\n\n${content}`;
65
65
  }
66
66
 
67
+ /** Hard caps for the deterministic recall manifest (no LLM involved). */
68
+ const MANIFEST_MAX_ENTRIES = 40;
69
+ const MANIFEST_MAX_CHARS = 2000;
70
+ const MANIFEST_DESC_MAX = 100;
71
+ // Chars reserved for the trailing "… (N notes total …)" overflow line so the
72
+ // final manifest still respects MANIFEST_MAX_CHARS once it is appended.
73
+ const MANIFEST_OVERFLOW_RESERVE = 96;
74
+
75
+ /**
76
+ * Extract a one-line summary for a single note file, deterministically.
77
+ *
78
+ * Prefers the YAML frontmatter `description:` field (written by
79
+ * withFrontmatter). Falls back to the first non-empty, non-frontmatter line.
80
+ * Returns a trimmed, single-line string truncated to MANIFEST_DESC_MAX chars.
81
+ */
82
+ function summarizeNote(content: string): string {
83
+ const text = content.replace(/\r\n/g, "\n");
84
+ let body = text;
85
+
86
+ // If a frontmatter block is present, scan it for a description first.
87
+ if (text.startsWith("---\n")) {
88
+ const end = text.indexOf("\n---", 4);
89
+ if (end !== -1) {
90
+ const fm = text.slice(4, end);
91
+ for (const rawLine of fm.split("\n")) {
92
+ const m = rawLine.match(/^\s*description\s*:\s*(.+?)\s*$/);
93
+ if (m) {
94
+ // Strip surrounding quotes if present.
95
+ const value = m[1].replace(/^["']|["']$/g, "").trim();
96
+ if (value) return collapseLine(value);
97
+ }
98
+ }
99
+ // No description in frontmatter: summarize the body that follows it.
100
+ body = text.slice(end + 4);
101
+ }
102
+ }
103
+
104
+ for (const rawLine of body.split("\n")) {
105
+ const line = rawLine.replace(/^#+\s*/, "").trim();
106
+ if (line && line !== "---") {
107
+ return collapseLine(line);
108
+ }
109
+ }
110
+ return "";
111
+ }
112
+
113
+ /** Collapse internal whitespace to single spaces and truncate cleanly. */
114
+ function collapseLine(value: string): string {
115
+ const flat = value.replace(/\s+/g, " ").trim();
116
+ return flat.length > MANIFEST_DESC_MAX ? `${flat.slice(0, MANIFEST_DESC_MAX - 1).trimEnd()}…` : flat;
117
+ }
118
+
119
+ /**
120
+ * Build a deterministic one-line-per-file manifest of all memory notes.
121
+ *
122
+ * Purely local: lists note files and reads their frontmatter / first line.
123
+ * No LLM call, no vector search. Bounded to MANIFEST_MAX_ENTRIES entries and
124
+ * MANIFEST_MAX_CHARS characters so it stays cheap to inject every turn.
125
+ *
126
+ * Returns an empty string when there are no notes, so callers can skip
127
+ * injection entirely.
128
+ */
129
+ function buildMemoryManifest(sigmaMemory: SigmaMemory): string {
130
+ let files: Array<{ name: string; size: number; date: string }>;
131
+ try {
132
+ files = sigmaMemory.notes.list();
133
+ } catch {
134
+ return "";
135
+ }
136
+ if (files.length === 0) {
137
+ return "";
138
+ }
139
+
140
+ const lines: string[] = [];
141
+ let total = 0;
142
+ let truncated = false;
143
+
144
+ for (const file of files) {
145
+ if (lines.length >= MANIFEST_MAX_ENTRIES) {
146
+ truncated = true;
147
+ break;
148
+ }
149
+ let summary = "";
150
+ try {
151
+ summary = summarizeNote(sigmaMemory.notes.read(file.name));
152
+ } catch {
153
+ // Unreadable note: still list its name so the model knows it exists.
154
+ summary = "";
155
+ }
156
+ const entry = summary ? `- ${file.name}: ${summary}` : `- ${file.name}`;
157
+ if (total + entry.length + 1 > MANIFEST_MAX_CHARS - MANIFEST_OVERFLOW_RESERVE) {
158
+ truncated = true;
159
+ break;
160
+ }
161
+ lines.push(entry);
162
+ total += entry.length + 1;
163
+ }
164
+
165
+ if (lines.length === 0) {
166
+ return "";
167
+ }
168
+ if (truncated || lines.length < files.length) {
169
+ lines.push(`- … (${files.length} notes total; use memory_read/memory_search for full content)`);
170
+ }
171
+ return lines.join("\n");
172
+ }
173
+
67
174
  export default function memoryExtension(pi: ExtensionAPI) {
68
175
  // Initialize sigma-memory with embedded vector store
69
176
  const sigmaMemory = new SigmaMemory();
@@ -244,8 +351,19 @@ export default function memoryExtension(pi: ExtensionAPI) {
244
351
  )
245
352
  .join("\n");
246
353
 
354
+ // Lead with the deterministic recall manifest (file + summary)
355
+ // so a no-arg memory_read surfaces what each note contains, not
356
+ // just file names. Falls back gracefully when empty.
357
+ const manifest = buildMemoryManifest(sigmaMemory);
358
+ const manifestSection = manifest ? `## Memory index (summaries)\n\n${manifest}\n\n` : "";
359
+
247
360
  return {
248
- content: [{ type: "text", text: `Available memory files (${files.length}):\n\n${fileList}` }],
361
+ content: [
362
+ {
363
+ type: "text",
364
+ text: `${manifestSection}## Available memory files (${files.length})\n\n${fileList}`,
365
+ },
366
+ ],
249
367
  details: { action: "list", fileCount: files.length },
250
368
  };
251
369
  }
@@ -529,10 +647,19 @@ export default function memoryExtension(pi: ExtensionAPI) {
529
647
  // Neutralize angle brackets so user content cannot close the
530
648
  // <system-reminder> block early and inject trusted instructions.
531
649
  const safe = truncated.replace(/[<>]/g, (c) => (c === "<" ? "&lt;" : "&gt;")).replace(/"/g, '\\"');
650
+
651
+ // Deterministic recall index: a one-line-per-file manifest of memory
652
+ // notes so the model ALWAYS sees which facts exist without having to
653
+ // guess and call memory_search blindly. Built locally (no LLM).
654
+ const manifest = buildMemoryManifest(sigmaMemory);
655
+ const manifestBlock = manifest
656
+ ? `\nMEMORY INDEX (existing saved notes, read with \`memory_read <file>\`):\n${manifest}\n`
657
+ : "";
658
+
532
659
  const reminder = `<system-reminder>
533
660
  You are about to respond to a new user message:
534
661
  "${safe}"
535
-
662
+ ${manifestBlock}
536
663
  REMINDER (project rule, applies every turn):
537
664
  1. Call \`memory_search\` FIRST with keywords from the user's intent. Recent
538
665
  project context, prior decisions, and saved learnings are accessible
@@ -20,14 +20,14 @@ import { Type } from "@sinclair/typebox";
20
20
  import type { ExtensionAPI } from "phi-code";
21
21
  import { writeFile, mkdir, readdir, readFile } from "node:fs/promises";
22
22
  import { join } from "node:path";
23
- import { existsSync, readFileSync } from "node:fs";
23
+ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
24
24
  import { homedir } from "node:os";
25
25
  import {
26
26
  extractBlockingFindings,
27
27
  extractHandoff,
28
28
  isTransientError,
29
29
  parsePhaseVerdict,
30
- } from "./orchestrator-helpers.js";
30
+ } from "./providers/orchestrator-helpers.js";
31
31
 
32
32
  // ─── Types ───────────────────────────────────────────────────────────────
33
33
 
@@ -152,6 +152,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
152
152
  if (orchestrationActive) {
153
153
  return {
154
154
  content: [{ type: "text", text: "⚠️ Orchestration is already active via /plan. Do NOT call orchestrate during /plan phases. Follow the phase instructions instead." }],
155
+ details: undefined,
155
156
  };
156
157
  }
157
158
 
@@ -229,6 +230,9 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
229
230
  // When true, switchModelForPhase resolves the fallback model first (used on retry
230
231
  // to swap to a different model family rather than re-hit the one that just failed).
231
232
  useFallback?: boolean;
233
+ // 0..4 for the five base phases (explore..review); undefined for synthetic
234
+ // phases (the review fix + re-review). Used to checkpoint resume position.
235
+ baseIndex?: number;
232
236
  }
233
237
 
234
238
  let phaseQueue: OrchestratorPhase[] = [];
@@ -251,6 +255,8 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
251
255
  let currentPhase: OrchestratorPhase | null = null;
252
256
  // Timestamp shared by all phase report file names in the current run.
253
257
  let currentRunTs = "";
258
+ // The original /plan description, kept so a checkpoint can be resumed.
259
+ let currentDescription = "";
254
260
  // Hard cap: at most one REVIEW->fix->re-REVIEW cycle per run.
255
261
  let reviewFixRounds = 0;
256
262
  // Set true right before an INTERNAL ctx.abort() (phase timeout) so the
@@ -266,6 +272,41 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
266
272
  return null;
267
273
  }
268
274
 
275
+ // ─── Resume checkpoint ───────────────────────────────────────────────
276
+ // A tiny JSON marker tracking POSITION in the 5 base phases, so a crashed /
277
+ // aborted orchestration can be resumed with `/plan --resume`. The handoff .md
278
+ // files hold the real content; this only records where to pick up.
279
+ function checkpointPath(): string {
280
+ return join(process.cwd(), ".phi", "plans", "orchestration-state.json");
281
+ }
282
+ function writeCheckpoint(nextBaseIndex: number): void {
283
+ try {
284
+ if (nextBaseIndex >= 5) { clearCheckpoint(); return; }
285
+ writeFileSync(
286
+ checkpointPath(),
287
+ JSON.stringify({ description: currentDescription, ts: currentRunTs, nextBaseIndex }, null, 2),
288
+ "utf-8",
289
+ );
290
+ } catch { /* best effort */ }
291
+ }
292
+ function readCheckpoint(): { description: string; ts: string; nextBaseIndex: number } | null {
293
+ try {
294
+ const p = checkpointPath();
295
+ if (!existsSync(p)) return null;
296
+ const c = JSON.parse(readFileSync(p, "utf-8"));
297
+ if (typeof c?.description === "string" && typeof c?.ts === "string" && typeof c?.nextBaseIndex === "number") {
298
+ return c;
299
+ }
300
+ } catch { /* ignore */ }
301
+ return null;
302
+ }
303
+ function clearCheckpoint(): void {
304
+ try {
305
+ const p = checkpointPath();
306
+ if (existsSync(p)) unlinkSync(p);
307
+ } catch { /* best effort */ }
308
+ }
309
+
269
310
  /**
270
311
  * Common rules appended to every phase instruction. Pure prompt (zero latency,
271
312
  * robust to the proxy): honest reporting, autonomous operation, a canonical
@@ -321,7 +362,7 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
321
362
  * Load routing config and build phase queue with model assignments + agent definitions.
322
363
  * Each phase now reads outputs from previous phases and writes structured outputs.
323
364
  */
324
- function buildPhases(description: string): OrchestratorPhase[] {
365
+ function buildPhases(description: string, tsOverride?: string): OrchestratorPhase[] {
325
366
  const routingPath = join(homedir(), ".phi", "agent", "routing.json");
326
367
  let routing: any = { routes: {}, default: { model: "default" } };
327
368
  try {
@@ -342,15 +383,15 @@ export default function orchestratorExtension(pi: ExtensionAPI) {
342
383
  const test = getModel("test");
343
384
  const review = getModel("review");
344
385
 
345
- const ts = timestamp();
346
- currentRunTs = ts; // shared by all phase report file names this run
386
+ const ts = tsOverride || timestamp();
387
+ currentRunTs = ts; // shared by all phase report file names this run (or resumed)
347
388
  // Inject runtime info so agents can adapt to the host OS
348
389
  const shellNote = process.platform === 'win32'
349
390
  ? `\nShell: bash (Git Bash), NOT cmd.exe. Always use Unix syntax: rm not del, test -f not if exist, / not \\\\`
350
391
  : '';
351
392
  const runtimeInfo = `\n\nRuntime: ${process.platform} (${process.arch})${shellNote}`;
352
393
 
353
- return [
394
+ const phases: OrchestratorPhase[] = [
354
395
  {
355
396
  key: "explore", label: "🔍 Phase 1 — EXPLORE", model: explore.preferred, fallback: explore.fallback,
356
397
  agent: loadAgentDef("explore"),
@@ -573,7 +614,7 @@ After testing, use \`memory_write\` to save test results, bugs found, and lesson
573
614
 
574
615
  **Project Request:** ${description}
575
616
 
576
- **Step 1:** Get the diff to review. Run \`git diff\` (and \`git status\`) if it is a git repo; otherwise read the files listed in the progress/handoff reports. Skip test/fixture hunks.
617
+ **Step 1:** Get the diff to review. Run \`git diff\` and \`git diff --stat\` (and \`git status\`) if it is a git repo; otherwise read the files listed in the progress/handoff reports. Skip test/fixture hunks. **Scale your effort to the diff size:** a small diff (under ~50 changed lines) gets one focused precision pass, bias to high confidence, at most 4 findings; a large diff gets all three angles below plus the verification step. Never spend so long that you risk the phase time limit.
577
618
  **Step 2:** Review in SEQUENTIAL ANGLES (do all in this one turn, no sub-agents). Collect raw candidates first, judge after:
578
619
  - **Angle 1 - Correctness:** read the diff line by line. What behaviour did it change or remove? Off-by-one, null/undefined, wrong condition, broken invariant.
579
620
  - **Angle 2 - Cross-file:** grep the callers and callees of changed symbols. Did a signature/return/contract change break a caller elsewhere?
@@ -615,6 +656,8 @@ Tag the note with relevant keywords for vector search.
615
656
  - Save any new architectural decisions or patterns discovered` + runtimeInfo + COMMON_PHASE_RULES,
616
657
  },
617
658
  ];
659
+ phases.forEach((p, i) => { p.baseIndex = i; });
660
+ return phases;
618
661
  }
619
662
 
620
663
  /**
@@ -684,13 +727,14 @@ Tag the note with relevant keywords for vector search.
684
727
  savedTools = pi.getActiveTools();
685
728
  }
686
729
  if (phase.agent) {
730
+ const agentDef = phase.agent;
687
731
  // Set agent's system prompt (will be injected via before_agent_start)
688
- activeAgentPrompt = phase.agent.systemPrompt;
732
+ activeAgentPrompt = agentDef.systemPrompt;
689
733
  // Restrict tools to agent's allowed tools
690
- if (phase.agent.tools.length > 0) {
734
+ if (agentDef.tools.length > 0) {
691
735
  // Always include memory tools in orchestration phases
692
736
  const memoryTools = ['memory_search', 'memory_write', 'memory_read', 'ontology_add', 'ontology_query'];
693
- const agentTools = [...phase.agent.tools, ...memoryTools.filter(t => !phase.agent.tools.includes(t))];
737
+ const agentTools = [...agentDef.tools, ...memoryTools.filter(t => !agentDef.tools.includes(t))];
694
738
  activeAgentTools = agentTools;
695
739
  pi.setActiveTools(agentTools);
696
740
  } else if (savedTools) {
@@ -752,6 +796,7 @@ Tag the note with relevant keywords for vector search.
752
796
  // All phases done — clean up and notify
753
797
  setOrchestrationActive(false);
754
798
  phasePending = false;
799
+ clearCheckpoint();
755
800
  deactivateAgent();
756
801
  // Restore the user's model so /plan does not overwrite their /model choice.
757
802
  if (originalModel) {
@@ -883,14 +928,16 @@ Tag the note with relevant keywords for vector search.
883
928
  const testResults: string[] = [];
884
929
  let toolCallCount = 0;
885
930
 
886
- for (const msg of messages) {
931
+ for (const rawMsg of messages) {
932
+ // Pi message variants are scanned dynamically; cast to a loose shape once.
933
+ const msg = rawMsg as { role?: string; content?: unknown; name?: string; toolName?: string; isError?: boolean };
887
934
  // Pi uses role: "toolResult" instead of "tool"
888
935
  if (msg.role === 'tool' || msg.role === 'function' || msg.role === 'toolResult') {
889
936
  toolCallCount++;
890
937
  const content = Array.isArray(msg.content)
891
938
  ? msg.content.map((c: any) => c.text || '').join('')
892
939
  : String(msg.content || '');
893
- const name = (msg as any).name || (msg as any).toolName || '';
940
+ const name = msg.name || msg.toolName || '';
894
941
  // Track writes
895
942
  if (name === 'write' && content.includes('Successfully wrote')) {
896
943
  const match = content.match(/wrote \d+ bytes to (.+)/);
@@ -898,7 +945,7 @@ Tag the note with relevant keywords for vector search.
898
945
  }
899
946
  // Track edits — the edit tool returns "Successfully replaced N block(s) in <path>."
900
947
  // Anchor the path capture so it does not over-capture unrelated text.
901
- if (name === 'edit' && !content.includes('ERR') && !(msg as any).isError) {
948
+ if (name === 'edit' && !content.includes('ERR') && !msg.isError) {
902
949
  const match = content.match(/replaced \d+ block\(s\) in (.+?)\.?$/m);
903
950
  if (match) filesEdited.push(match[1]);
904
951
  }
@@ -913,7 +960,7 @@ Tag the note with relevant keywords for vector search.
913
960
  }
914
961
  // Track test results
915
962
  if (content.includes('PASS') || content.includes('✅') || content.includes('✗') || content.includes('❌')) {
916
- const lines = content.split('\n').filter(l => /PASS|FAIL|✅|❌|✗/.test(l));
963
+ const lines = content.split('\n').filter((l: string) => /PASS|FAIL|✅|❌|✗/.test(l));
917
964
  testResults.push(...lines.slice(0, 10));
918
965
  }
919
966
  }
@@ -1027,6 +1074,12 @@ Tag the note with relevant keywords for vector search.
1027
1074
  }
1028
1075
  } catch { /* verdict logic is best-effort */ }
1029
1076
 
1077
+ // Checkpoint resume position after each base phase (synthetic fix/re-review
1078
+ // phases have no baseIndex and do not advance it).
1079
+ if (currentPhase && typeof currentPhase.baseIndex === "number") {
1080
+ writeCheckpoint(currentPhase.baseIndex + 1);
1081
+ }
1082
+
1030
1083
  // Phase complete — chain to next
1031
1084
  completedPhases++;
1032
1085
  phasePending = false;
@@ -1038,10 +1091,51 @@ Tag the note with relevant keywords for vector search.
1038
1091
  pi.registerCommand("plan", {
1039
1092
  description: "Plan AND execute a project — 5 phases, each with its own model from routing.json",
1040
1093
  handler: async (args, ctx) => {
1041
- const description = args.trim();
1094
+ const rawArgs = args.trim();
1095
+
1096
+ // Resume a crashed / aborted orchestration from its checkpoint.
1097
+ if (/^(--resume|resume)\b/i.test(rawArgs)) {
1098
+ const cp = readCheckpoint();
1099
+ if (!cp) {
1100
+ ctx.ui.notify("No orchestration checkpoint to resume. Start one with `/plan <description>`.", "warning");
1101
+ return;
1102
+ }
1103
+ await ensurePlansDir();
1104
+ currentDescription = cp.description;
1105
+ // Reuse the saved timestamp so the rebuilt phases point at the same
1106
+ // .phi/plans/*.md handoff files that the earlier run produced.
1107
+ const rphases = buildPhases(cp.description, cp.ts);
1108
+ const remaining = rphases.slice(Math.max(0, Math.min(5, cp.nextBaseIndex)));
1109
+ if (remaining.length === 0) {
1110
+ clearCheckpoint();
1111
+ ctx.ui.notify("Checkpoint already complete — nothing to resume.", "info");
1112
+ return;
1113
+ }
1114
+ setOrchestrationActive(true);
1115
+ phasePending = true;
1116
+ originalModel = ctx.model || null;
1117
+ savedTools = pi.getActiveTools();
1118
+ completedPhases = cp.nextBaseIndex;
1119
+ skippedPhases = 0;
1120
+ reviewFixRounds = 0;
1121
+ internalAbort = false;
1122
+ phaseStartTime = Date.now();
1123
+ const firstResume = remaining[0];
1124
+ currentPhase = firstResume;
1125
+ phaseQueue = remaining.slice(1);
1126
+ ctx.ui.notify(`📋 **Resuming orchestration** at ${firstResume.label} (${remaining.length} phase(s) left)\n`, "info");
1127
+ const { modelId, warning } = await switchModelForPhase(firstResume, ctx);
1128
+ activateAgent(firstResume, ctx);
1129
+ ctx.ui.notify(`${firstResume.label} → \`${modelId}\``, "info");
1130
+ if (warning) ctx.ui.notify(`\n⚠️ ${warning}`, "warning");
1131
+ setTimeout(() => pi.sendUserMessage(firstResume.instruction, { deliverAs: "followUp" }), 200);
1132
+ return;
1133
+ }
1134
+
1135
+ const description = rawArgs;
1042
1136
 
1043
1137
  if (!description) {
1044
- ctx.ui.notify(`**Usage:** \`/plan <project description>\`
1138
+ ctx.ui.notify(`**Usage:** \`/plan <project description>\` (or \`/plan --resume\` to continue a checkpointed run)
1045
1139
 
1046
1140
  **Examples:**
1047
1141
  /plan Build a REST API for user authentication with JWT
@@ -1060,6 +1154,7 @@ Tag the note with relevant keywords for vector search.
1060
1154
  await writeFile(join(plansDir, specFile), `# ${description}\n\n**Created:** ${new Date().toLocaleString()}\n`, "utf-8");
1061
1155
 
1062
1156
  // Build phases with model assignments + agent definitions
1157
+ currentDescription = description;
1063
1158
  const phases = buildPhases(description);
1064
1159
  phaseQueue = phases.slice(1); // Queue phases 2-5
1065
1160
  setOrchestrationActive(true);
@@ -1072,9 +1167,9 @@ Tag the note with relevant keywords for vector search.
1072
1167
  completedPhases = 0;
1073
1168
  skippedPhases = 0;
1074
1169
  reviewFixRounds = 0;
1075
- currentPhase = null;
1076
1170
  internalAbort = false;
1077
1171
  const firstPhase = phases[0];
1172
+ currentPhase = firstPhase;
1078
1173
 
1079
1174
  ctx.ui.notify(`📋 **Orchestrator started** — 5 phases with model routing + agent roles\n`, "info");
1080
1175