gentle-pi 2.6.3 → 2.6.4
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 +1 -1
- package/docs/gentle-shell.md +1 -1
- package/docs/readme-reference.md +4 -4
- package/extensions/gentle-ai.ts +174 -8
- package/package.json +1 -1
- package/tests/gentle-ai.test.ts +97 -1
- package/tests/package-manifest.test.ts +2 -2
package/README.md
CHANGED
|
@@ -141,7 +141,7 @@ Review the exact change, not a moving target. Native review keeps one candidate
|
|
|
141
141
|
The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases/tag/v2.6.0) brings a more persistent, inspectable Pi workspace:
|
|
142
142
|
|
|
143
143
|
- **Shell:** registered worktrees survive reloads; `/gentle:changes` groups dirty roots with diffs, status, and line counts; fullscreen navigation, responsive sidebars, and cached frames stay live without unnecessary redraws.
|
|
144
|
-
- **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles.
|
|
144
|
+
- **Agents and profiles:** the Agents view shows orchestrator/session hierarchy, retained completion, abort, and lost-exit history, parent-child handoff, and model, effort, and usage observability. Named `/gentle:profiles` atomically route the orchestrator independently from packaged and review roles; applying one replaces the routing of every agent, and the panel shows the routing the runtime actually uses even when `models.json` is sparse.
|
|
145
145
|
- **Control and recovery:** native SDD requires parent-confirmed preflight; native review supports intended-untracked selection, consent, and provider continuations. Subsystems install with explicit recovery guidance when npm lifecycle scripts were skipped; Pi Git installs are recognized globally; custom ask responses are opt-in. Windows keeps child consoles hidden and fixes ownership mode; Gentle Todo keeps the next pending task visible when collapsed.
|
|
146
146
|
|
|
147
147
|
---
|
package/docs/gentle-shell.md
CHANGED
|
@@ -13,7 +13,7 @@ The [v2.6.0 release](https://github.com/Gentleman-Programming/gentle-pi/releases
|
|
|
13
13
|
- The Agents List and Details views preserve the orchestrator/session hierarchy and completion, abort, and lost-exit history. Parent-child queries and notifications have an explicit handoff path, while model, effort, and usage stay observable per task.
|
|
14
14
|
- Named `/gentle:profiles` atomically route the orchestrator separately from packaged and review roles; see the [technical reference](readme-reference.md#agent-model-profiles) for the profile model.
|
|
15
15
|
|
|
16
|
-
The source checkout currently prepares `gentle-pi` `2.6.
|
|
16
|
+
The source checkout currently prepares `gentle-pi` `2.6.4` with a package-local Gentle AI `v2.9.0` pin; this is not a claim that `2.6.4` is published.
|
|
17
17
|
|
|
18
18
|
## Shell interactions and runtime behavior
|
|
19
19
|
|
package/docs/readme-reference.md
CHANGED
|
@@ -67,7 +67,7 @@ The stable release is [`v2.6.0`](https://github.com/Gentleman-Programming/gentle
|
|
|
67
67
|
|
|
68
68
|
### Source checkout
|
|
69
69
|
|
|
70
|
-
This checkout prepares `gentle-pi` `2.6.
|
|
70
|
+
This checkout prepares `gentle-pi` `2.6.4`; it is source state, not a published release. Its package-local native runtime pin is Gentle AI `v2.9.0`, distinct from the published `v2.6.0` pairing.
|
|
71
71
|
|
|
72
72
|
### Pi compatibility
|
|
73
73
|
|
|
@@ -600,7 +600,7 @@ Profiles are named, switchable snapshots of the global agent-model routing from
|
|
|
600
600
|
|
|
601
601
|
| Key | Action |
|
|
602
602
|
| ------- | ---------------------------------------------------------------------- |
|
|
603
|
-
| `enter` | Apply the selected profile live (writes `models.json`,
|
|
603
|
+
| `enter` | Apply the selected profile live (writes `models.json`, replaces the routing of every agent, sets the orchestrator when the profile defines one). |
|
|
604
604
|
| `c` | Create a new, empty profile. |
|
|
605
605
|
| `s` | Update the selected profile from the current routing (including the orchestrator currently set in `settings.json`). |
|
|
606
606
|
| `d` | Duplicate the selected profile. |
|
|
@@ -612,11 +612,11 @@ Profiles are named, switchable snapshots of the global agent-model routing from
|
|
|
612
612
|
| `pgup`/`pgdn`, `ctrl+j`/`ctrl+k` | Scroll the detail pane by a page. |
|
|
613
613
|
| `esc` | Close. |
|
|
614
614
|
|
|
615
|
-
Applying a profile writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. The reconciliation happens on the next subagent launch, and that launch still routes with the previous routing — expect one launch of lag after switching. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked.
|
|
615
|
+
Applying a profile writes `~/.pi/gentle-ai/models.json`, then reconciles agent frontmatter and `subagents.json` the same way `/gentle:models` does. A profile is a complete snapshot: every discoverable agent it omits returns to inherit, so routing materialized by a previous profile, by `/gentle:models`, or by a migration never survives a switch silently. The reconciliation happens on the next subagent launch, and that launch still routes with the previous routing — expect one launch of lag after switching. The active profile is persisted so `/gentle:profiles` reopens with the applied profile marked.
|
|
616
616
|
|
|
617
617
|
A profile also carries the orchestrator under the reserved routing key `orchestrator`. Applying a profile that defines it writes `defaultProvider`, `defaultModel`, and `defaultThinkingLevel` to Pi's global `settings.json` (preserving every other key; an unreadable `settings.json` aborts that part and is reported instead of being overwritten). Applying a profile without an `orchestrator` entry never moves the orchestrator, and `s` snapshots the currently effective orchestrator together with the routing. `orchestrator` is reserved: it is not a subagent name, is never written to `subagents.json`, and is not counted as a role.
|
|
618
618
|
|
|
619
|
-
When `profiles.json` is missing, the command seeds one profile named `current` captured from
|
|
619
|
+
The panel's current routing, the `current` seed, and `s` all read the routing in effect: `models.json` where it has an entry, and otherwise the `subagents.json` model profile or frontmatter routing the runtime actually resolves for that agent. A sparse `models.json` therefore never hides routing that is still live. When `profiles.json` is missing, the command seeds one profile named `current` captured from that effective routing, marked active only when it has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently.
|
|
620
620
|
|
|
621
621
|
Saved globally at:
|
|
622
622
|
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -2212,6 +2212,145 @@ function updateFrontmatterRouting(
|
|
|
2212
2212
|
return `---\n${lines.join("\n")}${body}`;
|
|
2213
2213
|
}
|
|
2214
2214
|
|
|
2215
|
+
/**
|
|
2216
|
+
* The routing an agent file currently carries, read the same way
|
|
2217
|
+
* `updateFrontmatterRouting` writes it: top-level `model:` and `thinking:`
|
|
2218
|
+
* frontmatter lines. Anything else is "no routing", not an error.
|
|
2219
|
+
*/
|
|
2220
|
+
function readFrontmatterRouting(content: string): AgentRoutingEntry | undefined {
|
|
2221
|
+
if (!content.startsWith("---\n")) return undefined;
|
|
2222
|
+
const endIndex = content.indexOf("\n---", 4);
|
|
2223
|
+
if (endIndex === -1) return undefined;
|
|
2224
|
+
const raw: Record<string, string> = {};
|
|
2225
|
+
for (const line of content.slice(4, endIndex).split("\n")) {
|
|
2226
|
+
if (line.startsWith("model:")) raw.model = line.slice("model:".length).trim();
|
|
2227
|
+
else if (line.startsWith("thinking:")) raw.thinking = line.slice("thinking:".length).trim();
|
|
2228
|
+
}
|
|
2229
|
+
if (raw.model === undefined && raw.thinking === undefined) return undefined;
|
|
2230
|
+
const entry = normalizeRoutingEntry(raw);
|
|
2231
|
+
return entry && !isClearRoutingEntry(entry) ? entry : undefined;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2234
|
+
function routingEntryFromModelProfile(value: unknown): AgentRoutingEntry | undefined {
|
|
2235
|
+
if (!isRecord(value)) return undefined;
|
|
2236
|
+
const entry = normalizeRoutingEntry({ model: value.model, thinking: value.effort });
|
|
2237
|
+
return entry && !isClearRoutingEntry(entry) ? entry : undefined;
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
function readSubagentModelProfiles(path: string): Record<string, unknown> {
|
|
2241
|
+
if (!existsSync(path)) return {};
|
|
2242
|
+
try {
|
|
2243
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
2244
|
+
return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {};
|
|
2245
|
+
} catch {
|
|
2246
|
+
return {};
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
2250
|
+
async function readSubagentModelProfilesAsync(path: string): Promise<Record<string, unknown>> {
|
|
2251
|
+
if (!(await pathExists(path))) return {};
|
|
2252
|
+
try {
|
|
2253
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
2254
|
+
return isRecord(parsed) && isRecord(parsed.model_profiles) ? parsed.model_profiles : {};
|
|
2255
|
+
} catch {
|
|
2256
|
+
return {};
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
/**
|
|
2261
|
+
* The routing an agent is materialized with — what subagent launches actually
|
|
2262
|
+
* resolve — regardless of what `models.json` records: the runtime reads
|
|
2263
|
+
* `subagents.json` model profiles first and the agent frontmatter otherwise.
|
|
2264
|
+
*/
|
|
2265
|
+
function readMaterializedRoutingEntry(
|
|
2266
|
+
cwd: string,
|
|
2267
|
+
agent: AgentEntry,
|
|
2268
|
+
profilesByPath: Map<string, Record<string, unknown>>,
|
|
2269
|
+
): AgentRoutingEntry | undefined {
|
|
2270
|
+
const profilesPath = agentModelProfileConfigPath(cwd, agent.source);
|
|
2271
|
+
let profiles = profilesByPath.get(profilesPath);
|
|
2272
|
+
if (!profiles) {
|
|
2273
|
+
profiles = readSubagentModelProfiles(profilesPath);
|
|
2274
|
+
profilesByPath.set(profilesPath, profiles);
|
|
2275
|
+
}
|
|
2276
|
+
const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
|
|
2277
|
+
if (fromProfile) return fromProfile;
|
|
2278
|
+
if (!agent.filePath || !existsSync(agent.filePath)) return undefined;
|
|
2279
|
+
try {
|
|
2280
|
+
return readFrontmatterRouting(readFileSync(agent.filePath, "utf8"));
|
|
2281
|
+
} catch {
|
|
2282
|
+
return undefined;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
async function readMaterializedRoutingEntryAsync(
|
|
2287
|
+
cwd: string,
|
|
2288
|
+
agent: AgentEntry,
|
|
2289
|
+
profilesByPath: Map<string, Record<string, unknown>>,
|
|
2290
|
+
): Promise<AgentRoutingEntry | undefined> {
|
|
2291
|
+
const profilesPath = agentModelProfileConfigPath(cwd, agent.source);
|
|
2292
|
+
let profiles = profilesByPath.get(profilesPath);
|
|
2293
|
+
if (!profiles) {
|
|
2294
|
+
profiles = await readSubagentModelProfilesAsync(profilesPath);
|
|
2295
|
+
profilesByPath.set(profilesPath, profiles);
|
|
2296
|
+
}
|
|
2297
|
+
const fromProfile = routingEntryFromModelProfile(profiles[agent.name]);
|
|
2298
|
+
if (fromProfile) return fromProfile;
|
|
2299
|
+
if (!agent.filePath || !(await pathExists(agent.filePath))) return undefined;
|
|
2300
|
+
try {
|
|
2301
|
+
return readFrontmatterRouting(await readFile(agent.filePath, "utf8"));
|
|
2302
|
+
} catch {
|
|
2303
|
+
return undefined;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
/**
|
|
2308
|
+
* The routing in effect: `models.json` where it speaks, and the materialized
|
|
2309
|
+
* stores the runtime resolves from for every discoverable agent it is silent
|
|
2310
|
+
* about. A sparse `models.json` therefore never hides routing that is still
|
|
2311
|
+
* live (#1012). Reading never writes.
|
|
2312
|
+
*/
|
|
2313
|
+
function readEffectiveModelConfig(cwd: string): AgentModelConfig {
|
|
2314
|
+
const effective = cloneModelConfig(readModelConfig(cwd));
|
|
2315
|
+
const profilesByPath = new Map<string, Record<string, unknown>>();
|
|
2316
|
+
for (const agent of listDiscoverableAgents(cwd)) {
|
|
2317
|
+
if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
|
|
2318
|
+
const entry = readMaterializedRoutingEntry(cwd, agent, profilesByPath);
|
|
2319
|
+
if (entry) effective[agent.name] = entry;
|
|
2320
|
+
}
|
|
2321
|
+
return effective;
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
async function readEffectiveModelConfigAsync(cwd: string): Promise<AgentModelConfig> {
|
|
2325
|
+
const effective = cloneModelConfig(await readModelConfigAsync(cwd));
|
|
2326
|
+
const profilesByPath = new Map<string, Record<string, unknown>>();
|
|
2327
|
+
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2328
|
+
if (isProviderReviewRole(agent.name) || agent.name in effective) continue;
|
|
2329
|
+
const entry = await readMaterializedRoutingEntryAsync(cwd, agent, profilesByPath);
|
|
2330
|
+
if (entry) effective[agent.name] = entry;
|
|
2331
|
+
}
|
|
2332
|
+
return effective;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* A profile is a complete routing snapshot: applying it must leave every
|
|
2337
|
+
* discoverable agent it omits on inherit, not on whatever was materialized
|
|
2338
|
+
* before. Padding the omitted agents with clear entries makes
|
|
2339
|
+
* `applyModelConfig` remove their model profiles and frontmatter routing, the
|
|
2340
|
+
* same way `/gentle:models` clears an agent set to inherit.
|
|
2341
|
+
*/
|
|
2342
|
+
async function withOmittedAgentsClearedAsync(
|
|
2343
|
+
cwd: string,
|
|
2344
|
+
config: AgentModelConfig,
|
|
2345
|
+
): Promise<AgentModelConfig> {
|
|
2346
|
+
const completed = cloneModelConfig(config);
|
|
2347
|
+
for (const agent of await listDiscoverableAgentsAsync(cwd)) {
|
|
2348
|
+
if (isProviderReviewRole(agent.name) || agent.name in completed) continue;
|
|
2349
|
+
completed[agent.name] = {};
|
|
2350
|
+
}
|
|
2351
|
+
return completed;
|
|
2352
|
+
}
|
|
2353
|
+
|
|
2215
2354
|
function parseAgentName(filePath: string): string | undefined {
|
|
2216
2355
|
let content: string;
|
|
2217
2356
|
try {
|
|
@@ -2457,6 +2596,9 @@ function updateSubagentModelProfileAtPath(
|
|
|
2457
2596
|
? { ...config.model_profiles }
|
|
2458
2597
|
: {};
|
|
2459
2598
|
const profile = modelProfileForRoutingEntry(entry);
|
|
2599
|
+
// A write that would leave the profile as it is (including removing a
|
|
2600
|
+
// profile that was never there) is not an update and touches no file.
|
|
2601
|
+
if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
|
|
2460
2602
|
if (profile) {
|
|
2461
2603
|
if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
|
|
2462
2604
|
modelProfiles[name] = profile;
|
|
@@ -2487,6 +2629,9 @@ async function updateSubagentModelProfileAtPathAsync(
|
|
|
2487
2629
|
? { ...config.model_profiles }
|
|
2488
2630
|
: {};
|
|
2489
2631
|
const profile = modelProfileForRoutingEntry(entry);
|
|
2632
|
+
// A write that would leave the profile as it is (including removing a
|
|
2633
|
+
// profile that was never there) is not an update and touches no file.
|
|
2634
|
+
if (JSON.stringify(modelProfiles[name]) === JSON.stringify(profile)) return false;
|
|
2490
2635
|
if (profile) {
|
|
2491
2636
|
if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
|
|
2492
2637
|
modelProfiles[name] = profile;
|
|
@@ -3660,7 +3805,7 @@ class ProfilesPanel implements OverlayComponent {
|
|
|
3660
3805
|
this.renderLine("Profile routing", width, "accent"),
|
|
3661
3806
|
...this.indentLines(this.routingLines(profileRows, widths), width),
|
|
3662
3807
|
"",
|
|
3663
|
-
this.renderLine("Current routing (
|
|
3808
|
+
this.renderLine("Current routing (effective)", width, "accent"),
|
|
3664
3809
|
...this.indentLines(this.routingLines(currentRows, widths), width),
|
|
3665
3810
|
];
|
|
3666
3811
|
}
|
|
@@ -3819,6 +3964,13 @@ async function runProfilesPanelAction(
|
|
|
3819
3964
|
if (routingWritten && previousActiveConfig !== undefined) {
|
|
3820
3965
|
try {
|
|
3821
3966
|
await writeModelConfigAsync(ctx.cwd, previousActiveConfig);
|
|
3967
|
+
// Materialize the previous profile again with the same
|
|
3968
|
+
// replacement semantics, so the failed profile's routes do not
|
|
3969
|
+
// linger in subagents.json or the agent frontmatter.
|
|
3970
|
+
await applyModelConfigAsync(
|
|
3971
|
+
ctx.cwd,
|
|
3972
|
+
await withOmittedAgentsClearedAsync(ctx.cwd, previousActiveConfig),
|
|
3973
|
+
);
|
|
3822
3974
|
} catch {
|
|
3823
3975
|
restored = "";
|
|
3824
3976
|
}
|
|
@@ -3856,8 +4008,20 @@ async function runProfilesPanelAction(
|
|
|
3856
4008
|
);
|
|
3857
4009
|
return revertClaim(false);
|
|
3858
4010
|
}
|
|
3859
|
-
|
|
3860
|
-
|
|
4011
|
+
// models.json holds the profile as written; the padding with clear
|
|
4012
|
+
// entries only drives materialization, so agents the profile omits
|
|
4013
|
+
// return to inherit instead of keeping a previously materialized route.
|
|
4014
|
+
let applyResult: { updated: number; skipped: number };
|
|
4015
|
+
try {
|
|
4016
|
+
applyResult = await applyModelConfigAsync(
|
|
4017
|
+
ctx.cwd,
|
|
4018
|
+
await withOmittedAgentsClearedAsync(ctx.cwd, normalized),
|
|
4019
|
+
);
|
|
4020
|
+
} catch (error) {
|
|
4021
|
+
ctx.ui.notify(
|
|
4022
|
+
`el Gentleman could not materialize profile "${result.name}": ${profilesErrorMessage(error)}`,
|
|
4023
|
+
"warning",
|
|
4024
|
+
);
|
|
3861
4025
|
return revertClaim(true);
|
|
3862
4026
|
}
|
|
3863
4027
|
let orchestratorNote = "";
|
|
@@ -3899,7 +4063,7 @@ async function runProfilesPanelAction(
|
|
|
3899
4063
|
}
|
|
3900
4064
|
}
|
|
3901
4065
|
case "update": {
|
|
3902
|
-
const current = await
|
|
4066
|
+
const current = await readEffectiveModelConfigAsync(ctx.cwd);
|
|
3903
4067
|
// A profile is a complete snapshot, so capturing the current routing also
|
|
3904
4068
|
// captures the orchestrator the routing is running under. A settings file
|
|
3905
4069
|
// that cannot be read leaves the snapshot without an orchestrator entry
|
|
@@ -4035,7 +4199,7 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
4035
4199
|
}
|
|
4036
4200
|
let file: AgentProfilesFile;
|
|
4037
4201
|
if (read.status === "missing") {
|
|
4038
|
-
file = bootstrapProfilesFile(await
|
|
4202
|
+
file = bootstrapProfilesFile(await readEffectiveModelConfigAsync(ctx.cwd));
|
|
4039
4203
|
try {
|
|
4040
4204
|
writeProfilesFileSync(path, file);
|
|
4041
4205
|
} catch (error) {
|
|
@@ -4045,17 +4209,17 @@ async function handleProfilesCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
4045
4209
|
);
|
|
4046
4210
|
return;
|
|
4047
4211
|
}
|
|
4048
|
-
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the
|
|
4212
|
+
ctx.ui.notify(`el Gentleman seeded the "current" profile in ${path} from the routing currently in effect.`, "info");
|
|
4049
4213
|
} else {
|
|
4050
4214
|
file = read.file;
|
|
4051
4215
|
reportProfilesDrops(ctx, path, read.drops);
|
|
4052
4216
|
}
|
|
4053
4217
|
let selectedName: string | undefined;
|
|
4054
|
-
let result = await showProfilesPanel(ctx, file, await
|
|
4218
|
+
let result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
|
|
4055
4219
|
while (result.type !== "close") {
|
|
4056
4220
|
selectedName = "name" in result ? result.name : undefined;
|
|
4057
4221
|
file = await runProfilesPanelAction(ctx, path, file, result);
|
|
4058
|
-
result = await showProfilesPanel(ctx, file, await
|
|
4222
|
+
result = await showProfilesPanel(ctx, file, await readEffectiveModelConfigAsync(ctx.cwd), selectedName);
|
|
4059
4223
|
}
|
|
4060
4224
|
}
|
|
4061
4225
|
|
|
@@ -7927,6 +8091,8 @@ async function executeReviewControllerOperation(
|
|
|
7927
8091
|
/** @internal */
|
|
7928
8092
|
export const __testing = {
|
|
7929
8093
|
resolveReviewModeGate,
|
|
8094
|
+
readEffectiveModelConfig,
|
|
8095
|
+
readEffectiveModelConfigAsync,
|
|
7930
8096
|
listAgentsFromDir,
|
|
7931
8097
|
listAgentsFromDirAsync,
|
|
7932
8098
|
listDiscoverableAgents,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.4",
|
|
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",
|
package/tests/gentle-ai.test.ts
CHANGED
|
@@ -1875,6 +1875,102 @@ test("a profile store entry with only the orchestrator key counts zero roles", a
|
|
|
1875
1875
|
assert.match(applied, /Orchestrator set to nan\/glm5\.3 · high/);
|
|
1876
1876
|
});
|
|
1877
1877
|
|
|
1878
|
+
test("applying a profile replaces materialized routing for agents the profile omits", async (t) => {
|
|
1879
|
+
const { fixture, writeStore, writeSettings } = profilesStoreFixture(t);
|
|
1880
|
+
writeSettings();
|
|
1881
|
+
// Routing materialized earlier (a previous profile, /gentle:models, or a
|
|
1882
|
+
// migration) for an agent the new profile does not mention.
|
|
1883
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
1884
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\nthinking: high\n---\nbody\n");
|
|
1885
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
1886
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta", effort: "high" } } }, null, 2)}\n`);
|
|
1887
|
+
writeStore({ team: { worker: { model: "openai/alpha" } } });
|
|
1888
|
+
applyOnce(fixture);
|
|
1889
|
+
await fixture.run("gentle:profiles");
|
|
1890
|
+
|
|
1891
|
+
const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
1892
|
+
assert.deepEqual(profiles.model_profiles, { worker: { model: "openai/alpha" } }, "omitted agents lose their materialized route");
|
|
1893
|
+
const helper = readFileSync(helperPath, "utf8");
|
|
1894
|
+
assert.doesNotMatch(helper, /^model:/m);
|
|
1895
|
+
assert.doesNotMatch(helper, /^thinking:/m);
|
|
1896
|
+
assert.match(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /model: openai\/alpha/);
|
|
1897
|
+
// models.json stays the profile itself, not a padded copy.
|
|
1898
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { worker: { model: "openai/alpha" } });
|
|
1899
|
+
const applied = fixture.notifications.at(-1)?.message ?? "";
|
|
1900
|
+
assert.match(applied, /applied profile "team"/);
|
|
1901
|
+
});
|
|
1902
|
+
|
|
1903
|
+
test("a failed apply restores the previous profile's routing with the same replacement semantics", async (t) => {
|
|
1904
|
+
const { fixture, settingsPath, writeStore } = profilesStoreFixture(t);
|
|
1905
|
+
// An unreadable settings.json makes the orchestrator write fail after the
|
|
1906
|
+
// routing was already materialized, which triggers the rollback path.
|
|
1907
|
+
writeFileSync(settingsPath, "{ not json\n");
|
|
1908
|
+
const helperPath = join(fixture.root, ".pi", "agents", "helper.md");
|
|
1909
|
+
writeMarkdown(helperPath, "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
1910
|
+
const subagentsPath = join(fixture.root, ".pi", "subagents.json");
|
|
1911
|
+
writeFileSync(subagentsPath, `${JSON.stringify({ model_profiles: { helper: { model: "openai/beta" } } }, null, 2)}\n`);
|
|
1912
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
1913
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ helper: { model: "openai/beta" } }, null, 2)}\n`);
|
|
1914
|
+
// "team" is listed first, so Enter applies it while "old" stays the active one.
|
|
1915
|
+
writeStore({
|
|
1916
|
+
team: { orchestrator: { model: "nan/glm5.3" }, worker: { model: "openai/alpha" } },
|
|
1917
|
+
old: { helper: { model: "openai/beta" } },
|
|
1918
|
+
}, "old");
|
|
1919
|
+
applyOnce(fixture);
|
|
1920
|
+
await fixture.run("gentle:profiles");
|
|
1921
|
+
|
|
1922
|
+
const warning = fixture.notifications.find((entry) => /could not apply profile "team"/.test(entry.message));
|
|
1923
|
+
assert.ok(warning, "the failed apply is reported");
|
|
1924
|
+
assert.deepEqual(JSON.parse(readFileSync(fixture.globalPath, "utf8")), { helper: { model: "openai/beta" } });
|
|
1925
|
+
const profiles = JSON.parse(readFileSync(subagentsPath, "utf8"));
|
|
1926
|
+
assert.deepEqual(profiles.model_profiles, { helper: { model: "openai/beta" } }, "the previous routing is materialized again and the failed profile's routes are cleared");
|
|
1927
|
+
assert.match(readFileSync(helperPath, "utf8"), /model: openai\/beta/);
|
|
1928
|
+
assert.doesNotMatch(readFileSync(join(fixture.root, ".pi", "agents", "worker.md"), "utf8"), /^model:/m);
|
|
1929
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
1930
|
+
assert.equal(store.active, "old");
|
|
1931
|
+
});
|
|
1932
|
+
|
|
1933
|
+
test("the profiles command seeds and shows the routing the runtime uses when models.json is sparse", async (t) => {
|
|
1934
|
+
const { fixture } = profilesStoreFixture(t);
|
|
1935
|
+
// No models.json at all, but routing is materialized where the runtime
|
|
1936
|
+
// reads it: subagents.json for worker, frontmatter only for helper.
|
|
1937
|
+
writeMarkdown(join(fixture.root, ".pi", "agents", "helper.md"), "---\nname: helper\ndescription: Helper\nmodel: openai/beta\n---\nbody\n");
|
|
1938
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({ model_profiles: { worker: { model: "openai/alpha", effort: "high" } } }, null, 2)}\n`);
|
|
1939
|
+
let rendered: string | undefined;
|
|
1940
|
+
fixture.onInput((panel) => {
|
|
1941
|
+
rendered = stripAnsi(renderComponent(panel));
|
|
1942
|
+
panel.handleInput("\x1b");
|
|
1943
|
+
});
|
|
1944
|
+
await fixture.run("gentle:profiles");
|
|
1945
|
+
|
|
1946
|
+
const store = JSON.parse(readFileSync(join(fixture.configHome, "profiles.json"), "utf8"));
|
|
1947
|
+
assert.equal(store.active, "current", "materialized routing counts as existing routing");
|
|
1948
|
+
assert.deepEqual(store.profiles.current, {
|
|
1949
|
+
worker: { model: "openai/alpha", thinking: "high" },
|
|
1950
|
+
helper: { model: "openai/beta" },
|
|
1951
|
+
});
|
|
1952
|
+
assert.ok(rendered);
|
|
1953
|
+
assert.match(rendered, /Current routing \(effective\)/);
|
|
1954
|
+
assert.match(rendered, /worker\s+openai\/alpha\s+high/);
|
|
1955
|
+
assert.match(rendered, /helper\s+openai\/beta/);
|
|
1956
|
+
assert.doesNotMatch(rendered, /No routing entries/);
|
|
1957
|
+
});
|
|
1958
|
+
|
|
1959
|
+
test("effective routing prefers models.json over the materialized stores", (t) => {
|
|
1960
|
+
const fixture = routingConsumerFixture(t, ["worker", "helper"]);
|
|
1961
|
+
mkdirSync(fixture.configHome, { recursive: true });
|
|
1962
|
+
writeFileSync(fixture.globalPath, `${JSON.stringify({ worker: { model: "openai/alpha" } }, null, 2)}\n`);
|
|
1963
|
+
writeFileSync(join(fixture.root, ".pi", "subagents.json"), `${JSON.stringify({
|
|
1964
|
+
model_profiles: { worker: { model: "openai/beta", effort: "high" }, helper: { model: "openai/beta" } },
|
|
1965
|
+
}, null, 2)}\n`);
|
|
1966
|
+
const effective = JSON.parse(JSON.stringify(__testing.readEffectiveModelConfig(fixture.root)));
|
|
1967
|
+
assert.deepEqual(effective, {
|
|
1968
|
+
worker: { model: "openai/alpha" },
|
|
1969
|
+
helper: { model: "openai/beta" },
|
|
1970
|
+
});
|
|
1971
|
+
assert.equal(existsSync(join(fixture.root, ".pi", "gentle-ai", "models.json")), false, "reading never writes");
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1878
1974
|
test("the profiles panel fills the terminal, lists routing per agent, and scrolls", async (t) => {
|
|
1879
1975
|
const { fixture, writeStore } = profilesStoreFixture(t);
|
|
1880
1976
|
writeStore({
|
|
@@ -1903,7 +1999,7 @@ test("the profiles panel fills the terminal, lists routing per agent, and scroll
|
|
|
1903
1999
|
// Routing is listed one agent per line, aligned in columns, never collapsed
|
|
1904
2000
|
// into "N agents → model: a, b, …" summaries.
|
|
1905
2001
|
assert.match(text, /Profile routing/);
|
|
1906
|
-
assert.match(text, /Current routing \(
|
|
2002
|
+
assert.match(text, /Current routing \(effective\)/);
|
|
1907
2003
|
assert.match(text, /orchestrator\s+nan\/glm5\.3 · high/);
|
|
1908
2004
|
assert.match(text, /worker\s+openai\/alpha\s+high/);
|
|
1909
2005
|
assert.match(text, /sdd-design\s+nan\/glm5\.3\s+high/);
|
|
@@ -1509,9 +1509,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1509
1509
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1510
1510
|
});
|
|
1511
1511
|
|
|
1512
|
-
test("v2.6.
|
|
1512
|
+
test("v2.6.4 release package and runtime stop before publication", () => {
|
|
1513
1513
|
const packageJson = readPackageJson();
|
|
1514
|
-
assert.equal(packageJson.version, "2.6.
|
|
1514
|
+
assert.equal(packageJson.version, "2.6.4", "the release manifest must remain explicitly pinned to v2.6.4");
|
|
1515
1515
|
assert.equal(
|
|
1516
1516
|
packageJson.scripts?.test,
|
|
1517
1517
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|