gentle-pi 0.3.1 → 0.3.3

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
 
@@ -130,6 +130,7 @@ const SDD_AGENT_NAMES = [
130
130
  "sdd-verify",
131
131
  "sdd-archive",
132
132
  ] as const;
133
+ const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
133
134
 
134
135
  type SddAgentName = (typeof SDD_AGENT_NAMES)[number];
135
136
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
@@ -138,6 +139,10 @@ interface AgentRoutingEntry {
138
139
  thinking?: ThinkingLevel;
139
140
  }
140
141
  type AgentModelConfig = Record<string, AgentRoutingEntry>;
142
+ type ModelConfigFileResult =
143
+ | { status: "missing" }
144
+ | { status: "invalid"; path: string }
145
+ | { status: "valid"; config: AgentModelConfig };
141
146
  type AgentSource = "project" | "user" | "builtin";
142
147
 
143
148
  interface AgentEntry {
@@ -166,6 +171,34 @@ const MODEL_CONTROL_OPTIONS = [
166
171
  CUSTOM_MODEL,
167
172
  ] as const;
168
173
 
174
+ function readStringPath(value: unknown, path: string[]): string | undefined {
175
+ let current = value;
176
+ for (const key of path) {
177
+ if (!isRecord(current)) return undefined;
178
+ current = current[key];
179
+ }
180
+ return typeof current === "string" ? current : undefined;
181
+ }
182
+
183
+ function isSddAgentStartEvent(event: unknown): boolean {
184
+ const candidates = [
185
+ readStringPath(event, ["agentName"]),
186
+ readStringPath(event, ["agent"]),
187
+ readStringPath(event, ["name"]),
188
+ readStringPath(event, ["agent", "name"]),
189
+ readStringPath(event, ["subagent", "name"]),
190
+ ]
191
+ .filter((value): value is string => value !== undefined)
192
+ .map((value) => value.trim());
193
+ if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
194
+
195
+ const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
196
+ return SDD_AGENT_NAMES.some((name) => {
197
+ const phase = name.replace(/^sdd-/, "");
198
+ return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
199
+ });
200
+ }
201
+
169
202
  function evaluateDeniedCommand(
170
203
  command: string,
171
204
  ): ToolCallEventResult | undefined {
@@ -217,7 +250,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
217
250
  return typeof value === "object" && value !== null && !Array.isArray(value);
218
251
  }
219
252
 
220
- function modelConfigPath(cwd: string): string {
253
+ function gentleAiConfigHome(): string {
254
+ return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai");
255
+ }
256
+
257
+ function modelConfigPath(_cwd: string): string {
258
+ return join(gentleAiConfigHome(), "models.json");
259
+ }
260
+
261
+ function legacyProjectModelConfigPath(cwd: string): string {
221
262
  return join(cwd, ".pi", "gentle-ai", "models.json");
222
263
  }
223
264
 
@@ -269,42 +310,72 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
269
310
  return { model, thinking };
270
311
  }
271
312
 
272
- export function readModelConfig(cwd: string): AgentModelConfig {
273
- const path = modelConfigPath(cwd);
274
- if (!existsSync(path)) return {};
313
+ function readModelConfigFile(path: string): ModelConfigFileResult {
314
+ if (!existsSync(path)) return { status: "missing" };
275
315
  try {
276
316
  const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
277
- if (!isRecord(parsed)) return {};
317
+ if (!isRecord(parsed)) return { status: "invalid", path };
278
318
  const config: AgentModelConfig = {};
279
319
  for (const [name, value] of Object.entries(parsed)) {
280
320
  const entry = normalizeRoutingEntry(value);
281
321
  if (entry) config[name] = entry;
282
322
  }
283
- return config;
323
+ return { status: "valid", config };
284
324
  } catch {
285
- return {};
325
+ return { status: "invalid", path };
286
326
  }
287
327
  }
288
328
 
289
- export async function readModelConfigAsync(
290
- cwd: string,
291
- ): Promise<AgentModelConfig> {
292
- const path = modelConfigPath(cwd);
293
- if (!(await pathExists(path))) return {};
329
+ async function readModelConfigFileAsync(
330
+ path: string,
331
+ ): Promise<ModelConfigFileResult> {
332
+ if (!(await pathExists(path))) return { status: "missing" };
294
333
  try {
295
334
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
296
- if (!isRecord(parsed)) return {};
335
+ if (!isRecord(parsed)) return { status: "invalid", path };
297
336
  const config: AgentModelConfig = {};
298
337
  for (const [name, value] of Object.entries(parsed)) {
299
338
  const entry = normalizeRoutingEntry(value);
300
339
  if (entry) config[name] = entry;
301
340
  }
302
- return config;
341
+ return { status: "valid", config };
303
342
  } catch {
304
- return {};
343
+ return { status: "invalid", path };
305
344
  }
306
345
  }
307
346
 
347
+ function readSavedModelConfig(cwd: string): ModelConfigFileResult {
348
+ const globalResult = readModelConfigFile(modelConfigPath(cwd));
349
+ if (globalResult.status !== "missing") return globalResult;
350
+ const legacyResult = readModelConfigFile(legacyProjectModelConfigPath(cwd));
351
+ if (legacyResult.status === "invalid") return { status: "valid", config: {} };
352
+ return legacyResult;
353
+ }
354
+
355
+ async function readSavedModelConfigAsync(
356
+ cwd: string,
357
+ ): Promise<ModelConfigFileResult> {
358
+ const globalResult = await readModelConfigFileAsync(modelConfigPath(cwd));
359
+ if (globalResult.status !== "missing") return globalResult;
360
+ const legacyResult = await readModelConfigFileAsync(
361
+ legacyProjectModelConfigPath(cwd),
362
+ );
363
+ if (legacyResult.status === "invalid") return { status: "valid", config: {} };
364
+ return legacyResult;
365
+ }
366
+
367
+ export function readModelConfig(cwd: string): AgentModelConfig {
368
+ const result = readSavedModelConfig(cwd);
369
+ return result.status === "valid" ? result.config : {};
370
+ }
371
+
372
+ export async function readModelConfigAsync(
373
+ cwd: string,
374
+ ): Promise<AgentModelConfig> {
375
+ const result = await readSavedModelConfigAsync(cwd);
376
+ return result.status === "valid" ? result.config : {};
377
+ }
378
+
308
379
  function writeModelConfig(cwd: string, config: AgentModelConfig): void {
309
380
  const path = modelConfigPath(cwd);
310
381
  mkdirSync(dirname(path), { recursive: true });
@@ -643,6 +714,19 @@ export async function applyModelConfigAsync(
643
714
  return { updated, skipped };
644
715
  }
645
716
 
717
+ export async function applySavedModelConfig(
718
+ ctx: ExtensionContext,
719
+ ): Promise<{ updated: number; skipped: number; invalidPath?: string }> {
720
+ const result = await readSavedModelConfigAsync(ctx.cwd);
721
+ if (result.status === "invalid") {
722
+ return { updated: 0, skipped: 0, invalidPath: result.path };
723
+ }
724
+ return applyModelConfigAsync(
725
+ ctx.cwd,
726
+ result.status === "valid" ? result.config : {},
727
+ );
728
+ }
729
+
646
730
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
647
731
  return listDiscoverableAgents(cwd).map((agent) => {
648
732
  const entry = config[agent.name];
@@ -1033,7 +1117,15 @@ async function showSddModelPanel(
1033
1117
  }
1034
1118
 
1035
1119
  async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1036
- let config = readModelConfig(ctx.cwd);
1120
+ const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
1121
+ if (savedConfig.status === "invalid") {
1122
+ ctx.ui.notify(
1123
+ `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.`,
1124
+ "warning",
1125
+ );
1126
+ return;
1127
+ }
1128
+ let config = savedConfig.status === "valid" ? savedConfig.config : {};
1037
1129
  let result = await showSddModelPanel(ctx, config);
1038
1130
  while (result.type === "custom") {
1039
1131
  config = cloneModelConfig(result.config);
@@ -1071,11 +1163,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1071
1163
  }
1072
1164
  if (result.type !== "save") return;
1073
1165
  writeModelConfig(ctx.cwd, result.config);
1074
- const applyResult = applyModelConfig(ctx.cwd, result.config);
1166
+ const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
1075
1167
  ctx.ui.notify(
1076
1168
  [
1077
- "el Gentleman model config saved.",
1078
- `Config: ${modelConfigPath(ctx.cwd)}`,
1169
+ "el Gentleman global model config saved.",
1170
+ `Global config: ${modelConfigPath(ctx.cwd)}`,
1079
1171
  `Agents updated: ${applyResult.updated}`,
1080
1172
  ...describeModelConfig(ctx.cwd, result.config),
1081
1173
  ].join("\n"),
@@ -1106,15 +1198,20 @@ export default function gentleAi(pi: ExtensionAPI): void {
1106
1198
  return ensureSddPreflight(ctx, {
1107
1199
  pi,
1108
1200
  installAssets: (cwd) => installSddAssets(cwd, false),
1109
- applyModelConfig: async (cwd) =>
1110
- applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
1201
+ applyModelConfig: async () => applySavedModelConfig(ctx),
1111
1202
  });
1112
1203
  }
1113
1204
 
1114
1205
  pi.on("session_start", async (_event, ctx) => {
1115
1206
  try {
1116
- const config = await readModelConfigAsync(ctx.cwd);
1117
- const modelResult = await applyModelConfigAsync(ctx.cwd, config);
1207
+ const modelResult = await applySavedModelConfig(ctx);
1208
+ if (ctx.hasUI && modelResult.invalidPath) {
1209
+ ctx.ui.notify(
1210
+ `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.`,
1211
+ "warning",
1212
+ );
1213
+ return;
1214
+ }
1118
1215
  if (ctx.hasUI && modelResult.updated > 0) {
1119
1216
  ctx.ui.notify(
1120
1217
  `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
@@ -1141,7 +1238,10 @@ export default function gentleAi(pi: ExtensionAPI): void {
1141
1238
  return { action: "continue" };
1142
1239
  });
1143
1240
 
1144
- pi.on("before_agent_start", (event, ctx) => {
1241
+ pi.on("before_agent_start", async (event, ctx) => {
1242
+ if (isSddAgentStartEvent(event) && !getSddPreflightPreferences(ctx)) {
1243
+ await runSddPreflight(ctx);
1244
+ }
1145
1245
  const prefs = getSddPreflightPreferences(ctx);
1146
1246
  const sddPrompt = prefs ? `\n\n${renderSddPreflightPrompt(prefs)}` : "";
1147
1247
  return {
@@ -1185,7 +1285,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1185
1285
  });
1186
1286
 
1187
1287
  pi.registerCommand("gentle:models", {
1188
- description: "Configure per-agent models for el Gentleman.",
1288
+ description: "Configure global per-agent models for el Gentleman.",
1189
1289
  handler: async (_args, ctx) => {
1190
1290
  await handleModelsCommand(ctx);
1191
1291
  },
@@ -1238,7 +1338,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1238
1338
  const openspecConfigured = existsSync(
1239
1339
  join(ctx.cwd, "openspec", "config.yaml"),
1240
1340
  );
1241
- const modelConfig = readModelConfig(ctx.cwd);
1341
+ const modelConfig = await readModelConfigAsync(ctx.cwd);
1242
1342
  ctx.ui.notify(
1243
1343
  [
1244
1344
  "el Gentleman package is active.",
@@ -1246,7 +1346,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1246
1346
  `SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
1247
1347
  `SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
1248
1348
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
1249
- `Model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
1349
+ `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
1250
1350
  ...describeModelConfig(ctx.cwd, modelConfig),
1251
1351
  ].join("\n"),
1252
1352
  "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.3",
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
 
@@ -159,9 +162,14 @@ async function run() {
159
162
  const promptCwd = await tempWorkspace();
160
163
  try {
161
164
  const promptHook = hooks.get("before_agent_start")[0];
162
- const promptResult = promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
165
+ const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
163
166
  assert.match(promptResult.systemPrompt, /base/);
164
167
  assert.match(promptResult.systemPrompt, /el Gentleman/);
168
+ assert.equal(
169
+ existsSync(join(promptCwd, ".pi", "agents", "sdd-apply.md")),
170
+ false,
171
+ "normal agent startup must not run SDD preflight",
172
+ );
165
173
  } finally {
166
174
  await rm(promptCwd, { recursive: true, force: true });
167
175
  }
@@ -201,9 +209,8 @@ async function run() {
201
209
 
202
210
  const lazySddCwd = await tempWorkspace();
203
211
  try {
204
- await mkdir(join(lazySddCwd, ".pi", "gentle-ai"), { recursive: true });
205
212
  await writeFile(
206
- join(lazySddCwd, ".pi", "gentle-ai", "models.json"),
213
+ globalModelsPath,
207
214
  JSON.stringify({ "sdd-apply": { model: "openai/gpt-5", thinking: "high" } }, null, 2),
208
215
  );
209
216
  const ctx = createCtx(lazySddCwd, true);
@@ -295,11 +302,12 @@ async function run() {
295
302
  await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
296
303
  assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
297
304
  const promptHook = hooks.get("before_agent_start")[0];
298
- const promptResult = promptHook({ systemPrompt: "base" }, ctx);
305
+ const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
299
306
  assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
300
307
  assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
301
308
  } finally {
302
309
  await rm(lazySddCwd, { recursive: true, force: true });
310
+ await rm(globalModelsPath, { force: true });
303
311
  }
304
312
 
305
313
  const commandSddCwd = await tempWorkspace();
@@ -314,6 +322,47 @@ async function run() {
314
322
  await rm(commandSddCwd, { recursive: true, force: true });
315
323
  }
316
324
 
325
+ const sddAgentGuardCwd = await tempWorkspace();
326
+ try {
327
+ const ctx = createCtx(sddAgentGuardCwd, true, "sdd-agent-guard-session");
328
+ const promptHook = hooks.get("before_agent_start")[0];
329
+ const promptResult = await promptHook(
330
+ {
331
+ systemPrompt: "You are the SDD proposal executor for Gentle AI.",
332
+ },
333
+ ctx,
334
+ );
335
+ assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "agents", "sdd-apply.md")), true);
336
+ assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), true);
337
+ assert.equal(ctx.ui.selections.length, 3);
338
+ assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
339
+ assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
340
+
341
+ await promptHook(
342
+ {
343
+ agentName: "sdd-tasks",
344
+ systemPrompt: "You are the SDD tasks executor for Gentle AI.",
345
+ },
346
+ ctx,
347
+ );
348
+ assert.equal(ctx.ui.selections.length, 3, "SDD agent guard should reuse session choices");
349
+ } finally {
350
+ await rm(sddAgentGuardCwd, { recursive: true, force: true });
351
+ }
352
+
353
+ const invalidPreflightCwd = await tempWorkspace();
354
+ try {
355
+ await writeFile(globalModelsPath, "{ invalid json");
356
+ const ctx = createCtx(invalidPreflightCwd, true, "invalid-preflight-session");
357
+ await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
358
+ assert.equal(ctx.ui.notifications.at(-1).level, "warning");
359
+ assert.match(ctx.ui.notifications.at(-1).message, /Model routing skipped:/);
360
+ assert.match(ctx.ui.notifications.at(-1).message, /invalid JSON or not an object/);
361
+ } finally {
362
+ await rm(invalidPreflightCwd, { recursive: true, force: true });
363
+ await rm(globalModelsPath, { force: true });
364
+ }
365
+
317
366
  const engramSddCwd = await tempWorkspace();
318
367
  try {
319
368
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
@@ -350,6 +399,92 @@ async function run() {
350
399
  await rm(sddCwd, { recursive: true, force: true });
351
400
  }
352
401
 
402
+ const invalidSddInitCwd = await tempWorkspace();
403
+ try {
404
+ await mkdir(join(invalidSddInitCwd, ".pi", "agents"), { recursive: true });
405
+ await writeFile(
406
+ join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
407
+ `---\nname: sdd-apply\ndescription: Apply phase\nmodel: keep/provider-model\n---\n\nbody\n`,
408
+ );
409
+ await writeFile(globalModelsPath, "{ invalid json");
410
+ const ctx = createCtx(invalidSddInitCwd, true, "invalid-sdd-init-session");
411
+ await commands.get("sdd-init").handler("", ctx);
412
+ assert.equal(ctx.ui.notifications[0].level, "warning");
413
+ assert.match(ctx.ui.notifications[0].message, /Model routing skipped:/);
414
+ assert.match(ctx.ui.notifications[0].message, /models\.json/);
415
+ assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
416
+ const preservedAgent = await readFile(
417
+ join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
418
+ "utf8",
419
+ );
420
+ assert.match(preservedAgent, /model: keep\/provider-model/);
421
+ } finally {
422
+ await rm(invalidSddInitCwd, { recursive: true, force: true });
423
+ await rm(globalModelsPath, { force: true });
424
+ }
425
+
426
+ const legacyModelsCwd = await tempWorkspace();
427
+ try {
428
+ await mkdir(join(legacyModelsCwd, ".pi", "agents"), { recursive: true });
429
+ await mkdir(join(legacyModelsCwd, ".pi", "gentle-ai"), { recursive: true });
430
+ await writeFile(
431
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
432
+ `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
433
+ );
434
+ await writeFile(
435
+ join(legacyModelsCwd, ".pi", "gentle-ai", "models.json"),
436
+ JSON.stringify({ "sdd-apply": "legacy/provider-model" }, null, 2),
437
+ );
438
+ const legacyCtx = createCtx(legacyModelsCwd, true);
439
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
440
+ const legacyAgent = await readFile(
441
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
442
+ "utf8",
443
+ );
444
+ assert.match(legacyAgent, /model: legacy\/provider-model/);
445
+ await writeFile(
446
+ globalModelsPath,
447
+ JSON.stringify({ "sdd-apply": "global/provider-model" }, null, 2),
448
+ );
449
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
450
+ const globalWinsAgent = await readFile(
451
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
452
+ "utf8",
453
+ );
454
+ assert.match(globalWinsAgent, /model: global\/provider-model/);
455
+ assert.doesNotMatch(globalWinsAgent, /model: legacy\/provider-model/);
456
+ await writeFile(globalModelsPath, "{ invalid json");
457
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
458
+ const invalidGlobalSkippedAgent = await readFile(
459
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
460
+ "utf8",
461
+ );
462
+ assert.match(invalidGlobalSkippedAgent, /model: global\/provider-model/);
463
+ assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/);
464
+ assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
465
+ assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/);
466
+ let modelPanelOpened = false;
467
+ legacyCtx.ui.custom = () => {
468
+ modelPanelOpened = true;
469
+ return Promise.resolve({ type: "save", config: {} });
470
+ };
471
+ await commands.get("gentle:models").handler("", legacyCtx);
472
+ assert.equal(modelPanelOpened, false);
473
+ assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
474
+ assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
475
+ assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
476
+ await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
477
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
478
+ const emptyGlobalSuppressesLegacyAgent = await readFile(
479
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
480
+ "utf8",
481
+ );
482
+ assert.doesNotMatch(emptyGlobalSuppressesLegacyAgent, /model:/);
483
+ } finally {
484
+ await rm(legacyModelsCwd, { recursive: true, force: true });
485
+ await rm(globalModelsPath, { force: true });
486
+ }
487
+
353
488
  const modelsCwd = await tempWorkspace();
354
489
  try {
355
490
  await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
@@ -373,9 +508,8 @@ async function run() {
373
508
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
374
509
  `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
375
510
  );
376
- await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
377
511
  await writeFile(
378
- join(modelsCwd, ".pi", "gentle-ai", "models.json"),
512
+ globalModelsPath,
379
513
  JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
380
514
  );
381
515
 
@@ -399,12 +533,17 @@ async function run() {
399
533
  await commands.get("gentle:models").handler("", ctx);
400
534
 
401
535
  const savedConfig = JSON.parse(
402
- await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
536
+ await readFile(globalModelsPath, "utf8"),
403
537
  );
404
538
  assert.deepEqual(savedConfig["sdd-apply"], {
405
539
  model: "openai/gpt-5",
406
540
  thinking: "high",
407
541
  });
542
+ assert.equal(
543
+ existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
544
+ false,
545
+ "/gentle:models must save model routing globally, not per project",
546
+ );
408
547
 
409
548
  const applyAgent = await readFile(
410
549
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
@@ -440,7 +579,7 @@ async function run() {
440
579
  await commands.get("gentle:models").handler("", ctx);
441
580
 
442
581
  const customSavedConfig = JSON.parse(
443
- await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
582
+ await readFile(globalModelsPath, "utf8"),
444
583
  );
445
584
  assert.deepEqual(customSavedConfig["sdd-apply"], {
446
585
  model: "custom/provider-model",
@@ -448,6 +587,7 @@ async function run() {
448
587
  });
449
588
  } finally {
450
589
  await rm(modelsCwd, { recursive: true, force: true });
590
+ await rm(globalModelsPath, { force: true });
451
591
  }
452
592
 
453
593
  const registryCwd = await tempWorkspace();