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 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
  ---
@@ -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.3` with a package-local Gentle AI `v2.9.0` pin; this is not a claim that `2.6.3` is published.
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
 
@@ -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.3`; 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.
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`, reconciles agents, sets the orchestrator when the profile defines one). |
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 the existing `models.json`, marked active only when `models.json` has routing entries. Profiles or routing entries dropped by normalization are named in a warning instead of being lost silently.
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
 
@@ -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 (models.json)", width, "accent"),
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
- const applyResult = await applySavedModelConfig(ctx);
3860
- if (applyResult.invalidPath) {
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 readModelConfigAsync(ctx.cwd);
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 readModelConfigAsync(ctx.cwd));
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 saved model routing.`, "info");
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 readModelConfigAsync(ctx.cwd), selectedName);
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 readModelConfigAsync(ctx.cwd), selectedName);
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",
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",
@@ -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 \(models\.json\)/);
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.3 release package and runtime stop before publication", () => {
1512
+ test("v2.6.4 release package and runtime stop before publication", () => {
1513
1513
  const packageJson = readPackageJson();
1514
- assert.equal(packageJson.version, "2.6.3", "the release manifest must remain explicitly pinned to v2.6.3");
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",