gentle-pi 0.3.10 → 0.4.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.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, watch } from "node:fs";
2
+ import { existsSync, type FSWatcher, watch } from "node:fs";
3
3
  import {
4
4
  access,
5
5
  mkdir,
@@ -29,6 +29,7 @@ const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
29
29
  ".pi/extensions/skill-registry.ts.disabled";
30
30
  const SKILL_REGISTRY_EXTENSION_SOURCE_KEY =
31
31
  "__gentlePiSkillRegistryExtensionSource";
32
+ const activeWatchers = new Set<FSWatcher>();
32
33
 
33
34
  interface SkillRegistryExtensionGlobal {
34
35
  [SKILL_REGISTRY_EXTENSION_SOURCE_KEY]?: string;
@@ -62,6 +63,7 @@ function userSkillDirs(): string[] {
62
63
  join(home, ".claude/skills"),
63
64
  join(home, ".gemini/skills"),
64
65
  join(home, ".gemini/antigravity/skills"),
66
+ join(home, ".trae/skills"),
65
67
  join(home, ".cursor/skills"),
66
68
  join(home, ".copilot/skills"),
67
69
  join(home, ".codex/skills"),
@@ -78,6 +80,7 @@ function projectSkillDirs(cwd: string): string[] {
78
80
  join(cwd, ".opencode/skills"),
79
81
  join(cwd, ".claude/skills"),
80
82
  join(cwd, ".gemini/skills"),
83
+ join(cwd, ".trae/skills"),
81
84
  join(cwd, ".cursor/skills"),
82
85
  join(cwd, ".github/skills"),
83
86
  join(cwd, ".codex/skills"),
@@ -116,11 +119,12 @@ async function findSkillFiles(root: string): Promise<string[]> {
116
119
  }
117
120
 
118
121
  function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
119
- if (!source.startsWith("---\n")) return { body: source };
120
- const end = source.indexOf("\n---", 4);
121
- if (end === -1) return { body: source };
122
- const fm = source.slice(4, end);
123
- const body = source.slice(end + 4).replace(/^\n/, "");
122
+ const normalized = source.replace(/\r\n?/g, "\n");
123
+ if (!normalized.startsWith("---\n")) return { body: normalized };
124
+ const end = normalized.indexOf("\n---", 4);
125
+ if (end === -1) return { body: normalized };
126
+ const fm = normalized.slice(4, end);
127
+ const body = normalized.slice(end + 4).replace(/^\n/, "");
124
128
  const out: { name?: string; description?: string } = {};
125
129
  const lines = fm.split("\n");
126
130
  for (let i = 0; i < lines.length; i++) {
@@ -457,6 +461,18 @@ function shouldSkipDuplicateExtensionLoad(
457
461
  return existingSource !== currentSource;
458
462
  }
459
463
 
464
+ function closeSkillRegistryWatchers(): void {
465
+ for (const watcher of activeWatchers) {
466
+ try {
467
+ watcher.close();
468
+ } catch {
469
+ // Best-effort shutdown; stale handles must not block process exit.
470
+ }
471
+ }
472
+ activeWatchers.clear();
473
+ watchedCwds.clear();
474
+ }
475
+
460
476
  async function startSkillRegistryWatcher(
461
477
  cwd: string,
462
478
  notify: (message: string) => void,
@@ -485,7 +501,8 @@ async function startSkillRegistryWatcher(
485
501
  };
486
502
  for (const dir of dirs) {
487
503
  try {
488
- watch(dir, { recursive: true }, refresh);
504
+ const watcher = watch(dir, { recursive: true }, refresh);
505
+ activeWatchers.add(watcher);
489
506
  } catch {
490
507
  // Some filesystems do not support recursive watches; session_start/manual refresh still work.
491
508
  }
@@ -503,11 +520,20 @@ export const __testing = {
503
520
  renderRegistry,
504
521
  shouldSkipSkillRegistryStartup,
505
522
  shouldSkipDuplicateExtensionLoad,
523
+ startSkillRegistryWatcher,
524
+ closeSkillRegistryWatchers,
525
+ activeWatcherCount() {
526
+ return activeWatchers.size;
527
+ },
506
528
  };
507
529
 
508
530
  export default function (pi: ExtensionAPI) {
509
531
  if (shouldSkipDuplicateExtensionLoad()) return;
510
532
 
533
+ pi.on("session_shutdown", () => {
534
+ closeSkillRegistryWatchers();
535
+ });
536
+
511
537
  pi.registerFlag(NO_SKILL_REGISTRY_FLAG, {
512
538
  description: "Skip the Gentle AI skill registry refresh and watcher on startup.",
513
539
  type: "boolean",
@@ -532,9 +558,11 @@ export default function (pi: ExtensionAPI) {
532
558
  "warning",
533
559
  );
534
560
  }
535
- await startSkillRegistryWatcher(ctx.cwd, (message) => {
536
- if (ctx.hasUI) ctx.ui.notify(message, "info");
537
- });
561
+ if (ctx.hasUI) {
562
+ await startSkillRegistryWatcher(ctx.cwd, (message) => {
563
+ ctx.ui.notify(message, "info");
564
+ });
565
+ }
538
566
  if (quarantinedLegacy) {
539
567
  setTimeout(() => {
540
568
  void (async () => {
@@ -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.10",
3
+ "version": "0.4.1",
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",
@@ -6,11 +6,13 @@ import { fileURLToPath } from "node:url";
6
6
  const root = join(fileURLToPath(new URL("..", import.meta.url)));
7
7
 
8
8
  const requiredPaths = [
9
+ "assets/orchestrator.md",
9
10
  "assets/agents/sdd-apply.md",
10
11
  "assets/agents/sdd-archive.md",
11
12
  "assets/agents/sdd-design.md",
12
13
  "assets/agents/sdd-explore.md",
13
14
  "assets/agents/sdd-init.md",
15
+ "assets/agents/sdd-onboard.md",
14
16
  "assets/agents/sdd-proposal.md",
15
17
  "assets/agents/sdd-spec.md",
16
18
  "assets/agents/sdd-sync.md",
@@ -29,7 +31,15 @@ const requiredPaths = [
29
31
  "prompts/gis.md",
30
32
  "prompts/gpr.md",
31
33
  "prompts/gwr.md",
34
+ "skills/branch-pr/SKILL.md",
35
+ "skills/chained-pr/SKILL.md",
36
+ "skills/cognitive-doc-design/SKILL.md",
37
+ "skills/comment-writer/SKILL.md",
32
38
  "skills/gentle-ai/SKILL.md",
39
+ "skills/issue-creation/SKILL.md",
40
+ "skills/judgment-day/SKILL.md",
41
+ "skills/release/SKILL.md",
42
+ "skills/skill-registry/SKILL.md",
33
43
  "skills/work-unit-commits/SKILL.md",
34
44
  ];
35
45
 
@@ -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 target context language | Write in the target context language by default: Spanish issue/thread -> Spanish comment, English issue/thread -> English comment, mixed context -> target message language. If the user explicitly requests a language or tone, follow that request. Do not use the active persona as the source of truth for public comments. For Spanish comments, use neutral/professional Spanish by default unless the user or target context clearly calls for regional tone. |
31
31
  | No em dashes | Use commas, periods, or parentheses instead. |
32
32
 
33
33
  ## Comment Formula
@@ -45,25 +45,25 @@ Use it for:
45
45
  ### Request change
46
46
 
47
47
  ```markdown
48
- Buenísimo el enfoque. Acá separaría este cambio en otro commit porque mezcla la validación con el wiring de UI.
48
+ Good approach overall. I'd split this into a separate commit because it mixes validation logic with UI wiring.
49
49
 
50
- Eso le baja carga al reviewer y hace que el rollback sea más claro si falla la integración.
50
+ That keeps the reviewer's focus narrower and makes rollback cleaner if the integration fails.
51
51
  ```
52
52
 
53
53
  ### Approve with a note
54
54
 
55
55
  ```markdown
56
- Está bien encaminado y el scope se entiende rápido.
56
+ Approved. The scope is clear and the change is well-contained.
57
57
 
58
- Dejo aprobado. Para el próximo PR, agregá el link al anterior y al siguiente así la cadena queda navegable.
58
+ For the next PR, add links to the previous and following PRs so the chain stays navigable.
59
59
  ```
60
60
 
61
61
  ### Ask for split
62
62
 
63
63
  ```markdown
64
- Este PR supera el presupuesto de 400 líneas cambiadas, así que necesitamos dividirlo o justificar `size:exception`.
64
+ This PR exceeds the 400-line budget, so we need to split it or justify `size:exception`.
65
65
 
66
- Mi sugerencia: primero foundation + tests, después integración, después docs. Así cada review tiene inicio y fin claros.
66
+ Suggested order: foundation + tests first, then integration, then docs. That gives each review a clear start and end.
67
67
  ```
68
68
 
69
69
  ## Commands
@@ -49,7 +49,14 @@ test("orchestrator keeps conversation language separate from generated artifact
49
49
  );
50
50
  assert.match(
51
51
  orchestrator,
52
- /Generated artifacts[\s\S]*default to English, regardless of the user's conversation language/,
52
+ /Generated technical artifacts[\s\S]*default to English, regardless of the user's conversation language or active persona/,
53
+ );
54
+ for (const artifactScope of ["code comments", "tests", "fixtures", "delegated phase outputs"]) {
55
+ assert.match(orchestrator, new RegExp(artifactScope));
56
+ }
57
+ assert.match(
58
+ orchestrator,
59
+ /Public\/contextual comments and replies[\s\S]*target context language by default/,
53
60
  );
54
61
  });
55
62
 
@@ -89,3 +96,35 @@ test("persistent harness prompt assets do not hardcode Spanish SDD artifact copy
89
96
 
90
97
  assert.deepEqual(failures, []);
91
98
  });
99
+
100
+ test("comment-writer is context-reactive and neutral by default for Spanish comments", async () => {
101
+ const skill = await readFile(join(ROOT, "skills/comment-writer/SKILL.md"), "utf8");
102
+
103
+ for (const required of [
104
+ "target context language",
105
+ "explicitly requests a language",
106
+ "neutral/professional Spanish by default",
107
+ ]) {
108
+ assert.match(skill, new RegExp(required.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
109
+ }
110
+
111
+ for (const regionalDefault of [
112
+ /\bAcá\b/,
113
+ /\bagregá\b/,
114
+ /\bpodés\b/,
115
+ /\btenés\b/,
116
+ /\bfijate\b/,
117
+ /\bdale\b/,
118
+ /\bquerés\b/i,
119
+ ]) {
120
+ assert.doesNotMatch(skill, regionalDefault);
121
+ }
122
+
123
+ for (const englishExample of [
124
+ "Good approach overall",
125
+ "Approved. The scope is clear",
126
+ "This PR exceeds the 400-line budget",
127
+ ]) {
128
+ assert.match(skill, new RegExp(englishExample.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
129
+ }
130
+ });
@@ -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
+ });