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.
package/README.md CHANGED
@@ -353,6 +353,8 @@ Saved globally at:
353
353
 
354
354
  Existing project-local `.pi/gentle-ai/models.json` files are still read as a legacy fallback when no global model config exists, but `/gentle:models` writes the shared global config.
355
355
 
356
+ Inside `/gentle:models`, press `x` to export the saved routing to `~/.pi/gentle-ai/models.export.json`, or `r` to restore from that file after confirmation. Export uses a versioned envelope and restore writes the normal `models.json` shape before applying routing to agents.
357
+
356
358
  Config shape (per agent):
357
359
 
358
360
  ```json
@@ -376,11 +378,20 @@ Legacy string entries are still accepted and treated as `model`-only config.
376
378
  | `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and global model config status. |
377
379
  | `/gentle:models` | Opens global model + effort assignment UI. |
378
380
  | `/gentle:persona` | Switches persona mode. |
381
+ | `/gentle:banner` | Configures startup banner rose, text logo, and color preset. |
382
+ | `/gentle:toggle-rose` | Toggles the startup rose. |
383
+ | `/gentle:toggle-text-logo` | Toggles the startup text logo. |
384
+ | `/gentle:banner-color` | Selects a startup banner color preset. |
379
385
  | `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
380
386
  | `/gentle-ai:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. |
381
387
  | `/gentle-ai:install-sdd --force` | Force-refreshes installed global SDD assets. |
388
+
382
389
  | `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
383
390
 
391
+ Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh.
392
+
393
+ Startup banner settings are global and default to the current pink rose + text logo. Supported color presets are `pink`, `cyan`, `yellow`, and `green`. The `/gentle-ai:*` aliases are also available for every banner command.
394
+
384
395
  Startup flag:
385
396
 
386
397
  ```text
@@ -53,10 +53,17 @@ function sddGlobalAssetDriftCount(): number {
53
53
  stale += 1;
54
54
  continue;
55
55
  }
56
- if (
57
- readFileSync(join(assetDir, entry.name), "utf8") !==
58
- readFileSync(installedPath, "utf8")
59
- ) {
56
+ const packaged = readFileSync(join(assetDir, entry.name), "utf8");
57
+ const installed = readFileSync(installedPath, "utf8");
58
+ const comparablePackaged =
59
+ assetSubdir === "agents"
60
+ ? updateFrontmatterRouting(packaged, undefined)
61
+ : packaged;
62
+ const comparableInstalled =
63
+ assetSubdir === "agents"
64
+ ? updateFrontmatterRouting(installed, undefined)
65
+ : installed;
66
+ if (comparablePackaged !== comparableInstalled) {
60
67
  stale += 1;
61
68
  }
62
69
  } catch {
@@ -181,8 +188,29 @@ const CONFIRM_BASH_PATTERNS: RegExp[] = [
181
188
  /\bpi\s+remove\b/,
182
189
  ];
183
190
 
191
+ const PATH_GUARDED_TOOL_NAMES = new Set(["read", "write", "edit"]);
192
+ const PATH_INPUT_KEYS = new Set([
193
+ "path",
194
+ "paths",
195
+ "file",
196
+ "files",
197
+ "filePath",
198
+ "filePaths",
199
+ ]);
200
+ const SENSITIVE_PATH_PATTERNS: RegExp[] = [
201
+ /(^|\/)\.ssh(?:\/|$)/,
202
+ /(^|\/)\.credentials(?:\/|$)/,
203
+ /(^|\/)library\/keychains(?:\/|$)/,
204
+ /(^|\/)\.aws\/credentials$/,
205
+ /(^|\/)\.config\/gh\/hosts\.ya?ml$/,
206
+ /(^|\/)secrets(?:\/|$)/,
207
+ /(^|\/)\.env(?:$|[./_-])/,
208
+ /\.(?:pem|key|p12|pfx)$/,
209
+ ];
210
+
184
211
  const SDD_AGENT_NAMES = [
185
212
  "sdd-init",
213
+ "sdd-onboard",
186
214
  "sdd-explore",
187
215
  "sdd-proposal",
188
216
  "sdd-spec",
@@ -293,6 +321,62 @@ function commandRequiresConfirmation(command: string): boolean {
293
321
  return CONFIRM_BASH_PATTERNS.some((pattern) => pattern.test(command));
294
322
  }
295
323
 
324
+ function normalizePolicyPath(value: string): string {
325
+ return value.trim().replace(/^~(?=\/|$)/, homedir()).replace(/\\/g, "/").toLowerCase();
326
+ }
327
+
328
+ function isSensitivePath(value: string): boolean {
329
+ const normalized = normalizePolicyPath(value);
330
+ return SENSITIVE_PATH_PATTERNS.some((pattern) => pattern.test(normalized));
331
+ }
332
+
333
+ function collectPathInputs(value: unknown, key?: string): string[] {
334
+ if (typeof value === "string") return key && PATH_INPUT_KEYS.has(key) ? [value] : [];
335
+ if (Array.isArray(value)) return value.flatMap((item) => collectPathInputs(item, key));
336
+ if (!isRecord(value)) return [];
337
+ return Object.entries(value).flatMap(([entryKey, entryValue]) =>
338
+ collectPathInputs(entryValue, entryKey),
339
+ );
340
+ }
341
+
342
+ function hasWritableEngramTool(pi: ExtensionAPI): boolean {
343
+ try {
344
+ const getActiveTools = (pi as unknown as { getActiveTools?: () => unknown[] })
345
+ .getActiveTools;
346
+ if (typeof getActiveTools !== "function") return false;
347
+ const tools = getActiveTools.call(pi);
348
+ return tools.some((tool) => {
349
+ const name =
350
+ typeof tool === "string"
351
+ ? tool
352
+ : isRecord(tool) && typeof tool.name === "string"
353
+ ? tool.name
354
+ : "";
355
+ return (
356
+ name === "mem_save" ||
357
+ name === "engram_mem_save" ||
358
+ name.endsWith(".mem_save") ||
359
+ name.endsWith(".engram_mem_save")
360
+ );
361
+ });
362
+ } catch {
363
+ return false;
364
+ }
365
+ }
366
+
367
+ function evaluateSensitivePathTool(
368
+ toolName: string,
369
+ input: unknown,
370
+ ): ToolCallEventResult | undefined {
371
+ if (!PATH_GUARDED_TOOL_NAMES.has(toolName)) return undefined;
372
+ const sensitivePath = collectPathInputs(input).find(isSensitivePath);
373
+ if (!sensitivePath) return undefined;
374
+ return {
375
+ block: true,
376
+ reason: `Gentle AI safety policy blocked access to sensitive path: ${sanitizeTerminalText(sensitivePath)}. Ask the user for an explicit safer plan.`,
377
+ };
378
+ }
379
+
296
380
  async function confirmCommand(
297
381
  command: string,
298
382
  ctx: ExtensionContext,
@@ -333,30 +417,53 @@ function modelConfigPath(_cwd: string): string {
333
417
  return join(gentleAiConfigHome(), "models.json");
334
418
  }
335
419
 
420
+ function modelExportPath(_cwd: string): string {
421
+ return join(gentleAiConfigHome(), "models.export.json");
422
+ }
423
+
424
+ const MODEL_EXPORT_KIND = "gentle-pi.agent_model_routing";
425
+ const MODEL_EXPORT_VERSION = 1;
426
+
336
427
  function legacyProjectModelConfigPath(cwd: string): string {
337
428
  return join(cwd, ".pi", "gentle-ai", "models.json");
338
429
  }
339
430
 
340
- function personaConfigPath(cwd: string): string {
431
+ function projectPersonaConfigPath(cwd: string): string {
341
432
  return join(cwd, ".pi", "gentle-ai", "persona.json");
342
433
  }
343
434
 
344
- function readPersonaMode(cwd: string): PersonaMode {
345
- const path = personaConfigPath(cwd);
346
- if (!existsSync(path)) return "gentleman";
435
+ function personaConfigPath(_cwd: string): string {
436
+ return join(gentleAiConfigHome(), "persona.json");
437
+ }
438
+
439
+ function readPersonaFile(path: string): PersonaMode | undefined {
440
+ if (!existsSync(path)) return undefined;
347
441
  try {
348
442
  const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
349
- if (!isRecord(parsed)) return "gentleman";
443
+ if (!isRecord(parsed)) return undefined;
350
444
  return parsed.mode === "neutral" ? "neutral" : "gentleman";
351
445
  } catch {
352
- return "gentleman";
446
+ return undefined;
353
447
  }
354
448
  }
355
449
 
356
- function writePersonaMode(cwd: string, mode: PersonaMode): void {
357
- const path = personaConfigPath(cwd);
358
- mkdirSync(dirname(path), { recursive: true });
359
- writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`);
450
+ function readPersonaMode(cwd: string): PersonaMode {
451
+ return (
452
+ readPersonaFile(projectPersonaConfigPath(cwd)) ??
453
+ readPersonaFile(personaConfigPath(cwd)) ??
454
+ "gentleman"
455
+ );
456
+ }
457
+
458
+ function writePersonaMode(cwd: string, mode: PersonaMode): string[] {
459
+ const paths = [personaConfigPath(cwd)];
460
+ const projectPath = projectPersonaConfigPath(cwd);
461
+ if (existsSync(projectPath)) paths.push(projectPath);
462
+ for (const path of paths) {
463
+ mkdirSync(dirname(path), { recursive: true });
464
+ writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`);
465
+ }
466
+ return paths;
360
467
  }
361
468
 
362
469
  function isThinkingLevel(value: unknown): value is ThinkingLevel {
@@ -467,17 +574,58 @@ export async function readModelConfigAsync(
467
574
  return result.status === "valid" ? result.config : {};
468
575
  }
469
576
 
470
- function writeModelConfig(cwd: string, config: AgentModelConfig): void {
471
- const path = modelConfigPath(cwd);
472
- mkdirSync(dirname(path), { recursive: true });
577
+ function normalizeModelConfig(value: unknown): AgentModelConfig | undefined {
578
+ if (!isRecord(value)) return undefined;
473
579
  const cleaned: AgentModelConfig = {};
474
- for (const [name, value] of Object.entries(config)) {
475
- const entry = normalizeRoutingEntry(value);
580
+ for (const [name, entryValue] of Object.entries(value)) {
581
+ if (!/^[A-Za-z0-9._:@/+%-]+$/.test(name)) continue;
582
+ const entry = normalizeRoutingEntry(entryValue);
476
583
  if (entry) cleaned[name] = entry;
477
584
  }
585
+ return cleaned;
586
+ }
587
+
588
+ function writeModelConfig(cwd: string, config: AgentModelConfig): void {
589
+ const path = modelConfigPath(cwd);
590
+ mkdirSync(dirname(path), { recursive: true });
591
+ const cleaned = normalizeModelConfig(config) ?? {};
478
592
  writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`);
479
593
  }
480
594
 
595
+ async function writeModelConfigAsync(cwd: string, config: AgentModelConfig): Promise<void> {
596
+ const path = modelConfigPath(cwd);
597
+ await mkdir(dirname(path), { recursive: true });
598
+ const cleaned = normalizeModelConfig(config) ?? {};
599
+ await writeFile(path, `${JSON.stringify(cleaned, null, 2)}\n`);
600
+ }
601
+
602
+ function parseModelExport(value: unknown): AgentModelConfig | undefined {
603
+ if (!isRecord(value)) return undefined;
604
+ if (value.kind !== MODEL_EXPORT_KIND || value.version !== MODEL_EXPORT_VERSION) return undefined;
605
+ return normalizeModelConfig(value.agents);
606
+ }
607
+
608
+ async function exportSavedModelConfig(ctx: ExtensionContext): Promise<number> {
609
+ const saved = await readSavedModelConfigAsync(ctx.cwd);
610
+ if (saved.status === "invalid") throw new Error(`Invalid model config: ${saved.path}`);
611
+ const agents = saved.status === "valid" ? saved.config : {};
612
+ const path = modelExportPath(ctx.cwd);
613
+ await mkdir(dirname(path), { recursive: true });
614
+ await writeFile(
615
+ path,
616
+ `${JSON.stringify({ kind: MODEL_EXPORT_KIND, version: MODEL_EXPORT_VERSION, agents }, null, 2)}\n`,
617
+ );
618
+ return Object.keys(agents).length;
619
+ }
620
+
621
+ async function readModelExport(ctx: ExtensionContext): Promise<AgentModelConfig | undefined> {
622
+ try {
623
+ return parseModelExport(JSON.parse(await readFile(modelExportPath(ctx.cwd), "utf8")));
624
+ } catch {
625
+ return undefined;
626
+ }
627
+ }
628
+
481
629
  function cloneModelConfig(config: AgentModelConfig): AgentModelConfig {
482
630
  return Object.fromEntries(
483
631
  Object.entries(config).map(([name, entry]) => [name, { ...entry }]),
@@ -549,8 +697,10 @@ function listAgentFilesRecursive(dir: string): string[] {
549
697
  const files: string[] = [];
550
698
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
551
699
  const path = join(dir, entry.name);
552
- if (entry.isDirectory()) files.push(...listAgentFilesRecursive(path));
553
- else if (
700
+ if (entry.isDirectory()) {
701
+ if (entry.name === "skills") continue;
702
+ files.push(...listAgentFilesRecursive(path));
703
+ } else if (
554
704
  entry.isFile() &&
555
705
  entry.name.endsWith(".md") &&
556
706
  !entry.name.endsWith(".chain.md")
@@ -572,6 +722,7 @@ async function listAgentFilesRecursiveAsync(dir: string): Promise<string[]> {
572
722
  for (const entry of entries) {
573
723
  const path = join(dir, entry.name);
574
724
  if (entry.isDirectory()) {
725
+ if (entry.name === "skills") continue;
575
726
  files.push(...(await listAgentFilesRecursiveAsync(path)));
576
727
  } else if (
577
728
  entry.isFile() &&
@@ -607,14 +758,15 @@ async function listAgentsFromDirAsync(
607
758
  }
608
759
 
609
760
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
761
+ const globalAgentDir = join(gentlePiAgentHome(), "agents");
610
762
  const builtinDirs = [
611
- join(gentlePiAgentHome(), "agents"),
612
763
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
613
764
  join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
614
765
  join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
615
766
  ];
616
767
  const agents = [
617
768
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
769
+ ...listAgentsFromDir(globalAgentDir, "user"),
618
770
  ...listAgentsFromDir(join(homedir(), ".agents"), "user"),
619
771
  ...listAgentsFromDir(join(cwd, ".agents"), "project"),
620
772
  ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
@@ -632,8 +784,8 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
632
784
  }
633
785
 
634
786
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
787
+ const globalAgentDir = join(gentlePiAgentHome(), "agents");
635
788
  const builtinDirs = [
636
- join(gentlePiAgentHome(), "agents"),
637
789
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
638
790
  join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
639
791
  join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
@@ -643,6 +795,7 @@ async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
643
795
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
644
796
  }
645
797
  const otherDirs: Array<[string, AgentSource]> = [
798
+ [globalAgentDir, "user"],
646
799
  [join(homedir(), ".agents"), "user"],
647
800
  [join(cwd, ".agents"), "project"],
648
801
  [join(cwd, ".pi", "agents"), "project"],
@@ -845,6 +998,8 @@ interface OverlayComponent {
845
998
  type ModelPanelResult =
846
999
  | { type: "save"; config: AgentModelConfig }
847
1000
  | { type: "custom"; agent: string | "all"; config: AgentModelConfig }
1001
+ | { type: "export"; config: AgentModelConfig }
1002
+ | { type: "restore"; config: AgentModelConfig }
848
1003
  | { type: "cancel" };
849
1004
 
850
1005
  const SET_ALL_AGENTS = "Set all agents";
@@ -945,6 +1100,14 @@ class SddModelPanel implements OverlayComponent {
945
1100
  this.effortCursor = 0;
946
1101
  return;
947
1102
  }
1103
+ if (matchesKey(data, "x")) {
1104
+ this.done({ type: "export", config: this.draft });
1105
+ return;
1106
+ }
1107
+ if (matchesKey(data, "r")) {
1108
+ this.done({ type: "restore", config: this.draft });
1109
+ return;
1110
+ }
948
1111
  if (matchesKey(data, "c")) {
949
1112
  const row = this.rows[this.cursor];
950
1113
  if (row === SET_ALL_AGENTS)
@@ -1121,7 +1284,7 @@ class SddModelPanel implements OverlayComponent {
1121
1284
  lines.push("");
1122
1285
  lines.push(
1123
1286
  line(
1124
- "j/k: scroll • g/G: top/bottom • enter: change model / confirm • e: effort • i: inherit • c: custom • ctrl+s: save • esc: back",
1287
+ "j/k scroll • enter model/save • e effort • i inherit • c custom • x export • r restore • ctrl+s save • esc back",
1125
1288
  ),
1126
1289
  );
1127
1290
  return lines;
@@ -1255,8 +1418,54 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1255
1418
  }
1256
1419
  let config = savedConfig.status === "valid" ? savedConfig.config : {};
1257
1420
  let result = await showSddModelPanel(ctx, config);
1258
- while (result.type === "custom") {
1421
+ while (result.type === "custom" || result.type === "export" || result.type === "restore") {
1259
1422
  config = cloneModelConfig(result.config);
1423
+ if (result.type === "export") {
1424
+ try {
1425
+ const count = await exportSavedModelConfig(ctx);
1426
+ ctx.ui.notify(`el Gentleman exported ${count} saved model routing entr${count === 1 ? "y" : "ies"} to ${modelExportPath(ctx.cwd)}.`, "info");
1427
+ } catch (error) {
1428
+ ctx.ui.notify(`Model routing export failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
1429
+ }
1430
+ result = await showSddModelPanel(ctx, config);
1431
+ continue;
1432
+ }
1433
+ if (result.type === "restore") {
1434
+ const restored = await readModelExport(ctx);
1435
+ if (!restored) {
1436
+ ctx.ui.notify(`Model routing restore failed: ${modelExportPath(ctx.cwd)} is missing or invalid.`, "warning");
1437
+ result = await showSddModelPanel(ctx, config);
1438
+ continue;
1439
+ }
1440
+ const approved = await ctx.ui.confirm("Restore saved model routing?", `Replace ${modelConfigPath(ctx.cwd)} with ${modelExportPath(ctx.cwd)}`);
1441
+ if (approved) {
1442
+ try {
1443
+ await writeModelConfigAsync(ctx.cwd, restored);
1444
+ } catch (error) {
1445
+ ctx.ui.notify(`Model routing restore failed before writing config: ${error instanceof Error ? error.message : String(error)}`, "warning");
1446
+ result = await showSddModelPanel(ctx, config);
1447
+ continue;
1448
+ }
1449
+ config = restored;
1450
+ try {
1451
+ const applyResult = await applyModelConfigAsync(ctx.cwd, restored);
1452
+ ctx.ui.notify([
1453
+ "el Gentleman restored global model config.",
1454
+ `Import: ${modelExportPath(ctx.cwd)}`,
1455
+ `Global config: ${modelConfigPath(ctx.cwd)}`,
1456
+ `Agents updated: ${applyResult.updated}`,
1457
+ ].join("\n"), "info");
1458
+ } catch (error) {
1459
+ ctx.ui.notify([
1460
+ "el Gentleman restored global model config, but applying it to agents failed.",
1461
+ `Global config: ${modelConfigPath(ctx.cwd)}`,
1462
+ `Apply error: ${error instanceof Error ? error.message : String(error)}`,
1463
+ ].join("\n"), "warning");
1464
+ }
1465
+ }
1466
+ result = await showSddModelPanel(ctx, config);
1467
+ continue;
1468
+ }
1260
1469
  const current =
1261
1470
  result.agent === "all"
1262
1471
  ? "inherit"
@@ -1319,17 +1528,26 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
1319
1528
  [...PERSONA_OPTIONS],
1320
1529
  );
1321
1530
  if (selected !== "gentleman" && selected !== "neutral") return;
1322
- writePersonaMode(ctx.cwd, selected);
1531
+ const writtenPaths = writePersonaMode(ctx.cwd, selected);
1323
1532
  ctx.ui.notify(
1324
1533
  [
1325
1534
  `el Gentleman persona set to: ${selected}`,
1326
- `Config: ${personaConfigPath(ctx.cwd)}`,
1535
+ `Global config: ${personaConfigPath(ctx.cwd)}`,
1536
+ ...(writtenPaths.length > 1
1537
+ ? [`Project override updated: ${projectPersonaConfigPath(ctx.cwd)}`]
1538
+ : []),
1327
1539
  "Run /reload or start a new Pi session for already-injected prompts to refresh.",
1328
1540
  ].join("\n"),
1329
1541
  "info",
1330
1542
  );
1331
1543
  }
1332
1544
 
1545
+ /** @internal */
1546
+ export const __testing = {
1547
+ listAgentsFromDir,
1548
+ listAgentsFromDirAsync,
1549
+ };
1550
+
1333
1551
  export default function gentleAi(pi: ExtensionAPI): void {
1334
1552
  function runSddPreflight(ctx: ExtensionContext): Promise<SddPreflightPreferences> {
1335
1553
  return ensureSddPreflight(ctx, {
@@ -1341,7 +1559,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1341
1559
 
1342
1560
  pi.on("session_start", async (_event, ctx) => {
1343
1561
  try {
1344
- const installResult = installSddAssets(ctx.cwd, false);
1562
+ const installResult = installSddAssets(ctx.cwd, true);
1345
1563
  const modelResult = await applySavedModelConfig(ctx);
1346
1564
  if (ctx.hasUI && modelResult.invalidPath) {
1347
1565
  ctx.ui.notify(
@@ -1396,6 +1614,11 @@ export default function gentleAi(pi: ExtensionAPI): void {
1396
1614
  });
1397
1615
 
1398
1616
  pi.on("tool_call", async (event, ctx) => {
1617
+ const sensitivePathDenied = evaluateSensitivePathTool(
1618
+ event.toolName,
1619
+ event.input,
1620
+ );
1621
+ if (sensitivePathDenied) return sensitivePathDenied;
1399
1622
  if (event.toolName !== "bash") return undefined;
1400
1623
  if (!isRecord(event.input) || typeof event.input.command !== "string")
1401
1624
  return undefined;
@@ -1472,6 +1695,50 @@ export default function gentleAi(pi: ExtensionAPI): void {
1472
1695
  },
1473
1696
  });
1474
1697
 
1698
+ pi.registerCommand("gentle-ai:doctor", {
1699
+ description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
1700
+ handler: async (_args, ctx) => {
1701
+ const agentsInstalled = existsSync(
1702
+ join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
1703
+ );
1704
+ const chainsInstalled = existsSync(
1705
+ join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
1706
+ );
1707
+ const openspecConfigured = existsSync(
1708
+ join(ctx.cwd, "openspec", "config.yaml"),
1709
+ );
1710
+ const skillRegistryPresent = existsSync(
1711
+ join(ctx.cwd, ".atl", "skill-registry.md"),
1712
+ );
1713
+ const staleSddAssets = sddGlobalAssetDriftCount();
1714
+ const staleLocalOverrides = sddLocalOverrideDriftCount(ctx.cwd);
1715
+ const modelConfig = await readSavedModelConfigAsync(ctx.cwd);
1716
+ const engramActive = hasWritableEngramTool(pi);
1717
+ const lines = [
1718
+ "el Gentleman doctor",
1719
+ `${agentsInstalled ? "pass" : "fail"}: Global SDD agents ${agentsInstalled ? "installed" : "missing"}`,
1720
+ `${chainsInstalled ? "pass" : "fail"}: Global SDD chains ${chainsInstalled ? "installed" : "missing"}`,
1721
+ `${staleSddAssets === 0 ? "pass" : "warn"}: Global SDD asset drift ${staleSddAssets} file(s)`,
1722
+ `${staleLocalOverrides === 0 ? "pass" : "warn"}: Project-local SDD override drift ${staleLocalOverrides} file(s)`,
1723
+ `${openspecConfigured ? "pass" : "warn"}: OpenSpec config ${openspecConfigured ? "present" : "missing"}`,
1724
+ `${skillRegistryPresent ? "pass" : "warn"}: Skill registry ${skillRegistryPresent ? "present" : "missing"}`,
1725
+ `${modelConfig.status === "invalid" ? "fail" : "pass"}: Global model config ${modelConfig.status}`,
1726
+ "pass: Sensitive-path guard active for read/write/edit tools",
1727
+ `${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`,
1728
+ ];
1729
+ if (!agentsInstalled || !chainsInstalled) {
1730
+ lines.push("remedy: run /gentle-ai:install-sdd --force to refresh global SDD assets intentionally");
1731
+ }
1732
+ if (modelConfig.status === "invalid") {
1733
+ lines.push(`remedy: fix or remove ${modelConfig.path}`);
1734
+ }
1735
+ ctx.ui.notify(
1736
+ lines.join("\n"),
1737
+ lines.some((line) => line.startsWith("fail:")) ? "warning" : "info",
1738
+ );
1739
+ },
1740
+ });
1741
+
1475
1742
  pi.registerCommand("gentle-ai:status", {
1476
1743
  description: "Show Gentle AI package status for this project.",
1477
1744
  handler: async (_args, ctx) => {
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { watch } from "node:fs";
2
+ import { existsSync, type FSWatcher, watch } from "node:fs";
3
3
  import {
4
4
  access,
5
5
  mkdir,
@@ -11,6 +11,7 @@ import {
11
11
  } from "node:fs/promises";
12
12
  import { homedir } from "node:os";
13
13
  import { basename, join, normalize, relative, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
16
 
16
17
  const REGISTRY_REL_PATH = ".atl/skill-registry.md";
@@ -26,6 +27,14 @@ const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
26
27
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
27
28
  const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
28
29
  ".pi/extensions/skill-registry.ts.disabled";
30
+ const SKILL_REGISTRY_EXTENSION_SOURCE_KEY =
31
+ "__gentlePiSkillRegistryExtensionSource";
32
+ const activeWatchers = new Set<FSWatcher>();
33
+
34
+ interface SkillRegistryExtensionGlobal {
35
+ [SKILL_REGISTRY_EXTENSION_SOURCE_KEY]?: string;
36
+ }
37
+
29
38
  async function pathExists(path: string): Promise<boolean> {
30
39
  try {
31
40
  await access(path);
@@ -54,6 +63,7 @@ function userSkillDirs(): string[] {
54
63
  join(home, ".claude/skills"),
55
64
  join(home, ".gemini/skills"),
56
65
  join(home, ".gemini/antigravity/skills"),
66
+ join(home, ".trae/skills"),
57
67
  join(home, ".cursor/skills"),
58
68
  join(home, ".copilot/skills"),
59
69
  join(home, ".codex/skills"),
@@ -70,6 +80,7 @@ function projectSkillDirs(cwd: string): string[] {
70
80
  join(cwd, ".opencode/skills"),
71
81
  join(cwd, ".claude/skills"),
72
82
  join(cwd, ".gemini/skills"),
83
+ join(cwd, ".trae/skills"),
73
84
  join(cwd, ".cursor/skills"),
74
85
  join(cwd, ".github/skills"),
75
86
  join(cwd, ".codex/skills"),
@@ -108,11 +119,12 @@ async function findSkillFiles(root: string): Promise<string[]> {
108
119
  }
109
120
 
110
121
  function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
111
- if (!source.startsWith("---\n")) return { body: source };
112
- const end = source.indexOf("\n---", 4);
113
- if (end === -1) return { body: source };
114
- const fm = source.slice(4, end);
115
- 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/, "");
116
128
  const out: { name?: string; description?: string } = {};
117
129
  const lines = fm.split("\n");
118
130
  for (let i = 0; i < lines.length; i++) {
@@ -415,6 +427,52 @@ function shouldSkipSkillRegistryStartup(
415
427
  );
416
428
  }
417
429
 
430
+ function normalizeExtensionSource(source: string): string {
431
+ return source.split(/[?#]/, 1)[0];
432
+ }
433
+
434
+ function extensionSourcePath(source: string): string | undefined {
435
+ const cleanSource = normalizeExtensionSource(source);
436
+ if (!cleanSource.startsWith("file:")) return undefined;
437
+ try {
438
+ return comparablePath(fileURLToPath(cleanSource));
439
+ } catch {
440
+ return undefined;
441
+ }
442
+ }
443
+
444
+ function shouldSkipDuplicateExtensionLoad(
445
+ source = import.meta.url,
446
+ cwd = process.cwd(),
447
+ state = globalThis as typeof globalThis & SkillRegistryExtensionGlobal,
448
+ ): boolean {
449
+ const currentPath = extensionSourcePath(source);
450
+ const projectLocalPath = comparablePath(join(cwd, "extensions", "skill-registry.ts"));
451
+ if (currentPath && currentPath !== projectLocalPath && existsSync(projectLocalPath)) {
452
+ return true;
453
+ }
454
+
455
+ const currentSource = currentPath ?? normalizeExtensionSource(source);
456
+ const existingSource = state[SKILL_REGISTRY_EXTENSION_SOURCE_KEY];
457
+ if (!existingSource) {
458
+ state[SKILL_REGISTRY_EXTENSION_SOURCE_KEY] = currentSource;
459
+ return false;
460
+ }
461
+ return existingSource !== currentSource;
462
+ }
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
+
418
476
  async function startSkillRegistryWatcher(
419
477
  cwd: string,
420
478
  notify: (message: string) => void,
@@ -443,7 +501,8 @@ async function startSkillRegistryWatcher(
443
501
  };
444
502
  for (const dir of dirs) {
445
503
  try {
446
- watch(dir, { recursive: true }, refresh);
504
+ const watcher = watch(dir, { recursive: true }, refresh);
505
+ activeWatchers.add(watcher);
447
506
  } catch {
448
507
  // Some filesystems do not support recursive watches; session_start/manual refresh still work.
449
508
  }
@@ -460,9 +519,21 @@ export const __testing = {
460
519
  parseFrontmatter,
461
520
  renderRegistry,
462
521
  shouldSkipSkillRegistryStartup,
522
+ shouldSkipDuplicateExtensionLoad,
523
+ startSkillRegistryWatcher,
524
+ closeSkillRegistryWatchers,
525
+ activeWatcherCount() {
526
+ return activeWatchers.size;
527
+ },
463
528
  };
464
529
 
465
530
  export default function (pi: ExtensionAPI) {
531
+ if (shouldSkipDuplicateExtensionLoad()) return;
532
+
533
+ pi.on("session_shutdown", () => {
534
+ closeSkillRegistryWatchers();
535
+ });
536
+
466
537
  pi.registerFlag(NO_SKILL_REGISTRY_FLAG, {
467
538
  description: "Skip the Gentle AI skill registry refresh and watcher on startup.",
468
539
  type: "boolean",
@@ -487,9 +558,11 @@ export default function (pi: ExtensionAPI) {
487
558
  "warning",
488
559
  );
489
560
  }
490
- await startSkillRegistryWatcher(ctx.cwd, (message) => {
491
- if (ctx.hasUI) ctx.ui.notify(message, "info");
492
- });
561
+ if (ctx.hasUI) {
562
+ await startSkillRegistryWatcher(ctx.cwd, (message) => {
563
+ ctx.ui.notify(message, "info");
564
+ });
565
+ }
493
566
  if (quarantinedLegacy) {
494
567
  setTimeout(() => {
495
568
  void (async () => {