gentle-pi 0.3.0 → 0.3.2
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 +22 -18
- package/extensions/gentle-ai.ts +305 -27
- package/extensions/sdd-init.ts +2 -2
- package/extensions/skill-registry.ts +120 -77
- package/extensions/startup-banner.ts +231 -102
- package/lib/sdd-preflight.ts +30 -11
- package/package.json +1 -1
- package/tests/runtime-harness.mjs +113 -6
- package/tests/skill-registry.test.ts +5 -2
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ Most coding-agent sessions fail for operational reasons, not model reasons:
|
|
|
49
49
|
| **Rose startup intro** | Adds a pink rose fade-in, compact project/runtime panel, and visible startup collaboration credit for @aporcelli's `pi-gentle-startup` ideas. |
|
|
50
50
|
| **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. |
|
|
51
51
|
| **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, and `archive`. |
|
|
52
|
+
| **Lazy SDD preflight** | Asks once per session for SDD mode, artifact store, PR chaining strategy, and review budget before the first SDD flow. |
|
|
52
53
|
| **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. |
|
|
53
54
|
| **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. |
|
|
54
55
|
| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. |
|
|
@@ -81,23 +82,24 @@ Then start Pi in a project:
|
|
|
81
82
|
pi
|
|
82
83
|
```
|
|
83
84
|
|
|
84
|
-
`gentle-pi` waits until the first SDD
|
|
85
|
+
`gentle-pi` waits until the first SDD flow in a session, then runs a one-time SDD preflight and installs local SDD assets without overwriting your edits. Slash SDD commands trigger this automatically; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first.
|
|
85
86
|
|
|
86
87
|
## Quick start
|
|
87
88
|
|
|
88
89
|
```text
|
|
89
|
-
/gentle-ai:status
|
|
90
|
-
/sdd-
|
|
91
|
-
/
|
|
92
|
-
/gentle:
|
|
90
|
+
/gentle-ai:status Check package, SDD assets, OpenSpec, and global model config.
|
|
91
|
+
/gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
|
|
92
|
+
/sdd-init Create or refresh openspec/config.yaml.
|
|
93
|
+
/gentle:models Assign global model/effort routing to SDD/custom agents.
|
|
94
|
+
/gentle:persona Switch between gentleman and neutral persona modes.
|
|
93
95
|
```
|
|
94
96
|
|
|
95
97
|
Typical flow:
|
|
96
98
|
|
|
97
99
|
1. Open Pi in your repo.
|
|
98
100
|
2. Run `/gentle-ai:status`.
|
|
99
|
-
3. Run `/sdd-init` once per project, or when test/project capabilities change.
|
|
100
|
-
4. For a substantial change, ask Pi to use SDD.
|
|
101
|
+
3. Run `/sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight.
|
|
102
|
+
4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes.
|
|
101
103
|
5. Review the phase artifacts instead of trusting floating chat context.
|
|
102
104
|
|
|
103
105
|
## How the harness decides what to do
|
|
@@ -282,12 +284,14 @@ Recommended model/effort shape:
|
|
|
282
284
|
| Verify / review | Strong fresh-context model. | `high` |
|
|
283
285
|
| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` |
|
|
284
286
|
|
|
285
|
-
Saved at:
|
|
287
|
+
Saved globally at:
|
|
286
288
|
|
|
287
289
|
```text
|
|
288
|
-
|
|
290
|
+
~/.pi/gentle-ai/models.json
|
|
289
291
|
```
|
|
290
292
|
|
|
293
|
+
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.
|
|
294
|
+
|
|
291
295
|
Config shape (per agent):
|
|
292
296
|
|
|
293
297
|
```json
|
|
@@ -306,15 +310,15 @@ Legacy string entries are still accepted and treated as `model`-only config.
|
|
|
306
310
|
|
|
307
311
|
## Commands
|
|
308
312
|
|
|
309
|
-
| Command | What it does
|
|
310
|
-
| -------------------------------- |
|
|
311
|
-
| `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and model config status. |
|
|
312
|
-
| `/gentle:models` | Opens model + effort assignment UI. |
|
|
313
|
-
| `/gentle:persona` | Switches persona mode.
|
|
314
|
-
| `/sdd-init` | Initializes or refreshes `openspec/config.yaml`.
|
|
315
|
-
| `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files.
|
|
316
|
-
| `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets.
|
|
317
|
-
| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`.
|
|
313
|
+
| Command | What it does |
|
|
314
|
+
| -------------------------------- | ------------------------------------------------------------------- |
|
|
315
|
+
| `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and global model config status. |
|
|
316
|
+
| `/gentle:models` | Opens global model + effort assignment UI. |
|
|
317
|
+
| `/gentle:persona` | Switches persona mode. |
|
|
318
|
+
| `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
|
|
319
|
+
| `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files. |
|
|
320
|
+
| `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets. |
|
|
321
|
+
| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
|
|
318
322
|
|
|
319
323
|
Startup flag:
|
|
320
324
|
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -5,6 +5,13 @@ import {
|
|
|
5
5
|
readFileSync,
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from "node:fs";
|
|
8
|
+
import {
|
|
9
|
+
access,
|
|
10
|
+
mkdir,
|
|
11
|
+
readFile,
|
|
12
|
+
readdir,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
8
15
|
import { homedir } from "node:os";
|
|
9
16
|
import { dirname, join } from "node:path";
|
|
10
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -25,10 +32,26 @@ import {
|
|
|
25
32
|
|
|
26
33
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
27
34
|
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
35
|
+
|
|
36
|
+
let orchestratorPromptCache: string | null = null;
|
|
37
|
+
function getOrchestratorPrompt(): string {
|
|
38
|
+
if (orchestratorPromptCache === null) {
|
|
39
|
+
orchestratorPromptCache = readFileSync(
|
|
40
|
+
join(ASSETS_DIR, "orchestrator.md"),
|
|
41
|
+
"utf8",
|
|
42
|
+
).trim();
|
|
43
|
+
}
|
|
44
|
+
return orchestratorPromptCache;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
48
|
+
try {
|
|
49
|
+
await access(path);
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
32
55
|
|
|
33
56
|
type PersonaMode = "gentleman" | "neutral";
|
|
34
57
|
|
|
@@ -76,7 +99,7 @@ Harness principles:
|
|
|
76
99
|
- Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
|
|
77
100
|
- Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
|
|
78
101
|
|
|
79
|
-
${
|
|
102
|
+
${getOrchestratorPrompt()}`;
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
const DENIED_BASH_PATTERNS: RegExp[] = [
|
|
@@ -115,6 +138,10 @@ interface AgentRoutingEntry {
|
|
|
115
138
|
thinking?: ThinkingLevel;
|
|
116
139
|
}
|
|
117
140
|
type AgentModelConfig = Record<string, AgentRoutingEntry>;
|
|
141
|
+
type ModelConfigFileResult =
|
|
142
|
+
| { status: "missing" }
|
|
143
|
+
| { status: "invalid"; path: string }
|
|
144
|
+
| { status: "valid"; config: AgentModelConfig };
|
|
118
145
|
type AgentSource = "project" | "user" | "builtin";
|
|
119
146
|
|
|
120
147
|
interface AgentEntry {
|
|
@@ -194,7 +221,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
194
221
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
195
222
|
}
|
|
196
223
|
|
|
197
|
-
function
|
|
224
|
+
function gentleAiConfigHome(): string {
|
|
225
|
+
return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai");
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function modelConfigPath(_cwd: string): string {
|
|
229
|
+
return join(gentleAiConfigHome(), "models.json");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function legacyProjectModelConfigPath(cwd: string): string {
|
|
198
233
|
return join(cwd, ".pi", "gentle-ai", "models.json");
|
|
199
234
|
}
|
|
200
235
|
|
|
@@ -246,23 +281,72 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
|
|
|
246
281
|
return { model, thinking };
|
|
247
282
|
}
|
|
248
283
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (!existsSync(path)) return {};
|
|
284
|
+
function readModelConfigFile(path: string): ModelConfigFileResult {
|
|
285
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
252
286
|
try {
|
|
253
287
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
254
|
-
if (!isRecord(parsed)) return {};
|
|
288
|
+
if (!isRecord(parsed)) return { status: "invalid", path };
|
|
289
|
+
const config: AgentModelConfig = {};
|
|
290
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
291
|
+
const entry = normalizeRoutingEntry(value);
|
|
292
|
+
if (entry) config[name] = entry;
|
|
293
|
+
}
|
|
294
|
+
return { status: "valid", config };
|
|
295
|
+
} catch {
|
|
296
|
+
return { status: "invalid", path };
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function readModelConfigFileAsync(
|
|
301
|
+
path: string,
|
|
302
|
+
): Promise<ModelConfigFileResult> {
|
|
303
|
+
if (!(await pathExists(path))) return { status: "missing" };
|
|
304
|
+
try {
|
|
305
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
306
|
+
if (!isRecord(parsed)) return { status: "invalid", path };
|
|
255
307
|
const config: AgentModelConfig = {};
|
|
256
308
|
for (const [name, value] of Object.entries(parsed)) {
|
|
257
309
|
const entry = normalizeRoutingEntry(value);
|
|
258
310
|
if (entry) config[name] = entry;
|
|
259
311
|
}
|
|
260
|
-
return config;
|
|
312
|
+
return { status: "valid", config };
|
|
261
313
|
} catch {
|
|
262
|
-
return {};
|
|
314
|
+
return { status: "invalid", path };
|
|
263
315
|
}
|
|
264
316
|
}
|
|
265
317
|
|
|
318
|
+
function readSavedModelConfig(cwd: string): ModelConfigFileResult {
|
|
319
|
+
const globalResult = readModelConfigFile(modelConfigPath(cwd));
|
|
320
|
+
if (globalResult.status !== "missing") return globalResult;
|
|
321
|
+
const legacyResult = readModelConfigFile(legacyProjectModelConfigPath(cwd));
|
|
322
|
+
if (legacyResult.status === "invalid") return { status: "valid", config: {} };
|
|
323
|
+
return legacyResult;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function readSavedModelConfigAsync(
|
|
327
|
+
cwd: string,
|
|
328
|
+
): Promise<ModelConfigFileResult> {
|
|
329
|
+
const globalResult = await readModelConfigFileAsync(modelConfigPath(cwd));
|
|
330
|
+
if (globalResult.status !== "missing") return globalResult;
|
|
331
|
+
const legacyResult = await readModelConfigFileAsync(
|
|
332
|
+
legacyProjectModelConfigPath(cwd),
|
|
333
|
+
);
|
|
334
|
+
if (legacyResult.status === "invalid") return { status: "valid", config: {} };
|
|
335
|
+
return legacyResult;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function readModelConfig(cwd: string): AgentModelConfig {
|
|
339
|
+
const result = readSavedModelConfig(cwd);
|
|
340
|
+
return result.status === "valid" ? result.config : {};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function readModelConfigAsync(
|
|
344
|
+
cwd: string,
|
|
345
|
+
): Promise<AgentModelConfig> {
|
|
346
|
+
const result = await readSavedModelConfigAsync(cwd);
|
|
347
|
+
return result.status === "valid" ? result.config : {};
|
|
348
|
+
}
|
|
349
|
+
|
|
266
350
|
function writeModelConfig(cwd: string, config: AgentModelConfig): void {
|
|
267
351
|
const path = modelConfigPath(cwd);
|
|
268
352
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -323,6 +407,23 @@ function parseAgentName(filePath: string): string | undefined {
|
|
|
323
407
|
return packageName ? `${packageName}.${name}` : name;
|
|
324
408
|
}
|
|
325
409
|
|
|
410
|
+
async function parseAgentNameAsync(
|
|
411
|
+
filePath: string,
|
|
412
|
+
): Promise<string | undefined> {
|
|
413
|
+
let content: string;
|
|
414
|
+
try {
|
|
415
|
+
content = await readFile(filePath, "utf8");
|
|
416
|
+
} catch {
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim();
|
|
420
|
+
if (!name) return undefined;
|
|
421
|
+
const packageName = content
|
|
422
|
+
.match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]
|
|
423
|
+
?.trim();
|
|
424
|
+
return packageName ? `${packageName}.${name}` : name;
|
|
425
|
+
}
|
|
426
|
+
|
|
326
427
|
function listAgentFilesRecursive(dir: string): string[] {
|
|
327
428
|
if (!existsSync(dir)) return [];
|
|
328
429
|
const files: string[] = [];
|
|
@@ -339,6 +440,30 @@ function listAgentFilesRecursive(dir: string): string[] {
|
|
|
339
440
|
return files;
|
|
340
441
|
}
|
|
341
442
|
|
|
443
|
+
async function listAgentFilesRecursiveAsync(dir: string): Promise<string[]> {
|
|
444
|
+
if (!(await pathExists(dir))) return [];
|
|
445
|
+
const files: string[] = [];
|
|
446
|
+
let entries;
|
|
447
|
+
try {
|
|
448
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
449
|
+
} catch {
|
|
450
|
+
return files;
|
|
451
|
+
}
|
|
452
|
+
for (const entry of entries) {
|
|
453
|
+
const path = join(dir, entry.name);
|
|
454
|
+
if (entry.isDirectory()) {
|
|
455
|
+
files.push(...(await listAgentFilesRecursiveAsync(path)));
|
|
456
|
+
} else if (
|
|
457
|
+
entry.isFile() &&
|
|
458
|
+
entry.name.endsWith(".md") &&
|
|
459
|
+
!entry.name.endsWith(".chain.md")
|
|
460
|
+
) {
|
|
461
|
+
files.push(path);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return files;
|
|
465
|
+
}
|
|
466
|
+
|
|
342
467
|
function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
|
|
343
468
|
return listAgentFilesRecursive(dir)
|
|
344
469
|
.map((filePath): AgentEntry | undefined => {
|
|
@@ -348,6 +473,19 @@ function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
|
|
|
348
473
|
.filter((entry): entry is AgentEntry => entry !== undefined);
|
|
349
474
|
}
|
|
350
475
|
|
|
476
|
+
async function listAgentsFromDirAsync(
|
|
477
|
+
dir: string,
|
|
478
|
+
source: AgentSource,
|
|
479
|
+
): Promise<AgentEntry[]> {
|
|
480
|
+
const filePaths = await listAgentFilesRecursiveAsync(dir);
|
|
481
|
+
const entries: AgentEntry[] = [];
|
|
482
|
+
for (const filePath of filePaths) {
|
|
483
|
+
const name = await parseAgentNameAsync(filePath);
|
|
484
|
+
if (name) entries.push({ name, source, filePath });
|
|
485
|
+
}
|
|
486
|
+
return entries;
|
|
487
|
+
}
|
|
488
|
+
|
|
351
489
|
function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
352
490
|
const builtinDirs = [
|
|
353
491
|
join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
|
|
@@ -373,6 +511,37 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
|
373
511
|
return [...sddFirst, ...rest];
|
|
374
512
|
}
|
|
375
513
|
|
|
514
|
+
async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
|
|
515
|
+
const builtinDirs = [
|
|
516
|
+
join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
|
|
517
|
+
join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
|
|
518
|
+
join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
|
|
519
|
+
];
|
|
520
|
+
const agents: AgentEntry[] = [];
|
|
521
|
+
for (const dir of builtinDirs) {
|
|
522
|
+
agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
|
|
523
|
+
}
|
|
524
|
+
const otherDirs: Array<[string, AgentSource]> = [
|
|
525
|
+
[join(homedir(), ".pi", "agent", "agents"), "user"],
|
|
526
|
+
[join(homedir(), ".agents"), "user"],
|
|
527
|
+
[join(cwd, ".agents"), "project"],
|
|
528
|
+
[join(cwd, ".pi", "agents"), "project"],
|
|
529
|
+
];
|
|
530
|
+
for (const [dir, source] of otherDirs) {
|
|
531
|
+
agents.push(...(await listAgentsFromDirAsync(dir, source)));
|
|
532
|
+
}
|
|
533
|
+
const byName = new Map<string, AgentEntry>();
|
|
534
|
+
for (const agent of agents) byName.set(agent.name, agent);
|
|
535
|
+
const discovered = Array.from(byName.values());
|
|
536
|
+
const sddFirst = SDD_AGENT_NAMES.map((name) =>
|
|
537
|
+
discovered.find((agent) => agent.name === name),
|
|
538
|
+
).filter((agent): agent is AgentEntry => agent !== undefined);
|
|
539
|
+
const rest = discovered
|
|
540
|
+
.filter((agent) => !SDD_AGENT_NAMES.includes(agent.name as SddAgentName))
|
|
541
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
542
|
+
return [...sddFirst, ...rest];
|
|
543
|
+
}
|
|
544
|
+
|
|
376
545
|
function projectSettingsPath(cwd: string): string {
|
|
377
546
|
return join(cwd, ".pi", "settings.json");
|
|
378
547
|
}
|
|
@@ -417,6 +586,46 @@ function updateBuiltinModelOverride(
|
|
|
417
586
|
return true;
|
|
418
587
|
}
|
|
419
588
|
|
|
589
|
+
async function updateBuiltinModelOverrideAsync(
|
|
590
|
+
cwd: string,
|
|
591
|
+
name: string,
|
|
592
|
+
entry: AgentRoutingEntry | undefined,
|
|
593
|
+
): Promise<boolean> {
|
|
594
|
+
const path = projectSettingsPath(cwd);
|
|
595
|
+
let settings: Record<string, unknown> = {};
|
|
596
|
+
if (await pathExists(path)) {
|
|
597
|
+
try {
|
|
598
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
599
|
+
if (isRecord(parsed)) settings = parsed;
|
|
600
|
+
} catch {
|
|
601
|
+
settings = {};
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const subagents = isRecord(settings.subagents)
|
|
605
|
+
? { ...settings.subagents }
|
|
606
|
+
: {};
|
|
607
|
+
const agentOverrides = isRecord(subagents.agentOverrides)
|
|
608
|
+
? { ...subagents.agentOverrides }
|
|
609
|
+
: {};
|
|
610
|
+
const current = isRecord(agentOverrides[name])
|
|
611
|
+
? { ...agentOverrides[name] }
|
|
612
|
+
: {};
|
|
613
|
+
if (entry?.model === undefined) delete current.model;
|
|
614
|
+
else current.model = entry.model;
|
|
615
|
+
if (entry?.thinking === undefined) delete current.thinking;
|
|
616
|
+
else current.thinking = entry.thinking;
|
|
617
|
+
if (Object.keys(current).length > 0) agentOverrides[name] = current;
|
|
618
|
+
else delete agentOverrides[name];
|
|
619
|
+
if (Object.keys(agentOverrides).length > 0)
|
|
620
|
+
subagents.agentOverrides = agentOverrides;
|
|
621
|
+
else delete subagents.agentOverrides;
|
|
622
|
+
if (Object.keys(subagents).length > 0) settings.subagents = subagents;
|
|
623
|
+
else delete settings.subagents;
|
|
624
|
+
await mkdir(dirname(path), { recursive: true });
|
|
625
|
+
await writeFile(path, `${JSON.stringify(settings, null, "\t")}\n`);
|
|
626
|
+
return true;
|
|
627
|
+
}
|
|
628
|
+
|
|
420
629
|
export function applyModelConfig(
|
|
421
630
|
cwd: string,
|
|
422
631
|
config: AgentModelConfig,
|
|
@@ -446,6 +655,49 @@ export function applyModelConfig(
|
|
|
446
655
|
return { updated, skipped };
|
|
447
656
|
}
|
|
448
657
|
|
|
658
|
+
export async function applyModelConfigAsync(
|
|
659
|
+
cwd: string,
|
|
660
|
+
config: AgentModelConfig,
|
|
661
|
+
): Promise<{ updated: number; skipped: number }> {
|
|
662
|
+
let updated = 0;
|
|
663
|
+
let skipped = 0;
|
|
664
|
+
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
665
|
+
const entry = config[agent.name];
|
|
666
|
+
if (agent.source === "builtin") {
|
|
667
|
+
if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
|
|
668
|
+
updated += 1;
|
|
669
|
+
else skipped += 1;
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
if (!agent.filePath || !(await pathExists(agent.filePath))) {
|
|
673
|
+
skipped += 1;
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
const original = await readFile(agent.filePath, "utf8");
|
|
677
|
+
const next = updateFrontmatterRouting(original, entry);
|
|
678
|
+
if (next === original) {
|
|
679
|
+
skipped += 1;
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
await writeFile(agent.filePath, next);
|
|
683
|
+
updated += 1;
|
|
684
|
+
}
|
|
685
|
+
return { updated, skipped };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export async function applySavedModelConfig(
|
|
689
|
+
ctx: ExtensionContext,
|
|
690
|
+
): Promise<{ updated: number; skipped: number; invalidPath?: string }> {
|
|
691
|
+
const result = await readSavedModelConfigAsync(ctx.cwd);
|
|
692
|
+
if (result.status === "invalid") {
|
|
693
|
+
return { updated: 0, skipped: 0, invalidPath: result.path };
|
|
694
|
+
}
|
|
695
|
+
return applyModelConfigAsync(
|
|
696
|
+
ctx.cwd,
|
|
697
|
+
result.status === "valid" ? result.config : {},
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
|
|
449
701
|
function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
450
702
|
return listDiscoverableAgents(cwd).map((agent) => {
|
|
451
703
|
const entry = config[agent.name];
|
|
@@ -836,7 +1088,15 @@ async function showSddModelPanel(
|
|
|
836
1088
|
}
|
|
837
1089
|
|
|
838
1090
|
async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
839
|
-
|
|
1091
|
+
const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
|
|
1092
|
+
if (savedConfig.status === "invalid") {
|
|
1093
|
+
ctx.ui.notify(
|
|
1094
|
+
`el Gentleman cannot open model config because ${savedConfig.path} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
|
|
1095
|
+
"warning",
|
|
1096
|
+
);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
let config = savedConfig.status === "valid" ? savedConfig.config : {};
|
|
840
1100
|
let result = await showSddModelPanel(ctx, config);
|
|
841
1101
|
while (result.type === "custom") {
|
|
842
1102
|
config = cloneModelConfig(result.config);
|
|
@@ -874,11 +1134,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
874
1134
|
}
|
|
875
1135
|
if (result.type !== "save") return;
|
|
876
1136
|
writeModelConfig(ctx.cwd, result.config);
|
|
877
|
-
const applyResult =
|
|
1137
|
+
const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
|
|
878
1138
|
ctx.ui.notify(
|
|
879
1139
|
[
|
|
880
|
-
"el Gentleman model config saved.",
|
|
881
|
-
`
|
|
1140
|
+
"el Gentleman global model config saved.",
|
|
1141
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
882
1142
|
`Agents updated: ${applyResult.updated}`,
|
|
883
1143
|
...describeModelConfig(ctx.cwd, result.config),
|
|
884
1144
|
].join("\n"),
|
|
@@ -909,17 +1169,35 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
909
1169
|
return ensureSddPreflight(ctx, {
|
|
910
1170
|
pi,
|
|
911
1171
|
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
912
|
-
applyModelConfig: (
|
|
1172
|
+
applyModelConfig: async () => applySavedModelConfig(ctx),
|
|
913
1173
|
});
|
|
914
1174
|
}
|
|
915
1175
|
|
|
916
|
-
pi.on("session_start", (_event, ctx) => {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
ctx.
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
1176
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1177
|
+
try {
|
|
1178
|
+
const modelResult = await applySavedModelConfig(ctx);
|
|
1179
|
+
if (ctx.hasUI && modelResult.invalidPath) {
|
|
1180
|
+
ctx.ui.notify(
|
|
1181
|
+
`el Gentleman skipped model config because ${modelResult.invalidPath} is invalid JSON or not an object. Fix or remove the file, then run /gentle:models again.`,
|
|
1182
|
+
"warning",
|
|
1183
|
+
);
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
if (ctx.hasUI && modelResult.updated > 0) {
|
|
1187
|
+
ctx.ui.notify(
|
|
1188
|
+
`el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
|
|
1189
|
+
"info",
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
if (ctx.hasUI) {
|
|
1194
|
+
const message =
|
|
1195
|
+
error instanceof Error ? error.message : String(error);
|
|
1196
|
+
ctx.ui.notify(
|
|
1197
|
+
`el Gentleman model config sweep failed: ${message}`,
|
|
1198
|
+
"warning",
|
|
1199
|
+
);
|
|
1200
|
+
}
|
|
923
1201
|
}
|
|
924
1202
|
});
|
|
925
1203
|
|
|
@@ -975,7 +1253,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
975
1253
|
});
|
|
976
1254
|
|
|
977
1255
|
pi.registerCommand("gentle:models", {
|
|
978
|
-
description: "Configure per-agent models for el Gentleman.",
|
|
1256
|
+
description: "Configure global per-agent models for el Gentleman.",
|
|
979
1257
|
handler: async (_args, ctx) => {
|
|
980
1258
|
await handleModelsCommand(ctx);
|
|
981
1259
|
},
|
|
@@ -1028,7 +1306,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1028
1306
|
const openspecConfigured = existsSync(
|
|
1029
1307
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
1030
1308
|
);
|
|
1031
|
-
const modelConfig =
|
|
1309
|
+
const modelConfig = await readModelConfigAsync(ctx.cwd);
|
|
1032
1310
|
ctx.ui.notify(
|
|
1033
1311
|
[
|
|
1034
1312
|
"el Gentleman package is active.",
|
|
@@ -1036,7 +1314,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1036
1314
|
`SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
1037
1315
|
`SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
1038
1316
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
1039
|
-
`
|
|
1317
|
+
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
1040
1318
|
...describeModelConfig(ctx.cwd, modelConfig),
|
|
1041
1319
|
].join("\n"),
|
|
1042
1320
|
"info",
|
package/extensions/sdd-init.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
writeFileSync,
|
|
7
7
|
} from "node:fs";
|
|
8
8
|
import { basename, dirname, join, relative } from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { applySavedModelConfig } from "./gentle-ai.ts";
|
|
10
10
|
import { ensureSddPreflight, installSddAssets } from "../lib/sdd-preflight.ts";
|
|
11
11
|
type ExtensionAPI = any;
|
|
12
12
|
|
|
@@ -778,7 +778,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
778
778
|
await ensureSddPreflight(ctx, {
|
|
779
779
|
pi,
|
|
780
780
|
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
781
|
-
applyModelConfig: (
|
|
781
|
+
applyModelConfig: () => applySavedModelConfig(ctx),
|
|
782
782
|
});
|
|
783
783
|
const configPath = join(ctx.cwd, CONFIG_REL_PATH);
|
|
784
784
|
if (existsSync(configPath)) {
|