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.
@@ -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
  });
@@ -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
+ }