gentle-pi 0.3.1 → 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 +16 -14
- package/extensions/gentle-ai.ts +94 -26
- package/extensions/sdd-init.ts +2 -3
- package/lib/sdd-preflight.ts +7 -4
- package/package.json +1 -1
- package/tests/runtime-harness.mjs +113 -6
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
|
@@ -138,6 +138,10 @@ interface AgentRoutingEntry {
|
|
|
138
138
|
thinking?: ThinkingLevel;
|
|
139
139
|
}
|
|
140
140
|
type AgentModelConfig = Record<string, AgentRoutingEntry>;
|
|
141
|
+
type ModelConfigFileResult =
|
|
142
|
+
| { status: "missing" }
|
|
143
|
+
| { status: "invalid"; path: string }
|
|
144
|
+
| { status: "valid"; config: AgentModelConfig };
|
|
141
145
|
type AgentSource = "project" | "user" | "builtin";
|
|
142
146
|
|
|
143
147
|
interface AgentEntry {
|
|
@@ -217,7 +221,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
217
221
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
218
222
|
}
|
|
219
223
|
|
|
220
|
-
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 {
|
|
221
233
|
return join(cwd, ".pi", "gentle-ai", "models.json");
|
|
222
234
|
}
|
|
223
235
|
|
|
@@ -269,42 +281,72 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
|
|
|
269
281
|
return { model, thinking };
|
|
270
282
|
}
|
|
271
283
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (!existsSync(path)) return {};
|
|
284
|
+
function readModelConfigFile(path: string): ModelConfigFileResult {
|
|
285
|
+
if (!existsSync(path)) return { status: "missing" };
|
|
275
286
|
try {
|
|
276
287
|
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
277
|
-
if (!isRecord(parsed)) return {};
|
|
288
|
+
if (!isRecord(parsed)) return { status: "invalid", path };
|
|
278
289
|
const config: AgentModelConfig = {};
|
|
279
290
|
for (const [name, value] of Object.entries(parsed)) {
|
|
280
291
|
const entry = normalizeRoutingEntry(value);
|
|
281
292
|
if (entry) config[name] = entry;
|
|
282
293
|
}
|
|
283
|
-
return config;
|
|
294
|
+
return { status: "valid", config };
|
|
284
295
|
} catch {
|
|
285
|
-
return {};
|
|
296
|
+
return { status: "invalid", path };
|
|
286
297
|
}
|
|
287
298
|
}
|
|
288
299
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
): Promise<
|
|
292
|
-
|
|
293
|
-
if (!(await pathExists(path))) return {};
|
|
300
|
+
async function readModelConfigFileAsync(
|
|
301
|
+
path: string,
|
|
302
|
+
): Promise<ModelConfigFileResult> {
|
|
303
|
+
if (!(await pathExists(path))) return { status: "missing" };
|
|
294
304
|
try {
|
|
295
305
|
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
296
|
-
if (!isRecord(parsed)) return {};
|
|
306
|
+
if (!isRecord(parsed)) return { status: "invalid", path };
|
|
297
307
|
const config: AgentModelConfig = {};
|
|
298
308
|
for (const [name, value] of Object.entries(parsed)) {
|
|
299
309
|
const entry = normalizeRoutingEntry(value);
|
|
300
310
|
if (entry) config[name] = entry;
|
|
301
311
|
}
|
|
302
|
-
return config;
|
|
312
|
+
return { status: "valid", config };
|
|
303
313
|
} catch {
|
|
304
|
-
return {};
|
|
314
|
+
return { status: "invalid", path };
|
|
305
315
|
}
|
|
306
316
|
}
|
|
307
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
|
+
|
|
308
350
|
function writeModelConfig(cwd: string, config: AgentModelConfig): void {
|
|
309
351
|
const path = modelConfigPath(cwd);
|
|
310
352
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -643,6 +685,19 @@ export async function applyModelConfigAsync(
|
|
|
643
685
|
return { updated, skipped };
|
|
644
686
|
}
|
|
645
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
|
+
|
|
646
701
|
function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
|
|
647
702
|
return listDiscoverableAgents(cwd).map((agent) => {
|
|
648
703
|
const entry = config[agent.name];
|
|
@@ -1033,7 +1088,15 @@ async function showSddModelPanel(
|
|
|
1033
1088
|
}
|
|
1034
1089
|
|
|
1035
1090
|
async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
1036
|
-
|
|
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 : {};
|
|
1037
1100
|
let result = await showSddModelPanel(ctx, config);
|
|
1038
1101
|
while (result.type === "custom") {
|
|
1039
1102
|
config = cloneModelConfig(result.config);
|
|
@@ -1071,11 +1134,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
1071
1134
|
}
|
|
1072
1135
|
if (result.type !== "save") return;
|
|
1073
1136
|
writeModelConfig(ctx.cwd, result.config);
|
|
1074
|
-
const applyResult =
|
|
1137
|
+
const applyResult = await applyModelConfigAsync(ctx.cwd, result.config);
|
|
1075
1138
|
ctx.ui.notify(
|
|
1076
1139
|
[
|
|
1077
|
-
"el Gentleman model config saved.",
|
|
1078
|
-
`
|
|
1140
|
+
"el Gentleman global model config saved.",
|
|
1141
|
+
`Global config: ${modelConfigPath(ctx.cwd)}`,
|
|
1079
1142
|
`Agents updated: ${applyResult.updated}`,
|
|
1080
1143
|
...describeModelConfig(ctx.cwd, result.config),
|
|
1081
1144
|
].join("\n"),
|
|
@@ -1106,15 +1169,20 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1106
1169
|
return ensureSddPreflight(ctx, {
|
|
1107
1170
|
pi,
|
|
1108
1171
|
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
1109
|
-
applyModelConfig: async (
|
|
1110
|
-
applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
|
|
1172
|
+
applyModelConfig: async () => applySavedModelConfig(ctx),
|
|
1111
1173
|
});
|
|
1112
1174
|
}
|
|
1113
1175
|
|
|
1114
1176
|
pi.on("session_start", async (_event, ctx) => {
|
|
1115
1177
|
try {
|
|
1116
|
-
const
|
|
1117
|
-
|
|
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
|
+
}
|
|
1118
1186
|
if (ctx.hasUI && modelResult.updated > 0) {
|
|
1119
1187
|
ctx.ui.notify(
|
|
1120
1188
|
`el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
|
|
@@ -1185,7 +1253,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1185
1253
|
});
|
|
1186
1254
|
|
|
1187
1255
|
pi.registerCommand("gentle:models", {
|
|
1188
|
-
description: "Configure per-agent models for el Gentleman.",
|
|
1256
|
+
description: "Configure global per-agent models for el Gentleman.",
|
|
1189
1257
|
handler: async (_args, ctx) => {
|
|
1190
1258
|
await handleModelsCommand(ctx);
|
|
1191
1259
|
},
|
|
@@ -1238,7 +1306,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1238
1306
|
const openspecConfigured = existsSync(
|
|
1239
1307
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
1240
1308
|
);
|
|
1241
|
-
const modelConfig =
|
|
1309
|
+
const modelConfig = await readModelConfigAsync(ctx.cwd);
|
|
1242
1310
|
ctx.ui.notify(
|
|
1243
1311
|
[
|
|
1244
1312
|
"el Gentleman package is active.",
|
|
@@ -1246,7 +1314,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1246
1314
|
`SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
1247
1315
|
`SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
1248
1316
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
1249
|
-
`
|
|
1317
|
+
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
1250
1318
|
...describeModelConfig(ctx.cwd, modelConfig),
|
|
1251
1319
|
].join("\n"),
|
|
1252
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,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.2",
|
|
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
|
|
|
@@ -201,9 +204,8 @@ async function run() {
|
|
|
201
204
|
|
|
202
205
|
const lazySddCwd = await tempWorkspace();
|
|
203
206
|
try {
|
|
204
|
-
await mkdir(join(lazySddCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
205
207
|
await writeFile(
|
|
206
|
-
|
|
208
|
+
globalModelsPath,
|
|
207
209
|
JSON.stringify({ "sdd-apply": { model: "openai/gpt-5", thinking: "high" } }, null, 2),
|
|
208
210
|
);
|
|
209
211
|
const ctx = createCtx(lazySddCwd, true);
|
|
@@ -300,6 +302,7 @@ async function run() {
|
|
|
300
302
|
assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
|
|
301
303
|
} finally {
|
|
302
304
|
await rm(lazySddCwd, { recursive: true, force: true });
|
|
305
|
+
await rm(globalModelsPath, { force: true });
|
|
303
306
|
}
|
|
304
307
|
|
|
305
308
|
const commandSddCwd = await tempWorkspace();
|
|
@@ -314,6 +317,19 @@ async function run() {
|
|
|
314
317
|
await rm(commandSddCwd, { recursive: true, force: true });
|
|
315
318
|
}
|
|
316
319
|
|
|
320
|
+
const invalidPreflightCwd = await tempWorkspace();
|
|
321
|
+
try {
|
|
322
|
+
await writeFile(globalModelsPath, "{ invalid json");
|
|
323
|
+
const ctx = createCtx(invalidPreflightCwd, true, "invalid-preflight-session");
|
|
324
|
+
await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
|
|
325
|
+
assert.equal(ctx.ui.notifications.at(-1).level, "warning");
|
|
326
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Model routing skipped:/);
|
|
327
|
+
assert.match(ctx.ui.notifications.at(-1).message, /invalid JSON or not an object/);
|
|
328
|
+
} finally {
|
|
329
|
+
await rm(invalidPreflightCwd, { recursive: true, force: true });
|
|
330
|
+
await rm(globalModelsPath, { force: true });
|
|
331
|
+
}
|
|
332
|
+
|
|
317
333
|
const engramSddCwd = await tempWorkspace();
|
|
318
334
|
try {
|
|
319
335
|
pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
|
|
@@ -350,6 +366,92 @@ async function run() {
|
|
|
350
366
|
await rm(sddCwd, { recursive: true, force: true });
|
|
351
367
|
}
|
|
352
368
|
|
|
369
|
+
const invalidSddInitCwd = await tempWorkspace();
|
|
370
|
+
try {
|
|
371
|
+
await mkdir(join(invalidSddInitCwd, ".pi", "agents"), { recursive: true });
|
|
372
|
+
await writeFile(
|
|
373
|
+
join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
|
|
374
|
+
`---\nname: sdd-apply\ndescription: Apply phase\nmodel: keep/provider-model\n---\n\nbody\n`,
|
|
375
|
+
);
|
|
376
|
+
await writeFile(globalModelsPath, "{ invalid json");
|
|
377
|
+
const ctx = createCtx(invalidSddInitCwd, true, "invalid-sdd-init-session");
|
|
378
|
+
await commands.get("sdd-init").handler("", ctx);
|
|
379
|
+
assert.equal(ctx.ui.notifications[0].level, "warning");
|
|
380
|
+
assert.match(ctx.ui.notifications[0].message, /Model routing skipped:/);
|
|
381
|
+
assert.match(ctx.ui.notifications[0].message, /models\.json/);
|
|
382
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
|
|
383
|
+
const preservedAgent = await readFile(
|
|
384
|
+
join(invalidSddInitCwd, ".pi", "agents", "sdd-apply.md"),
|
|
385
|
+
"utf8",
|
|
386
|
+
);
|
|
387
|
+
assert.match(preservedAgent, /model: keep\/provider-model/);
|
|
388
|
+
} finally {
|
|
389
|
+
await rm(invalidSddInitCwd, { recursive: true, force: true });
|
|
390
|
+
await rm(globalModelsPath, { force: true });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const legacyModelsCwd = await tempWorkspace();
|
|
394
|
+
try {
|
|
395
|
+
await mkdir(join(legacyModelsCwd, ".pi", "agents"), { recursive: true });
|
|
396
|
+
await mkdir(join(legacyModelsCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
397
|
+
await writeFile(
|
|
398
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
399
|
+
`---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
|
|
400
|
+
);
|
|
401
|
+
await writeFile(
|
|
402
|
+
join(legacyModelsCwd, ".pi", "gentle-ai", "models.json"),
|
|
403
|
+
JSON.stringify({ "sdd-apply": "legacy/provider-model" }, null, 2),
|
|
404
|
+
);
|
|
405
|
+
const legacyCtx = createCtx(legacyModelsCwd, true);
|
|
406
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
407
|
+
const legacyAgent = await readFile(
|
|
408
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
409
|
+
"utf8",
|
|
410
|
+
);
|
|
411
|
+
assert.match(legacyAgent, /model: legacy\/provider-model/);
|
|
412
|
+
await writeFile(
|
|
413
|
+
globalModelsPath,
|
|
414
|
+
JSON.stringify({ "sdd-apply": "global/provider-model" }, null, 2),
|
|
415
|
+
);
|
|
416
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
417
|
+
const globalWinsAgent = await readFile(
|
|
418
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
419
|
+
"utf8",
|
|
420
|
+
);
|
|
421
|
+
assert.match(globalWinsAgent, /model: global\/provider-model/);
|
|
422
|
+
assert.doesNotMatch(globalWinsAgent, /model: legacy\/provider-model/);
|
|
423
|
+
await writeFile(globalModelsPath, "{ invalid json");
|
|
424
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
425
|
+
const invalidGlobalSkippedAgent = await readFile(
|
|
426
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
427
|
+
"utf8",
|
|
428
|
+
);
|
|
429
|
+
assert.match(invalidGlobalSkippedAgent, /model: global\/provider-model/);
|
|
430
|
+
assert.doesNotMatch(invalidGlobalSkippedAgent, /model: legacy\/provider-model/);
|
|
431
|
+
assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
|
|
432
|
+
assert.match(legacyCtx.ui.notifications.at(-1).message, /skipped model config/);
|
|
433
|
+
let modelPanelOpened = false;
|
|
434
|
+
legacyCtx.ui.custom = () => {
|
|
435
|
+
modelPanelOpened = true;
|
|
436
|
+
return Promise.resolve({ type: "save", config: {} });
|
|
437
|
+
};
|
|
438
|
+
await commands.get("gentle:models").handler("", legacyCtx);
|
|
439
|
+
assert.equal(modelPanelOpened, false);
|
|
440
|
+
assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
|
|
441
|
+
assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
|
|
442
|
+
assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
|
|
443
|
+
await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
|
|
444
|
+
await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
|
|
445
|
+
const emptyGlobalSuppressesLegacyAgent = await readFile(
|
|
446
|
+
join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
447
|
+
"utf8",
|
|
448
|
+
);
|
|
449
|
+
assert.doesNotMatch(emptyGlobalSuppressesLegacyAgent, /model:/);
|
|
450
|
+
} finally {
|
|
451
|
+
await rm(legacyModelsCwd, { recursive: true, force: true });
|
|
452
|
+
await rm(globalModelsPath, { force: true });
|
|
453
|
+
}
|
|
454
|
+
|
|
353
455
|
const modelsCwd = await tempWorkspace();
|
|
354
456
|
try {
|
|
355
457
|
await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
|
|
@@ -373,9 +475,8 @@ async function run() {
|
|
|
373
475
|
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
374
476
|
`---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
|
|
375
477
|
);
|
|
376
|
-
await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
377
478
|
await writeFile(
|
|
378
|
-
|
|
479
|
+
globalModelsPath,
|
|
379
480
|
JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
|
|
380
481
|
);
|
|
381
482
|
|
|
@@ -399,12 +500,17 @@ async function run() {
|
|
|
399
500
|
await commands.get("gentle:models").handler("", ctx);
|
|
400
501
|
|
|
401
502
|
const savedConfig = JSON.parse(
|
|
402
|
-
await readFile(
|
|
503
|
+
await readFile(globalModelsPath, "utf8"),
|
|
403
504
|
);
|
|
404
505
|
assert.deepEqual(savedConfig["sdd-apply"], {
|
|
405
506
|
model: "openai/gpt-5",
|
|
406
507
|
thinking: "high",
|
|
407
508
|
});
|
|
509
|
+
assert.equal(
|
|
510
|
+
existsSync(join(modelsCwd, ".pi", "gentle-ai", "models.json")),
|
|
511
|
+
false,
|
|
512
|
+
"/gentle:models must save model routing globally, not per project",
|
|
513
|
+
);
|
|
408
514
|
|
|
409
515
|
const applyAgent = await readFile(
|
|
410
516
|
join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
|
|
@@ -440,7 +546,7 @@ async function run() {
|
|
|
440
546
|
await commands.get("gentle:models").handler("", ctx);
|
|
441
547
|
|
|
442
548
|
const customSavedConfig = JSON.parse(
|
|
443
|
-
await readFile(
|
|
549
|
+
await readFile(globalModelsPath, "utf8"),
|
|
444
550
|
);
|
|
445
551
|
assert.deepEqual(customSavedConfig["sdd-apply"], {
|
|
446
552
|
model: "custom/provider-model",
|
|
@@ -448,6 +554,7 @@ async function run() {
|
|
|
448
554
|
});
|
|
449
555
|
} finally {
|
|
450
556
|
await rm(modelsCwd, { recursive: true, force: true });
|
|
557
|
+
await rm(globalModelsPath, { force: true });
|
|
451
558
|
}
|
|
452
559
|
|
|
453
560
|
const registryCwd = await tempWorkspace();
|