gentle-pi 0.3.10 → 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 +11 -0
- package/extensions/gentle-ai.ts +295 -28
- package/extensions/skill-registry.ts +38 -10
- package/extensions/startup-banner.ts +189 -25
- package/package.json +1 -1
- package/scripts/verify-package-files.mjs +1 -0
- package/skills/comment-writer/SKILL.md +1 -1
- package/tests/gentle-ai.test.ts +36 -0
- package/tests/runtime-harness.mjs +161 -5
- package/tests/skill-registry.test.ts +32 -0
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
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -53,10 +53,17 @@ function sddGlobalAssetDriftCount(): number {
|
|
|
53
53
|
stale += 1;
|
|
54
54
|
continue;
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
431
|
+
function projectPersonaConfigPath(cwd: string): string {
|
|
341
432
|
return join(cwd, ".pi", "gentle-ai", "persona.json");
|
|
342
433
|
}
|
|
343
434
|
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
443
|
+
if (!isRecord(parsed)) return undefined;
|
|
350
444
|
return parsed.mode === "neutral" ? "neutral" : "gentleman";
|
|
351
445
|
} catch {
|
|
352
|
-
return
|
|
446
|
+
return undefined;
|
|
353
447
|
}
|
|
354
448
|
}
|
|
355
449
|
|
|
356
|
-
function
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
471
|
-
|
|
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,
|
|
475
|
-
|
|
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())
|
|
553
|
-
|
|
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
|
|
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
|
-
`
|
|
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,
|
|
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 { 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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
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
|
-
|
|
536
|
-
|
|
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(
|
|
659
|
+
join(PI_AGENT_DIR, "settings.json"),
|
|
498
660
|
"utf8",
|
|
499
661
|
);
|
|
500
662
|
const cfg = JSON.parse(raw);
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
["
|
|
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
|
-
`${
|
|
917
|
+
`${sddAgentsCount} phases`,
|
|
756
918
|
"EXTENSIONS:",
|
|
757
919
|
`${extensionsCount} active`,
|
|
758
920
|
);
|
|
759
921
|
addWideRow(
|
|
760
|
-
"
|
|
761
|
-
|
|
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:", `${
|
|
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(
|
|
838
|
-
const gBase = Math.floor(
|
|
839
|
-
const bBase = Math.floor(
|
|
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
|
-
?
|
|
878
|
-
:
|
|
1041
|
+
? paletteColor(bannerConfig.color, "logoDim", cell.char)
|
|
1042
|
+
: paletteColor(bannerConfig.color, "logoFresh", cell.char);
|
|
879
1043
|
} else {
|
|
880
1044
|
line += cell.char === "▒"
|
|
881
|
-
?
|
|
882
|
-
:
|
|
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 +=
|
|
1053
|
+
line += paletteColor(bannerConfig.color, "label", cell.char);
|
|
890
1054
|
break;
|
|
891
1055
|
case "value":
|
|
892
|
-
line +=
|
|
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
|
+
"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",
|
|
@@ -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.
|
|
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(
|
|
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
|
|
346
|
-
|
|
347
|
-
|
|
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 });
|
|
@@ -14,6 +14,7 @@ test("project skill dirs include supported workspace roots", () => {
|
|
|
14
14
|
".opencode/skills",
|
|
15
15
|
".claude/skills",
|
|
16
16
|
".gemini/skills",
|
|
17
|
+
".trae/skills",
|
|
17
18
|
".cursor/skills",
|
|
18
19
|
".github/skills",
|
|
19
20
|
".codex/skills",
|
|
@@ -50,6 +51,17 @@ test("registry renders indexed skill paths instead of compact rules", () => {
|
|
|
50
51
|
assert.doesNotMatch(registry, /Rules:/);
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
test("frontmatter parser accepts CRLF line endings", () => {
|
|
55
|
+
const parsed = __testing.parseFrontmatter("---\r\nname: windows-skill\r\ndescription: >\r\n Trigger: Windows-authored skills.\r\n Preserve frontmatter metadata.\r\n---\r\n\r\n## Body\r\n");
|
|
56
|
+
|
|
57
|
+
assert.equal(parsed.name, "windows-skill");
|
|
58
|
+
assert.equal(
|
|
59
|
+
parsed.description,
|
|
60
|
+
"Trigger: Windows-authored skills. Preserve frontmatter metadata.",
|
|
61
|
+
);
|
|
62
|
+
assert.match(parsed.body, /## Body/);
|
|
63
|
+
});
|
|
64
|
+
|
|
53
65
|
test("frontmatter parser keeps full multiline descriptions", () => {
|
|
54
66
|
const parsed = __testing.parseFrontmatter(`---
|
|
55
67
|
name: ai-sdk-5
|
|
@@ -102,6 +114,26 @@ test("uniqueExistingDirs normalizes duplicates and ignores missing roots", async
|
|
|
102
114
|
);
|
|
103
115
|
});
|
|
104
116
|
|
|
117
|
+
test("skill registry watchers close on shutdown", async () => {
|
|
118
|
+
const root = join(tmpdir(), `gentle-pi-watchers-${Date.now()}`);
|
|
119
|
+
const skillPath = join(root, "skills", "docs", "SKILL.md");
|
|
120
|
+
mkdirSync(dirname(skillPath), { recursive: true });
|
|
121
|
+
writeFileSync(skillPath, "---\nname: docs\ndescription: Docs.\n---\n");
|
|
122
|
+
|
|
123
|
+
await __testing.startSkillRegistryWatcher(root, () => undefined);
|
|
124
|
+
const attempted = __testing.activeWatcherCount();
|
|
125
|
+
__testing.closeSkillRegistryWatchers();
|
|
126
|
+
assert.equal(__testing.activeWatcherCount(), 0);
|
|
127
|
+
|
|
128
|
+
await __testing.startSkillRegistryWatcher(root, () => undefined);
|
|
129
|
+
assert.equal(
|
|
130
|
+
__testing.activeWatcherCount(),
|
|
131
|
+
attempted,
|
|
132
|
+
"shutdown must clear watched cwd state so a later session can re-watch",
|
|
133
|
+
);
|
|
134
|
+
__testing.closeSkillRegistryWatchers();
|
|
135
|
+
});
|
|
136
|
+
|
|
105
137
|
test("startup skip honors no skill registry controls", () => {
|
|
106
138
|
const enabled = { getFlag: () => true };
|
|
107
139
|
const disabled = { getFlag: () => false };
|