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.
- package/README.md +22 -18
- package/extensions/gentle-ai.ts +305 -27
- package/extensions/sdd-init.ts +2 -2
- package/extensions/skill-registry.ts +120 -77
- package/extensions/startup-banner.ts +231 -102
- package/lib/sdd-preflight.ts +30 -11
- package/package.json +1 -1
- package/tests/runtime-harness.mjs +113 -6
- package/tests/skill-registry.test.ts +5 -2
|
@@ -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
|
-
|
|
283
|
+
type LetterStroke = { orderMap: Map<string, number>; maxOrder: number };
|
|
284
|
+
|
|
284
285
|
const WRITING_START_TICK = 6;
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
);
|
|
288
|
-
const
|
|
289
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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
|
-
|
|
780
|
-
clearInterval(state.timer);
|
|
781
|
-
state.timer = null;
|
|
782
|
-
}
|
|
911
|
+
cleanup();
|
|
783
912
|
},
|
|
784
913
|
};
|
|
785
914
|
});
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -24,13 +24,24 @@ export interface SddPreflightPreferences {
|
|
|
24
24
|
|
|
25
25
|
interface SddPreflightCallbacks {
|
|
26
26
|
pi: ExtensionAPI;
|
|
27
|
-
installAssets?: (cwd: string) =>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
223
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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();
|