gentle-pi 0.3.9 → 0.4.0

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.
@@ -4,10 +4,31 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
4
4
  import * as os from "node:os";
5
5
  import { exec } from "node:child_process";
6
6
  import { promisify } from "node:util";
7
- import { readFile } from "node:fs/promises";
7
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
8
8
  import { join } from "node:path";
9
9
 
10
10
  const execAsync = promisify(exec);
11
+ const PI_AGENT_DIR = join(os.homedir(), ".pi", "agent");
12
+ const PI_NPM_DIR = join(PI_AGENT_DIR, "npm", "node_modules");
13
+
14
+ type BannerColor = "pink" | "cyan" | "yellow" | "green";
15
+ interface BannerConfig {
16
+ showRose: boolean;
17
+ showTextLogo: boolean;
18
+ color: BannerColor;
19
+ }
20
+ const DEFAULT_BANNER_CONFIG: BannerConfig = {
21
+ showRose: true,
22
+ showTextLogo: true,
23
+ color: "pink",
24
+ };
25
+ const BANNER_COLORS: BannerColor[] = ["pink", "cyan", "yellow", "green"];
26
+ const BANNER_PALETTES: Record<BannerColor, { rose: [number, number, number]; label: [number, number, number]; value: [number, number, number]; logoFresh: [number, number, number]; logoDim: [number, number, number] }> = {
27
+ pink: { rose: [255, 118, 195], label: [200, 100, 160], value: [255, 140, 210], logoFresh: [255, 138, 206], logoDim: [95, 30, 60] },
28
+ cyan: { rose: [95, 210, 255], label: [85, 170, 205], value: [130, 225, 255], logoFresh: [105, 220, 255], logoDim: [25, 80, 100] },
29
+ yellow: { rose: [255, 210, 95], label: [210, 165, 65], value: [255, 225, 135], logoFresh: [255, 215, 105], logoDim: [105, 75, 25] },
30
+ green: { rose: [110, 220, 145], label: [85, 175, 115], value: [145, 240, 170], logoFresh: [120, 230, 150], logoDim: [30, 95, 50] },
31
+ };
11
32
 
12
33
  const TEXT_LOGO = [
13
34
  " ▄▄▄▀▀▀▀▀██ ▄▄▀▄▄ ▄▄█▀▀▀██ ▀▀█▄ ▄▄▄",
@@ -47,6 +68,43 @@ function rgb(r: number, g: number, b: number, text: string): string {
47
68
  return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
48
69
  }
49
70
 
71
+ function gentleAiConfigHome(): string {
72
+ return process.env.GENTLE_PI_CONFIG_HOME ?? join(os.homedir(), ".pi", "gentle-ai");
73
+ }
74
+
75
+ function bannerConfigPath(): string {
76
+ return join(gentleAiConfigHome(), "banner.json");
77
+ }
78
+
79
+ function normalizeBannerConfig(value: unknown): BannerConfig {
80
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return { ...DEFAULT_BANNER_CONFIG };
81
+ const record = value as Record<string, unknown>;
82
+ return {
83
+ showRose: typeof record.showRose === "boolean" ? record.showRose : DEFAULT_BANNER_CONFIG.showRose,
84
+ showTextLogo: typeof record.showTextLogo === "boolean" ? record.showTextLogo : DEFAULT_BANNER_CONFIG.showTextLogo,
85
+ color: BANNER_COLORS.includes(record.color as BannerColor) ? record.color as BannerColor : DEFAULT_BANNER_CONFIG.color,
86
+ };
87
+ }
88
+
89
+ async function readBannerConfig(): Promise<BannerConfig> {
90
+ try {
91
+ return normalizeBannerConfig(JSON.parse(await readFile(bannerConfigPath(), "utf8")));
92
+ } catch {
93
+ return { ...DEFAULT_BANNER_CONFIG };
94
+ }
95
+ }
96
+
97
+ async function writeBannerConfig(config: BannerConfig): Promise<void> {
98
+ const path = bannerConfigPath();
99
+ await mkdir(join(path, ".."), { recursive: true });
100
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
101
+ }
102
+
103
+ function paletteColor(color: BannerColor, key: keyof typeof BANNER_PALETTES[BannerColor], text: string): string {
104
+ const [r, g, b] = BANNER_PALETTES[color][key];
105
+ return rgb(r, g, b, text);
106
+ }
107
+
50
108
  function normalizeAscii(lines: string[]): string[] {
51
109
  const trimmed = lines.map((l) => l.replace(/\s+$/g, ""));
52
110
  const nonEmpty = trimmed.filter((l) => l.trim().length > 0);
@@ -433,7 +491,107 @@ function currentIntroMode(): IntroMode {
433
491
  return pickIntroMode(rows, cols);
434
492
  }
435
493
 
494
+ async function countSddAgents(): Promise<number> {
495
+ try {
496
+ const entries = await readdir(join(PI_AGENT_DIR, "agents"), { withFileTypes: true });
497
+ return entries.filter((entry) => entry.isFile() && /^sdd-.*\.md$/.test(entry.name)).length;
498
+ } catch {
499
+ return 0;
500
+ }
501
+ }
502
+
503
+ function packageNameFromSpec(spec: unknown): string | undefined {
504
+ if (typeof spec !== "string") return undefined;
505
+ const clean = spec.replace(/^npm:/, "");
506
+ if (clean.startsWith("@")) {
507
+ const parts = clean.split("@");
508
+ return parts.length > 2 ? `@${parts[1]}` : clean;
509
+ }
510
+ return clean.split("@")[0] || undefined;
511
+ }
512
+
513
+ async function countPackageExtensions(packages: unknown[]): Promise<number> {
514
+ let count = 0;
515
+ for (const spec of packages) {
516
+ const name = packageNameFromSpec(spec);
517
+ if (!name) continue;
518
+ try {
519
+ const raw = await readFile(join(PI_NPM_DIR, name, "package.json"), "utf8");
520
+ const pkg = JSON.parse(raw);
521
+ const extensions = pkg?.pi?.extensions;
522
+ if (Array.isArray(extensions)) count += extensions.length;
523
+ } catch {
524
+ // Packages installed from non-npm sources may not live in PI_NPM_DIR.
525
+ }
526
+ }
527
+ return count;
528
+ }
529
+
436
530
  export default function (pi: ExtensionAPI) {
531
+ const notifyBannerConfig = (ctx: any, config: BannerConfig) => {
532
+ ctx.ui.notify(
533
+ [
534
+ `Startup banner: rose=${config.showRose ? "on" : "off"}, text logo=${config.showTextLogo ? "on" : "off"}, color=${config.color}`,
535
+ `Config: ${bannerConfigPath()}`,
536
+ "Changes apply on the next startup banner render.",
537
+ ].join("\n"),
538
+ "info",
539
+ );
540
+ };
541
+
542
+ const registerBannerCommand = (name: string) => {
543
+ pi.registerCommand(name, {
544
+ description: "Configure the Gentle Pi startup banner.",
545
+ handler: async (_args, ctx) => {
546
+ const config = await readBannerConfig();
547
+ const selected = await ctx.ui.select("Startup banner", [
548
+ `Rose: ${config.showRose ? "on" : "off"}`,
549
+ `Text logo: ${config.showTextLogo ? "on" : "off"}`,
550
+ `Color: ${config.color}`,
551
+ ]);
552
+ if (!selected) return;
553
+ if (selected.startsWith("Rose:")) config.showRose = !config.showRose;
554
+ else if (selected.startsWith("Text logo:")) config.showTextLogo = !config.showTextLogo;
555
+ else if (selected.startsWith("Color:")) {
556
+ config.color = await ctx.ui.select("Startup banner color", [...BANNER_COLORS]) as BannerColor;
557
+ }
558
+ await writeBannerConfig(config);
559
+ notifyBannerConfig(ctx, config);
560
+ },
561
+ });
562
+ };
563
+ const registerToggleCommand = (name: string, key: "showRose" | "showTextLogo") => {
564
+ pi.registerCommand(name, {
565
+ description: `Toggle startup banner ${key === "showRose" ? "rose" : "text logo"}.`,
566
+ handler: async (_args, ctx) => {
567
+ const config = await readBannerConfig();
568
+ config[key] = !config[key];
569
+ await writeBannerConfig(config);
570
+ notifyBannerConfig(ctx, config);
571
+ },
572
+ });
573
+ };
574
+ const registerColorCommand = (name: string) => {
575
+ pi.registerCommand(name, {
576
+ description: "Set startup banner color preset.",
577
+ handler: async (args, ctx) => {
578
+ const config = await readBannerConfig();
579
+ const requested = String(args ?? "").trim() as BannerColor;
580
+ config.color = BANNER_COLORS.includes(requested)
581
+ ? requested
582
+ : await ctx.ui.select("Startup banner color", [...BANNER_COLORS]) as BannerColor;
583
+ await writeBannerConfig(config);
584
+ notifyBannerConfig(ctx, config);
585
+ },
586
+ });
587
+ };
588
+ for (const prefix of ["gentle", "gentle-ai"] as const) {
589
+ registerBannerCommand(`${prefix}:banner`);
590
+ registerToggleCommand(`${prefix}:toggle-rose`, "showRose");
591
+ registerToggleCommand(`${prefix}:toggle-text-logo`, "showTextLogo");
592
+ registerColorCommand(`${prefix}:banner-color`);
593
+ }
594
+
437
595
  pi.on("session_start", async (_event, ctx) => {
438
596
  if (!ctx.hasUI) return;
439
597
 
@@ -451,6 +609,8 @@ export default function (pi: ExtensionAPI) {
451
609
 
452
610
  process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
453
611
 
612
+ const bannerConfig = await readBannerConfig();
613
+ const palette = BANNER_PALETTES[bannerConfig.color];
454
614
  const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW));
455
615
  const logoBase = padLines(TEXT_LOGO);
456
616
 
@@ -458,6 +618,7 @@ export default function (pi: ExtensionAPI) {
458
618
  let mcpServersCount = 0;
459
619
  let extensionsCount = 0;
460
620
  let packagesCount = 0;
621
+ let sddAgentsCount = 0;
461
622
 
462
623
  const allCommands = pi.getCommands();
463
624
  const skills = allCommands.filter((c) => c.source === "skill");
@@ -493,15 +654,15 @@ export default function (pi: ExtensionAPI) {
493
654
  setTimeout(() => {
494
655
  (async () => {
495
656
  try {
657
+ sddAgentsCount = await countSddAgents();
496
658
  const raw = await readFile(
497
- join(os.homedir(), ".pi", "agent", "settings.json"),
659
+ join(PI_AGENT_DIR, "settings.json"),
498
660
  "utf8",
499
661
  );
500
662
  const cfg = JSON.parse(raw);
501
- extensionsCount = Array.isArray(cfg.extensions)
502
- ? cfg.extensions.length
503
- : 0;
504
- packagesCount = Array.isArray(cfg.packages) ? cfg.packages.length : 0;
663
+ const packages = Array.isArray(cfg.packages) ? cfg.packages : [];
664
+ packagesCount = packages.length;
665
+ extensionsCount = await countPackageExtensions(packages);
505
666
  } catch {
506
667
  extensionsCount = 0;
507
668
  packagesCount = 0;
@@ -595,7 +756,7 @@ export default function (pi: ExtensionAPI) {
595
756
  const sideBySideMinWidth = roseBase.width + 3 + logoBase.width + 4;
596
757
  const wideStatsMinWidth = 122;
597
758
  const horizontal =
598
- state.mode === "full" && width >= sideBySideMinWidth;
759
+ state.mode === "full" && bannerConfig.showRose && bannerConfig.showTextLogo && width >= sideBySideMinWidth;
599
760
  const wideStats = width >= wideStatsMinWidth;
600
761
 
601
762
  const b = new LayoutBuilder();
@@ -603,7 +764,7 @@ export default function (pi: ExtensionAPI) {
603
764
  b.center(width);
604
765
 
605
766
  if (state.mode === "minimal") {
606
- for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
767
+ if (bannerConfig.showTextLogo) for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
607
768
  const logoLine = logoBase.lines[logoI];
608
769
  b.addRow();
609
770
  b.lines[b.lines.length - 1].push(
@@ -660,8 +821,8 @@ export default function (pi: ExtensionAPI) {
660
821
  b.center(width);
661
822
  }
662
823
  } else {
663
- const showBanner = width >= logoBase.width + 2;
664
- const showRose = width >= roseBase.width + 2;
824
+ const showBanner = bannerConfig.showTextLogo && width >= logoBase.width + 2;
825
+ const showRose = bannerConfig.showRose && width >= roseBase.width + 2;
665
826
  if (showBanner) {
666
827
  for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
667
828
  const logoLine = logoBase.lines[logoI];
@@ -690,7 +851,7 @@ export default function (pi: ExtensionAPI) {
690
851
  }
691
852
  }
692
853
 
693
- if (state.mode === "full") {
854
+ if (state.mode === "full" || (!bannerConfig.showRose && !bannerConfig.showTextLogo)) {
694
855
  b.addRow();
695
856
  b.center(width);
696
857
 
@@ -720,8 +881,9 @@ export default function (pi: ExtensionAPI) {
720
881
  ["GIT:", gitBranch],
721
882
  ["PATH:", ctx.cwd],
722
883
  ["MCP:", `${mcpServersCount} server(s)`],
884
+ ["AGENTS:", `${sddAgentsCount} phases`],
723
885
  ["PLUGINS:", `${packagesCount} package(s)`],
724
- ["AGENTS:", `${skills.length} loaded`],
886
+ ["SKILLS:", `${skills.length} loaded`],
725
887
  ["EXTENSIONS:", `${extensionsCount} active`],
726
888
  ["VER:", `v${VERSION}`],
727
889
  ["TOOLS:", `${customTools.length} custom`],
@@ -752,22 +914,24 @@ export default function (pi: ExtensionAPI) {
752
914
  );
753
915
  addWideRow(
754
916
  "AGENTS:",
755
- `${skills.length} loaded`,
917
+ `${sddAgentsCount} phases`,
756
918
  "EXTENSIONS:",
757
919
  `${extensionsCount} active`,
758
920
  );
759
921
  addWideRow(
760
- "VER:",
761
- `v${VERSION}`,
922
+ "SKILLS:",
923
+ `${skills.length} loaded`,
762
924
  "TOOLS:",
763
925
  `${customTools.length} custom`,
764
926
  );
927
+ addWideRow("VER:", `v${VERSION}`, "", "");
765
928
  } else {
766
929
  addNarrowRow("GIT:", gitBranch);
767
930
  addNarrowRow("PATH:", ctx.cwd);
768
931
  addNarrowRow("MCP:", `${mcpServersCount} server(s)`);
769
932
  addNarrowRow("PLUGINS:", `${packagesCount} package(s)`);
770
- addNarrowRow("AGENTS:", `${skills.length} loaded`);
933
+ addNarrowRow("AGENTS:", `${sddAgentsCount} phases`);
934
+ addNarrowRow("SKILLS:", `${skills.length} loaded`);
771
935
  addNarrowRow("EXTENSIONS:", `${extensionsCount} active`);
772
936
  addNarrowRow("VER:", `v${VERSION}`);
773
937
  addNarrowRow("TOOLS:", `${customTools.length} custom`);
@@ -834,9 +998,9 @@ export default function (pi: ExtensionAPI) {
834
998
  const k = Math.max(0.01, roseOpacity * pulse);
835
999
  const f = flashPhase ** 0.4;
836
1000
 
837
- const rBase = Math.floor(255 * k);
838
- const gBase = Math.floor(118 * k);
839
- const bBase = Math.floor(195 * k);
1001
+ const rBase = Math.floor(palette.rose[0] * k);
1002
+ const gBase = Math.floor(palette.rose[1] * k);
1003
+ const bBase = Math.floor(palette.rose[2] * k);
840
1004
 
841
1005
  if (f > 0.85) {
842
1006
  line += `\x1b[1m\x1b[38;2;255;255;255m${cell.char}\x1b[0m`;
@@ -874,22 +1038,22 @@ export default function (pi: ExtensionAPI) {
874
1038
  line += `\x1b[1m` + rgb(255, 205, 238, cell.char) + `\x1b[22m`;
875
1039
  } else if (cell.type === "logo-fresh") {
876
1040
  line += cell.char === "▒"
877
- ? rgb(110, 36, 70, cell.char)
878
- : rgb(255, 138, 206, cell.char);
1041
+ ? paletteColor(bannerConfig.color, "logoDim", cell.char)
1042
+ : paletteColor(bannerConfig.color, "logoFresh", cell.char);
879
1043
  } else {
880
1044
  line += cell.char === "▒"
881
- ? rgb(95, 30, 60, cell.char)
882
- : rgb(255, 120, 198, cell.char);
1045
+ ? paletteColor(bannerConfig.color, "logoDim", cell.char)
1046
+ : paletteColor(bannerConfig.color, "value", cell.char);
883
1047
  }
884
1048
  continue;
885
1049
  }
886
1050
 
887
1051
  switch (cell.type) {
888
1052
  case "label":
889
- line += rgb(200, 100, 160, cell.char);
1053
+ line += paletteColor(bannerConfig.color, "label", cell.char);
890
1054
  break;
891
1055
  case "value":
892
- line += rgb(255, 140, 210, cell.char);
1056
+ line += paletteColor(bannerConfig.color, "value", cell.char);
893
1057
  break;
894
1058
  case "dim":
895
1059
  line += theme.fg("dim", cell.char);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
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",
@@ -11,6 +11,7 @@ const requiredPaths = [
11
11
  "assets/agents/sdd-design.md",
12
12
  "assets/agents/sdd-explore.md",
13
13
  "assets/agents/sdd-init.md",
14
+ "assets/agents/sdd-onboard.md",
14
15
  "assets/agents/sdd-proposal.md",
15
16
  "assets/agents/sdd-spec.md",
16
17
  "assets/agents/sdd-sync.md",
@@ -27,7 +27,7 @@ Use it for:
27
27
  | Keep it short | Prefer 1 to 3 short paragraphs or a tight bullet list. |
28
28
  | Explain why | Give the technical reason when asking for a change. |
29
29
  | Avoid pile-ons | Comment on the highest-value issue, not every tiny preference. |
30
- | Match thread language | Write in the thread/user language. If writing in Spanish, use Rioplatense Spanish/voseo: `podés`, `tenés`, `fijate`, `dale`. |
30
+ | Match thread language | Write in the thread/user language and follow the active persona/tone. Do not force regional Spanish; use voseo only when the active persona or existing thread does. |
31
31
  | No em dashes | Use commas, periods, or parentheses instead. |
32
32
 
33
33
  ## Comment Formula
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import test from "node:test";
6
+ import { __testing } from "../extensions/gentle-ai.ts";
7
+
8
+ function writeMarkdown(path: string, content: string): void {
9
+ mkdirSync(dirname(path), { recursive: true });
10
+ writeFileSync(path, content);
11
+ }
12
+
13
+ test("agent discovery skips skills directories", async (t) => {
14
+ const root = mkdtempSync(join(tmpdir(), "gentle-pi-agents-"));
15
+ t.after(() => rmSync(root, { recursive: true, force: true }));
16
+ const dotAgents = join(root, ".agents");
17
+ writeMarkdown(join(dotAgents, "reviewer.md"), "name: reviewer\n");
18
+ writeMarkdown(join(dotAgents, "team", "worker.md"), "name: worker\n");
19
+ writeMarkdown(join(dotAgents, "skills", "ai-sdk", "SKILL.md"), "name: ai-sdk\n");
20
+ writeMarkdown(
21
+ join(dotAgents, "skills", "ai-sdk", "references", "evaluation.md"),
22
+ "name: Prompt Evaluation\n",
23
+ );
24
+
25
+ const syncAgents = __testing.listAgentsFromDir(dotAgents, "user");
26
+ const asyncAgents = await __testing.listAgentsFromDirAsync(dotAgents, "user");
27
+
28
+ assert.deepEqual(
29
+ syncAgents.map((agent) => agent.name),
30
+ ["reviewer", "worker"],
31
+ );
32
+ assert.deepEqual(
33
+ asyncAgents.map((agent) => agent.name),
34
+ ["reviewer", "worker"],
35
+ );
36
+ });
@@ -16,6 +16,17 @@ const EXTENSIONS = [
16
16
  "extensions/startup-banner.ts",
17
17
  ];
18
18
 
19
+ const EXPECTED_BANNER_COMMANDS = [
20
+ "gentle:banner",
21
+ "gentle:toggle-rose",
22
+ "gentle:toggle-text-logo",
23
+ "gentle:banner-color",
24
+ "gentle-ai:banner",
25
+ "gentle-ai:toggle-rose",
26
+ "gentle-ai:toggle-text-logo",
27
+ "gentle-ai:banner-color",
28
+ ];
29
+
19
30
  const EXPECTED_COMMANDS = [
20
31
  "gentle-ai:install-sdd",
21
32
  "gentle-ai:sdd-preflight",
@@ -27,8 +38,10 @@ const EXPECTED_COMMANDS = [
27
38
  "gentle-ai:persona",
28
39
  "gentleman:persona",
29
40
  "gentle-ai:status",
41
+ "gentle-ai:doctor",
30
42
  "sdd-init",
31
43
  "skill-registry:refresh",
44
+ ...EXPECTED_BANNER_COMMANDS,
32
45
  ];
33
46
 
34
47
  function createPi() {
@@ -151,6 +164,7 @@ async function run() {
151
164
  }
152
165
  assert.ok(flags.has("no-skill-registry"), "missing no-skill-registry flag");
153
166
  assert.ok(hooks.has("session_start"), "missing session_start hook");
167
+ assert.ok(hooks.has("session_shutdown"), "missing session_shutdown hook");
154
168
  assert.ok(hooks.has("input"), "missing input hook");
155
169
  assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
156
170
  assert.ok(hooks.has("tool_call"), "missing tool_call hook");
@@ -180,9 +194,8 @@ async function run() {
180
194
  assert.match(promptResult.systemPrompt, /el Gentleman/);
181
195
  assert.match(promptResult.systemPrompt, /openspec\/config\.yaml.*not session preflight/s);
182
196
  assert.match(promptResult.systemPrompt, /Do not mark SDD preflight complete/);
183
- await mkdir(join(promptCwd, ".pi", "gentle-ai"), { recursive: true });
184
197
  await writeFile(
185
- join(promptCwd, ".pi", "gentle-ai", "persona.json"),
198
+ join(globalConfigHome, "persona.json"),
186
199
  '{"mode":"neutral"}\n',
187
200
  );
188
201
  const neutralPromptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
@@ -202,6 +215,37 @@ async function run() {
202
215
  false,
203
216
  "normal agent startup must not run SDD preflight",
204
217
  );
218
+ await mkdir(join(promptCwd, ".pi", "gentle-ai"), { recursive: true });
219
+ await writeFile(
220
+ join(promptCwd, ".pi", "gentle-ai", "persona.json"),
221
+ '{"mode":"gentleman"}\n',
222
+ );
223
+ const localOverridePromptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
224
+ assert.match(
225
+ localOverridePromptResult.systemPrompt,
226
+ /When the user writes Spanish, answer in natural Rioplatense Spanish with voseo/,
227
+ );
228
+ const personaCtx = createCtx(promptCwd, true);
229
+ personaCtx.ui.select = async () => "neutral";
230
+ await commands.get("gentle:persona").handler("", personaCtx);
231
+ assert.equal(
232
+ await readFile(join(globalConfigHome, "persona.json"), "utf8"),
233
+ '{\n "mode": "neutral"\n}\n',
234
+ );
235
+ assert.equal(
236
+ await readFile(join(promptCwd, ".pi", "gentle-ai", "persona.json"), "utf8"),
237
+ '{\n "mode": "neutral"\n}\n',
238
+ );
239
+ assert.match(personaCtx.ui.notifications.at(-1).message, /Global config:/);
240
+ const onboardCtx = createCtx(promptCwd, true, "sdd-onboard-session");
241
+ onboardCtx.ui.select = async (_label, options) => options[0];
242
+ const onboardPromptResult = await promptHook(
243
+ { agentName: "sdd-onboard", systemPrompt: "onboard base" },
244
+ onboardCtx,
245
+ );
246
+ assert.match(onboardPromptResult.systemPrompt, /onboard base/);
247
+ assert.match(onboardPromptResult.systemPrompt, /## SDD Session Preflight/);
248
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-onboard.md")), true);
205
249
  } finally {
206
250
  await rm(promptCwd, { recursive: true, force: true });
207
251
  }
@@ -213,6 +257,14 @@ async function run() {
213
257
  const denied = await toolHook({ toolName: "bash", input: { command: "rm -rf /" } }, createCtx(toolCwd));
214
258
  assert.equal(denied.block, true);
215
259
  assert.match(denied.reason, /destructive/);
260
+ const sensitiveRead = await toolHook({ toolName: "read", input: { path: join(toolCwd, ".env.local") } }, createCtx(toolCwd));
261
+ assert.equal(sensitiveRead.block, true);
262
+ assert.match(sensitiveRead.reason, /sensitive path/);
263
+ const sensitiveWrite = await toolHook({ toolName: "write", input: { path: join(toolCwd, "secrets", "token.txt"), content: "x" } }, createCtx(toolCwd));
264
+ assert.equal(sensitiveWrite.block, true);
265
+ const sensitiveEdit = await toolHook({ toolName: "edit", input: { edits: [], path: join(toolCwd, "id_rsa.pem") } }, createCtx(toolCwd));
266
+ assert.equal(sensitiveEdit.block, true);
267
+ assert.equal(await toolHook({ toolName: "read", input: { path: join(toolCwd, "src", "index.ts") } }, createCtx(toolCwd)), undefined);
216
268
  const needsConfirm = await toolHook({ toolName: "bash", input: { command: "git push" } }, createCtx(toolCwd));
217
269
  assert.equal(needsConfirm.block, true);
218
270
  assert.match(needsConfirm.reason, /confirmation/);
@@ -220,6 +272,28 @@ async function run() {
220
272
  await rm(toolCwd, { recursive: true, force: true });
221
273
  }
222
274
 
275
+ const bannerCwd = await tempWorkspace();
276
+ try {
277
+ const ctx = createCtx(bannerCwd, true);
278
+ await commands.get("gentle:toggle-rose").handler("", ctx);
279
+ let bannerConfig = JSON.parse(await readFile(join(globalConfigHome, "banner.json"), "utf8"));
280
+ assert.equal(bannerConfig.showRose, false);
281
+ assert.equal(bannerConfig.showTextLogo, true);
282
+ assert.equal(bannerConfig.color, "pink");
283
+ await commands.get("gentle-ai:toggle-text-logo").handler("", ctx);
284
+ bannerConfig = JSON.parse(await readFile(join(globalConfigHome, "banner.json"), "utf8"));
285
+ assert.equal(bannerConfig.showTextLogo, false);
286
+ await commands.get("gentle:banner-color").handler("cyan", ctx);
287
+ bannerConfig = JSON.parse(await readFile(join(globalConfigHome, "banner.json"), "utf8"));
288
+ assert.equal(bannerConfig.color, "cyan");
289
+ await commands.get("gentle:banner").handler("", ctx);
290
+ bannerConfig = JSON.parse(await readFile(join(globalConfigHome, "banner.json"), "utf8"));
291
+ assert.equal(bannerConfig.showRose, true);
292
+ } finally {
293
+ await rm(bannerCwd, { recursive: true, force: true });
294
+ await rm(join(globalConfigHome, "banner.json"), { force: true });
295
+ }
296
+
223
297
  const noUiCwd = await tempWorkspace();
224
298
  try {
225
299
  for (const handler of hooks.get("session_start")) {
@@ -237,6 +311,28 @@ async function run() {
237
311
  );
238
312
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
239
313
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
314
+ await writeFile(join(globalAgentHome, "agents", "sdd-apply.md"), "stale global apply\n");
315
+ await writeFile(join(globalAgentHome, "chains", "sdd-full.chain.md"), "stale global chain\n");
316
+ await mkdir(join(noUiCwd, ".pi", "agents"), { recursive: true });
317
+ await writeFile(join(noUiCwd, ".pi", "agents", "sdd-apply.md"), "project override must stay\n");
318
+ for (const handler of hooks.get("session_start")) {
319
+ await handler({ reason: "startup" }, createCtx(noUiCwd, false));
320
+ }
321
+ assert.notEqual(
322
+ await readFile(join(globalAgentHome, "agents", "sdd-apply.md"), "utf8"),
323
+ "stale global apply\n",
324
+ "session_start must refresh stale global SDD agents",
325
+ );
326
+ assert.notEqual(
327
+ await readFile(join(globalAgentHome, "chains", "sdd-full.chain.md"), "utf8"),
328
+ "stale global chain\n",
329
+ "session_start must refresh stale global SDD chains",
330
+ );
331
+ assert.equal(
332
+ await readFile(join(noUiCwd, ".pi", "agents", "sdd-apply.md"), "utf8"),
333
+ "project override must stay\n",
334
+ "session_start must not overwrite project-local SDD overrides",
335
+ );
240
336
  } finally {
241
337
  await rm(noUiCwd, { recursive: true, force: true });
242
338
  }
@@ -342,12 +438,27 @@ async function run() {
342
438
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
343
439
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
344
440
  assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
345
- const lazySettings = JSON.parse(await readFile(join(lazySddCwd, ".pi", "settings.json"), "utf8"));
346
- assert.equal(lazySettings.subagents.agentOverrides["sdd-apply"].model, "openai/gpt-5");
347
- assert.equal(lazySettings.subagents.agentOverrides["sdd-apply"].thinking, "high");
441
+ const globalSddApply = await readFile(
442
+ join(globalAgentHome, "agents", "sdd-apply.md"),
443
+ "utf8",
444
+ );
445
+ assert.match(globalSddApply, /model: openai\/gpt-5/);
446
+ assert.match(globalSddApply, /thinking: high/);
447
+ const lazySettingsPath = join(lazySddCwd, ".pi", "settings.json");
448
+ if (existsSync(lazySettingsPath)) {
449
+ const lazySettings = JSON.parse(await readFile(lazySettingsPath, "utf8"));
450
+ assert.equal(
451
+ lazySettings.subagents?.agentOverrides?.["sdd-apply"],
452
+ undefined,
453
+ "global SDD model routing must be materialized in agent frontmatter, not project settings overrides",
454
+ );
455
+ }
348
456
  assert.equal(ctx.ui.selections.length, 3);
349
457
  assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
350
458
  assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
459
+ await commands.get("gentle-ai:status").handler("", ctx);
460
+ assert.match(ctx.ui.notifications.at(-1).message, /Global SDD assets stale: 0 file\(s\)/);
461
+ assert.doesNotMatch(ctx.ui.notifications.at(-1).message, /install-sdd --force/);
351
462
 
352
463
  await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
353
464
  assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
@@ -509,6 +620,13 @@ async function run() {
509
620
  await commands.get("gentle-ai:status").handler("", ctx);
510
621
  assert.match(ctx.ui.notifications.at(-1).message, /Project-local SDD override drift: \d+ file\(s\)/);
511
622
  assert.match(ctx.ui.notifications.at(-1).message, /gentle-ai:install-sdd --force/);
623
+ await commands.get("gentle-ai:doctor").handler("", ctx);
624
+ assert.match(ctx.ui.notifications.at(-1).message, /el Gentleman doctor/);
625
+ assert.match(ctx.ui.notifications.at(-1).message, /Sensitive-path guard active/);
626
+ pi.setActiveTools([{ name: "engram.mem_save" }]);
627
+ await commands.get("gentle-ai:doctor").handler("", ctx);
628
+ assert.match(ctx.ui.notifications.at(-1).message, /Engram memory tools active/);
629
+ pi.setActiveTools(["read", "bash", "edit", "write"]);
512
630
  } finally {
513
631
  await rm(staleAssetsCwd, { recursive: true, force: true });
514
632
  }
@@ -815,6 +933,44 @@ async function run() {
815
933
  model: "custom/provider-model",
816
934
  thinking: "medium",
817
935
  });
936
+
937
+ let exportPanelCalls = 0;
938
+ ctx.ui.custom = () => {
939
+ exportPanelCalls += 1;
940
+ return Promise.resolve(exportPanelCalls === 1 ? { type: "export", config: {} } : { type: "cancel" });
941
+ };
942
+ await commands.get("gentle:models").handler("", ctx);
943
+ const exported = JSON.parse(await readFile(join(globalConfigHome, "models.export.json"), "utf8"));
944
+ assert.equal(exported.kind, "gentle-pi.agent_model_routing");
945
+ assert.equal(exported.version, 1);
946
+ assert.deepEqual(exported.agents["sdd-apply"], {
947
+ model: "custom/provider-model",
948
+ thinking: "medium",
949
+ });
950
+
951
+ await writeFile(
952
+ join(globalConfigHome, "models.export.json"),
953
+ JSON.stringify({
954
+ kind: "gentle-pi.agent_model_routing",
955
+ version: 1,
956
+ agents: { "sdd-apply": { model: "restore/provider", thinking: "high" } },
957
+ }, null, 2),
958
+ );
959
+ let restorePanelCalls = 0;
960
+ ctx.ui.confirm = async () => true;
961
+ ctx.ui.custom = () => {
962
+ restorePanelCalls += 1;
963
+ return Promise.resolve(restorePanelCalls === 1 ? { type: "restore", config: {} } : { type: "cancel" });
964
+ };
965
+ await commands.get("gentle:models").handler("", ctx);
966
+ const restoredConfig = JSON.parse(await readFile(globalModelsPath, "utf8"));
967
+ assert.deepEqual(restoredConfig["sdd-apply"], {
968
+ model: "restore/provider",
969
+ thinking: "high",
970
+ });
971
+ const restoredAgent = await readFile(join(modelsCwd, ".pi", "agents", "sdd-apply.md"), "utf8");
972
+ assert.match(restoredAgent, /model: restore\/provider/);
973
+ assert.match(restoredAgent, /thinking: high/);
818
974
  } finally {
819
975
  await rm(modelsCwd, { recursive: true, force: true });
820
976
  await rm(globalModelsPath, { force: true });