gentle-pi 0.3.10 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -11
- package/assets/orchestrator.md +3 -1
- 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 +10 -0
- package/skills/comment-writer/SKILL.md +7 -7
- package/tests/artifact-language.test.ts +40 -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
|
@@ -45,10 +45,10 @@ Most coding-agent sessions fail for operational reasons, not model reasons:
|
|
|
45
45
|
|
|
46
46
|
| Capability | What it does |
|
|
47
47
|
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
48
|
-
| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default.
|
|
49
|
-
| **
|
|
48
|
+
| **el Gentleman persona** | Makes Pi behave like a senior architect and teacher, not a generic chatbot. Spanish responses use Rioplatense voseo by default; neutral mode is saved globally with project overrides. |
|
|
49
|
+
| **Configurable startup intro** | Adds a rose/text-logo startup intro, compact runtime panel, color presets, and commands to hide or show the decorative parts. |
|
|
50
50
|
| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. |
|
|
51
|
-
| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, and `archive`.
|
|
51
|
+
| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `onboard`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, `sync`, and `archive`. |
|
|
52
52
|
| **Lazy SDD preflight** | Asks once per session for SDD mode, artifact store, PR chaining strategy, and review budget before the first SDD flow. |
|
|
53
53
|
| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. |
|
|
54
54
|
| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. |
|
|
@@ -56,7 +56,7 @@ Most coding-agent sessions fail for operational reasons, not model reasons:
|
|
|
56
56
|
| **Per-agent model assignment** | Pi-native modal for assigning stronger or cheaper models to specific SDD/custom agents. |
|
|
57
57
|
| **Skill discovery registry** | Maintains `.atl/skill-registry.md` from project and user skills so review/comment/PR workflows do not silently miss the right skill. |
|
|
58
58
|
| **Delivery skills** | Includes issue-first PRs, chained PRs, work-unit commits, cognitive docs, comment writing, and Judgment Day review. |
|
|
59
|
-
| **
|
|
59
|
+
| **Runtime safety** | Blocks destructive shell commands, asks for confirmation for sensitive operations, and blocks direct read/write/edit access to sensitive paths. |
|
|
60
60
|
|
|
61
61
|
## Install
|
|
62
62
|
|
|
@@ -88,10 +88,12 @@ pi
|
|
|
88
88
|
|
|
89
89
|
```text
|
|
90
90
|
/gentle-ai:status Check package, SDD assets, OpenSpec, and global model config.
|
|
91
|
+
/gentle-ai:doctor Run read-only diagnostics for SDD assets, config, tools, and guards.
|
|
91
92
|
/gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
|
|
92
93
|
/sdd-init Create or refresh openspec/config.yaml.
|
|
93
94
|
/gentle:models Assign global model/effort routing to SDD/custom agents.
|
|
94
95
|
/gentle:persona Switch between gentleman and neutral persona modes.
|
|
96
|
+
/gentle:banner Configure startup rose, text logo, and color preset.
|
|
95
97
|
```
|
|
96
98
|
|
|
97
99
|
Typical flow:
|
|
@@ -315,13 +317,19 @@ Delegation contract:
|
|
|
315
317
|
| `gentleman` | Senior architect, teacher, direct technical feedback, Rioplatense Spanish/voseo when the user writes Spanish. |
|
|
316
318
|
| `neutral` | Same discipline, warmer professional language, no regional expression. |
|
|
317
319
|
|
|
318
|
-
Saved at:
|
|
320
|
+
Saved globally at:
|
|
321
|
+
|
|
322
|
+
```text
|
|
323
|
+
~/.pi/gentle-ai/persona.json
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
A project can still override the global default with:
|
|
319
327
|
|
|
320
328
|
```text
|
|
321
329
|
.pi/gentle-ai/persona.json
|
|
322
330
|
```
|
|
323
331
|
|
|
324
|
-
Run `/reload` or start a new Pi session after switching persona.
|
|
332
|
+
`/gentle:persona` writes the global config and updates an existing project override when one is present, so the current project does not stay stale. Run `/reload` or start a new Pi session after switching persona.
|
|
325
333
|
|
|
326
334
|
## Model and effort assignment
|
|
327
335
|
|
|
@@ -353,6 +361,8 @@ Saved globally at:
|
|
|
353
361
|
|
|
354
362
|
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
363
|
|
|
364
|
+
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.
|
|
365
|
+
|
|
356
366
|
Config shape (per agent):
|
|
357
367
|
|
|
358
368
|
```json
|
|
@@ -374,13 +384,22 @@ Legacy string entries are still accepted and treated as `model`-only config.
|
|
|
374
384
|
| Command | What it does |
|
|
375
385
|
| -------------------------------- | ------------------------------------------------------------------- |
|
|
376
386
|
| `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and global model config status. |
|
|
377
|
-
| `/gentle:
|
|
378
|
-
| `/gentle:
|
|
387
|
+
| `/gentle-ai:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. |
|
|
388
|
+
| `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. |
|
|
389
|
+
| `/gentle:persona` | Switches global persona mode, with project override support. |
|
|
390
|
+
| `/gentle:banner` | Configures startup banner rose, text logo, and color preset. |
|
|
391
|
+
| `/gentle:toggle-rose` | Toggles the startup rose. |
|
|
392
|
+
| `/gentle:toggle-text-logo` | Toggles the startup text logo. |
|
|
393
|
+
| `/gentle:banner-color` | Selects a startup banner color preset. |
|
|
379
394
|
| `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
|
|
380
395
|
| `/gentle-ai:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. |
|
|
381
396
|
| `/gentle-ai:install-sdd --force` | Force-refreshes installed global SDD assets. |
|
|
382
397
|
| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
|
|
383
398
|
|
|
399
|
+
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.
|
|
400
|
+
|
|
401
|
+
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.
|
|
402
|
+
|
|
384
403
|
Startup flag:
|
|
385
404
|
|
|
386
405
|
```text
|
|
@@ -396,6 +415,10 @@ Compatibility aliases:
|
|
|
396
415
|
/gentleman:models
|
|
397
416
|
/gentle-ai:persona
|
|
398
417
|
/gentleman:persona
|
|
418
|
+
/gentle-ai:banner
|
|
419
|
+
/gentle-ai:toggle-rose
|
|
420
|
+
/gentle-ai:toggle-text-logo
|
|
421
|
+
/gentle-ai:banner-color
|
|
399
422
|
```
|
|
400
423
|
|
|
401
424
|
## Included skills
|
|
@@ -432,10 +455,10 @@ Memory contract for SDD delegation:
|
|
|
432
455
|
|
|
433
456
|
| Path | Purpose |
|
|
434
457
|
| ------------------------------ | ---------------------------------------------------------------------------------------------------------- |
|
|
435
|
-
| `extensions/gentle-ai.ts` | Injects identity,
|
|
436
|
-
| `extensions/startup-banner.ts` | Shows the
|
|
458
|
+
| `extensions/gentle-ai.ts` | Injects identity, auto-refreshes global SDD assets, registers commands, applies model/persona config, exports/restores model routing, and enforces runtime safety. |
|
|
459
|
+
| `extensions/startup-banner.ts` | Shows and configures the startup intro, color presets, compact runtime panel, and collaboration credit. |
|
|
437
460
|
| `extensions/sdd-init.ts` | Registers `/sdd-init` for OpenSpec initialization. |
|
|
438
|
-
| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills.
|
|
461
|
+
| `extensions/skill-registry.ts` | Maintains `.atl/skill-registry.md` from project/user skills and closes file watchers on shutdown. |
|
|
439
462
|
| `assets/orchestrator.md` | Parent-session orchestration contract. |
|
|
440
463
|
| `assets/agents/` | SDD agents installed as global Pi runtime assets. |
|
|
441
464
|
| `assets/chains/` | SDD chains installed as global Pi runtime assets. |
|
package/assets/orchestrator.md
CHANGED
|
@@ -31,7 +31,9 @@ User-facing conversation should stay in the user's language and follow the curre
|
|
|
31
31
|
|
|
32
32
|
Subagent-facing prompts should be written in English by default, even when the user speaks Spanish. Translate the user's request into concise English before delegation. This keeps token usage lower and gives built-in/project subagents a consistent operating language without changing the user-facing persona.
|
|
33
33
|
|
|
34
|
-
Generated artifacts — whether by the parent inline or by subagents — (code, UI copy,
|
|
34
|
+
Generated technical artifacts — whether by the parent inline or by subagents — (code, code comments, UI copy, identifiers, commit messages, filenames, PR descriptions, tests, fixtures, SDD/OpenSpec files, delegated phase outputs, and repository-facing documentation) default to English, regardless of the user's conversation language or active persona. Override only when the user explicitly requests another language for that artifact, or when extending a project whose existing convention is non-English.
|
|
35
|
+
|
|
36
|
+
Public/contextual comments and replies are different from technical artifacts. When using `comment-writer` or drafting a human-facing GitHub, PR review, Slack, Discord, or async comment, write in the target context language by default. Spanish issue/thread -> Spanish comment. English thread -> English comment. Mixed context -> target message language. Explicit user language or tone override wins. Spanish comments default to neutral/professional Spanish unless the user or target context clearly calls for regional tone.
|
|
35
37
|
|
|
36
38
|
Exceptions:
|
|
37
39
|
|
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) => {
|