supipowers 1.2.5 → 1.3.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.
@@ -9,19 +9,255 @@ const CAPS = {
9
9
  git: 5,
10
10
  };
11
11
 
12
+ /** Escape all 5 XML special characters in user data */
13
+ function escapeXML(str: string): string {
14
+ return str
15
+ .replace(/&/g, "&")
16
+ .replace(/</g, "&lt;")
17
+ .replace(/>/g, "&gt;")
18
+ .replace(/"/g, "&quot;")
19
+ .replace(/'/g, "&apos;");
20
+ }
21
+
22
+ interface SnapshotOpts {
23
+ compactCount?: number;
24
+ searchTool?: string;
25
+ searchAvailable?: boolean;
26
+ }
27
+
12
28
  /** Build a resume snapshot from tracked events for a session */
13
- export function buildResumeSnapshot(eventStore: EventStore, sessionId: string): string {
29
+ export function buildResumeSnapshot(
30
+ eventStore: EventStore,
31
+ sessionId: string,
32
+ opts?: SnapshotOpts,
33
+ ): string {
14
34
  const counts = eventStore.getEventCounts(sessionId);
15
35
  const hasAnyEvents = Object.values(counts).some((c) => c > 0);
16
36
  if (!hasAnyEvents) return "";
17
37
 
38
+ if (opts?.searchAvailable) {
39
+ return buildReferenceSnapshot(eventStore, sessionId, opts);
40
+ }
41
+ return buildFallbackSnapshot(eventStore, sessionId);
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Reference-based format (context-mode MCP available)
46
+ // ---------------------------------------------------------------------------
47
+
48
+ function buildReferenceSnapshot(eventStore: EventStore, sessionId: string, opts: SnapshotOpts): string {
49
+ const compactCount = opts.compactCount ?? 0;
50
+ const now = new Date().toISOString();
51
+ const sections: string[] = [
52
+ `<session_knowledge compact_count="${compactCount}" generated_at="${escapeXML(now)}">`,
53
+ " <how_to_search>",
54
+ " Each section below contains a summary of prior work.",
55
+ " For FULL DETAILS, run the exact tool call shown under each section.",
56
+ " Do NOT ask the user to re-explain prior work. Search first.",
57
+ " </how_to_search>",
58
+ ];
59
+
60
+ let hasSections = false;
61
+
62
+ // --- rules ---
63
+ const ruleEvents = eventStore.getEvents(sessionId, { categories: ["rule"] });
64
+ if (ruleEvents.length > 0) {
65
+ const files = new Set<string>();
66
+ for (const r of ruleEvents) {
67
+ const data = safeParse(r.data);
68
+ const file = typeof data?.file === "string" ? data.file : typeof data?.path === "string" ? data.path : null;
69
+ if (file) files.add(file);
70
+ }
71
+ if (files.size > 0) {
72
+ const fileList = [...files];
73
+ sections.push("");
74
+ sections.push(` <rules>`);
75
+ sections.push(` Loaded ${fileList.length} project rule files: ${fileList.map(escapeXML).join(", ")}`);
76
+ sections.push(` For full details:`);
77
+ sections.push(` ctx_search(queries: [${fileList.map((f) => `"${escapeXML(f)}"`).join(", ")}], source: "session-events")`);
78
+ sections.push(` </rules>`);
79
+ hasSections = true;
80
+ }
81
+ }
82
+
83
+ // --- files ---
84
+ const fileEvents = eventStore.getEvents(sessionId, { categories: ["file"], limit: 200 });
85
+ if (fileEvents.length > 0) {
86
+ const edited = new Set<string>();
87
+ const read = new Set<string>();
88
+ for (const f of fileEvents) {
89
+ const data = safeParse(f.data);
90
+ const p = typeof data?.path === "string" ? data.path : null;
91
+ if (!p) continue;
92
+ if (data?.op === "edit" || data?.op === "write") edited.add(p);
93
+ else if (data?.op === "read") read.add(p);
94
+ }
95
+ if (edited.size > 0 || read.size > 0) {
96
+ sections.push("");
97
+ sections.push(` <files count="${edited.size + read.size}">`);
98
+ if (edited.size > 0) sections.push(` Edited: ${[...edited].map(escapeXML).join(", ")}`);
99
+ if (read.size > 0) sections.push(` Read: ${[...read].map(escapeXML).join(", ")}`);
100
+ const queryPaths = [...edited, ...read].slice(0, 5);
101
+ sections.push(` For full details:`);
102
+ sections.push(` ctx_search(queries: [${queryPaths.map((p) => `"${escapeXML(p)}"`).join(", ")}], source: "session-events")`);
103
+ sections.push(` </files>`);
104
+ hasSections = true;
105
+ }
106
+ }
107
+
108
+ // --- tasks ---
109
+ const tasks = eventStore.getEvents(sessionId, { categories: ["task"], limit: CAPS.tasks });
110
+ if (tasks.length > 0) {
111
+ const summaries: string[] = [];
112
+ for (const t of tasks) {
113
+ const data = safeParse(t.data);
114
+ const content = extractTaskContent(data);
115
+ if (content) summaries.push(escapeXML(content.slice(0, 100)));
116
+ }
117
+ if (summaries.length > 0) {
118
+ sections.push("");
119
+ sections.push(` <tasks>`);
120
+ for (const s of summaries) sections.push(` ${s}`);
121
+ sections.push(` For full details:`);
122
+ sections.push(` ctx_search(queries: ["task", "todo"], source: "session-events")`);
123
+ sections.push(` </tasks>`);
124
+ hasSections = true;
125
+ }
126
+ }
127
+
128
+ // --- decisions ---
129
+ const decisions = eventStore.getEvents(sessionId, { categories: ["decision"], limit: CAPS.decisions });
130
+ if (decisions.length > 0) {
131
+ const summaries: string[] = [];
132
+ for (const d of decisions) {
133
+ const data = safeParse(d.data);
134
+ const prompt = typeof data?.prompt === "string" ? data.prompt.slice(0, 100) : "";
135
+ if (prompt) summaries.push(escapeXML(prompt));
136
+ }
137
+ if (summaries.length > 0) {
138
+ sections.push("");
139
+ sections.push(` <decisions>`);
140
+ for (const s of summaries) sections.push(` ${s}`);
141
+ sections.push(` </decisions>`);
142
+ hasSections = true;
143
+ }
144
+ }
145
+
146
+ // --- errors ---
147
+ const errors = eventStore.getEvents(sessionId, { categories: ["error"], limit: CAPS.errors });
148
+ if (errors.length > 0) {
149
+ const summaries: string[] = [];
150
+ for (const e of errors) {
151
+ const data = safeParse(e.data);
152
+ const summary = formatErrorSummary(data);
153
+ if (summary) summaries.push(escapeXML(summary.slice(0, 150)));
154
+ }
155
+ if (summaries.length > 0) {
156
+ sections.push("");
157
+ sections.push(` <errors>`);
158
+ for (const s of summaries) sections.push(` ${s}`);
159
+ sections.push(` </errors>`);
160
+ hasSections = true;
161
+ }
162
+ }
163
+
164
+ // --- git ---
165
+ const gitEvents = eventStore.getEvents(sessionId, { categories: ["git"], limit: CAPS.git });
166
+ if (gitEvents.length > 0) {
167
+ const summaries: string[] = [];
168
+ for (const g of gitEvents) {
169
+ const data = safeParse(g.data);
170
+ const cmd = typeof data?.command === "string" ? data.command.slice(0, 100) : "";
171
+ if (cmd) summaries.push(escapeXML(cmd));
172
+ }
173
+ if (summaries.length > 0) {
174
+ sections.push("");
175
+ sections.push(` <git>`);
176
+ for (const s of summaries) sections.push(` ${s}`);
177
+ sections.push(` </git>`);
178
+ hasSections = true;
179
+ }
180
+ }
181
+
182
+ // --- skills ---
183
+ const skillEvents = eventStore.getEvents(sessionId, { categories: ["skill"] });
184
+ if (skillEvents.length > 0) {
185
+ const names = new Set<string>();
186
+ for (const s of skillEvents) {
187
+ const data = safeParse(s.data);
188
+ const name = typeof data?.name === "string" ? data.name : typeof data?.skill === "string" ? data.skill : null;
189
+ if (name) names.add(name);
190
+ }
191
+ if (names.size > 0) {
192
+ sections.push("");
193
+ sections.push(` <skills>`);
194
+ sections.push(` Activated: ${[...names].map(escapeXML).join(", ")}`);
195
+ sections.push(` </skills>`);
196
+ hasSections = true;
197
+ }
198
+ }
199
+
200
+ // --- intent ---
201
+ const intentEvents = eventStore.getEvents(sessionId, { categories: ["intent"], limit: 1 });
202
+ if (intentEvents.length > 0) {
203
+ const data = safeParse(intentEvents[0].data);
204
+ const mode = typeof data?.mode === "string" ? data.mode : typeof data?.intent === "string" ? data.intent : null;
205
+ if (mode) {
206
+ sections.push("");
207
+ sections.push(` <intent>Session mode: ${escapeXML(mode)}</intent>`);
208
+ hasSections = true;
209
+ }
210
+ }
211
+
212
+ // --- env ---
213
+ const envEvents = eventStore.getEvents(sessionId, { categories: ["env"] });
214
+ if (envEvents.length > 0) {
215
+ const details: string[] = [];
216
+ for (const e of envEvents) {
217
+ const data = safeParse(e.data);
218
+ const detail = typeof data?.detail === "string" ? data.detail : typeof data?.env === "string" ? data.env : null;
219
+ if (detail) details.push(escapeXML(detail.slice(0, 100)));
220
+ }
221
+ if (details.length > 0) {
222
+ sections.push("");
223
+ sections.push(` <env>`);
224
+ for (const d of details) sections.push(` ${d}`);
225
+ sections.push(` </env>`);
226
+ hasSections = true;
227
+ }
228
+ }
229
+
230
+ // --- cwd ---
231
+ const cwdEvents = eventStore.getEvents(sessionId, { categories: ["cwd"], limit: 1 });
232
+ if (cwdEvents.length > 0) {
233
+ const data = safeParse(cwdEvents[0].data);
234
+ const cwd = typeof data?.cwd === "string" ? data.cwd : typeof data?.path === "string" ? data.path : null;
235
+ if (cwd) {
236
+ sections.push("");
237
+ sections.push(` <cwd>${escapeXML(cwd)}</cwd>`);
238
+ hasSections = true;
239
+ }
240
+ }
241
+
242
+ sections.push("</session_knowledge>");
243
+
244
+ if (!hasSections) return "";
245
+
246
+ return sections.join("\n");
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Fallback inline-truncated format (no context-mode MCP)
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function buildFallbackSnapshot(eventStore: EventStore, sessionId: string): string {
18
254
  const sections: string[] = ["<session_knowledge>"];
19
255
 
20
256
  // Last request
21
257
  const prompts = eventStore.getEvents(sessionId, { categories: ["prompt"], limit: 1 });
22
258
  if (prompts.length > 0) {
23
259
  const data = safeParse(prompts[0].data);
24
- const prompt = typeof data?.prompt === "string" ? data.prompt.slice(0, 200) : "";
260
+ const prompt = typeof data?.prompt === "string" ? escapeXML(data.prompt.slice(0, 200)) : "";
25
261
  if (prompt) {
26
262
  sections.push(` <last_request>${prompt}</last_request>`);
27
263
  }
@@ -34,7 +270,7 @@ export function buildResumeSnapshot(eventStore: EventStore, sessionId: string):
34
270
  for (const t of tasks) {
35
271
  const data = safeParse(t.data);
36
272
  const content = extractTaskContent(data);
37
- if (content) sections.push(` - ${content.slice(0, 100)}`);
273
+ if (content) sections.push(` - ${escapeXML(content.slice(0, 100))}`);
38
274
  }
39
275
  sections.push(" </pending_tasks>");
40
276
  }
@@ -45,7 +281,7 @@ export function buildResumeSnapshot(eventStore: EventStore, sessionId: string):
45
281
  sections.push(" <key_decisions>");
46
282
  for (const d of decisions) {
47
283
  const data = safeParse(d.data);
48
- const prompt = typeof data?.prompt === "string" ? data.prompt.slice(0, 100) : "";
284
+ const prompt = typeof data?.prompt === "string" ? escapeXML(data.prompt.slice(0, 100)) : "";
49
285
  if (prompt) sections.push(` - ${prompt}`);
50
286
  }
51
287
  sections.push(" </key_decisions>");
@@ -63,7 +299,7 @@ export function buildResumeSnapshot(eventStore: EventStore, sessionId: string):
63
299
  if (modifiedPaths.size > 0) {
64
300
  sections.push(" <files_modified>");
65
301
  const paths = [...modifiedPaths].slice(0, CAPS.files);
66
- for (const p of paths) sections.push(` - ${p}`);
302
+ for (const p of paths) sections.push(` - ${escapeXML(p)}`);
67
303
  sections.push(" </files_modified>");
68
304
  }
69
305
 
@@ -74,7 +310,7 @@ export function buildResumeSnapshot(eventStore: EventStore, sessionId: string):
74
310
  for (const e of errors) {
75
311
  const data = safeParse(e.data);
76
312
  const summary = formatErrorSummary(data);
77
- if (summary) sections.push(` - ${summary.slice(0, 150)}`);
313
+ if (summary) sections.push(` - ${escapeXML(summary.slice(0, 150))}`);
78
314
  }
79
315
  sections.push(" </recent_errors>");
80
316
  }
@@ -85,7 +321,7 @@ export function buildResumeSnapshot(eventStore: EventStore, sessionId: string):
85
321
  sections.push(" <git_state>");
86
322
  for (const g of gitEvents) {
87
323
  const data = safeParse(g.data);
88
- const cmd = typeof data?.command === "string" ? data.command.slice(0, 100) : "";
324
+ const cmd = typeof data?.command === "string" ? escapeXML(data.command.slice(0, 100)) : "";
89
325
  if (cmd) sections.push(` - ${cmd}`);
90
326
  }
91
327
  sections.push(" </git_state>");
@@ -13,11 +13,6 @@ export const DEFAULT_FIX_PR_CONFIG: FixPrConfig = {
13
13
  reviewer: { type: "none", triggerMethod: null },
14
14
  commentPolicy: "answer-selective",
15
15
  loop: { delaySeconds: 180, maxIterations: 3 },
16
- models: {
17
- orchestrator: { provider: "anthropic", model: "claude-opus-4-6", tier: "high" },
18
- planner: { provider: "anthropic", model: "claude-opus-4-6", tier: "high" },
19
- fixer: { provider: "anthropic", model: "claude-sonnet-4-6", tier: "low" },
20
- },
21
16
  };
22
17
 
23
18
  export function loadFixPrConfig(paths: PlatformPaths, cwd: string): FixPrConfig | null {
@@ -10,6 +10,8 @@ export interface FixPrPromptOptions {
10
10
  config: FixPrConfig;
11
11
  iteration: number;
12
12
  skillContent: string;
13
+ /** Resolved model ID for sub-agent tasks (planner, fixer roles). */
14
+ taskModel: string;
13
15
  }
14
16
 
15
17
  function buildReplyInstructions(config: FixPrConfig): string {
@@ -47,8 +49,8 @@ function buildReplyInstructions(config: FixPrConfig): string {
47
49
  }
48
50
 
49
51
  export function buildFixPrOrchestratorPrompt(options: FixPrPromptOptions): string {
50
- const { prNumber, repo, comments, sessionDir, scriptsDir, config, iteration, skillContent } = options;
51
- const { loop, models, reviewer } = config;
52
+ const { prNumber, repo, comments, sessionDir, scriptsDir, config, iteration, skillContent, taskModel } = options;
53
+ const { loop, reviewer } = config;
52
54
  const maxIter = loop.maxIterations;
53
55
  const delay = loop.delaySeconds;
54
56
 
@@ -190,11 +192,10 @@ export function buildFixPrOrchestratorPrompt(options: FixPrPromptOptions): strin
190
192
  sections.push(
191
193
  "## Model Guidance",
192
194
  "",
193
- `- **Orchestrator** (assessment, grouping): ${models.orchestrator.model} (${models.orchestrator.tier} tier) — thorough analysis`,
194
- `- **Planner** (fix planning): ${models.planner.model} (${models.planner.tier} tier) — detailed planning`,
195
- `- **Fixer** (code changes): ${models.fixer.model} (${models.fixer.tier} tier) — focused execution`,
195
+ `- **Orchestrator** (this session): handles assessment & grouping`,
196
+ `- **Planner & Fixer** (sub-agents): use model \`${taskModel}\``,
196
197
  "",
197
- "These indicate the expected reasoning depth for each phase of work.",
198
+ "Sub-agents inherit the task model for planning and code changes.",
198
199
  );
199
200
 
200
201
  return sections.join("\n");
@@ -4,12 +4,6 @@ export type ReviewerType = "coderabbit" | "copilot" | "gemini" | "none";
4
4
  /** How to handle comment replies */
5
5
  export type CommentReplyPolicy = "answer-all" | "answer-selective" | "no-answer";
6
6
 
7
- /** Model preference for a specific role */
8
- export interface ModelPref {
9
- provider: string;
10
- model: string;
11
- tier: "low" | "high";
12
- }
13
7
 
14
8
  /** Per-repo fix-pr configuration */
15
9
  export interface FixPrConfig {
@@ -22,11 +16,6 @@ export interface FixPrConfig {
22
16
  delaySeconds: number;
23
17
  maxIterations: number;
24
18
  };
25
- models: {
26
- orchestrator: ModelPref;
27
- planner: ModelPref;
28
- fixer: ModelPref;
29
- };
30
19
  }
31
20
 
32
21
  /** A PR review comment from GitHub API */
package/src/git/commit.ts CHANGED
@@ -10,6 +10,9 @@ import { validateCommitMessage } from "./commit-msg.js";
10
10
  import { getWorkingTreeStatus } from "./status.js";
11
11
  import { discoverCommitConventions } from "./conventions.js";
12
12
  import { notifyInfo, notifyError, notifySuccess } from "../notifications/renderer.js";
13
+ import { modelRegistry } from "../config/model-registry-instance.js";
14
+ import { resolveModelForAction, createModelBridge } from "../config/model-resolver.js";
15
+ import { loadModelConfig } from "../config/model-config.js";
13
16
 
14
17
  // ── Public types ───────────────────────────────────────────
15
18
 
@@ -213,6 +216,7 @@ function createProgress(ctx: any) {
213
216
  dispose() {
214
217
  stopTimer();
215
218
  ctx.ui.setStatus?.(STATUS_KEY, undefined);
219
+ ctx.ui.setStatus?.("supi-model", undefined);
216
220
  ctx.ui.setWidget?.(WIDGET_KEY, undefined);
217
221
  },
218
222
  };
@@ -292,8 +296,26 @@ export async function analyzeAndCommit(
292
296
 
293
297
  if (platform.capabilities.agentSessions) {
294
298
  progress.activate(4, `${fileList.length} file(s)`);
295
- plan = await tryAgentPlan(platform, cwd, prompt);
299
+ // Resolve the commit sub-agent model from config (falls back to session default)
300
+ const modelCfg = loadModelConfig(platform.paths, cwd);
301
+ const bridge = createModelBridge(platform);
302
+ const commitModel = resolveModelForAction("commit", modelRegistry, modelCfg, bridge);
303
+
304
+ // Show model override in status bar if not using the main session model
305
+ if (commitModel.source !== "main" && commitModel.model) {
306
+ const sourceLabel =
307
+ commitModel.source === "action" ? "configured for commit" :
308
+ commitModel.source === "default" ? "supipowers default" :
309
+ "harness role";
310
+ let detail = sourceLabel;
311
+ if (commitModel.thinkingLevel) {
312
+ detail += ` \u00b7 ${commitModel.thinkingLevel} thinking`;
313
+ }
314
+ ctx.ui?.setStatus?.("supi-model", `Model: ${commitModel.model} (${detail})`);
315
+ }
316
+ plan = await tryAgentPlan(platform, cwd, prompt, commitModel.model);
296
317
  if (plan) {
318
+ plan = validatePlanFiles(plan, fileList);
297
319
  progress.complete(4, `${plan.commits.length} commit(s)`);
298
320
  } else {
299
321
  progress.skip(4, "unavailable");
@@ -332,7 +354,7 @@ export async function analyzeAndCommit(
332
354
 
333
355
  // 7. Execute commits
334
356
  progress.activate(6, `0/${plan.commits.length}`);
335
- return executeCommitPlan(platform, ctx, cwd, plan, progress);
357
+ return executeCommitPlan(platform, ctx, cwd, plan, fileList, progress);
336
358
  } finally {
337
359
  // Always clean up, even on unexpected errors
338
360
  progress.dispose();
@@ -345,10 +367,11 @@ async function tryAgentPlan(
345
367
  platform: Platform,
346
368
  cwd: string,
347
369
  prompt: string,
370
+ model?: string,
348
371
  ): Promise<CommitPlan | null> {
349
372
  let session: Awaited<ReturnType<Platform["createAgentSession"]>> | null = null;
350
373
  try {
351
- session = await platform.createAgentSession({ cwd, hasUI: false });
374
+ session = await platform.createAgentSession({ cwd, hasUI: false, ...(model ? { model } : {}) });
352
375
 
353
376
  const agentDone = new Promise<void>((resolve) => {
354
377
  session!.subscribe((event: any) => {
@@ -426,6 +449,26 @@ async function manualFallback(
426
449
  return { committed: 1, messages: [message] };
427
450
  }
428
451
 
452
+ // ── Plan validation ────────────────────────────────────────
453
+
454
+ /**
455
+ * Filter an AI-generated commit plan against the actual staged file list.
456
+ * Removes hallucinated paths that aren't staged, and drops empty groups.
457
+ * Falls back to the original plan if filtering would leave nothing.
458
+ */
459
+ export function validatePlanFiles(plan: CommitPlan, stagedFiles: string[]): CommitPlan {
460
+ const stagedSet = new Set(stagedFiles);
461
+ const validCommits = plan.commits
462
+ .map((group) => ({
463
+ ...group,
464
+ files: group.files.filter((f) => stagedSet.has(f)),
465
+ }))
466
+ .filter((group) => group.files.length > 0);
467
+
468
+ return validCommits.length > 0 ? { commits: validCommits } : plan;
469
+ }
470
+
471
+
429
472
  // ── Commit execution ───────────────────────────────────────
430
473
 
431
474
  async function executeCommitPlan(
@@ -433,38 +476,45 @@ async function executeCommitPlan(
433
476
  ctx: any,
434
477
  cwd: string,
435
478
  plan: CommitPlan,
479
+ stagedFiles: string[],
436
480
  progress: ReturnType<typeof createProgress>,
437
481
  ): Promise<CommitResult | null> {
438
482
  const exec = platform.exec.bind(platform);
439
483
  const committedMessages: string[] = [];
440
484
 
485
+ // Snapshot the full index as a tree object. This lets us restore the
486
+ // staging area for each commit group via `git read-tree` — which reads
487
+ // from git's object store and never consults .gitignore.
488
+ const writeTreeResult = await exec("git", ["write-tree"], { cwd });
489
+ if (writeTreeResult.code !== 0) {
490
+ progress.dispose();
491
+ notifyError(ctx, "Commit failed", "Could not snapshot index (git write-tree)");
492
+ return null;
493
+ }
494
+ const savedTree = writeTreeResult.stdout.trim();
495
+
441
496
  for (let i = 0; i < plan.commits.length; i++) {
442
497
  const group = plan.commits[i];
443
498
  const header = formatCommitMessage(group).split("\n")[0];
444
499
  progress.detail(`${i + 1}/${plan.commits.length}: ${header}`);
445
500
 
446
- // Reset staging area
447
- await exec("git", ["reset", "HEAD"], { cwd });
501
+ // Restore the full saved index (no gitignore involvement)
502
+ await exec("git", ["read-tree", savedTree], { cwd });
448
503
 
449
- // Stage only this group's files
450
- const addResult = await exec("git", ["add", ...group.files], { cwd });
451
- if (addResult.code !== 0) {
452
- progress.dispose();
453
- const failedFiles = group.files.join(", ");
454
- const reason = addResult.stderr?.trim() || "git add returned non-zero";
455
- return reportPartialFailure(ctx, exec, cwd, committedMessages, {
456
- step: `Commit ${i + 1}/${plan.commits.length}`,
457
- error: `Could not stage files (${failedFiles}): ${reason}`,
458
- });
504
+ // Unstage everything NOT in this group
505
+ const groupSet = new Set(group.files);
506
+ const filesToUnstage = stagedFiles.filter((f) => !groupSet.has(f));
507
+ if (filesToUnstage.length > 0) {
508
+ await exec("git", ["reset", "HEAD", "--", ...filesToUnstage], { cwd });
459
509
  }
460
510
 
461
- // Build commit message
462
511
  const message = formatCommitMessage(group);
463
-
464
512
  const commitResult = await commitStaged(exec, cwd, message);
465
513
  if (!commitResult.success) {
466
514
  progress.dispose();
467
- return reportPartialFailure(ctx, exec, cwd, committedMessages, {
515
+ // Restore full staging area so the user isn't left with a partial index
516
+ await exec("git", ["read-tree", savedTree], { cwd });
517
+ return reportPartialFailure(ctx, committedMessages, {
468
518
  step: `Commit ${i + 1}/${plan.commits.length}`,
469
519
  error: commitResult.error!,
470
520
  });
@@ -473,6 +523,10 @@ async function executeCommitPlan(
473
523
  committedMessages.push(message);
474
524
  }
475
525
 
526
+ // Restore the saved index so any staged files NOT in the plan remain staged.
527
+ // Files already committed now match HEAD, so they appear as not-staged.
528
+ await exec("git", ["read-tree", savedTree], { cwd });
529
+
476
530
  progress.complete(6, `${committedMessages.length} done`);
477
531
  progress.dispose();
478
532
  notifySuccess(
@@ -486,18 +540,12 @@ async function executeCommitPlan(
486
540
 
487
541
  /**
488
542
  * Report a mid-plan failure with context on what succeeded and what failed.
489
- * Re-stages remaining files so the user isn't left with a half-reset index.
490
543
  */
491
- async function reportPartialFailure(
544
+ function reportPartialFailure(
492
545
  ctx: any,
493
- exec: Platform["exec"],
494
- cwd: string,
495
546
  committedMessages: string[],
496
547
  failure: { step: string; error: string },
497
- ): Promise<CommitResult | null> {
498
- // Re-stage everything so the user isn't stuck with a partial index
499
- await exec("git", ["add", "-A"], { cwd });
500
-
548
+ ): CommitResult | null {
501
549
  const lines: string[] = [];
502
550
  lines.push(`Failed at ${failure.step}: ${failure.error}`);
503
551
 
@@ -1,4 +1,6 @@
1
1
  import type { Platform } from "../platform/types.js";
2
+ import type { ResolvedModel } from "../types.js";
3
+ import { applyModelOverride } from "../config/model-resolver.js";
2
4
  import { listPlans, readPlanFile } from "../storage/plans.js";
3
5
 
4
6
  /**
@@ -14,17 +16,21 @@ let plansBefore: string[] = [];
14
16
  let planCwd: string = "";
15
17
  /** newSession function captured from the command context at plan start. */
16
18
  let capturedNewSession: ((options?: any) => Promise<{ cancelled: boolean }>) | null = null;
19
+ /** Resolved model for plan action — re-applied on execution handoff. */
20
+ let capturedResolvedModel: ResolvedModel | null = null;
17
21
 
18
22
  /** Mark planning as started (called by plan command after sending steer). */
19
23
  export function startPlanTracking(
20
24
  cwd: string,
21
25
  paths: any,
22
26
  newSession?: (options?: any) => Promise<{ cancelled: boolean }>,
27
+ resolvedModel?: ResolvedModel,
23
28
  ): void {
24
29
  planningActive = true;
25
30
  planCwd = cwd;
26
31
  plansBefore = listPlans(paths, cwd);
27
32
  capturedNewSession = newSession ?? null;
33
+ capturedResolvedModel = resolvedModel ?? null;
28
34
  }
29
35
 
30
36
  /** Cancel plan tracking (e.g., session change). */
@@ -33,6 +39,7 @@ export function cancelPlanTracking(): void {
33
39
  plansBefore = [];
34
40
  planCwd = "";
35
41
  capturedNewSession = null;
42
+ capturedResolvedModel = null;
36
43
  }
37
44
 
38
45
  /** Whether a planning session is currently active. */
@@ -89,10 +96,16 @@ async function executeApproveFlow(
89
96
  ): Promise<void> {
90
97
  const prompt = buildExecutionPrompt(planContent, planPath);
91
98
 
99
+ // Re-apply the plan model override for the execution turn.
100
+ // The planning turn's restore hook already fired (model reverted to default).
101
+ // We must switch again so the execution LLM turn uses the configured model.
102
+ if (capturedResolvedModel) {
103
+ await applyModelOverride(platform, ctx, "plan", capturedResolvedModel);
104
+ }
105
+
92
106
  if (capturedNewSession) {
93
107
  const result = await capturedNewSession();
94
108
  if (result?.cancelled) {
95
- // User dismissed the new-session prompt — keep plan state intact.
96
109
  ctx.ui.notify("Session start cancelled. Plan saved; run /supi:plan again to execute.");
97
110
  return;
98
111
  }
@@ -17,8 +17,11 @@ export function createOmpAdapter(api: any): Platform {
17
17
  sendUserMessage: (text: string) => api.sendUserMessage(text),
18
18
  registerMessageRenderer: (type, fn) => api.registerMessageRenderer(type, fn),
19
19
 
20
- setModel(model: string): void {
21
- api.setModel(model);
20
+ async setModel(model: any): Promise<boolean> {
21
+ return api.setModel(model);
22
+ },
23
+ setThinkingLevel(level: string, persist?: boolean): void {
24
+ api.setThinkingLevel?.(level, persist);
22
25
  },
23
26
  getCurrentModel(): string {
24
27
  return api.getCurrentModel?.() ?? "unknown";
@@ -138,7 +138,8 @@ export interface Platform {
138
138
  registerMessageRenderer<T>(type: string, renderer: any): void;
139
139
 
140
140
  // Model access
141
- setModel?(model: string): void;
141
+ setModel?(model: any): Promise<boolean>;
142
+ setThinkingLevel?(level: string, persist?: boolean): void;
142
143
  getCurrentModel?(): string;
143
144
  getModelForRole?(role: string): string | null;
144
145