gentle-pi 0.3.1 → 0.3.2

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/README.md CHANGED
@@ -49,7 +49,7 @@ Most coding-agent sessions fail for operational reasons, not model reasons:
49
49
  | **Rose startup intro** | Adds a pink rose fade-in, compact project/runtime panel, and visible startup collaboration credit for @aporcelli's `pi-gentle-startup` ideas. |
50
50
  | **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. |
51
51
  | **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, and `archive`. |
52
- | **Lazy SDD preflight** | Asks once per session for SDD mode, artifact store, PR chaining strategy, and review budget before the first SDD flow. |
52
+ | **Lazy SDD preflight** | Asks once per session for SDD mode, artifact store, PR chaining strategy, and review budget before the first SDD flow. |
53
53
  | **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. |
54
54
  | **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. |
55
55
  | **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. |
@@ -87,10 +87,10 @@ pi
87
87
  ## Quick start
88
88
 
89
89
  ```text
90
- /gentle-ai:status Check package, SDD assets, OpenSpec, and model config.
90
+ /gentle-ai:status Check package, SDD assets, OpenSpec, and global model config.
91
91
  /gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
92
92
  /sdd-init Create or refresh openspec/config.yaml.
93
- /gentle:models Assign models to SDD/custom agents.
93
+ /gentle:models Assign global model/effort routing to SDD/custom agents.
94
94
  /gentle:persona Switch between gentleman and neutral persona modes.
95
95
  ```
96
96
 
@@ -284,12 +284,14 @@ Recommended model/effort shape:
284
284
  | Verify / review | Strong fresh-context model. | `high` |
285
285
  | Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` |
286
286
 
287
- Saved at:
287
+ Saved globally at:
288
288
 
289
289
  ```text
290
- .pi/gentle-ai/models.json
290
+ ~/.pi/gentle-ai/models.json
291
291
  ```
292
292
 
293
+ Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config.
294
+
293
295
  Config shape (per agent):
294
296
 
295
297
  ```json
@@ -308,15 +310,15 @@ Legacy string entries are still accepted and treated as `model`-only config.
308
310
 
309
311
  ## Commands
310
312
 
311
- | Command | What it does |
312
- | -------------------------------- | ------------------------------------------------------------ |
313
- | `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and model config status. |
314
- | `/gentle:models` | Opens model + effort assignment UI. |
315
- | `/gentle:persona` | Switches persona mode. |
316
- | `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
317
- | `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files. |
318
- | `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets. |
319
- | `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
313
+ | Command | What it does |
314
+ | -------------------------------- | ------------------------------------------------------------------- |
315
+ | `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and global model config status. |
316
+ | `/gentle:models` | Opens global model + effort assignment UI. |
317
+ | `/gentle:persona` | Switches persona mode. |
318
+ | `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
319
+ | `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files. |
320
+ | `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets. |
321
+ | `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
320
322
 
321
323
  Startup flag:
322
324
 
@@ -138,6 +138,10 @@ interface AgentRoutingEntry {
138
138
  thinking?: ThinkingLevel;
139
139
  }
140
140
  type AgentModelConfig = Record<string, AgentRoutingEntry>;
141
+ type ModelConfigFileResult =
142
+ | { status: "missing" }
143
+ | { status: "invalid"; path: string }
144
+ | { status: "valid"; config: AgentModelConfig };
141
145
  type AgentSource = "project" | "user" | "builtin";
142
146
 
143
147
  interface AgentEntry {
@@ -217,7 +221,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
217
221
  return typeof value === "object" && value !== null && !Array.isArray(value);
218
222
  }
219
223
 
220
- function modelConfigPath(cwd: string): string {
224
+ function gentleAiConfigHome(): string {
225
+ return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai");
226
+ }
227
+
228
+ function modelConfigPath(_cwd: string): string {
229
+ return join(gentleAiConfigHome(), "models.json");
230
+ }
231
+
232
+ function legacyProjectModelConfigPath(cwd: string): string {
221
233
  return join(cwd, ".pi", "gentle-ai", "models.json");
222
234
  }
223
235
 
@@ -269,42 +281,72 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
269
281
  return { model, thinking };
270
282
  }
271
283
 
272
- export function readModelConfig(cwd: string): AgentModelConfig {
273
- const path = modelConfigPath(cwd);
274
- if (!existsSync(path)) return {};
284
+ function readModelConfigFile(path: string): ModelConfigFileResult {
285
+ if (!existsSync(path)) return { status: "missing" };
275
286
  try {
276
287
  const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
277
- if (!isRecord(parsed)) return {};
288
+ if (!isRecord(parsed)) return { status: "invalid", path };
278
289
  const config: AgentModelConfig = {};
279
290
  for (const [name, value] of Object.entries(parsed)) {
280
291
  const entry = normalizeRoutingEntry(value);
281
292
  if (entry) config[name] = entry;
282
293
  }
283
- return config;
294
+ return { status: "valid", config };
284
295
  } catch {
285
- return {};
296
+ return { status: "invalid", path };
286
297
  }
287
298
  }
288
299
 
289
- export async function readModelConfigAsync(
290
- cwd: string,
291
- ): Promise<AgentModelConfig> {
292
- const path = modelConfigPath(cwd);
293
- if (!(await pathExists(path))) return {};
300
+ async function readModelConfigFileAsync(
301
+ path: string,
302
+ ): Promise<ModelConfigFileResult> {
303
+ if (!(await pathExists(path))) return { status: "missing" };
294
304
  try {
295
305
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
296
- if (!isRecord(parsed)) return {};
306
+ if (!isRecord(parsed)) return { status: "invalid", path };
297
307
  const config: AgentModelConfig = {};
298
308
  for (const [name, value] of Object.entries(parsed)) {
299
309
  const entry = normalizeRoutingEntry(value);
300
310
  if (entry) config[name] = entry;
301
311
  }
302
- return config;
312
+ return { status: "valid", config };
303
313
  } catch {
304
- return {};
314
+ return { status: "invalid", path };
305
315
  }
306
316
  }
307
317
 
318
+ function readSavedModelConfig(cwd: string): ModelConfigFileResult {
319
+ const globalResult = readModelConfigFile(modelConfigPath(cwd));
320
+ if (globalResult.status !== "missing") return globalResult;
321
+ const legacyResult = readModelConfigFile(legacyProjectModelConfigPath(cwd));
322
+ if (legacyResult.status === "invalid") return { status: "valid", config: {} };
323
+ return legacyResult;
324
+ }
325
+
326
+ async function readSavedModelConfigAsync(
327
+ cwd: string,
328
+ ): Promise<ModelConfigFileResult> {
329
+ const globalResult = await readModelConfigFileAsync(modelConfigPath(cwd));
330
+ if (globalResult.status !== "missing") return globalResult;
331
+ const legacyResult = await readModelConfigFileAsync(
332
+ legacyProjectModelConfigPath(cwd),
333
+ );
334
+ if (legacyResult.status === "invalid") return { status: "valid", config: {} };
335
+ return legacyResult;
336
+ }
337
+
338
+ export function readModelConfig(cwd: string): AgentModelConfig {
339
+ const result = readSavedModelConfig(cwd);
340
+ return result.status === "valid" ? result.config : {};
341
+ }
342
+
343
+ export async function readModelConfigAsync(
344
+ cwd: string,
345
+ ): Promise<AgentModelConfig> {
346
+ const result = await readSavedModelConfigAsync(cwd);
347
+ return result.status === "valid" ? result.config : {};
348
+ }
349
+
308
350
  function writeModelConfig(cwd: string, config: AgentModelConfig): void {
309
351
  const path = modelConfigPath(cwd);
310
352
  mkdirSync(dirname(path), { recursive: true });
@@ -643,6 +685,19 @@ export async function applyModelConfigAsync(
643
685
  return { updated, skipped };
644
686
  }
645
687
 
688
+ export async function applySavedModelConfig(
689
+ ctx: ExtensionContext,
690
+ ): Promise<{ updated: number; skipped: number; invalidPath?: string }> {
691
+ const result = await readSavedModelConfigAsync(ctx.cwd);
692
+ if (result.status === "invalid") {
693
+ return { updated: 0, skipped: 0, invalidPath: result.path };
694
+ }
695
+ return applyModelConfigAsync(
696
+ ctx.cwd,
697
+ result.status === "valid" ? result.config : {},
698
+ );
699
+ }
700
+
646
701
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
647
702
  return listDiscoverableAgents(cwd).map((agent) => {
648
703
  const entry = config[agent.name];
@@ -1033,7 +1088,15 @@ async function showSddModelPanel(
1033
1088
  }
1034
1089
 
1035
1090
  async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1036
- let config = readModelConfig(ctx.cwd);
1091
+ const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
1092
+ if (savedConfig.status === "invalid") {
1093
+ ctx.ui.notify(
1094
+ `el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
1095
+ "warning",
1096
+ );
1097
+ return;
1098
+ }
1099
+ let config = savedConfig.status === "valid" ? savedConfig.config : {};
1037
1100
  let result = await showSddModelPanel(ctx, config);
1038
1101
  while (result.type === "custom") {
1039
1102
  config = cloneModelConfig(result.config);
@@ -1071,11 +1134,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1071
1134
  }
1072
1135
  if (result.type !== "save") return;
1073
1136
  writeModelConfig(ctx.cwd, result.config);
1074
- const applyResult = applyModelConfig(ctx.cwd, result.config);
1137
+ const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
1075
1138
  ctx.ui.notify(
1076
1139
  [
1077
- "el Gentleman model config saved.",
1078
- `Config: ${modelConfigPath(ctx.cwd)}`,
1140
+ "el Gentleman global model config saved.",
1141
+ `Global config: ${modelConfigPath(ctx.cwd)}`,
1079
1142
  `Agents updated: ${applyResult.updated}`,
1080
1143
  ...describeModelConfig(ctx.cwd, result.config),
1081
1144
  ].join("\n"),
@@ -1106,15 +1169,20 @@ export default function gentleAi(pi: ExtensionAPI): void {
1106
1169
  return ensureSddPreflight(ctx, {
1107
1170
  pi,
1108
1171
  installAssets: (cwd) => installSddAssets(cwd, false),
1109
- applyModelConfig: async (cwd) =>
1110
- applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
1172
+ applyModelConfig: async () => applySavedModelConfig(ctx),
1111
1173
  });
1112
1174
  }
1113
1175
 
1114
1176
  pi.on("session_start", async (_event, ctx) => {
1115
1177
  try {
1116
- const config = await readModelConfigAsync(ctx.cwd);
1117
- const modelResult = await applyModelConfigAsync(ctx.cwd, config);
1178
+ const modelResult = await applySavedModelConfig(ctx);
1179
+ if (ctx.hasUI && modelResult.invalidPath) {
1180
+ ctx.ui.notify(
1181
+ `el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
1182
+ "warning",
1183
+ );
1184
+ return;
1185
+ }
1118
1186
  if (ctx.hasUI && modelResult.updated > 0) {
1119
1187
  ctx.ui.notify(
1120
1188
  `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
@@ -1185,7 +1253,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1185
1253
  });
1186
1254
 
1187
1255
  pi.registerCommand("gentle:models", {
1188
- description: "Configure per-agent models for el Gentleman.",
1256
+ description: "Configure global per-agent models for el Gentleman.",
1189
1257
  handler: async (_args, ctx) => {
1190
1258
  await handleModelsCommand(ctx);
1191
1259
  },
@@ -1238,7 +1306,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1238
1306
  const openspecConfigured = existsSync(
1239
1307
  join(ctx.cwd, "openspec", "config.yaml"),
1240
1308
  );
1241
- const modelConfig = readModelConfig(ctx.cwd);
1309
+ const modelConfig = await readModelConfigAsync(ctx.cwd);
1242
1310
  ctx.ui.notify(
1243
1311
  [
1244
1312
  "el Gentleman package is active.",
@@ -1246,7 +1314,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1246
1314
  `SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
1247
1315
  `SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
1248
1316
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
1249
- `Model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
1317
+ `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
1250
1318
  ...describeModelConfig(ctx.cwd, modelConfig),
1251
1319
  ].join("\n"),
1252
1320
  "info",
@@ -6,7 +6,7 @@ import {
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
8
  import { basename, dirname, join, relative } from "node:path";
9
- import { applyModelConfigAsync, readModelConfigAsync } from "./gentle-ai.ts";
9
+ import { applySavedModelConfig } from "./gentle-ai.ts";
10
10
  import { ensureSddPreflight, installSddAssets } from "../lib/sdd-preflight.ts";
11
11
  type ExtensionAPI = any;
12
12
 
@@ -778,8 +778,7 @@ export default function (pi: ExtensionAPI) {
778
778
  await ensureSddPreflight(ctx, {
779
779
  pi,
780
780
  installAssets: (cwd) => installSddAssets(cwd, false),
781
- applyModelConfig: async (cwd) =>
782
- applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
781
+ applyModelConfig: () => applySavedModelConfig(ctx),
783
782
  });
784
783
  const configPath = join(ctx.cwd, CONFIG_REL_PATH);
785
784
  if (existsSync(configPath)) {
@@ -40,8 +40,8 @@ interface SddPreflightCallbacks {
40
40
  applyModelConfig?: (
41
41
  cwd: string,
42
42
  ) =>
43
- | { updated: number; skipped: number }
44
- | Promise<{ updated: number; skipped: number }>;
43
+ | { updated: number; skipped: number; invalidPath?: string }
44
+ | Promise<{ updated: number; skipped: number; invalidPath?: string }>;
45
45
  }
46
46
 
47
47
  const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
@@ -238,6 +238,9 @@ export async function ensureSddPreflight(
238
238
  skipped: 0,
239
239
  };
240
240
  if (ctx.hasUI) {
241
+ const modelRoutingLine = modelResult.invalidPath
242
+ ? `Model routing skipped: ${modelResult.invalidPath} is invalid JSON or not an object.`
243
+ : `Model-routed agents updated: ${modelResult.updated}`;
241
244
  ctx.ui.notify(
242
245
  [
243
246
  "Gentle AI SDD preflight complete.",
@@ -246,9 +249,9 @@ export async function ensureSddPreflight(
246
249
  `PR chaining: ${prefs.chainedPrStrategy}`,
247
250
  `Review budget: ${prefs.reviewBudgetLines} changed lines`,
248
251
  `Assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} skipped.`,
249
- `Model-routed agents updated: ${modelResult.updated}`,
252
+ modelRoutingLine,
250
253
  ].join("\n"),
251
- "info",
254
+ modelResult.invalidPath ? "warning" : "info",
252
255
  );
253
256
  }
254
257
  sddPreflightBySession.set(sessionKey, prefs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
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",
@@ -137,6 +137,9 @@ async function loadExtensions(pi) {
137
137
  }
138
138
 
139
139
  async function run() {
140
+ const globalConfigHome = await tempWorkspace();
141
+ process.env.GENTLE_PI_CONFIG_HOME = globalConfigHome;
142
+ const globalModelsPath = join(globalConfigHome, "models.json");
140
143
  const { pi, hooks, commands, flags } = createPi();
141
144
  await loadExtensions(pi);
142
145
 
@@ -201,9 +204,8 @@ async function run() {
201
204
 
202
205
  const lazySddCwd = await tempWorkspace();
203
206
  try {
204
- await mkdir(join(lazySddCwd, ".pi", "gentle-ai"), { recursive: true });
205
207
  await writeFile(
206
- join(lazySddCwd, ".pi", "gentle-ai", "models.json"),
208
+ globalModelsPath,
207
209
  JSON.stringify({ "sdd-apply": { model: "openai/gpt-5", thinking: "high" } }, null, 2),
208
210
  );
209
211
  const ctx = createCtx(lazySddCwd, true);
@@ -300,6 +302,7 @@ async function run() {
300
302
  assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
301
303
  } finally {
302
304
  await rm(lazySddCwd, { recursive: true, force: true });
305
+ await rm(globalModelsPath, { force: true });
303
306
  }
304
307
 
305
308
  const commandSddCwd = await tempWorkspace();
@@ -314,6 +317,19 @@ async function run() {
314
317
  await rm(commandSddCwd, { recursive: true, force: true });
315
318
  }
316
319
 
320
+ const invalidPreflightCwd = await tempWorkspace();
321
+ try {
322
+ await writeFile(globalModelsPath, "{ invalid json");
323
+ const ctx = createCtx(invalidPreflightCwd, true, "invalid-preflight-session");
324
+ await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
325
+ assert.equal(ctx.ui.notifications.at(-1).level, "warning");
326
+ assert.match(ctx.ui.notifications.at(-1).message, /Model routing skipped:/);
327
+ assert.match(ctx.ui.notifications.at(-1).message, /invalid JSON or not an object/);
328
+ } finally {
329
+ await rm(invalidPreflightCwd, { recursive: true, force: true });
330
+ await rm(globalModelsPath, { force: true });
331
+ }
332
+
317
333
  const engramSddCwd = await tempWorkspace();
318
334
  try {
319
335
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
@@ -350,6 +366,92 @@ async function run() {
350
366
  await rm(sddCwd, { recursive: true, force: true });
351
367
  }
352
368
 
369
+ const invalidSddInitCwd = await tempWorkspace();
370
+ try {
371
+ await mkdir(join(invalidSddInitCwd, ".pi", "agents"), { recursive: true });
372
+ await writeFile(
373
+ join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
374
+ `---\nname: sdd-apply\ndescription: Apply phase\nmodel: keep/provider-model\n---\n\nbody\n`,
375
+ );
376
+ await writeFile(globalModelsPath, "{ invalid json");
377
+ const ctx = createCtx(invalidSddInitCwd, true, "invalid-sdd-init-session");
378
+ await commands.get("sdd-init").handler("", ctx);
379
+ assert.equal(ctx.ui.notifications[0].level, "warning");
380
+ assert.match(ctx.ui.notifications[0].message, /Model routing skipped:/);
381
+ assert.match(ctx.ui.notifications[0].message, /models\.json/);
382
+ assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
383
+ const preservedAgent = await readFile(
384
+ join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
385
+ "utf8",
386
+ );
387
+ assert.match(preservedAgent, /model: keep\/provider-model/);
388
+ } finally {
389
+ await rm(invalidSddInitCwd, { recursive: true, force: true });
390
+ await rm(globalModelsPath, { force: true });
391
+ }
392
+
393
+ const legacyModelsCwd = await tempWorkspace();
394
+ try {
395
+ await mkdir(join(legacyModelsCwd, ".pi", "agents"), { recursive: true });
396
+ await mkdir(join(legacyModelsCwd, ".pi", "gentle-ai"), { recursive: true });
397
+ await writeFile(
398
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
399
+ `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
400
+ );
401
+ await writeFile(
402
+ join(legacyModelsCwd, ".pi", "gentle-ai", "models.json"),
403
+ JSON.stringify({ "sdd-apply": "legacy/provider-model" }, null, 2),
404
+ );
405
+ const legacyCtx = createCtx(legacyModelsCwd, true);
406
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
407
+ const legacyAgent = await readFile(
408
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
409
+ "utf8",
410
+ );
411
+ assert.match(legacyAgent, /model: legacy\/provider-model/);
412
+ await writeFile(
413
+ globalModelsPath,
414
+ JSON.stringify({ "sdd-apply": "global/provider-model" }, null, 2),
415
+ );
416
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
417
+ const globalWinsAgent = await readFile(
418
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
419
+ "utf8",
420
+ );
421
+ assert.match(globalWinsAgent, /model: global\/provider-model/);
422
+ assert.doesNotMatch(globalWinsAgent, /model: legacy\/provider-model/);
423
+ await writeFile(globalModelsPath, "{ invalid json");
424
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
425
+ const invalidGlobalSkippedAgent = await readFile(
426
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
427
+ "utf8",
428
+ );
429
+ assert.match(invalidGlobalSkippedAgent, /model: global\/provider-model/);
430
+ assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/);
431
+ assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
432
+ assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/);
433
+ let modelPanelOpened = false;
434
+ legacyCtx.ui.custom = () => {
435
+ modelPanelOpened = true;
436
+ return Promise.resolve({ type: "save", config: {} });
437
+ };
438
+ await commands.get("gentle:models").handler("", legacyCtx);
439
+ assert.equal(modelPanelOpened, false);
440
+ assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
441
+ assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
442
+ assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
443
+ await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
444
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
445
+ const emptyGlobalSuppressesLegacyAgent = await readFile(
446
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
447
+ "utf8",
448
+ );
449
+ assert.doesNotMatch(emptyGlobalSuppressesLegacyAgent, /model:/);
450
+ } finally {
451
+ await rm(legacyModelsCwd, { recursive: true, force: true });
452
+ await rm(globalModelsPath, { force: true });
453
+ }
454
+
353
455
  const modelsCwd = await tempWorkspace();
354
456
  try {
355
457
  await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
@@ -373,9 +475,8 @@ async function run() {
373
475
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
374
476
  `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
375
477
  );
376
- await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
377
478
  await writeFile(
378
- join(modelsCwd, ".pi", "gentle-ai", "models.json"),
479
+ globalModelsPath,
379
480
  JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
380
481
  );
381
482
 
@@ -399,12 +500,17 @@ async function run() {
399
500
  await commands.get("gentle:models").handler("", ctx);
400
501
 
401
502
  const savedConfig = JSON.parse(
402
- await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
503
+ await readFile(globalModelsPath, "utf8"),
403
504
  );
404
505
  assert.deepEqual(savedConfig["sdd-apply"], {
405
506
  model: "openai/gpt-5",
406
507
  thinking: "high",
407
508
  });
509
+ assert.equal(
510
+ existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
511
+ false,
512
+ "/gentle:models must save model routing globally, not per project",
513
+ );
408
514
 
409
515
  const applyAgent = await readFile(
410
516
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
@@ -440,7 +546,7 @@ async function run() {
440
546
  await commands.get("gentle:models").handler("", ctx);
441
547
 
442
548
  const customSavedConfig = JSON.parse(
443
- await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
549
+ await readFile(globalModelsPath, "utf8"),
444
550
  );
445
551
  assert.deepEqual(customSavedConfig["sdd-apply"], {
446
552
  model: "custom/provider-model",
@@ -448,6 +554,7 @@ async function run() {
448
554
  });
449
555
  } finally {
450
556
  await rm(modelsCwd, { recursive: true, force: true });
557
+ await rm(globalModelsPath, { force: true });
451
558
  }
452
559
 
453
560
  const registryCwd = await tempWorkspace();