gentle-pi 0.2.7 → 0.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.
@@ -0,0 +1,253 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
7
+ const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
8
+
9
+ export type SddExecutionMode = "interactive" | "auto";
10
+ export type SddArtifactStore = "openspec" | "engram" | "both";
11
+ export type SddChainedPrStrategy =
12
+ | "auto-forecast"
13
+ | "ask-always"
14
+ | "single-pr-default"
15
+ | "force-chained";
16
+
17
+ export interface SddPreflightPreferences {
18
+ executionMode: SddExecutionMode;
19
+ artifactStore: SddArtifactStore;
20
+ chainedPrStrategy: SddChainedPrStrategy;
21
+ reviewBudgetLines: number;
22
+ engramAvailable: boolean;
23
+ }
24
+
25
+ interface SddPreflightCallbacks {
26
+ pi: ExtensionAPI;
27
+ installAssets?: (cwd: string) => {
28
+ agents: number;
29
+ chains: number;
30
+ support: number;
31
+ skipped: number;
32
+ };
33
+ applyModelConfig?: (cwd: string) => { updated: number; skipped: number };
34
+ }
35
+
36
+ const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
37
+ executionMode: "interactive",
38
+ artifactStore: "openspec",
39
+ chainedPrStrategy: "auto-forecast",
40
+ reviewBudgetLines: 400,
41
+ engramAvailable: false,
42
+ };
43
+
44
+ const sddPreflightBySession = new Map<string, SddPreflightPreferences>();
45
+ const sddPreflightInFlight = new Map<string, Promise<SddPreflightPreferences>>();
46
+
47
+ function isRecord(value: unknown): value is Record<string, unknown> {
48
+ return typeof value === "object" && value !== null && !Array.isArray(value);
49
+ }
50
+
51
+ function copyDirectoryFiles(
52
+ sourceDir: string,
53
+ targetDir: string,
54
+ force: boolean,
55
+ ): { copied: number; skipped: number } {
56
+ if (!existsSync(sourceDir)) return { copied: 0, skipped: 0 };
57
+ mkdirSync(targetDir, { recursive: true });
58
+ let copied = 0;
59
+ let skipped = 0;
60
+ for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
61
+ const sourcePath = join(sourceDir, entry.name);
62
+ const targetPath = join(targetDir, entry.name);
63
+ if (entry.isDirectory()) {
64
+ const child = copyDirectoryFiles(sourcePath, targetPath, force);
65
+ copied += child.copied;
66
+ skipped += child.skipped;
67
+ continue;
68
+ }
69
+ if (!entry.isFile()) continue;
70
+ if (!force && existsSync(targetPath)) {
71
+ skipped += 1;
72
+ continue;
73
+ }
74
+ writeFileSync(targetPath, readFileSync(sourcePath));
75
+ copied += 1;
76
+ }
77
+ return { copied, skipped };
78
+ }
79
+
80
+ export function installSddAssets(
81
+ cwd: string,
82
+ force: boolean,
83
+ ): { agents: number; chains: number; support: number; skipped: number } {
84
+ const agents = copyDirectoryFiles(
85
+ join(ASSETS_DIR, "agents"),
86
+ join(cwd, ".pi", "agents"),
87
+ force,
88
+ );
89
+ const chains = copyDirectoryFiles(
90
+ join(ASSETS_DIR, "chains"),
91
+ join(cwd, ".pi", "chains"),
92
+ force,
93
+ );
94
+ const support = copyDirectoryFiles(
95
+ join(ASSETS_DIR, "support"),
96
+ join(cwd, ".pi", "gentle-ai", "support"),
97
+ force,
98
+ );
99
+ return {
100
+ agents: agents.copied,
101
+ chains: chains.copied,
102
+ support: support.copied,
103
+ skipped: agents.skipped + chains.skipped + support.skipped,
104
+ };
105
+ }
106
+
107
+ export function isSddPreflightTrigger(text: string): boolean {
108
+ return /^\/sdd-[^\s]*(?:\s|$)/i.test(text.trim());
109
+ }
110
+
111
+ export function sddPreflightSessionKey(ctx: ExtensionContext): string {
112
+ const manager = (ctx as unknown as { sessionManager?: unknown }).sessionManager;
113
+ if (isRecord(manager)) {
114
+ const getSessionFile = manager.getSessionFile;
115
+ if (typeof getSessionFile === "function") {
116
+ const value = getSessionFile.call(manager);
117
+ if (typeof value === "string" && value.length > 0) return value;
118
+ }
119
+ const getSessionId = manager.getSessionId;
120
+ if (typeof getSessionId === "function") {
121
+ const value = getSessionId.call(manager);
122
+ if (typeof value === "string" && value.length > 0) return value;
123
+ }
124
+ }
125
+ return ctx.cwd;
126
+ }
127
+
128
+ function hasWritableEngramTool(pi: ExtensionAPI): boolean {
129
+ try {
130
+ const getActiveTools = (pi as unknown as { getActiveTools?: () => unknown[] })
131
+ .getActiveTools;
132
+ if (typeof getActiveTools !== "function") return false;
133
+ const tools = getActiveTools.call(pi);
134
+ return tools.some((tool) => {
135
+ const name =
136
+ typeof tool === "string"
137
+ ? tool
138
+ : isRecord(tool) && typeof tool.name === "string"
139
+ ? tool.name
140
+ : "";
141
+ return (
142
+ name === "mem_save" ||
143
+ name === "engram_mem_save" ||
144
+ name.endsWith(".mem_save") ||
145
+ name.endsWith(".engram_mem_save")
146
+ );
147
+ });
148
+ } catch {
149
+ return false;
150
+ }
151
+ }
152
+
153
+ function normalizeSddReviewBudget(value: string): number {
154
+ const parsed = Number.parseInt(value.trim(), 10);
155
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 400;
156
+ }
157
+
158
+ async function collectSddPreflightPreferences(
159
+ ctx: ExtensionContext,
160
+ engramAvailable: boolean,
161
+ ): Promise<SddPreflightPreferences> {
162
+ if (!ctx.hasUI) return { ...DEFAULT_SDD_PREFLIGHT, engramAvailable };
163
+ const executionMode = await ctx.ui.select("SDD execution mode", [
164
+ "interactive",
165
+ "auto",
166
+ ]);
167
+ const artifactOptions = engramAvailable
168
+ ? ["openspec", "engram", "both"]
169
+ : ["openspec"];
170
+ const artifactStore = await ctx.ui.select("SDD artifact store", artifactOptions);
171
+ const chainedPrStrategy = await ctx.ui.select("SDD PR chaining", [
172
+ "auto-forecast",
173
+ "ask-always",
174
+ "single-pr-default",
175
+ "force-chained",
176
+ ]);
177
+ const reviewBudgetLines = normalizeSddReviewBudget(
178
+ (await ctx.ui.input("SDD review budget lines", "400")) ?? "400",
179
+ );
180
+ return {
181
+ executionMode:
182
+ executionMode === "auto" ? "auto" : DEFAULT_SDD_PREFLIGHT.executionMode,
183
+ artifactStore:
184
+ artifactStore === "engram" || artifactStore === "both"
185
+ ? artifactStore
186
+ : DEFAULT_SDD_PREFLIGHT.artifactStore,
187
+ chainedPrStrategy:
188
+ chainedPrStrategy === "ask-always" ||
189
+ chainedPrStrategy === "single-pr-default" ||
190
+ chainedPrStrategy === "force-chained"
191
+ ? chainedPrStrategy
192
+ : DEFAULT_SDD_PREFLIGHT.chainedPrStrategy,
193
+ reviewBudgetLines,
194
+ engramAvailable,
195
+ };
196
+ }
197
+
198
+ export function renderSddPreflightPrompt(prefs: SddPreflightPreferences): string {
199
+ return [
200
+ "## SDD Session Preflight",
201
+ "The user already chose these SDD preferences for this Pi session. Reuse them unless the user explicitly changes them.",
202
+ `- Execution mode: ${prefs.executionMode}`,
203
+ `- Artifact store: ${prefs.artifactStore}${prefs.engramAvailable ? "" : " (Engram unavailable in this session)"}`,
204
+ `- Chained PR strategy: ${prefs.chainedPrStrategy}`,
205
+ `- Review budget: ${prefs.reviewBudgetLines} changed lines`,
206
+ "- If task/workload forecasts conflict with these preferences, pause before sdd-apply and ask the user for a delivery decision.",
207
+ ].join("\n");
208
+ }
209
+
210
+ export async function ensureSddPreflight(
211
+ ctx: ExtensionContext,
212
+ callbacks: SddPreflightCallbacks,
213
+ ): Promise<SddPreflightPreferences> {
214
+ const sessionKey = sddPreflightSessionKey(ctx);
215
+ const existing = sddPreflightBySession.get(sessionKey);
216
+ if (existing) return existing;
217
+ const inFlight = sddPreflightInFlight.get(sessionKey);
218
+ if (inFlight) return inFlight;
219
+ const promise = (async () => {
220
+ const engramAvailable = hasWritableEngramTool(callbacks.pi);
221
+ const prefs = await collectSddPreflightPreferences(ctx, engramAvailable);
222
+ const result = callbacks.installAssets?.(ctx.cwd) ?? installSddAssets(ctx.cwd, false);
223
+ const modelResult = callbacks.applyModelConfig?.(ctx.cwd) ?? { updated: 0, skipped: 0 };
224
+ if (ctx.hasUI) {
225
+ ctx.ui.notify(
226
+ [
227
+ "Gentle AI SDD preflight complete.",
228
+ `Mode: ${prefs.executionMode}`,
229
+ `Artifacts: ${prefs.artifactStore}`,
230
+ `PR chaining: ${prefs.chainedPrStrategy}`,
231
+ `Review budget: ${prefs.reviewBudgetLines} changed lines`,
232
+ `Assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} skipped.`,
233
+ `Model-routed agents updated: ${modelResult.updated}`,
234
+ ].join("\n"),
235
+ "info",
236
+ );
237
+ }
238
+ sddPreflightBySession.set(sessionKey, prefs);
239
+ return prefs;
240
+ })();
241
+ sddPreflightInFlight.set(sessionKey, promise);
242
+ try {
243
+ return await promise;
244
+ } finally {
245
+ sddPreflightInFlight.delete(sessionKey);
246
+ }
247
+ }
248
+
249
+ export function getSddPreflightPreferences(
250
+ ctx: ExtensionContext,
251
+ ): SddPreflightPreferences | undefined {
252
+ return sddPreflightBySession.get(sddPreflightSessionKey(ctx));
253
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,7 @@
25
25
  "files": [
26
26
  "assets/",
27
27
  "extensions/",
28
+ "lib/",
28
29
  "prompts/",
29
30
  "skills/",
30
31
  "scripts/",
@@ -12,6 +12,7 @@ const requiredPaths = [
12
12
  "extensions/gentle-ai.ts",
13
13
  "extensions/sdd-init.ts",
14
14
  "extensions/skill-registry.ts",
15
+ "lib/sdd-preflight.ts",
15
16
  "prompts/gcl.md",
16
17
  "prompts/gis.md",
17
18
  "prompts/gpr.md",
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import assert from "node:assert/strict";
3
- import { mkdtemp, rm } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import { tmpdir } from "node:os";
5
6
  import { dirname, join } from "node:path";
7
+ import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
6
8
  import { fileURLToPath, pathToFileURL } from "node:url";
7
9
 
8
10
  const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -15,6 +17,8 @@ const EXTENSIONS = [
15
17
 
16
18
  const EXPECTED_COMMANDS = [
17
19
  "gentle-ai:install-sdd",
20
+ "gentle-ai:sdd-preflight",
21
+ "gentle:sdd-preflight",
18
22
  "gentle:models",
19
23
  "gentle-ai:models",
20
24
  "gentleman:models",
@@ -31,6 +35,7 @@ function createPi() {
31
35
  const commands = new Map();
32
36
  const flags = new Map();
33
37
  const flagValues = new Map([["no-skill-registry", true]]);
38
+ let activeTools = ["read", "bash", "edit", "write"];
34
39
 
35
40
  const pi = {
36
41
  on(name, handler) {
@@ -53,12 +58,19 @@ function createPi() {
53
58
  getCommands() {
54
59
  return Array.from(commands, ([name, definition]) => ({ name, ...definition }));
55
60
  },
61
+ getActiveTools() {
62
+ return activeTools;
63
+ },
64
+ setActiveTools(value) {
65
+ activeTools = value;
66
+ },
56
67
  getAllTools() {
57
68
  return [
58
69
  { name: "read" },
59
70
  { name: "bash" },
60
71
  { name: "edit" },
61
72
  { name: "write" },
73
+ { name: "mem_save" },
62
74
  ];
63
75
  },
64
76
  };
@@ -68,15 +80,18 @@ function createPi() {
68
80
 
69
81
  function createUi() {
70
82
  const notifications = [];
83
+ const selections = [];
71
84
  return {
72
85
  notifications,
86
+ selections,
73
87
  notify(message, level = "info") {
74
88
  notifications.push({ message, level });
75
89
  },
76
90
  async confirm() {
77
91
  return false;
78
92
  },
79
- async select(_label, options) {
93
+ async select(label, options) {
94
+ selections.push({ label, options });
80
95
  return options[0];
81
96
  },
82
97
  async input(_label, placeholder) {
@@ -88,11 +103,19 @@ function createUi() {
88
103
  };
89
104
  }
90
105
 
91
- function createCtx(cwd, hasUI = false) {
106
+ function createCtx(cwd, hasUI = false, sessionId = "session-1") {
92
107
  return {
93
108
  cwd,
94
109
  hasUI,
95
110
  ui: createUi(),
111
+ sessionManager: {
112
+ getSessionFile() {
113
+ return join(cwd, `${sessionId}.jsonl`);
114
+ },
115
+ getSessionId() {
116
+ return sessionId;
117
+ },
118
+ },
96
119
  modelRegistry: {
97
120
  async getAvailable() {
98
121
  return [];
@@ -122,9 +145,17 @@ async function run() {
122
145
  }
123
146
  assert.ok(flags.has("no-skill-registry"), "missing no-skill-registry flag");
124
147
  assert.ok(hooks.has("session_start"), "missing session_start hook");
148
+ assert.ok(hooks.has("input"), "missing input hook");
125
149
  assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
126
150
  assert.ok(hooks.has("tool_call"), "missing tool_call hook");
127
151
 
152
+ const discovered = await discoverAndLoadExtensions(["./extensions"], ROOT);
153
+ assert.deepEqual(
154
+ discovered.errors,
155
+ [],
156
+ "declared extension directory must load without invalid helper modules",
157
+ );
158
+
128
159
  const promptCwd = await tempWorkspace();
129
160
  try {
130
161
  const promptHook = hooks.get("before_agent_start")[0];
@@ -154,10 +185,146 @@ async function run() {
154
185
  for (const handler of hooks.get("session_start")) {
155
186
  await handler({ reason: "startup" }, createCtx(noUiCwd, false));
156
187
  }
188
+ assert.equal(
189
+ existsSync(join(noUiCwd, ".pi", "agents", "sdd-apply.md")),
190
+ false,
191
+ "session_start must not install SDD agents before first SDD intent",
192
+ );
193
+ assert.equal(
194
+ existsSync(join(noUiCwd, ".pi", "chains", "sdd-full.chain.md")),
195
+ false,
196
+ "session_start must not install SDD chains before first SDD intent",
197
+ );
157
198
  } finally {
158
199
  await rm(noUiCwd, { recursive: true, force: true });
159
200
  }
160
201
 
202
+ const lazySddCwd = await tempWorkspace();
203
+ try {
204
+ await mkdir(join(lazySddCwd, ".pi", "gentle-ai"), { recursive: true });
205
+ await writeFile(
206
+ join(lazySddCwd, ".pi", "gentle-ai", "models.json"),
207
+ JSON.stringify({ "sdd-apply": { model: "openai/gpt-5", thinking: "high" } }, null, 2),
208
+ );
209
+ const ctx = createCtx(lazySddCwd, true);
210
+ const inputHook = hooks.get("input")[0];
211
+ assert.deepEqual(
212
+ await inputHook({ text: "hola, solo mirando", source: "interactive" }, ctx),
213
+ { action: "continue" },
214
+ );
215
+ assert.deepEqual(
216
+ await inputHook({ text: "what is SDD?", source: "interactive" }, ctx),
217
+ { action: "continue" },
218
+ );
219
+ assert.deepEqual(
220
+ await inputHook({ text: "what can I do with SDD?", source: "interactive" }, ctx),
221
+ { action: "continue" },
222
+ );
223
+ assert.deepEqual(
224
+ await inputHook({ text: "how do I use SDD?", source: "interactive" }, ctx),
225
+ { action: "continue" },
226
+ );
227
+ assert.deepEqual(
228
+ await inputHook({ text: "Can I use SDD?", source: "interactive" }, ctx),
229
+ { action: "continue" },
230
+ );
231
+ assert.deepEqual(
232
+ await inputHook({ text: "don't use sdd for this", source: "interactive" }, ctx),
233
+ { action: "continue" },
234
+ );
235
+ assert.deepEqual(
236
+ await inputHook({ text: "sin usar SDD por ahora", source: "interactive" }, ctx),
237
+ { action: "continue" },
238
+ );
239
+ assert.deepEqual(
240
+ await inputHook({ text: "let's not use SDD for this", source: "interactive" }, ctx),
241
+ { action: "continue" },
242
+ );
243
+ assert.deepEqual(
244
+ await inputHook({ text: "never use SDD here", source: "interactive" }, ctx),
245
+ { action: "continue" },
246
+ );
247
+ assert.deepEqual(
248
+ await inputHook({ text: "no quiero usar SDD por ahora", source: "interactive" }, ctx),
249
+ { action: "continue" },
250
+ );
251
+ assert.deepEqual(
252
+ await inputHook({ text: "I use SDD sometimes", source: "interactive" }, ctx),
253
+ { action: "continue" },
254
+ );
255
+ assert.deepEqual(
256
+ await inputHook({ text: "I'm using SDD in another repo", source: "interactive" }, ctx),
257
+ { action: "continue" },
258
+ );
259
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
260
+
261
+ assert.deepEqual(
262
+ await inputHook({ text: "please use sdd for this change", source: "interactive" }, ctx),
263
+ { action: "continue" },
264
+ );
265
+ assert.deepEqual(
266
+ await inputHook({ text: "/sdd", source: "interactive" }, ctx),
267
+ { action: "continue" },
268
+ );
269
+ assert.deepEqual(
270
+ await inputHook({ text: "/sdd plan", source: "interactive" }, ctx),
271
+ { action: "continue" },
272
+ );
273
+ assert.deepEqual(
274
+ await inputHook({ text: "/sdd:plan", source: "interactive" }, ctx),
275
+ { action: "continue" },
276
+ );
277
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
278
+
279
+ assert.deepEqual(
280
+ await inputHook({ text: "/sdd-plan this change", source: "interactive" }, ctx),
281
+ { action: "continue" },
282
+ );
283
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), true);
284
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
285
+ const lazyAppliedAgent = await readFile(
286
+ join(lazySddCwd, ".pi", "agents", "sdd-apply.md"),
287
+ "utf8",
288
+ );
289
+ assert.match(lazyAppliedAgent, /model: openai\/gpt-5/);
290
+ assert.match(lazyAppliedAgent, /thinking: high/);
291
+ assert.equal(ctx.ui.selections.length, 3);
292
+ assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
293
+ assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
294
+
295
+ await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
296
+ assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
297
+ const promptHook = hooks.get("before_agent_start")[0];
298
+ const promptResult = promptHook({ systemPrompt: "base" }, ctx);
299
+ assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
300
+ assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
301
+ } finally {
302
+ await rm(lazySddCwd, { recursive: true, force: true });
303
+ }
304
+
305
+ const commandSddCwd = await tempWorkspace();
306
+ try {
307
+ const ctx = createCtx(commandSddCwd, true, "command-session");
308
+ await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
309
+ assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), true);
310
+ assert.equal(ctx.ui.selections.length, 3);
311
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
312
+ assert.equal(ctx.ui.selections.length, 3, "manual preflight command should reuse session choices");
313
+ } finally {
314
+ await rm(commandSddCwd, { recursive: true, force: true });
315
+ }
316
+
317
+ const engramSddCwd = await tempWorkspace();
318
+ try {
319
+ pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
320
+ const ctx = createCtx(engramSddCwd, true, "engram-session");
321
+ await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
322
+ assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "both"]);
323
+ } finally {
324
+ pi.setActiveTools(["read", "bash", "edit", "write"]);
325
+ await rm(engramSddCwd, { recursive: true, force: true });
326
+ }
327
+
161
328
  const installCwd = await tempWorkspace();
162
329
  try {
163
330
  const ctx = createCtx(installCwd, true);
@@ -171,11 +338,118 @@ async function run() {
171
338
  try {
172
339
  const ctx = createCtx(sddCwd, true);
173
340
  await commands.get("sdd-init").handler("", ctx);
341
+ assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), true);
342
+ assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
343
+ assert.equal(ctx.ui.selections.length, 3);
344
+ assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
174
345
  assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
346
+
347
+ await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
348
+ assert.equal(ctx.ui.selections.length, 3, "/sdd-init preflight should be reused by later manual preflight");
175
349
  } finally {
176
350
  await rm(sddCwd, { recursive: true, force: true });
177
351
  }
178
352
 
353
+ const modelsCwd = await tempWorkspace();
354
+ try {
355
+ await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
356
+ await mkdir(
357
+ join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
358
+ { recursive: true },
359
+ );
360
+ await writeFile(
361
+ join(
362
+ modelsCwd,
363
+ ".pi",
364
+ "npm",
365
+ "node_modules",
366
+ "pi-subagents",
367
+ "agents",
368
+ "worker.md",
369
+ ),
370
+ `---\nname: worker\ndescription: Builtin worker\n---\n`,
371
+ );
372
+ await writeFile(
373
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
374
+ `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
375
+ );
376
+ await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
377
+ await writeFile(
378
+ join(modelsCwd, ".pi", "gentle-ai", "models.json"),
379
+ JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
380
+ );
381
+
382
+ const ctx = createCtx(modelsCwd, true);
383
+ await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
384
+ const legacyAppliedAgent = await readFile(
385
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
386
+ "utf8",
387
+ );
388
+ assert.match(legacyAppliedAgent, /model: openai\/gpt-5/);
389
+ assert.doesNotMatch(legacyAppliedAgent, /thinking:/);
390
+
391
+ ctx.ui.custom = () =>
392
+ Promise.resolve({
393
+ type: "save",
394
+ config: {
395
+ "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
396
+ worker: { model: "openai/gpt-5-mini", thinking: "low" },
397
+ },
398
+ });
399
+ await commands.get("gentle:models").handler("", ctx);
400
+
401
+ const savedConfig = JSON.parse(
402
+ await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
403
+ );
404
+ assert.deepEqual(savedConfig["sdd-apply"], {
405
+ model: "openai/gpt-5",
406
+ thinking: "high",
407
+ });
408
+
409
+ const applyAgent = await readFile(
410
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
411
+ "utf8",
412
+ );
413
+ assert.match(applyAgent, /model: openai\/gpt-5/);
414
+ assert.match(applyAgent, /thinking: high/);
415
+
416
+ const settings = JSON.parse(
417
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
418
+ );
419
+ assert.equal(
420
+ settings.subagents.agentOverrides.worker.model,
421
+ "openai/gpt-5-mini",
422
+ );
423
+ assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
424
+
425
+ let customPanelCalls = 0;
426
+ ctx.ui.input = async () => "custom/provider-model";
427
+ ctx.ui.custom = (factory) =>
428
+ new Promise((resolve) => {
429
+ customPanelCalls += 1;
430
+ const panel = factory(null, null, null, resolve);
431
+ if (customPanelCalls === 1) {
432
+ panel.handleInput("e"); // effort picker for all agents
433
+ for (let i = 0; i < 4; i++) panel.handleInput("j"); // medium
434
+ panel.handleInput("\r");
435
+ panel.handleInput("c"); // custom model from the same unsaved draft
436
+ return;
437
+ }
438
+ panel.handleInput("\u0013"); // ctrl+s saves the draft reopened after custom model input
439
+ });
440
+ await commands.get("gentle:models").handler("", ctx);
441
+
442
+ const customSavedConfig = JSON.parse(
443
+ await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
444
+ );
445
+ assert.deepEqual(customSavedConfig["sdd-apply"], {
446
+ model: "custom/provider-model",
447
+ thinking: "medium",
448
+ });
449
+ } finally {
450
+ await rm(modelsCwd, { recursive: true, force: true });
451
+ }
452
+
179
453
  const registryCwd = await tempWorkspace();
180
454
  try {
181
455
  const ctx = createCtx(registryCwd, true);