gentle-pi 0.2.8 → 0.3.1
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 +25 -9
- package/assets/orchestrator.md +13 -4
- package/extensions/gentle-ai.ts +269 -82
- package/extensions/sdd-init.ts +8 -0
- package/extensions/skill-registry.ts +120 -77
- package/extensions/startup-banner.ts +231 -102
- package/lib/sdd-preflight.ts +269 -0
- package/package.json +2 -1
- package/scripts/verify-package-files.mjs +1 -0
- package/tests/runtime-harness.mjs +176 -2
- 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
|
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
|
+
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
8
|
+
|
|
9
|
+
export type SddExecutionMode = "interactive" | "auto";
|
|
10
|
+
export type SddArtifactStore = "openspec" | "engram" | "both";
|
|
11
|
+
export type SddChainedPrStrategy =
|
|
12
|
+
| "auto-forecast"
|
|
13
|
+
| "ask-always"
|
|
14
|
+
| "single-pr-default"
|
|
15
|
+
| "force-chained";
|
|
16
|
+
|
|
17
|
+
export interface SddPreflightPreferences {
|
|
18
|
+
executionMode: SddExecutionMode;
|
|
19
|
+
artifactStore: SddArtifactStore;
|
|
20
|
+
chainedPrStrategy: SddChainedPrStrategy;
|
|
21
|
+
reviewBudgetLines: number;
|
|
22
|
+
engramAvailable: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SddPreflightCallbacks {
|
|
26
|
+
pi: ExtensionAPI;
|
|
27
|
+
installAssets?: (cwd: string) =>
|
|
28
|
+
| {
|
|
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 }
|
|
44
|
+
| Promise<{ updated: number; skipped: number }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
|
|
48
|
+
executionMode: "interactive",
|
|
49
|
+
artifactStore: "openspec",
|
|
50
|
+
chainedPrStrategy: "auto-forecast",
|
|
51
|
+
reviewBudgetLines: 400,
|
|
52
|
+
engramAvailable: false,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const sddPreflightBySession = new Map<string, SddPreflightPreferences>();
|
|
56
|
+
const sddPreflightInFlight = new Map<string, Promise<SddPreflightPreferences>>();
|
|
57
|
+
|
|
58
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
59
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function copyDirectoryFiles(
|
|
63
|
+
sourceDir: string,
|
|
64
|
+
targetDir: string,
|
|
65
|
+
force: boolean,
|
|
66
|
+
): { copied: number; skipped: number } {
|
|
67
|
+
if (!existsSync(sourceDir)) return { copied: 0, skipped: 0 };
|
|
68
|
+
mkdirSync(targetDir, { recursive: true });
|
|
69
|
+
let copied = 0;
|
|
70
|
+
let skipped = 0;
|
|
71
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
72
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
73
|
+
const targetPath = join(targetDir, entry.name);
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
const child = copyDirectoryFiles(sourcePath, targetPath, force);
|
|
76
|
+
copied += child.copied;
|
|
77
|
+
skipped += child.skipped;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (!entry.isFile()) continue;
|
|
81
|
+
if (!force && existsSync(targetPath)) {
|
|
82
|
+
skipped += 1;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
writeFileSync(targetPath, readFileSync(sourcePath));
|
|
86
|
+
copied += 1;
|
|
87
|
+
}
|
|
88
|
+
return { copied, skipped };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function installSddAssets(
|
|
92
|
+
cwd: string,
|
|
93
|
+
force: boolean,
|
|
94
|
+
): { agents: number; chains: number; support: number; skipped: number } {
|
|
95
|
+
const agents = copyDirectoryFiles(
|
|
96
|
+
join(ASSETS_DIR, "agents"),
|
|
97
|
+
join(cwd, ".pi", "agents"),
|
|
98
|
+
force,
|
|
99
|
+
);
|
|
100
|
+
const chains = copyDirectoryFiles(
|
|
101
|
+
join(ASSETS_DIR, "chains"),
|
|
102
|
+
join(cwd, ".pi", "chains"),
|
|
103
|
+
force,
|
|
104
|
+
);
|
|
105
|
+
const support = copyDirectoryFiles(
|
|
106
|
+
join(ASSETS_DIR, "support"),
|
|
107
|
+
join(cwd, ".pi", "gentle-ai", "support"),
|
|
108
|
+
force,
|
|
109
|
+
);
|
|
110
|
+
return {
|
|
111
|
+
agents: agents.copied,
|
|
112
|
+
chains: chains.copied,
|
|
113
|
+
support: support.copied,
|
|
114
|
+
skipped: agents.skipped + chains.skipped + support.skipped,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function isSddPreflightTrigger(text: string): boolean {
|
|
119
|
+
return /^\/sdd-[^\s]*(?:\s|$)/i.test(text.trim());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function sddPreflightSessionKey(ctx: ExtensionContext): string {
|
|
123
|
+
const manager = (ctx as unknown as { sessionManager?: unknown }).sessionManager;
|
|
124
|
+
if (isRecord(manager)) {
|
|
125
|
+
const getSessionFile = manager.getSessionFile;
|
|
126
|
+
if (typeof getSessionFile === "function") {
|
|
127
|
+
const value = getSessionFile.call(manager);
|
|
128
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
129
|
+
}
|
|
130
|
+
const getSessionId = manager.getSessionId;
|
|
131
|
+
if (typeof getSessionId === "function") {
|
|
132
|
+
const value = getSessionId.call(manager);
|
|
133
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return ctx.cwd;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hasWritableEngramTool(pi: ExtensionAPI): boolean {
|
|
140
|
+
try {
|
|
141
|
+
const getActiveTools = (pi as unknown as { getActiveTools?: () => unknown[] })
|
|
142
|
+
.getActiveTools;
|
|
143
|
+
if (typeof getActiveTools !== "function") return false;
|
|
144
|
+
const tools = getActiveTools.call(pi);
|
|
145
|
+
return tools.some((tool) => {
|
|
146
|
+
const name =
|
|
147
|
+
typeof tool === "string"
|
|
148
|
+
? tool
|
|
149
|
+
: isRecord(tool) && typeof tool.name === "string"
|
|
150
|
+
? tool.name
|
|
151
|
+
: "";
|
|
152
|
+
return (
|
|
153
|
+
name === "mem_save" ||
|
|
154
|
+
name === "engram_mem_save" ||
|
|
155
|
+
name.endsWith(".mem_save") ||
|
|
156
|
+
name.endsWith(".engram_mem_save")
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function normalizeSddReviewBudget(value: string): number {
|
|
165
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
166
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 400;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function collectSddPreflightPreferences(
|
|
170
|
+
ctx: ExtensionContext,
|
|
171
|
+
engramAvailable: boolean,
|
|
172
|
+
): Promise<SddPreflightPreferences> {
|
|
173
|
+
if (!ctx.hasUI) return { ...DEFAULT_SDD_PREFLIGHT, engramAvailable };
|
|
174
|
+
const executionMode = await ctx.ui.select("SDD execution mode", [
|
|
175
|
+
"interactive",
|
|
176
|
+
"auto",
|
|
177
|
+
]);
|
|
178
|
+
const artifactOptions = engramAvailable
|
|
179
|
+
? ["openspec", "engram", "both"]
|
|
180
|
+
: ["openspec"];
|
|
181
|
+
const artifactStore = await ctx.ui.select("SDD artifact store", artifactOptions);
|
|
182
|
+
const chainedPrStrategy = await ctx.ui.select("SDD PR chaining", [
|
|
183
|
+
"auto-forecast",
|
|
184
|
+
"ask-always",
|
|
185
|
+
"single-pr-default",
|
|
186
|
+
"force-chained",
|
|
187
|
+
]);
|
|
188
|
+
const reviewBudgetLines = normalizeSddReviewBudget(
|
|
189
|
+
(await ctx.ui.input("SDD review budget lines", "400")) ?? "400",
|
|
190
|
+
);
|
|
191
|
+
return {
|
|
192
|
+
executionMode:
|
|
193
|
+
executionMode === "auto" ? "auto" : DEFAULT_SDD_PREFLIGHT.executionMode,
|
|
194
|
+
artifactStore:
|
|
195
|
+
artifactStore === "engram" || artifactStore === "both"
|
|
196
|
+
? artifactStore
|
|
197
|
+
: DEFAULT_SDD_PREFLIGHT.artifactStore,
|
|
198
|
+
chainedPrStrategy:
|
|
199
|
+
chainedPrStrategy === "ask-always" ||
|
|
200
|
+
chainedPrStrategy === "single-pr-default" ||
|
|
201
|
+
chainedPrStrategy === "force-chained"
|
|
202
|
+
? chainedPrStrategy
|
|
203
|
+
: DEFAULT_SDD_PREFLIGHT.chainedPrStrategy,
|
|
204
|
+
reviewBudgetLines,
|
|
205
|
+
engramAvailable,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function renderSddPreflightPrompt(prefs: SddPreflightPreferences): string {
|
|
210
|
+
return [
|
|
211
|
+
"## SDD Session Preflight",
|
|
212
|
+
"The user already chose these SDD preferences for this Pi session. Reuse them unless the user explicitly changes them.",
|
|
213
|
+
`- Execution mode: ${prefs.executionMode}`,
|
|
214
|
+
`- Artifact store: ${prefs.artifactStore}${prefs.engramAvailable ? "" : " (Engram unavailable in this session)"}`,
|
|
215
|
+
`- Chained PR strategy: ${prefs.chainedPrStrategy}`,
|
|
216
|
+
`- Review budget: ${prefs.reviewBudgetLines} changed lines`,
|
|
217
|
+
"- If task/workload forecasts conflict with these preferences, pause before sdd-apply and ask the user for a delivery decision.",
|
|
218
|
+
].join("\n");
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function ensureSddPreflight(
|
|
222
|
+
ctx: ExtensionContext,
|
|
223
|
+
callbacks: SddPreflightCallbacks,
|
|
224
|
+
): Promise<SddPreflightPreferences> {
|
|
225
|
+
const sessionKey = sddPreflightSessionKey(ctx);
|
|
226
|
+
const existing = sddPreflightBySession.get(sessionKey);
|
|
227
|
+
if (existing) return existing;
|
|
228
|
+
const inFlight = sddPreflightInFlight.get(sessionKey);
|
|
229
|
+
if (inFlight) return inFlight;
|
|
230
|
+
const promise = (async () => {
|
|
231
|
+
const engramAvailable = hasWritableEngramTool(callbacks.pi);
|
|
232
|
+
const prefs = await collectSddPreflightPreferences(ctx, engramAvailable);
|
|
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
|
+
};
|
|
240
|
+
if (ctx.hasUI) {
|
|
241
|
+
ctx.ui.notify(
|
|
242
|
+
[
|
|
243
|
+
"Gentle AI SDD preflight complete.",
|
|
244
|
+
`Mode: ${prefs.executionMode}`,
|
|
245
|
+
`Artifacts: ${prefs.artifactStore}`,
|
|
246
|
+
`PR chaining: ${prefs.chainedPrStrategy}`,
|
|
247
|
+
`Review budget: ${prefs.reviewBudgetLines} changed lines`,
|
|
248
|
+
`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}`,
|
|
250
|
+
].join("\n"),
|
|
251
|
+
"info",
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
sddPreflightBySession.set(sessionKey, prefs);
|
|
255
|
+
return prefs;
|
|
256
|
+
})();
|
|
257
|
+
sddPreflightInFlight.set(sessionKey, promise);
|
|
258
|
+
try {
|
|
259
|
+
return await promise;
|
|
260
|
+
} finally {
|
|
261
|
+
sddPreflightInFlight.delete(sessionKey);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function getSddPreflightPreferences(
|
|
266
|
+
ctx: ExtensionContext,
|
|
267
|
+
): SddPreflightPreferences | undefined {
|
|
268
|
+
return sddPreflightBySession.get(sddPreflightSessionKey(ctx));
|
|
269
|
+
}
|