gentle-pi 0.3.0 → 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.
@@ -280,15 +280,50 @@ function buildLetterStrokeMap(letterIdx: number): { orderMap: Map<string, number
280
280
  return { orderMap, maxOrder: Math.max(1, order - 1) };
281
281
  }
282
282
 
283
- const LETTER_STROKES = LETTER_SPANS.map((_, i) => buildLetterStrokeMap(i));
283
+ type LetterStroke = { orderMap: Map<string, number>; maxOrder: number };
284
+
284
285
  const WRITING_START_TICK = 6;
285
- const LETTER_TICKS = LETTER_STROKES.map((s) =>
286
- Math.max(5, Math.ceil(((s.maxOrder + 8) / 11) * 0.48)),
287
- );
288
- const LETTER_START_TICKS = LETTER_TICKS.map((_, i) =>
289
- WRITING_START_TICK + LETTER_TICKS.slice(0, i).reduce((a, b) => a + b, 0),
286
+ const FALLBACK_LETTER_TICKS = 8;
287
+
288
+ const LETTER_STROKES: Array<LetterStroke | null> = LETTER_SPANS.map(() => null);
289
+ const LETTER_TICKS: number[] = LETTER_SPANS.map(() => FALLBACK_LETTER_TICKS);
290
+ const LETTER_START_TICKS: number[] = LETTER_SPANS.map(
291
+ (_, i) => WRITING_START_TICK + i * FALLBACK_LETTER_TICKS,
290
292
  );
291
- const WRITING_END_TICK = WRITING_START_TICK + LETTER_TICKS.reduce((a, b) => a + b, 0);
293
+ let WRITING_END_TICK =
294
+ WRITING_START_TICK + LETTER_TICKS.reduce((a, b) => a + b, 0);
295
+
296
+ function recomputeLetterTicks(): void {
297
+ for (let i = 0; i < LETTER_STROKES.length; i++) {
298
+ const stroke = LETTER_STROKES[i];
299
+ LETTER_TICKS[i] = stroke
300
+ ? Math.max(5, Math.ceil(((stroke.maxOrder + 8) / 11) * 0.48))
301
+ : FALLBACK_LETTER_TICKS;
302
+ }
303
+ let acc = WRITING_START_TICK;
304
+ for (let i = 0; i < LETTER_TICKS.length; i++) {
305
+ LETTER_START_TICKS[i] = acc;
306
+ acc += LETTER_TICKS[i];
307
+ }
308
+ WRITING_END_TICK = acc;
309
+ }
310
+
311
+ function allStrokesReady(): boolean {
312
+ for (const stroke of LETTER_STROKES) if (stroke === null) return false;
313
+ return true;
314
+ }
315
+
316
+ let warmupStarted = false;
317
+ async function warmupLetterStrokes(): Promise<void> {
318
+ if (warmupStarted) return;
319
+ warmupStarted = true;
320
+ for (let i = 0; i < LETTER_SPANS.length; i++) {
321
+ if (LETTER_STROKES[i] !== null) continue;
322
+ LETTER_STROKES[i] = buildLetterStrokeMap(i);
323
+ recomputeLetterTicks();
324
+ await new Promise<void>((resolve) => setImmediate(resolve));
325
+ }
326
+ }
292
327
 
293
328
  function buildPenLogoLine(
294
329
  line: string,
@@ -307,6 +342,10 @@ function buildPenLogoLine(
307
342
 
308
343
  const letterIdx = letterIndexAtX(x);
309
344
  const stroke = LETTER_STROKES[letterIdx];
345
+ if (stroke === null) {
346
+ out.push({ char: ch, type: "logo-ink" });
347
+ continue;
348
+ }
310
349
  const startTick = LETTER_START_TICKS[letterIdx];
311
350
  const duration = LETTER_TICKS[letterIdx];
312
351
  const progress = (tick - startTick) / Math.max(1, duration);
@@ -371,6 +410,29 @@ class LayoutBuilder {
371
410
  }
372
411
  }
373
412
 
413
+ const FULL_INTRO_MIN_ROWS = 30;
414
+ const FULL_INTRO_MIN_COLS = 80;
415
+ const MINIMAL_INTRO_MIN_ROWS = 20;
416
+ const MINIMAL_INTRO_MIN_COLS = 40;
417
+ const RESIZE_DEBOUNCE_MS = 150;
418
+ const RESIZE_GRACE_PERIOD_MS = 300;
419
+
420
+ type IntroMode = "full" | "minimal" | "skip";
421
+
422
+ function pickIntroMode(rows: number, cols: number): IntroMode {
423
+ if (rows >= FULL_INTRO_MIN_ROWS && cols >= FULL_INTRO_MIN_COLS) return "full";
424
+ if (rows >= MINIMAL_INTRO_MIN_ROWS && cols >= MINIMAL_INTRO_MIN_COLS) return "minimal";
425
+ return "skip";
426
+ }
427
+
428
+ function currentIntroMode(): IntroMode {
429
+ // process.stdout.rows/columns reflejan el tamaño real del TTY del proceso;
430
+ // el TUI no expone alto en render(width) y por eso lo leemos directo acá.
431
+ const rows = process.stdout.rows ?? 0;
432
+ const cols = process.stdout.columns ?? 0;
433
+ return pickIntroMode(rows, cols);
434
+ }
435
+
374
436
  export default function (pi: ExtensionAPI) {
375
437
  pi.on("session_start", async (_event, ctx) => {
376
438
  if (!ctx.hasUI) return;
@@ -381,6 +443,12 @@ export default function (pi: ExtensionAPI) {
381
443
  !process.argv.every((arg) => arg.startsWith("-") || arg.endsWith(".ts"));
382
444
  if (isCLICommand) return;
383
445
 
446
+ if (currentIntroMode() === "skip") return;
447
+
448
+ // Fire-and-forget: el setup geométrico de cada letra corre en background
449
+ // para que la animación arranque en el primer frame sin bloquear el event loop.
450
+ void warmupLetterStrokes();
451
+
384
452
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
385
453
 
386
454
  const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW));
@@ -442,33 +510,80 @@ export default function (pi: ExtensionAPI) {
442
510
  }, 200);
443
511
 
444
512
  let tick = 0;
445
- const state = { timer: null as NodeJS.Timeout | null };
513
+ const state = {
514
+ timer: null as NodeJS.Timeout | null,
515
+ mode: currentIntroMode() as IntroMode,
516
+ resizeHandler: null as (() => void) | null,
517
+ resizeDebounceTimer: null as NodeJS.Timeout | null,
518
+ };
519
+
520
+ const cleanup = () => {
521
+ if (state.timer) {
522
+ clearInterval(state.timer);
523
+ state.timer = null;
524
+ }
525
+ if (state.resizeHandler) {
526
+ process.stdout.off("resize", state.resizeHandler);
527
+ state.resizeHandler = null;
528
+ }
529
+ if (state.resizeDebounceTimer) {
530
+ clearTimeout(state.resizeDebounceTimer);
531
+ state.resizeDebounceTimer = null;
532
+ }
533
+ };
446
534
 
447
535
  setTimeout(() => {
448
536
  ctx.ui.setHeader((tui, theme) => {
449
537
  if (state.timer) clearInterval(state.timer);
450
538
 
539
+ const animStart = Date.now();
540
+ const HARD_TIMEOUT_MS = 5000;
541
+
451
542
  state.timer = setInterval(() => {
452
543
  tick++;
453
- if (tick > WRITING_END_TICK + 22) {
454
- if (state.timer) {
455
- clearInterval(state.timer);
456
- state.timer = null;
457
- }
544
+ const elapsed = Date.now() - animStart;
545
+ const finishedAnimation =
546
+ allStrokesReady() && tick > WRITING_END_TICK + 22;
547
+ if (finishedAnimation || elapsed > HARD_TIMEOUT_MS) {
548
+ cleanup();
458
549
  return;
459
550
  }
460
551
  try {
461
552
  tui.requestRender();
462
553
  } catch {
463
- if (state.timer) {
464
- clearInterval(state.timer);
465
- state.timer = null;
466
- }
554
+ cleanup();
467
555
  }
468
556
  }, 25);
469
557
 
558
+ // Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
559
+ const bootStart = Date.now();
560
+ const resizeHandler = () => {
561
+ if (Date.now() - bootStart < RESIZE_GRACE_PERIOD_MS) return;
562
+ if (state.resizeDebounceTimer) clearTimeout(state.resizeDebounceTimer);
563
+ state.resizeDebounceTimer = setTimeout(() => {
564
+ state.resizeDebounceTimer = null;
565
+ const next = currentIntroMode();
566
+ if (next === state.mode) return;
567
+ state.mode = next;
568
+ if (next === "skip") {
569
+ cleanup();
570
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
571
+ return;
572
+ }
573
+ try {
574
+ tui.requestRender();
575
+ } catch {
576
+ cleanup();
577
+ }
578
+ }, RESIZE_DEBOUNCE_MS);
579
+ };
580
+ state.resizeHandler = resizeHandler;
581
+ process.stdout.on("resize", resizeHandler);
582
+
470
583
  return {
471
584
  render(width: number): string[] {
585
+ if (state.mode === "skip") return [];
586
+
472
587
  const flashStartTick = 10;
473
588
  const roseOpacity = Math.min(1, tick / 10);
474
589
  const flashPhase =
@@ -479,14 +594,29 @@ export default function (pi: ExtensionAPI) {
479
594
 
480
595
  const sideBySideMinWidth = roseBase.width + 3 + logoBase.width + 4;
481
596
  const wideStatsMinWidth = 122;
482
- const horizontal = width >= sideBySideMinWidth;
597
+ const horizontal =
598
+ state.mode === "full" && width >= sideBySideMinWidth;
483
599
  const wideStats = width >= wideStatsMinWidth;
484
600
 
485
601
  const b = new LayoutBuilder();
486
602
  b.addRow();
487
603
  b.center(width);
488
604
 
489
- if (horizontal) {
605
+ if (state.mode === "minimal") {
606
+ for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
607
+ const logoLine = logoBase.lines[logoI];
608
+ b.addRow();
609
+ b.lines[b.lines.length - 1].push(
610
+ ...buildPenLogoLine(
611
+ logoLine,
612
+ logoI,
613
+ logoBase.lines.length,
614
+ tick,
615
+ ),
616
+ );
617
+ b.center(width);
618
+ }
619
+ } else if (horizontal) {
490
620
  const rowCount = Math.max(
491
621
  roseBase.lines.length,
492
622
  logoBase.lines.length,
@@ -560,91 +690,93 @@ export default function (pi: ExtensionAPI) {
560
690
  }
561
691
  }
562
692
 
563
- b.addRow();
564
- b.center(width);
565
-
566
- const fit = (v: unknown, w: number) =>
567
- String(v ?? "")
568
- .replace(/\s+/g, " ")
569
- .trim()
570
- .slice(0, w)
571
- .padEnd(w);
572
- const addWideRow = (
573
- l1: string,
574
- v1: string,
575
- l2: string,
576
- v2: string,
577
- ) => {
693
+ if (state.mode === "full") {
578
694
  b.addRow();
579
- b.add("label", fit(l1, 10));
580
- b.add("none", " ");
581
- b.add("value", fit(v1, 48));
582
- b.add("none", " ");
583
- b.add("label", fit(l2, 12));
584
- b.add("none", " ");
585
- b.add("value", fit(v2, 46));
586
695
  b.center(width);
587
- };
588
- const narrowRows: Array<[string, string]> = [
589
- ["GIT:", gitBranch],
590
- ["PATH:", ctx.cwd],
591
- ["MCP:", `${mcpServersCount} server(s)`],
592
- ["PLUGINS:", `${packagesCount} package(s)`],
593
- ["AGENTS:", `${skills.length} loaded`],
594
- ["EXTENSIONS:", `${extensionsCount} active`],
595
- ["VER:", `v${VERSION}`],
596
- ["TOOLS:", `${customTools.length} custom`],
597
- ];
598
- const narrowLabelW = Math.max(...narrowRows.map(([l]) => l.length));
599
- const narrowValueW = Math.max(
600
- 0,
601
- Math.min(
602
- Math.max(...narrowRows.map(([, v]) => v.length)),
603
- Math.max(8, width - narrowLabelW - 4),
604
- ),
605
- );
606
- const addNarrowRow = (label: string, value: string) => {
696
+
697
+ const fit = (v: unknown, w: number) =>
698
+ String(v ?? "")
699
+ .replace(/\s+/g, " ")
700
+ .trim()
701
+ .slice(0, w)
702
+ .padEnd(w);
703
+ const addWideRow = (
704
+ l1: string,
705
+ v1: string,
706
+ l2: string,
707
+ v2: string,
708
+ ) => {
709
+ b.addRow();
710
+ b.add("label", fit(l1, 10));
711
+ b.add("none", " ");
712
+ b.add("value", fit(v1, 48));
713
+ b.add("none", " ");
714
+ b.add("label", fit(l2, 12));
715
+ b.add("none", " ");
716
+ b.add("value", fit(v2, 46));
717
+ b.center(width);
718
+ };
719
+ const narrowRows: Array<[string, string]> = [
720
+ ["GIT:", gitBranch],
721
+ ["PATH:", ctx.cwd],
722
+ ["MCP:", `${mcpServersCount} server(s)`],
723
+ ["PLUGINS:", `${packagesCount} package(s)`],
724
+ ["AGENTS:", `${skills.length} loaded`],
725
+ ["EXTENSIONS:", `${extensionsCount} active`],
726
+ ["VER:", `v${VERSION}`],
727
+ ["TOOLS:", `${customTools.length} custom`],
728
+ ];
729
+ const narrowLabelW = Math.max(...narrowRows.map(([l]) => l.length));
730
+ const narrowValueW = Math.max(
731
+ 0,
732
+ Math.min(
733
+ Math.max(...narrowRows.map(([, v]) => v.length)),
734
+ Math.max(8, width - narrowLabelW - 4),
735
+ ),
736
+ );
737
+ const addNarrowRow = (label: string, value: string) => {
738
+ b.addRow();
739
+ b.add("label", label.padEnd(narrowLabelW));
740
+ b.add("none", " ");
741
+ b.add("value", fit(value, narrowValueW));
742
+ b.center(width);
743
+ };
744
+
745
+ if (wideStats) {
746
+ addWideRow("GIT:", gitBranch, "PATH:", ctx.cwd);
747
+ addWideRow(
748
+ "MCP:",
749
+ `${mcpServersCount} server(s)`,
750
+ "PLUGINS:",
751
+ `${packagesCount} package(s)`,
752
+ );
753
+ addWideRow(
754
+ "AGENTS:",
755
+ `${skills.length} loaded`,
756
+ "EXTENSIONS:",
757
+ `${extensionsCount} active`,
758
+ );
759
+ addWideRow(
760
+ "VER:",
761
+ `v${VERSION}`,
762
+ "TOOLS:",
763
+ `${customTools.length} custom`,
764
+ );
765
+ } else {
766
+ addNarrowRow("GIT:", gitBranch);
767
+ addNarrowRow("PATH:", ctx.cwd);
768
+ addNarrowRow("MCP:", `${mcpServersCount} server(s)`);
769
+ addNarrowRow("PLUGINS:", `${packagesCount} package(s)`);
770
+ addNarrowRow("AGENTS:", `${skills.length} loaded`);
771
+ addNarrowRow("EXTENSIONS:", `${extensionsCount} active`);
772
+ addNarrowRow("VER:", `v${VERSION}`);
773
+ addNarrowRow("TOOLS:", `${customTools.length} custom`);
774
+ }
775
+
607
776
  b.addRow();
608
- b.add("label", label.padEnd(narrowLabelW));
609
- b.add("none", " ");
610
- b.add("value", fit(value, narrowValueW));
611
777
  b.center(width);
612
- };
613
-
614
- if (wideStats) {
615
- addWideRow("GIT:", gitBranch, "PATH:", ctx.cwd);
616
- addWideRow(
617
- "MCP:",
618
- `${mcpServersCount} server(s)`,
619
- "PLUGINS:",
620
- `${packagesCount} package(s)`,
621
- );
622
- addWideRow(
623
- "AGENTS:",
624
- `${skills.length} loaded`,
625
- "EXTENSIONS:",
626
- `${extensionsCount} active`,
627
- );
628
- addWideRow(
629
- "VER:",
630
- `v${VERSION}`,
631
- "TOOLS:",
632
- `${customTools.length} custom`,
633
- );
634
- } else {
635
- addNarrowRow("GIT:", gitBranch);
636
- addNarrowRow("PATH:", ctx.cwd);
637
- addNarrowRow("MCP:", `${mcpServersCount} server(s)`);
638
- addNarrowRow("PLUGINS:", `${packagesCount} package(s)`);
639
- addNarrowRow("AGENTS:", `${skills.length} loaded`);
640
- addNarrowRow("EXTENSIONS:", `${extensionsCount} active`);
641
- addNarrowRow("VER:", `v${VERSION}`);
642
- addNarrowRow("TOOLS:", `${customTools.length} custom`);
643
778
  }
644
779
 
645
- b.addRow();
646
- b.center(width);
647
-
648
780
  const out: string[] = [];
649
781
  const layout = b.lines;
650
782
 
@@ -776,10 +908,7 @@ export default function (pi: ExtensionAPI) {
776
908
  return out;
777
909
  },
778
910
  invalidate() {
779
- if (state.timer) {
780
- clearInterval(state.timer);
781
- state.timer = null;
782
- }
911
+ cleanup();
783
912
  },
784
913
  };
785
914
  });
@@ -24,13 +24,24 @@ export interface SddPreflightPreferences {
24
24
 
25
25
  interface SddPreflightCallbacks {
26
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 };
27
+ installAssets?: (cwd: string) =>
28
+ | {
29
+ agents: number;
30
+ chains: number;
31
+ support: number;
32
+ skipped: number;
33
+ }
34
+ | Promise<{
35
+ agents: number;
36
+ chains: number;
37
+ support: number;
38
+ skipped: number;
39
+ }>;
40
+ applyModelConfig?: (
41
+ cwd: string,
42
+ ) =>
43
+ | { updated: number; skipped: number; invalidPath?: string }
44
+ | Promise<{ updated: number; skipped: number; invalidPath?: string }>;
34
45
  }
35
46
 
36
47
  const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
@@ -219,9 +230,17 @@ export async function ensureSddPreflight(
219
230
  const promise = (async () => {
220
231
  const engramAvailable = hasWritableEngramTool(callbacks.pi);
221
232
  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 };
233
+ const result =
234
+ (await callbacks.installAssets?.(ctx.cwd)) ??
235
+ installSddAssets(ctx.cwd, false);
236
+ const modelResult = (await callbacks.applyModelConfig?.(ctx.cwd)) ?? {
237
+ updated: 0,
238
+ skipped: 0,
239
+ };
224
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}`;
225
244
  ctx.ui.notify(
226
245
  [
227
246
  "Gentle AI SDD preflight complete.",
@@ -230,9 +249,9 @@ export async function ensureSddPreflight(
230
249
  `PR chaining: ${prefs.chainedPrStrategy}`,
231
250
  `Review budget: ${prefs.reviewBudgetLines} changed lines`,
232
251
  `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}`,
252
+ modelRoutingLine,
234
253
  ].join("\n"),
235
- "info",
254
+ modelResult.invalidPath ? "warning" : "info",
236
255
  );
237
256
  }
238
257
  sddPreflightBySession.set(sessionKey, prefs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.0",
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();