gentle-pi 0.3.1 → 0.3.3
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 +16 -14
- package/extensions/gentle-ai.ts +127 -27
- package/extensions/sdd-init.ts +2 -3
- package/lib/sdd-preflight.ts +7 -4
- package/package.json +1 -1
- package/tests/runtime-harness.mjs +148 -8
package/README.md
CHANGED
|
@@ -49,7 +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
|
+
| **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. |
|
|
55
55
|
| **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. |
|
|
@@ -87,10 +87,10 @@ pi
|
|
|
87
87
|
## Quick start
|
|
88
88
|
|
|
89
89
|
```text
|
|
90
|
-
/gentle-ai:status Check package, SDD assets, OpenSpec, and model config.
|
|
90
|
+
/gentle-ai:status Check package, SDD assets, OpenSpec, and global model config.
|
|
91
91
|
/gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
|
|
92
92
|
/sdd-init Create or refresh openspec/config.yaml.
|
|
93
|
-
/gentle:models Assign
|
|
93
|
+
/gentle:models Assign global model/effort routing to SDD/custom agents.
|
|
94
94
|
/gentle:persona Switch between gentleman and neutral persona modes.
|
|
95
95
|
```
|
|
96
96
|
|
|
@@ -284,12 +284,14 @@ Recommended model/effort shape:
|
|
|
284
284
|
| Verify / review | Strong fresh-context model. | `high` |
|
|
285
285
|
| Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` |
|
|
286
286
|
|
|
287
|
-
Saved at:
|
|
287
|
+
Saved globally at:
|
|
288
288
|
|
|
289
289
|
```text
|
|
290
|
-
|
|
290
|
+
~/.pi/gentle-ai/models.json
|
|
291
291
|
```
|
|
292
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
|
+
|
|
293
295
|
Config shape (per agent):
|
|
294
296
|
|
|
295
297
|
```json
|
|
@@ -308,15 +310,15 @@ Legacy string entries are still accepted and treated as `model`-only config.
|
|
|
308
310
|
|
|
309
311
|
## Commands
|
|
310
312
|
|
|
311
|
-
| Command | What it does
|
|
312
|
-
| -------------------------------- |
|
|
313
|
-
| `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and model config status. |
|
|
314
|
-
| `/gentle:models` | Opens model + effort assignment UI. |
|
|
315
|
-
| `/gentle:persona` | Switches persona mode.
|
|
316
|
-
| `/sdd-init` | Initializes or refreshes `openspec/config.yaml`.
|
|
317
|
-
| `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files.
|
|
318
|
-
| `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets.
|
|
319
|
-
| `/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`. |
|
|
320
322
|
|
|
321
323
|
Startup flag:
|
|
322
324
|
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -130,6 +130,7 @@ const SDD_AGENT_NAMES = [
|
|
|
130
130
|
"sdd-verify",
|
|
131
131
|
"sdd-archive",
|
|
132
132
|
] as const;
|
|
133
|
+
const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
|
|
133
134
|
|
|
134
135
|
type SddAgentName = (typeof SDD_AGENT_NAMES)[number];
|
|
135
136
|
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
|
|
@@ -138,6 +139,10 @@ interface AgentRoutingEntry {
|
|
|
138
139
|
thinking?: ThinkingLevel;
|
|
139
140
|
}
|
|
140
141
|
type AgentModelConfig = Record<string, AgentRoutingEntry>;
|
|
142
|
+
type ModelConfigFileResult =
|
|
143
|
+
| { status: "missing" }
|
|
144
|
+
| { status: "invalid"; path: string }
|
|
145
|
+
| { status: "valid"; config: AgentModelConfig };
|
|
141
146
|
type AgentSource = "project" | "user" | "builtin";
|
|
142
147
|
|
|
143
148
|
interface AgentEntry {
|
|
@@ -166,6 +171,34 @@ const MODEL_CONTROL_OPTIONS = [
|
|
|
166
171
|
CUSTOM_MODEL,
|
|
167
172
|
] as const;
|
|
168
173
|
|
|
174
|
+
function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
175
|
+
let current = value;
|
|
176
|
+
for (const key of path) {
|
|
177
|
+
if (!isRecord(current)) return undefined;
|
|
178
|
+
current = current[key];
|
|
179
|
+
}
|
|
180
|
+
return typeof current === "string" ? current : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isSddAgentStartEvent(event: unknown): boolean {
|
|
184
|
+
const candidates = [
|
|
185
|
+
readStringPath(event, ["agentName"]),
|
|
186
|
+
readStringPath(event, ["agent"]),
|
|
187
|
+
readStringPath(event, ["name"]),
|
|
188
|
+
readStringPath(event, ["agent", "name"]),
|
|
189
|
+
readStringPath(event, ["subagent", "name"]),
|
|
190
|
+
]
|
|
191
|
+
.filter((value): value is string => value !== undefined)
|
|
192
|
+
.map((value) => value.trim());
|
|
193
|
+
if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
|
|
194
|
+
|
|
195
|
+
const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
|
|
196
|
+
return SDD_AGENT_NAMES.some((name) => {
|
|
197
|
+
const phase = name.replace(/^sdd-/, "");
|
|
198
|
+
return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
169
202
|
function evaluateDeniedCommand(
|
|
170
203
|
command: string,
|
|
171
204
|
): ToolCallEventResult | undefined {
|
|
@@ -217,7 +250,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
217
250
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
218
251
|
}
|
|
219
252
|
|
|
220
|
-
function
|
|
253
|
+
function gentleAiConfigHome(): string {
|
|
254
|
+
return process.env.GENTLE_PI_CONFIG_HOME ?? join(homedir(), ".pi", "gentle-ai");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function modelConfigPath(_cwd: string): string {
|
|
258
|
+
return join(gentleAiConfigHome(), "models.json");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function legacyProjectModelConfigPath(cwd: string): string {
|
|
221
262
|
return join(cwd, ".pi", "gentle-ai", "models.json");
|
|
222
263
|
}
|
|
223
264
|
|
|
@@ -269,42 +310,72 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
|
|
|
269
310
|
return { model, thinking };
|
|
270
311
|
}
|
|
271
312
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (!existsSync(path)) return {};
|
|
313
|
+
function readModelConfigFile(path: string): ModelConfigFileResult {
|
|
314
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
275
315
|
try {
|
|
276
316
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
277
|
-
if (!isRecord(parsed)) return {};
|
|
317
|
+
if (!isRecord(parsed)) return { status: "invalid", path };
|
|
278
318
|
const config: AgentModelConfig = {};
|
|
279
319
|
for (const [name, value] of Object.entries(parsed)) {
|
|
280
320
|
const entry = normalizeRoutingEntry(value);
|
|
281
321
|
if (entry) config[name] = entry;
|
|
282
322
|
}
|
|
283
|
-
return config;
|
|
323
|
+
return { status: "valid", config };
|
|
284
324
|
} catch {
|
|
285
|
-
return {};
|
|
325
|
+
return { status: "invalid", path };
|
|
286
326
|
}
|
|
287
327
|
}
|
|
288
328
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
): Promise<
|
|
292
|
-
|
|
293
|
-
if (!(await pathExists(path))) return {};
|
|
329
|
+
async function readModelConfigFileAsync(
|
|
330
|
+
path: string,
|
|
331
|
+
): Promise<ModelConfigFileResult> {
|
|
332
|
+
if (!(await pathExists(path))) return { status: "missing" };
|
|
294
333
|
try {
|
|
295
334
|
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
296
|
-
if (!isRecord(parsed)) return {};
|
|
335
|
+
if (!isRecord(parsed)) return { status: "invalid", path };
|
|
297
336
|
const config: AgentModelConfig = {};
|
|
298
337
|
for (const [name, value] of Object.entries(parsed)) {
|
|
299
338
|
const entry = normalizeRoutingEntry(value);
|
|
300
339
|
if (entry) config[name] = entry;
|
|
301
340
|
}
|
|
302
|
-
return config;
|
|
341
|
+
return { status: "valid", config };
|
|
303
342
|
} catch {
|
|
304
|
-
return {};
|
|
343
|
+
return { status: "invalid", path };
|
|
305
344
|
}
|
|
306
345
|
}
|
|
307
346
|
|
|
347
|
+
function readSavedModelConfig(cwd: string): ModelConfigFileResult {
|
|
348
|
+
const globalResult = readModelConfigFile(modelConfigPath(cwd));
|
|
349
|
+
if (globalResult.status !== "missing") return globalResult;
|
|
350
|
+
const legacyResult = readModelConfigFile(legacyProjectModelConfigPath(cwd));
|
|
351
|
+
if (legacyResult.status === "invalid") return { status: "valid", config: {} };
|
|
352
|
+
return legacyResult;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function readSavedModelConfigAsync(
|
|
356
|
+
cwd: string,
|
|
357
|
+
): Promise<ModelConfigFileResult> {
|
|
358
|
+
const globalResult = await readModelConfigFileAsync(modelConfigPath(cwd));
|
|
359
|
+
if (globalResult.status !== "missing") return globalResult;
|
|
360
|
+
const legacyResult = await readModelConfigFileAsync(
|
|
361
|
+
legacyProjectModelConfigPath(cwd),
|
|
362
|
+
);
|
|
363
|
+
if (legacyResult.status === "invalid") return { status: "valid", config: {} };
|
|
364
|
+
return legacyResult;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export function readModelConfig(cwd: string): AgentModelConfig {
|
|
368
|
+
const result = readSavedModelConfig(cwd);
|
|
369
|
+
return result.status === "valid" ? result.config : {};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function readModelConfigAsync(
|
|
373
|
+
cwd: string,
|
|
374
|
+
): Promise<AgentModelConfig> {
|
|
375
|
+
const result = await readSavedModelConfigAsync(cwd);
|
|
376
|
+
return result.status === "valid" ? result.config : {};
|
|
377
|
+
}
|
|
378
|
+
|
|
308
379
|
function writeModelConfig(cwd: string, config: AgentModelConfig): void {
|
|
309
380
|
const path = modelConfigPath(cwd);
|
|
310
381
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -643,6 +714,19 @@ export async function applyModelConfigAsync(
|
|
|
643
714
|
return { updated, skipped };
|
|
644
715
|
}
|
|
645
716
|
|
|
717
|
+
export async function applySavedModelConfig(
|
|
718
|
+
ctx: ExtensionContext,
|
|
719
|
+
): Promise<{ updated: number; skipped: number; invalidPath?: string }> {
|
|
720
|
+
const result = await readSavedModelConfigAsync(ctx.cwd);
|
|
721
|
+
if (result.status === "invalid") {
|
|
722
|
+
return { updated: 0, skipped: 0, invalidPath: result.path };
|
|
723
|
+
}
|
|
724
|
+
return applyModelConfigAsync(
|
|
725
|
+
ctx.cwd,
|
|
726
|
+
result.status === "valid" ? result.config : {},
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
646
730
|
function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
647
731
|
return listDiscoverableAgents(cwd).map((agent) => {
|
|
648
732
|
const entry = config[agent.name];
|
|
@@ -1033,7 +1117,15 @@ async function showSddModelPanel(
|
|
|
1033
1117
|
}
|
|
1034
1118
|
|
|
1035
1119
|
async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
1036
|
-
|
|
1120
|
+
const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
|
|
1121
|
+
if (savedConfig.status === "invalid") {
|
|
1122
|
+
ctx.ui.notify(
|
|
1123
|
+
`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.`,
|
|
1124
|
+
"warning",
|
|
1125
|
+
);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
let config = savedConfig.status === "valid" ? savedConfig.config : {};
|
|
1037
1129
|
let result = await showSddModelPanel(ctx, config);
|
|
1038
1130
|
while (result.type === "custom") {
|
|
1039
1131
|
config = cloneModelConfig(result.config);
|
|
@@ -1071,11 +1163,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
1071
1163
|
}
|
|
1072
1164
|
if (result.type !== "save") return;
|
|
1073
1165
|
writeModelConfig(ctx.cwd, result.config);
|
|
1074
|
-
const applyResult =
|
|
1166
|
+
const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
|
|
1075
1167
|
ctx.ui.notify(
|
|
1076
1168
|
[
|
|
1077
|
-
"el Gentleman model config saved.",
|
|
1078
|
-
`
|
|
1169
|
+
"el Gentleman global model config saved.",
|
|
1170
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
1079
1171
|
`Agents updated: ${applyResult.updated}`,
|
|
1080
1172
|
...describeModelConfig(ctx.cwd, result.config),
|
|
1081
1173
|
].join("\n"),
|
|
@@ -1106,15 +1198,20 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1106
1198
|
return ensureSddPreflight(ctx, {
|
|
1107
1199
|
pi,
|
|
1108
1200
|
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
1109
|
-
applyModelConfig: async (
|
|
1110
|
-
applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
|
|
1201
|
+
applyModelConfig: async () => applySavedModelConfig(ctx),
|
|
1111
1202
|
});
|
|
1112
1203
|
}
|
|
1113
1204
|
|
|
1114
1205
|
pi.on("session_start", async (_event, ctx) => {
|
|
1115
1206
|
try {
|
|
1116
|
-
const
|
|
1117
|
-
|
|
1207
|
+
const modelResult = await applySavedModelConfig(ctx);
|
|
1208
|
+
if (ctx.hasUI && modelResult.invalidPath) {
|
|
1209
|
+
ctx.ui.notify(
|
|
1210
|
+
`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.`,
|
|
1211
|
+
"warning",
|
|
1212
|
+
);
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1118
1215
|
if (ctx.hasUI && modelResult.updated > 0) {
|
|
1119
1216
|
ctx.ui.notify(
|
|
1120
1217
|
`el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
|
|
@@ -1141,7 +1238,10 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1141
1238
|
return { action: "continue" };
|
|
1142
1239
|
});
|
|
1143
1240
|
|
|
1144
|
-
pi.on("before_agent_start", (event, ctx) => {
|
|
1241
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
1242
|
+
if (isSddAgentStartEvent(event) && !getSddPreflightPreferences(ctx)) {
|
|
1243
|
+
await runSddPreflight(ctx);
|
|
1244
|
+
}
|
|
1145
1245
|
const prefs = getSddPreflightPreferences(ctx);
|
|
1146
1246
|
const sddPrompt = prefs ? `\n\n${renderSddPreflightPrompt(prefs)}` : "";
|
|
1147
1247
|
return {
|
|
@@ -1185,7 +1285,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1185
1285
|
});
|
|
1186
1286
|
|
|
1187
1287
|
pi.registerCommand("gentle:models", {
|
|
1188
|
-
description: "Configure per-agent models for el Gentleman.",
|
|
1288
|
+
description: "Configure global per-agent models for el Gentleman.",
|
|
1189
1289
|
handler: async (_args, ctx) => {
|
|
1190
1290
|
await handleModelsCommand(ctx);
|
|
1191
1291
|
},
|
|
@@ -1238,7 +1338,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1238
1338
|
const openspecConfigured = existsSync(
|
|
1239
1339
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
1240
1340
|
);
|
|
1241
|
-
const modelConfig =
|
|
1341
|
+
const modelConfig = await readModelConfigAsync(ctx.cwd);
|
|
1242
1342
|
ctx.ui.notify(
|
|
1243
1343
|
[
|
|
1244
1344
|
"el Gentleman package is active.",
|
|
@@ -1246,7 +1346,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1246
1346
|
`SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
1247
1347
|
`SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
1248
1348
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
1249
|
-
`
|
|
1349
|
+
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
1250
1350
|
...describeModelConfig(ctx.cwd, modelConfig),
|
|
1251
1351
|
].join("\n"),
|
|
1252
1352
|
"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,8 +778,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
778
778
|
await ensureSddPreflight(ctx, {
|
|
779
779
|
pi,
|
|
780
780
|
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
781
|
-
applyModelConfig:
|
|
782
|
-
applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
|
|
781
|
+
applyModelConfig: () => applySavedModelConfig(ctx),
|
|
783
782
|
});
|
|
784
783
|
const configPath = join(ctx.cwd, CONFIG_REL_PATH);
|
|
785
784
|
if (existsSync(configPath)) {
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -40,8 +40,8 @@ interface SddPreflightCallbacks {
|
|
|
40
40
|
applyModelConfig?: (
|
|
41
41
|
cwd: string,
|
|
42
42
|
) =>
|
|
43
|
-
| { updated: number; skipped: number }
|
|
44
|
-
| Promise<{ updated: number; skipped: number }>;
|
|
43
|
+
| { updated: number; skipped: number; invalidPath?: string }
|
|
44
|
+
| Promise<{ updated: number; skipped: number; invalidPath?: string }>;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
|
|
@@ -238,6 +238,9 @@ export async function ensureSddPreflight(
|
|
|
238
238
|
skipped: 0,
|
|
239
239
|
};
|
|
240
240
|
if (ctx.hasUI) {
|
|
241
|
+
const modelRoutingLine = modelResult.invalidPath
|
|
242
|
+
? `Model routing skipped: ${modelResult.invalidPath} is invalid JSON or not an object.`
|
|
243
|
+
: `Model-routed agents updated: ${modelResult.updated}`;
|
|
241
244
|
ctx.ui.notify(
|
|
242
245
|
[
|
|
243
246
|
"Gentle AI SDD preflight complete.",
|
|
@@ -246,9 +249,9 @@ export async function ensureSddPreflight(
|
|
|
246
249
|
`PR chaining: ${prefs.chainedPrStrategy}`,
|
|
247
250
|
`Review budget: ${prefs.reviewBudgetLines} changed lines`,
|
|
248
251
|
`Assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} skipped.`,
|
|
249
|
-
|
|
252
|
+
modelRoutingLine,
|
|
250
253
|
].join("\n"),
|
|
251
|
-
"info",
|
|
254
|
+
modelResult.invalidPath ? "warning" : "info",
|
|
252
255
|
);
|
|
253
256
|
}
|
|
254
257
|
sddPreflightBySession.set(sessionKey, prefs);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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",
|
|
@@ -137,6 +137,9 @@ async function loadExtensions(pi) {
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
async function run() {
|
|
140
|
+
const globalConfigHome = await tempWorkspace();
|
|
141
|
+
process.env.GENTLE_PI_CONFIG_HOME = globalConfigHome;
|
|
142
|
+
const globalModelsPath = join(globalConfigHome, "models.json");
|
|
140
143
|
const { pi, hooks, commands, flags } = createPi();
|
|
141
144
|
await loadExtensions(pi);
|
|
142
145
|
|
|
@@ -159,9 +162,14 @@ async function run() {
|
|
|
159
162
|
const promptCwd = await tempWorkspace();
|
|
160
163
|
try {
|
|
161
164
|
const promptHook = hooks.get("before_agent_start")[0];
|
|
162
|
-
const promptResult = promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
|
|
165
|
+
const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
|
|
163
166
|
assert.match(promptResult.systemPrompt, /base/);
|
|
164
167
|
assert.match(promptResult.systemPrompt, /el Gentleman/);
|
|
168
|
+
assert.equal(
|
|
169
|
+
existsSync(join(promptCwd, ".pi", "agents", "sdd-apply.md")),
|
|
170
|
+
false,
|
|
171
|
+
"normal agent startup must not run SDD preflight",
|
|
172
|
+
);
|
|
165
173
|
} finally {
|
|
166
174
|
await rm(promptCwd, { recursive: true, force: true });
|
|
167
175
|
}
|
|
@@ -201,9 +209,8 @@ async function run() {
|
|
|
201
209
|
|
|
202
210
|
const lazySddCwd = await tempWorkspace();
|
|
203
211
|
try {
|
|
204
|
-
await mkdir(join(lazySddCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
205
212
|
await writeFile(
|
|
206
|
-
|
|
213
|
+
globalModelsPath,
|
|
207
214
|
JSON.stringify({ "sdd-apply": { model: "openai/gpt-5", thinking: "high" } }, null, 2),
|
|
208
215
|
);
|
|
209
216
|
const ctx = createCtx(lazySddCwd, true);
|
|
@@ -295,11 +302,12 @@ async function run() {
|
|
|
295
302
|
await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
|
|
296
303
|
assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
|
|
297
304
|
const promptHook = hooks.get("before_agent_start")[0];
|
|
298
|
-
const promptResult = promptHook({ systemPrompt: "base" }, ctx);
|
|
305
|
+
const promptResult = await promptHook({ systemPrompt: "base" }, ctx);
|
|
299
306
|
assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
|
|
300
307
|
assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
|
|
301
308
|
} finally {
|
|
302
309
|
await rm(lazySddCwd, { recursive: true, force: true });
|
|
310
|
+
await rm(globalModelsPath, { force: true });
|
|
303
311
|
}
|
|
304
312
|
|
|
305
313
|
const commandSddCwd = await tempWorkspace();
|
|
@@ -314,6 +322,47 @@ async function run() {
|
|
|
314
322
|
await rm(commandSddCwd, { recursive: true, force: true });
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
const sddAgentGuardCwd = await tempWorkspace();
|
|
326
|
+
try {
|
|
327
|
+
const ctx = createCtx(sddAgentGuardCwd, true, "sdd-agent-guard-session");
|
|
328
|
+
const promptHook = hooks.get("before_agent_start")[0];
|
|
329
|
+
const promptResult = await promptHook(
|
|
330
|
+
{
|
|
331
|
+
systemPrompt: "You are the SDD proposal executor for Gentle AI.",
|
|
332
|
+
},
|
|
333
|
+
ctx,
|
|
334
|
+
);
|
|
335
|
+
assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "agents", "sdd-apply.md")), true);
|
|
336
|
+
assert.equal(existsSync(join(sddAgentGuardCwd, ".pi", "chains", "sdd-full.chain.md")), true);
|
|
337
|
+
assert.equal(ctx.ui.selections.length, 3);
|
|
338
|
+
assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
|
|
339
|
+
assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
|
|
340
|
+
|
|
341
|
+
await promptHook(
|
|
342
|
+
{
|
|
343
|
+
agentName: "sdd-tasks",
|
|
344
|
+
systemPrompt: "You are the SDD tasks executor for Gentle AI.",
|
|
345
|
+
},
|
|
346
|
+
ctx,
|
|
347
|
+
);
|
|
348
|
+
assert.equal(ctx.ui.selections.length, 3, "SDD agent guard should reuse session choices");
|
|
349
|
+
} finally {
|
|
350
|
+
await rm(sddAgentGuardCwd, { recursive: true, force: true });
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const invalidPreflightCwd = await tempWorkspace();
|
|
354
|
+
try {
|
|
355
|
+
await writeFile(globalModelsPath, "{ invalid json");
|
|
356
|
+
const ctx = createCtx(invalidPreflightCwd, true, "invalid-preflight-session");
|
|
357
|
+
await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
|
|
358
|
+
assert.equal(ctx.ui.notifications.at(-1).level, "warning");
|
|
359
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Model routing skipped:/);
|
|
360
|
+
assert.match(ctx.ui.notifications.at(-1).message, /invalid JSON or not an object/);
|
|
361
|
+
} finally {
|
|
362
|
+
await rm(invalidPreflightCwd, { recursive: true, force: true });
|
|
363
|
+
await rm(globalModelsPath, { force: true });
|
|
364
|
+
}
|
|
365
|
+
|
|
317
366
|
const engramSddCwd = await tempWorkspace();
|
|
318
367
|
try {
|
|
319
368
|
pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
|
|
@@ -350,6 +399,92 @@ async function run() {
|
|
|
350
399
|
await rm(sddCwd, { recursive: true, force: true });
|
|
351
400
|
}
|
|
352
401
|
|
|
402
|
+
const invalidSddInitCwd = await tempWorkspace();
|
|
403
|
+
try {
|
|
404
|
+
await mkdir(join(invalidSddInitCwd, ".pi", "agents"), { recursive: true });
|
|
405
|
+
await writeFile(
|
|
406
|
+
join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
|
|
407
|
+
`---\nname: sdd-apply\ndescription: Apply phase\nmodel: keep/provider-model\n---\n\nbody\n`,
|
|
408
|
+
);
|
|
409
|
+
await writeFile(globalModelsPath, "{ invalid json");
|
|
410
|
+
const ctx = createCtx(invalidSddInitCwd, true, "invalid-sdd-init-session");
|
|
411
|
+
await commands.get("sdd-init").handler("", ctx);
|
|
412
|
+
assert.equal(ctx.ui.notifications[0].level, "warning");
|
|
413
|
+
assert.match(ctx.ui.notifications[0].message, /Model routing skipped:/);
|
|
414
|
+
assert.match(ctx.ui.notifications[0].message, /models\.json/);
|
|
415
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
|
|
416
|
+
const preservedAgent = await readFile(
|
|
417
|
+
join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
|
|
418
|
+
"utf8",
|
|
419
|
+
);
|
|
420
|
+
assert.match(preservedAgent, /model: keep\/provider-model/);
|
|
421
|
+
} finally {
|
|
422
|
+
await rm(invalidSddInitCwd, { recursive: true, force: true });
|
|
423
|
+
await rm(globalModelsPath, { force: true });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const legacyModelsCwd = await tempWorkspace();
|
|
427
|
+
try {
|
|
428
|
+
await mkdir(join(legacyModelsCwd, ".pi", "agents"), { recursive: true });
|
|
429
|
+
await mkdir(join(legacyModelsCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
430
|
+
await writeFile(
|
|
431
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
432
|
+
`---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
|
|
433
|
+
);
|
|
434
|
+
await writeFile(
|
|
435
|
+
join(legacyModelsCwd, ".pi", "gentle-ai", "models.json"),
|
|
436
|
+
JSON.stringify({ "sdd-apply": "legacy/provider-model" }, null, 2),
|
|
437
|
+
);
|
|
438
|
+
const legacyCtx = createCtx(legacyModelsCwd, true);
|
|
439
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
440
|
+
const legacyAgent = await readFile(
|
|
441
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
442
|
+
"utf8",
|
|
443
|
+
);
|
|
444
|
+
assert.match(legacyAgent, /model: legacy\/provider-model/);
|
|
445
|
+
await writeFile(
|
|
446
|
+
globalModelsPath,
|
|
447
|
+
JSON.stringify({ "sdd-apply": "global/provider-model" }, null, 2),
|
|
448
|
+
);
|
|
449
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
450
|
+
const globalWinsAgent = await readFile(
|
|
451
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
452
|
+
"utf8",
|
|
453
|
+
);
|
|
454
|
+
assert.match(globalWinsAgent, /model: global\/provider-model/);
|
|
455
|
+
assert.doesNotMatch(globalWinsAgent, /model: legacy\/provider-model/);
|
|
456
|
+
await writeFile(globalModelsPath, "{ invalid json");
|
|
457
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
458
|
+
const invalidGlobalSkippedAgent = await readFile(
|
|
459
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
460
|
+
"utf8",
|
|
461
|
+
);
|
|
462
|
+
assert.match(invalidGlobalSkippedAgent, /model: global\/provider-model/);
|
|
463
|
+
assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/);
|
|
464
|
+
assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
|
|
465
|
+
assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/);
|
|
466
|
+
let modelPanelOpened = false;
|
|
467
|
+
legacyCtx.ui.custom = () => {
|
|
468
|
+
modelPanelOpened = true;
|
|
469
|
+
return Promise.resolve({ type: "save", config: {} });
|
|
470
|
+
};
|
|
471
|
+
await commands.get("gentle:models").handler("", legacyCtx);
|
|
472
|
+
assert.equal(modelPanelOpened, false);
|
|
473
|
+
assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
|
|
474
|
+
assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
|
|
475
|
+
assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
|
|
476
|
+
await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
|
|
477
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
478
|
+
const emptyGlobalSuppressesLegacyAgent = await readFile(
|
|
479
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
480
|
+
"utf8",
|
|
481
|
+
);
|
|
482
|
+
assert.doesNotMatch(emptyGlobalSuppressesLegacyAgent, /model:/);
|
|
483
|
+
} finally {
|
|
484
|
+
await rm(legacyModelsCwd, { recursive: true, force: true });
|
|
485
|
+
await rm(globalModelsPath, { force: true });
|
|
486
|
+
}
|
|
487
|
+
|
|
353
488
|
const modelsCwd = await tempWorkspace();
|
|
354
489
|
try {
|
|
355
490
|
await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
|
|
@@ -373,9 +508,8 @@ async function run() {
|
|
|
373
508
|
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
374
509
|
`---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
|
|
375
510
|
);
|
|
376
|
-
await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
377
511
|
await writeFile(
|
|
378
|
-
|
|
512
|
+
globalModelsPath,
|
|
379
513
|
JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
|
|
380
514
|
);
|
|
381
515
|
|
|
@@ -399,12 +533,17 @@ async function run() {
|
|
|
399
533
|
await commands.get("gentle:models").handler("", ctx);
|
|
400
534
|
|
|
401
535
|
const savedConfig = JSON.parse(
|
|
402
|
-
await readFile(
|
|
536
|
+
await readFile(globalModelsPath, "utf8"),
|
|
403
537
|
);
|
|
404
538
|
assert.deepEqual(savedConfig["sdd-apply"], {
|
|
405
539
|
model: "openai/gpt-5",
|
|
406
540
|
thinking: "high",
|
|
407
541
|
});
|
|
542
|
+
assert.equal(
|
|
543
|
+
existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
|
|
544
|
+
false,
|
|
545
|
+
"/gentle:models must save model routing globally, not per project",
|
|
546
|
+
);
|
|
408
547
|
|
|
409
548
|
const applyAgent = await readFile(
|
|
410
549
|
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
@@ -440,7 +579,7 @@ async function run() {
|
|
|
440
579
|
await commands.get("gentle:models").handler("", ctx);
|
|
441
580
|
|
|
442
581
|
const customSavedConfig = JSON.parse(
|
|
443
|
-
await readFile(
|
|
582
|
+
await readFile(globalModelsPath, "utf8"),
|
|
444
583
|
);
|
|
445
584
|
assert.deepEqual(customSavedConfig["sdd-apply"], {
|
|
446
585
|
model: "custom/provider-model",
|
|
@@ -448,6 +587,7 @@ async function run() {
|
|
|
448
587
|
});
|
|
449
588
|
} finally {
|
|
450
589
|
await rm(modelsCwd, { recursive: true, force: true });
|
|
590
|
+
await rm(globalModelsPath, { force: true });
|
|
451
591
|
}
|
|
452
592
|
|
|
453
593
|
const registryCwd = await tempWorkspace();
|