gentle-pi 0.11.1 → 0.11.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 CHANGED
@@ -88,9 +88,9 @@ pi
88
88
  ## Quick start
89
89
 
90
90
  ```text
91
- /gentle-ai:status Check package, SDD assets, OpenSpec, and global model config.
92
- /gentle-ai:doctor Run read-only diagnostics for SDD assets, config, tools, and guards.
93
- /gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
91
+ /gentle:status Check package, SDD assets, OpenSpec, and global model config.
92
+ /gentle:doctor Run read-only diagnostics for SDD assets, config, tools, and guards.
93
+ /gentle:sdd-preflight Run or reuse the session SDD preflight explicitly.
94
94
  /sdd-init Create or refresh openspec/config.yaml.
95
95
  /gentle:models Assign global model/effort routing to SDD/custom agents.
96
96
  /gentle:persona Switch between gentleman and neutral persona modes.
@@ -100,7 +100,7 @@ pi
100
100
  Typical flow:
101
101
 
102
102
  1. Open Pi in your repo.
103
- 2. Run `/gentle-ai:status`.
103
+ 2. Run `/gentle:status`.
104
104
  3. Run `/sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight.
105
105
  4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes.
106
106
  5. Review the phase artifacts instead of trusting floating chat context.
@@ -228,7 +228,7 @@ Engram-only mode is different by design: Engram is working memory and does not m
228
228
 
229
229
  ## SDD preflight and project files
230
230
 
231
- `gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/sdd-init`, and the explicit `/gentle-ai:sdd-preflight` command run a lazy preflight and ask for session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle-ai:sdd-preflight` before continuing.
231
+ `gentle-pi` does not require SDD agents to be copied into every project. The package ensures global Pi SDD assets exist under the Pi agent home and treats project-local files only as overrides/debug copies. Slash SDD flows such as `/sdd-*`, `/sdd-init`, and the explicit `/gentle:sdd-preflight` command run a lazy preflight and ask for session-scoped SDD preferences. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle:sdd-preflight` before continuing.
232
232
 
233
233
  ```text
234
234
  ~/.pi/agent/agents/sdd-*.md
@@ -246,13 +246,12 @@ The preflight choices are reused for later SDD flows in the same session:
246
246
  It does **not** overwrite existing global assets unless you explicitly run:
247
247
 
248
248
  ```text
249
- /gentle-ai:install-sdd --force
249
+ /gentle:install-sdd --force
250
250
  ```
251
251
 
252
- Manual preflight commands:
252
+ Manual preflight command:
253
253
 
254
254
  ```text
255
- /gentle-ai:sdd-preflight
256
255
  /gentle:sdd-preflight
257
256
  ```
258
257
 
@@ -356,10 +355,12 @@ A project can still override the global default with:
356
355
 
357
356
  The modal discovers:
358
357
 
359
- - project agents in `.pi/agents/` and `.agents/`;
360
- - user agents in `~/.pi/agent/agents/` and `~/.agents/`;
358
+ - project agents in `.pi/subagents/`, `.pi/agents/`, and `.agents/`;
359
+ - user agents in `~/.pi/agent/subagents/`, `~/.pi/agent/agents/`, and `~/.agents/`;
361
360
  - built-in agents from `pi-subagents-j0k3r` when present.
362
361
 
362
+ When applying routing, project agents write runtime profiles to `.pi/subagents.json`; global and built-in agents write profiles to `~/.pi/agent/subagents.json`.
363
+
363
364
  Recommended model/effort shape:
364
365
 
365
366
  | Agent kind | Recommended model | Recommended effort (`thinking`) |
@@ -400,8 +401,8 @@ Legacy string entries are still accepted and treated as `model`-only config.
400
401
 
401
402
  | Command | What it does |
402
403
  | -------------------------------- | ------------------------------------------------------------------- |
403
- | `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and global model config status. |
404
- | `/gentle-ai:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. |
404
+ | `/gentle:status` | Shows package, SDD asset, OpenSpec, and global model config status. |
405
+ | `/gentle:doctor` | Runs read-only diagnostics for SDD assets, model/persona config, memory tools, and safety guards. |
405
406
  | `/gentle:models` | Opens global model + effort assignment UI. Press `x` to export and `r` to restore saved routing. |
406
407
  | `/gentle:persona` | Switches global persona mode, with project override support. |
407
408
  | `/gentle:banner` | Configures startup banner rose, text logo, and color preset. |
@@ -409,14 +410,14 @@ Legacy string entries are still accepted and treated as `model`-only config.
409
410
  | `/gentle:toggle-text-logo` | Toggles the startup text logo. |
410
411
  | `/gentle:banner-color` | Selects a startup banner color preset. |
411
412
  | `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
412
- | `/gentle-ai:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. |
413
- | `/gentle-ai:install-sdd --force` | Force-refreshes installed global SDD assets. |
413
+ | `/gentle:install-sdd` | Repairs missing global SDD runtime assets without overwriting files. |
414
+ | `/gentle:install-sdd --force` | Force-refreshes installed global SDD assets. |
414
415
  | `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
415
416
  | `/skill-creation` | Creates or updates an LLM-first skill using the packaged `skill-creator` contract and style guide. |
416
417
 
417
418
  Package-owned global SDD runtime assets are also refreshed automatically on session start when `gentle-pi` changes. Project-local `.pi/agents` and `.pi/chains` remain manual overrides and are never overwritten by startup refresh.
418
419
 
419
- Startup banner settings are global and default to the current pink rose + text logo. Supported color presets are `pink`, `cyan`, `yellow`, and `green`. The `/gentle-ai:*` aliases are also available for every banner command.
420
+ Startup banner settings are global and default to the current pink rose + text logo. Supported color presets are `pink`, `cyan`, `yellow`, and `green`.
420
421
 
421
422
  Startup flag:
422
423
 
@@ -426,19 +427,6 @@ pi --no-skill-registry
426
427
 
427
428
  Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading.
428
429
 
429
- Compatibility aliases:
430
-
431
- ```text
432
- /gentle-ai:models
433
- /gentleman:models
434
- /gentle-ai:persona
435
- /gentleman:persona
436
- /gentle-ai:banner
437
- /gentle-ai:toggle-rose
438
- /gentle-ai:toggle-text-logo
439
- /gentle-ai:banner-color
440
- ```
441
-
442
430
  ## Included skills
443
431
 
444
432
  - `gentle-ai` — harness discipline for controlled Pi work.
@@ -55,14 +55,14 @@ Do not guess the active change. If change selection is ambiguous, ask the user a
55
55
 
56
56
  ## Lazy SDD Preflight
57
57
 
58
- Do not ask SDD setup questions on session start. The first time the user initiates an SDD process in a Pi session, run the SDD preflight once and keep those choices for the rest of that session. Runtime trigger detection is intentionally deterministic: slash SDD flows and `/sdd-init` run preflight automatically; for natural-language requests, the parent/orchestrator decides semantically whether SDD is needed and must run/reuse `/gentle-ai:sdd-preflight` before continuing.
58
+ Do not ask SDD setup questions on session start. The first time the user initiates an SDD process in a Pi session, run the SDD preflight once and keep those choices for the rest of that session. Runtime trigger detection is intentionally deterministic: slash SDD flows and `/sdd-init` run preflight automatically; for natural-language requests, the parent/orchestrator decides semantically whether SDD is needed and must run/reuse `/gentle:sdd-preflight` before continuing.
59
59
 
60
60
  **Hard gate:** `openspec/config.yaml`, existing SDD changes, installed `.pi`/global SDD assets, or a todo named "preflight" are not session preflight. They are project context only. Do not mark SDD preflight complete, start `sdd-init`, launch SDD subagents/chains, or move to explore/proposal/spec/design/tasks until this session has either:
61
61
 
62
62
  1. an injected `## SDD Session Preflight` block, or
63
63
  2. an explicit user answer in the current conversation covering all four preflight choices below.
64
64
 
65
- If neither exists and `/gentle-ai:sdd-preflight` cannot be invoked from the current context, ask the four choices manually with `ask_user_question` before any SDD phase work. Treat missing Engram availability as a reason to ask/confirm artifact store, not as permission to assume defaults.
65
+ If neither exists and `/gentle:sdd-preflight` cannot be invoked from the current context, ask the four choices manually with `ask_user_question` before any SDD phase work. Treat missing Engram availability as a reason to ask/confirm artifact store, not as permission to assume defaults.
66
66
 
67
67
  The preflight captures:
68
68
 
@@ -78,7 +78,7 @@ The package should ensure SDD assets are present as global Pi runtime assets wit
78
78
  ~/.pi/agent/chains/sdd-*.chain.md
79
79
  ```
80
80
 
81
- Manual install commands are recovery/debug paths, not the happy path. `/gentle-ai:sdd-preflight` and `/gentle:sdd-preflight` are the explicit preflight commands for agent/orchestrator use. If the user explicitly changes SDD preferences later in the same session, follow the new instruction.
81
+ Manual install commands are recovery/debug paths, not the happy path. `/gentle:sdd-preflight` is the explicit preflight command for agent/orchestrator use. If the user explicitly changes SDD preferences later in the same session, follow the new instruction.
82
82
 
83
83
  ## Init Guard
84
84
 
@@ -132,6 +132,28 @@ skill_resolution
132
132
 
133
133
  The parent should synthesize these envelopes, not paste long raw reports unless needed.
134
134
 
135
+ ## Automatic Mode Gatekeeper
136
+
137
+ In `auto` execution mode, the parent/orchestrator is the quality gate between SDD phases. After a delegated phase returns and before launching the next phase, validate that the phase actually reached its objective. This validation is autonomous: do not ask the user on the happy path, but stop and report if the gate catches a real problem.
138
+
139
+ Check every phase result against the Result Contract:
140
+
141
+ - **Contract conformance:** the phase returned `status`, `executive_summary`, `artifacts`, `next_recommended`, `risks`, and `skill_resolution`, and `status` indicates success rather than partial, failed, or blocked.
142
+ - **Artifact existence:** every declared artifact exists and is readable in the active backend. For memory-backed flows, retrieve the topic with the available memory tools; for OpenSpec/file-backed flows, read the declared path. A successful phase with no retrievable artifact fails the gate.
143
+ - **No hallucinated references:** spot-check concrete file paths, symbols, commands, and artifacts the phase claims it created or used. Referenced paths or artifacts that do not resolve fail the gate.
144
+ - **No scope drift:** the output must stay consistent with its inputs and the dependency graph: spec stays within proposal scope, design answers the proposal, tasks cover spec and design, apply implements the tasks, verify checks the implementation against the spec, and sync reflects the verified state before archive.
145
+ - **Routing coherence:** `next_recommended` must follow the SDD dependency graph, and no unaddressed critical risk may be carried silently into the next phase.
146
+
147
+ Use cost-aware validation:
148
+
149
+ - For lower-risk phases (`sdd-explore`, `sdd-spec`, `sdd-tasks`, `sdd-sync`, `sdd-archive`), the parent may validate inline by reading artifacts back and checking claims.
150
+ - For higher-risk phases (`sdd-design`, `sdd-apply`), run fresh-context validation/review before continuing because errors there compound downstream.
151
+ - If an inline gate finds any smell — missing artifact, status mismatch, unresolved path, likely drift, or critical risk — escalate to fresh-context validation before deciding.
152
+
153
+ On gate pass, continue automatically to the next phase. On gate fail, rerun the same phase exactly once with corrective feedback naming the specific failures. Validate the rerun. If it fails again, stop the automatic chain and report the phase, failures from both attempts, and the recommended fix. Never advance to dependent phases on a failed gate.
154
+
155
+ The gatekeeper is additive: it does not relax the Review Workload Guard, Strict TDD Forwarding, native status dependency checks, or mandatory delegation/review rules.
156
+
135
157
  ## SDD Phase Delegation Mode
136
158
 
137
159
  Launch SDD phase subagents with `subagent_run` `mode: "task"` when the parent needs the phase result to route the next step. Do not use `mode: "background"` for SDD phases that must feed continuation; background completion is a notification/history mechanism, not an orchestration resume guarantee.
@@ -1004,14 +1004,16 @@ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
1004
1004
  }
1005
1005
 
1006
1006
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
1007
- const globalAgentDir = join(gentlePiAgentHome(), "agents");
1007
+ const globalAgentHome = gentlePiAgentHome();
1008
1008
  const builtinDirs = builtinAgentDirs(cwd);
1009
1009
  const agents = [
1010
1010
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
1011
- ...listAgentsFromDir(globalAgentDir, "user"),
1011
+ ...listAgentsFromDir(join(globalAgentHome, "agents"), "user"),
1012
+ ...listAgentsFromDir(join(globalAgentHome, "subagents"), "user"),
1012
1013
  ...listAgentsFromDir(join(homedir(), ".agents"), "user"),
1013
1014
  ...listAgentsFromDir(join(cwd, ".agents"), "project"),
1014
1015
  ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
1016
+ ...listAgentsFromDir(join(cwd, ".pi", "subagents"), "project"),
1015
1017
  ];
1016
1018
  const byName = new Map<string, AgentEntry>();
1017
1019
  for (const agent of agents) byName.set(agent.name, agent);
@@ -1019,17 +1021,19 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1019
1021
  }
1020
1022
 
1021
1023
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
1022
- const globalAgentDir = join(gentlePiAgentHome(), "agents");
1024
+ const globalAgentHome = gentlePiAgentHome();
1023
1025
  const builtinDirs = builtinAgentDirs(cwd);
1024
1026
  const agents: AgentEntry[] = [];
1025
1027
  for (const dir of builtinDirs) {
1026
1028
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
1027
1029
  }
1028
1030
  const otherDirs: Array<[string, AgentSource]> = [
1029
- [globalAgentDir, "user"],
1031
+ [join(globalAgentHome, "agents"), "user"],
1032
+ [join(globalAgentHome, "subagents"), "user"],
1030
1033
  [join(homedir(), ".agents"), "user"],
1031
1034
  [join(cwd, ".agents"), "project"],
1032
1035
  [join(cwd, ".pi", "agents"), "project"],
1036
+ [join(cwd, ".pi", "subagents"), "project"],
1033
1037
  ];
1034
1038
  for (const [dir, source] of otherDirs) {
1035
1039
  agents.push(...(await listAgentsFromDirAsync(dir, source)));
@@ -1049,92 +1053,179 @@ function orderDiscoverableAgents(agents: AgentEntry[]): AgentEntry[] {
1049
1053
  return [...coreFirst, ...rest];
1050
1054
  }
1051
1055
 
1052
- function projectSettingsPath(cwd: string): string {
1053
- return join(cwd, ".pi", "settings.json");
1054
- }
1055
-
1056
1056
  function isClearRoutingEntry(entry: AgentRoutingEntry): boolean {
1057
1057
  return entry.model === undefined && entry.thinking === undefined;
1058
1058
  }
1059
1059
 
1060
- function updateBuiltinModelOverride(
1061
- cwd: string,
1060
+ function agentModelProfileConfigPath(cwd: string, source: AgentSource): string {
1061
+ return source === "project"
1062
+ ? join(cwd, ".pi", "subagents.json")
1063
+ : join(gentlePiAgentHome(), "subagents.json");
1064
+ }
1065
+
1066
+ function modelProfileForRoutingEntry(
1067
+ entry: AgentRoutingEntry | undefined,
1068
+ ): Record<string, string> | undefined {
1069
+ if (!entry || isClearRoutingEntry(entry)) return undefined;
1070
+ const profile: Record<string, string> = {};
1071
+ if (entry.model) profile.model = entry.model;
1072
+ if (entry.thinking) profile.effort = entry.thinking;
1073
+ return Object.keys(profile).length > 0 ? profile : undefined;
1074
+ }
1075
+
1076
+ function updateSubagentModelProfileAtPath(
1077
+ path: string,
1062
1078
  name: string,
1063
1079
  entry: AgentRoutingEntry | undefined,
1080
+ options: { preserveExisting?: boolean } = {},
1064
1081
  ): boolean {
1065
- const path = projectSettingsPath(cwd);
1066
- let settings: Record<string, unknown> = {};
1082
+ let config: Record<string, unknown> = {};
1067
1083
  if (existsSync(path)) {
1068
1084
  try {
1069
1085
  const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
1070
- if (isRecord(parsed)) settings = parsed;
1086
+ if (isRecord(parsed)) config = { ...parsed };
1071
1087
  } catch {
1072
- settings = {};
1088
+ config = {};
1073
1089
  }
1074
1090
  }
1075
- const subagents = isRecord(settings.subagents)
1076
- ? { ...settings.subagents }
1077
- : {};
1078
- const agentOverrides = isRecord(subagents.agentOverrides)
1079
- ? { ...subagents.agentOverrides }
1080
- : {};
1081
- const current = isRecord(agentOverrides[name])
1082
- ? { ...agentOverrides[name] }
1091
+ const modelProfiles = isRecord(config.model_profiles)
1092
+ ? { ...config.model_profiles }
1083
1093
  : {};
1084
- if (entry?.model === undefined) delete current.model;
1085
- else current.model = entry.model;
1086
- if (entry?.thinking === undefined) delete current.thinking;
1087
- else current.thinking = entry.thinking;
1088
- if (Object.keys(current).length > 0) agentOverrides[name] = current;
1089
- else delete agentOverrides[name];
1090
- if (Object.keys(agentOverrides).length > 0)
1091
- subagents.agentOverrides = agentOverrides;
1092
- else delete subagents.agentOverrides;
1093
- if (Object.keys(subagents).length > 0) settings.subagents = subagents;
1094
- else delete settings.subagents;
1094
+ const profile = modelProfileForRoutingEntry(entry);
1095
+ if (profile) {
1096
+ if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
1097
+ modelProfiles[name] = profile;
1098
+ } else delete modelProfiles[name];
1099
+ if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles;
1100
+ else delete config.model_profiles;
1095
1101
  mkdirSync(dirname(path), { recursive: true });
1096
- writeFileSync(path, `${JSON.stringify(settings, null, "\t")}\n`);
1102
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
1097
1103
  return true;
1098
1104
  }
1099
1105
 
1100
- async function updateBuiltinModelOverrideAsync(
1101
- cwd: string,
1106
+ async function updateSubagentModelProfileAtPathAsync(
1107
+ path: string,
1102
1108
  name: string,
1103
1109
  entry: AgentRoutingEntry | undefined,
1110
+ options: { preserveExisting?: boolean } = {},
1104
1111
  ): Promise<boolean> {
1105
- const path = projectSettingsPath(cwd);
1106
- let settings: Record<string, unknown> = {};
1112
+ let config: Record<string, unknown> = {};
1107
1113
  if (await pathExists(path)) {
1108
1114
  try {
1109
1115
  const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
1110
- if (isRecord(parsed)) settings = parsed;
1116
+ if (isRecord(parsed)) config = { ...parsed };
1111
1117
  } catch {
1112
- settings = {};
1118
+ config = {};
1113
1119
  }
1114
1120
  }
1121
+ const modelProfiles = isRecord(config.model_profiles)
1122
+ ? { ...config.model_profiles }
1123
+ : {};
1124
+ const profile = modelProfileForRoutingEntry(entry);
1125
+ if (profile) {
1126
+ if (options.preserveExisting && isRecord(modelProfiles[name])) return false;
1127
+ modelProfiles[name] = profile;
1128
+ } else delete modelProfiles[name];
1129
+ if (Object.keys(modelProfiles).length > 0) config.model_profiles = modelProfiles;
1130
+ else delete config.model_profiles;
1131
+ await mkdir(dirname(path), { recursive: true });
1132
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`);
1133
+ return true;
1134
+ }
1135
+
1136
+ function updateSubagentModelProfile(
1137
+ cwd: string,
1138
+ source: AgentSource,
1139
+ name: string,
1140
+ entry: AgentRoutingEntry | undefined,
1141
+ options: { preserveExisting?: boolean } = {},
1142
+ ): boolean {
1143
+ return updateSubagentModelProfileAtPath(
1144
+ agentModelProfileConfigPath(cwd, source),
1145
+ name,
1146
+ entry,
1147
+ options,
1148
+ );
1149
+ }
1150
+
1151
+ function projectSettingsPath(cwd: string): string {
1152
+ return join(cwd, ".pi", "settings.json");
1153
+ }
1154
+
1155
+ function removeLegacyAgentOverridesFromSettings(
1156
+ settingsPath: string,
1157
+ settings: Record<string, unknown>,
1158
+ ): void {
1115
1159
  const subagents = isRecord(settings.subagents)
1116
1160
  ? { ...settings.subagents }
1117
- : {};
1118
- const agentOverrides = isRecord(subagents.agentOverrides)
1119
- ? { ...subagents.agentOverrides }
1120
- : {};
1121
- const current = isRecord(agentOverrides[name])
1122
- ? { ...agentOverrides[name] }
1123
- : {};
1124
- if (entry?.model === undefined) delete current.model;
1125
- else current.model = entry.model;
1126
- if (entry?.thinking === undefined) delete current.thinking;
1127
- else current.thinking = entry.thinking;
1128
- if (Object.keys(current).length > 0) agentOverrides[name] = current;
1129
- else delete agentOverrides[name];
1130
- if (Object.keys(agentOverrides).length > 0)
1131
- subagents.agentOverrides = agentOverrides;
1132
- else delete subagents.agentOverrides;
1161
+ : undefined;
1162
+ if (!subagents) return;
1163
+ delete subagents.agentOverrides;
1133
1164
  if (Object.keys(subagents).length > 0) settings.subagents = subagents;
1134
1165
  else delete settings.subagents;
1135
- await mkdir(dirname(path), { recursive: true });
1136
- await writeFile(path, `${JSON.stringify(settings, null, "\t")}\n`);
1137
- return true;
1166
+ mkdirSync(dirname(settingsPath), { recursive: true });
1167
+ writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
1168
+ }
1169
+
1170
+ function isValidJsonObjectFileOrMissing(path: string): boolean {
1171
+ if (!existsSync(path)) return true;
1172
+ try {
1173
+ return isRecord(JSON.parse(readFileSync(path, "utf8")));
1174
+ } catch {
1175
+ return false;
1176
+ }
1177
+ }
1178
+
1179
+ function migrateLegacyProjectModelOverrides(cwd: string): number {
1180
+ const settingsPath = projectSettingsPath(cwd);
1181
+ if (!existsSync(settingsPath)) return 0;
1182
+ let settings: Record<string, unknown>;
1183
+ try {
1184
+ const parsed: unknown = JSON.parse(readFileSync(settingsPath, "utf8"));
1185
+ if (!isRecord(parsed)) return 0;
1186
+ settings = { ...parsed };
1187
+ } catch {
1188
+ return 0;
1189
+ }
1190
+ const subagents = isRecord(settings.subagents) ? settings.subagents : undefined;
1191
+ const agentOverrides = isRecord(subagents?.agentOverrides)
1192
+ ? subagents.agentOverrides
1193
+ : undefined;
1194
+ if (!agentOverrides) return 0;
1195
+ const agentsByName = new Map(listDiscoverableAgents(cwd).map((agent) => [agent.name, agent]));
1196
+ const migratableEntries = Object.entries(agentOverrides)
1197
+ .map(([name, value]) => ({ name, entry: normalizeRoutingEntry(value) }))
1198
+ .filter((item): item is { name: string; entry: AgentRoutingEntry } =>
1199
+ item.entry !== undefined && !isClearRoutingEntry(item.entry),
1200
+ );
1201
+ const targetPaths = new Set(
1202
+ migratableEntries.map(({ name }) =>
1203
+ agentModelProfileConfigPath(cwd, agentsByName.get(name)?.source ?? "project"),
1204
+ ),
1205
+ );
1206
+ if (![...targetPaths].every(isValidJsonObjectFileOrMissing)) return 0;
1207
+ let migrated = 0;
1208
+ for (const { name, entry } of migratableEntries) {
1209
+ const source = agentsByName.get(name)?.source ?? "project";
1210
+ if (updateSubagentModelProfile(cwd, source, name, entry, { preserveExisting: true })) migrated += 1;
1211
+ }
1212
+ removeLegacyAgentOverridesFromSettings(settingsPath, settings);
1213
+ return migrated;
1214
+ }
1215
+
1216
+ async function updateSubagentModelProfileAsync(
1217
+ cwd: string,
1218
+ source: AgentSource,
1219
+ name: string,
1220
+ entry: AgentRoutingEntry | undefined,
1221
+ options: { preserveExisting?: boolean } = {},
1222
+ ): Promise<boolean> {
1223
+ return updateSubagentModelProfileAtPathAsync(
1224
+ agentModelProfileConfigPath(cwd, source),
1225
+ name,
1226
+ entry,
1227
+ options,
1228
+ );
1138
1229
  }
1139
1230
 
1140
1231
  export function applyModelConfig(
@@ -1143,28 +1234,17 @@ export function applyModelConfig(
1143
1234
  ): { updated: number; skipped: number } {
1144
1235
  let updated = 0;
1145
1236
  let skipped = 0;
1146
- const builtinNames = listBuiltinAgentNames(cwd);
1147
1237
  const seenAgents = new Set<string>();
1148
1238
  for (const agent of listDiscoverableAgents(cwd)) {
1149
1239
  seenAgents.add(agent.name);
1150
1240
  const entry = config[agent.name];
1151
- if (agent.source === "builtin") {
1152
- if (entry === undefined) {
1153
- skipped += 1;
1154
- continue;
1155
- }
1156
- if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
1157
- else skipped += 1;
1158
- continue;
1159
- }
1160
1241
  if (entry === undefined) {
1161
1242
  skipped += 1;
1162
1243
  continue;
1163
1244
  }
1164
- if (builtinNames.has(agent.name) || isClearRoutingEntry(entry)) {
1165
- if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
1166
- else skipped += 1;
1167
- }
1245
+ if (updateSubagentModelProfile(cwd, agent.source, agent.name, entry)) updated += 1;
1246
+ else skipped += 1;
1247
+ if (agent.source === "builtin") continue;
1168
1248
  if (!agent.filePath || !existsSync(agent.filePath)) {
1169
1249
  skipped += 1;
1170
1250
  continue;
@@ -1180,7 +1260,7 @@ export function applyModelConfig(
1180
1260
  }
1181
1261
  for (const [name, entry] of Object.entries(config)) {
1182
1262
  if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1183
- if (updateBuiltinModelOverride(cwd, name, entry)) updated += 1;
1263
+ if (updateSubagentModelProfile(cwd, "user", name, entry)) updated += 1;
1184
1264
  else skipped += 1;
1185
1265
  }
1186
1266
  }
@@ -1193,30 +1273,18 @@ export async function applyModelConfigAsync(
1193
1273
  ): Promise<{ updated: number; skipped: number }> {
1194
1274
  let updated = 0;
1195
1275
  let skipped = 0;
1196
- const builtinNames = await listBuiltinAgentNamesAsync(cwd);
1197
1276
  const seenAgents = new Set<string>();
1198
1277
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
1199
1278
  seenAgents.add(agent.name);
1200
1279
  const entry = config[agent.name];
1201
- if (agent.source === "builtin") {
1202
- if (entry === undefined) {
1203
- skipped += 1;
1204
- continue;
1205
- }
1206
- if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
1207
- updated += 1;
1208
- else skipped += 1;
1209
- continue;
1210
- }
1211
1280
  if (entry === undefined) {
1212
1281
  skipped += 1;
1213
1282
  continue;
1214
1283
  }
1215
- if (builtinNames.has(agent.name) || isClearRoutingEntry(entry)) {
1216
- if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
1217
- updated += 1;
1218
- else skipped += 1;
1219
- }
1284
+ if (await updateSubagentModelProfileAsync(cwd, agent.source, agent.name, entry))
1285
+ updated += 1;
1286
+ else skipped += 1;
1287
+ if (agent.source === "builtin") continue;
1220
1288
  if (!agent.filePath || !(await pathExists(agent.filePath))) {
1221
1289
  skipped += 1;
1222
1290
  continue;
@@ -1232,7 +1300,7 @@ export async function applyModelConfigAsync(
1232
1300
  }
1233
1301
  for (const [name, entry] of Object.entries(config)) {
1234
1302
  if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1235
- if (await updateBuiltinModelOverrideAsync(cwd, name, entry))
1303
+ if (await updateSubagentModelProfileAsync(cwd, "user", name, entry))
1236
1304
  updated += 1;
1237
1305
  else skipped += 1;
1238
1306
  }
@@ -1785,6 +1853,7 @@ async function showSddModelPanel(
1785
1853
  }
1786
1854
 
1787
1855
  async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
1856
+ migrateLegacyProjectModelOverrides(ctx.cwd);
1788
1857
  const savedConfig = await readSavedModelConfigAsync(ctx.cwd);
1789
1858
  if (savedConfig.status === "invalid") {
1790
1859
  ctx.ui.notify(
@@ -2078,6 +2147,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2078
2147
  pi.on("session_start", async (_event, ctx) => {
2079
2148
  try {
2080
2149
  const installResult = installSddAssets(ctx.cwd, true);
2150
+ migrateLegacyProjectModelOverrides(ctx.cwd);
2081
2151
  const modelResult = await applySavedModelConfig(ctx);
2082
2152
  if (ctx.hasUI && modelResult.invalidPath) {
2083
2153
  ctx.ui.notify(
@@ -2153,7 +2223,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2153
2223
  return confirmCommand(event.input.command, ctx);
2154
2224
  });
2155
2225
 
2156
- pi.registerCommand("gentle-ai:install-sdd", {
2226
+ pi.registerCommand("gentle:install-sdd", {
2157
2227
  description:
2158
2228
  "Repair or refresh global Gentle AI SDD subagent and chain assets.",
2159
2229
  handler: async (args, ctx) => {
@@ -2166,7 +2236,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2166
2236
  },
2167
2237
  });
2168
2238
 
2169
- pi.registerCommand("gentle-ai:sdd-preflight", {
2239
+ pi.registerCommand("gentle:sdd-preflight", {
2170
2240
  description:
2171
2241
  "Run or reuse the lazy SDD preflight for this Pi session.",
2172
2242
  handler: async (_args, ctx) => {
@@ -2174,13 +2244,6 @@ export default function gentleAi(pi: ExtensionAPI): void {
2174
2244
  },
2175
2245
  });
2176
2246
 
2177
- pi.registerCommand("gentle:sdd-preflight", {
2178
- description: "Compatibility alias for /gentle-ai:sdd-preflight.",
2179
- handler: async (_args, ctx) => {
2180
- await runSddPreflight(ctx);
2181
- },
2182
- });
2183
-
2184
2247
  const handleSddStatusCommand = (args: string, ctx: ExtensionContext) => {
2185
2248
  const parsed = parseSddStatusCommandArgs(args);
2186
2249
  const status = resolveSddStatus({
@@ -2202,13 +2265,6 @@ export default function gentleAi(pi: ExtensionAPI): void {
2202
2265
  },
2203
2266
  });
2204
2267
 
2205
- pi.registerCommand("gentle-ai:sdd-status", {
2206
- description: "Compatibility alias for /sdd-status.",
2207
- handler: async (args, ctx) => {
2208
- handleSddStatusCommand(args, ctx);
2209
- },
2210
- });
2211
-
2212
2268
  const handleSddContinueCommand = (args: string, ctx: ExtensionContext) => {
2213
2269
  const parsed = parseSddStatusCommandArgs(args);
2214
2270
  const status = resolveSddStatus({
@@ -2230,13 +2286,6 @@ export default function gentleAi(pi: ExtensionAPI): void {
2230
2286
  },
2231
2287
  });
2232
2288
 
2233
- pi.registerCommand("gentle-ai:sdd-continue", {
2234
- description: "Compatibility alias for /sdd-continue.",
2235
- handler: async (args, ctx) => {
2236
- handleSddContinueCommand(args, ctx);
2237
- },
2238
- });
2239
-
2240
2289
  pi.registerCommand("gentle:models", {
2241
2290
  description: "Configure global per-agent models for el Gentleman.",
2242
2291
  handler: async (_args, ctx) => {
@@ -2244,20 +2293,6 @@ export default function gentleAi(pi: ExtensionAPI): void {
2244
2293
  },
2245
2294
  });
2246
2295
 
2247
- pi.registerCommand("gentle-ai:models", {
2248
- description: "Compatibility alias for /gentle:models.",
2249
- handler: async (_args, ctx) => {
2250
- await handleModelsCommand(ctx);
2251
- },
2252
- });
2253
-
2254
- pi.registerCommand("gentleman:models", {
2255
- description: "Compatibility alias for /gentle:models.",
2256
- handler: async (_args, ctx) => {
2257
- await handleModelsCommand(ctx);
2258
- },
2259
- });
2260
-
2261
2296
  pi.registerCommand("gentle:persona", {
2262
2297
  description: "Switch el Gentleman persona between gentleman and neutral.",
2263
2298
  handler: async (_args, ctx) => {
@@ -2265,21 +2300,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2265
2300
  },
2266
2301
  });
2267
2302
 
2268
- pi.registerCommand("gentle-ai:persona", {
2269
- description: "Compatibility alias for /gentle:persona.",
2270
- handler: async (_args, ctx) => {
2271
- await handlePersonaCommand(ctx);
2272
- },
2273
- });
2274
-
2275
- pi.registerCommand("gentleman:persona", {
2276
- description: "Compatibility alias for /gentle:persona.",
2277
- handler: async (_args, ctx) => {
2278
- await handlePersonaCommand(ctx);
2279
- },
2280
- });
2281
-
2282
- pi.registerCommand("gentle-ai:doctor", {
2303
+ pi.registerCommand("gentle:doctor", {
2283
2304
  description: "Run read-only Gentle AI diagnostics for this Pi workspace.",
2284
2305
  handler: async (_args, ctx) => {
2285
2306
  const agentsInstalled = existsSync(
@@ -2311,7 +2332,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2311
2332
  `${engramActive ? "pass" : "warn"}: Engram memory tools ${engramActive ? "active" : "not active in this session"}`,
2312
2333
  ];
2313
2334
  if (!agentsInstalled || !chainsInstalled) {
2314
- lines.push("remedy: run /gentle-ai:install-sdd --force to refresh global SDD assets intentionally");
2335
+ lines.push("remedy: run /gentle:install-sdd --force to refresh global SDD assets intentionally");
2315
2336
  }
2316
2337
  if (modelConfig.status === "invalid") {
2317
2338
  lines.push(`remedy: fix or remove ${modelConfig.path}`);
@@ -2326,7 +2347,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2326
2347
  },
2327
2348
  });
2328
2349
 
2329
- pi.registerCommand("gentle-ai:status", {
2350
+ pi.registerCommand("gentle:status", {
2330
2351
  description: "Show Gentle AI package status for this project.",
2331
2352
  handler: async (_args, ctx) => {
2332
2353
  const agentsInstalled = existsSync(
@@ -2349,7 +2370,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
2349
2370
  `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
2350
2371
  `Global SDD assets stale: ${staleSddAssets} file(s)${
2351
2372
  staleSddAssets > 0
2352
- ? " — run /gentle-ai:install-sdd --force to refresh intentionally"
2373
+ ? " — run /gentle:install-sdd --force to refresh intentionally"
2353
2374
  : ""
2354
2375
  }`,
2355
2376
  `Project-local SDD agent overrides: ${localSddAgentOverrides} file(s)${
@@ -585,12 +585,10 @@ export default function (pi: ExtensionAPI) {
585
585
  },
586
586
  });
587
587
  };
588
- for (const prefix of ["gentle", "gentle-ai"] as const) {
589
- registerBannerCommand(`${prefix}:banner`);
590
- registerToggleCommand(`${prefix}:toggle-rose`, "showRose");
591
- registerToggleCommand(`${prefix}:toggle-text-logo`, "showTextLogo");
592
- registerColorCommand(`${prefix}:banner-color`);
593
- }
588
+ registerBannerCommand("gentle:banner");
589
+ registerToggleCommand("gentle:toggle-rose", "showRose");
590
+ registerToggleCommand("gentle:toggle-text-logo", "showTextLogo");
591
+ registerColorCommand("gentle:banner-color");
594
592
 
595
593
  pi.on("session_start", async (_event, ctx) => {
596
594
  if (!ctx.hasUI) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.11.1",
3
+ "version": "0.11.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",
@@ -69,4 +69,4 @@ Never request a subagent named `reviewer`; it is an intent, not an installed age
69
69
 
70
70
  If multiple rows match, run the narrow set that covers the risk. Example: shell integration that mutates live state should use `review-reliability` plus `review-resilience`, not `review-readability` by default.
71
71
 
72
- The package ensures SDD agents and chains are available as global Pi runtime assets. Project-local SDD files are overrides/debug copies only. Use `/gentle-ai:install-sdd --force` only for recovery or intentional global refresh.
72
+ The package ensures SDD agents and chains are available as global Pi runtime assets. Project-local SDD files are overrides/debug copies only. Use `/gentle:install-sdd --force` only for recovery or intentional global refresh.
@@ -22,31 +22,37 @@ const EXPECTED_BANNER_COMMANDS = [
22
22
  "gentle:toggle-rose",
23
23
  "gentle:toggle-text-logo",
24
24
  "gentle:banner-color",
25
- "gentle-ai:banner",
26
- "gentle-ai:toggle-rose",
27
- "gentle-ai:toggle-text-logo",
28
- "gentle-ai:banner-color",
29
25
  ];
30
26
 
31
27
  const EXPECTED_COMMANDS = [
32
- "gentle-ai:install-sdd",
33
- "gentle-ai:sdd-preflight",
28
+ "gentle:install-sdd",
34
29
  "gentle:sdd-preflight",
35
30
  "sdd-status",
36
- "gentle-ai:sdd-status",
37
31
  "sdd-continue",
38
- "gentle-ai:sdd-continue",
39
32
  "gentle:models",
33
+ "gentle:persona",
34
+ "gentle:status",
35
+ "gentle:doctor",
36
+ "sdd-init",
37
+ "skill-registry:refresh",
38
+ ...EXPECTED_BANNER_COMMANDS,
39
+ ];
40
+
41
+ const FORBIDDEN_COMPAT_COMMANDS = [
42
+ "gentle-ai:install-sdd",
43
+ "gentle-ai:sdd-preflight",
44
+ "gentle-ai:sdd-status",
45
+ "gentle-ai:sdd-continue",
40
46
  "gentle-ai:models",
41
47
  "gentleman:models",
42
- "gentle:persona",
43
48
  "gentle-ai:persona",
44
49
  "gentleman:persona",
45
50
  "gentle-ai:status",
46
51
  "gentle-ai:doctor",
47
- "sdd-init",
48
- "skill-registry:refresh",
49
- ...EXPECTED_BANNER_COMMANDS,
52
+ "gentle-ai:banner",
53
+ "gentle-ai:toggle-rose",
54
+ "gentle-ai:toggle-text-logo",
55
+ "gentle-ai:banner-color",
50
56
  ];
51
57
 
52
58
  function createPi() {
@@ -161,12 +167,16 @@ async function run() {
161
167
  process.env.GENTLE_PI_CONFIG_HOME = globalConfigHome;
162
168
  process.env.GENTLE_PI_AGENT_HOME = globalAgentHome;
163
169
  const globalModelsPath = join(globalConfigHome, "models.json");
170
+ const globalSubagentsPath = join(globalAgentHome, "subagents.json");
164
171
  const { pi, hooks, commands, flags } = createPi();
165
172
  await loadExtensions(pi);
166
173
 
167
174
  for (const name of EXPECTED_COMMANDS) {
168
175
  assert.ok(commands.has(name), `missing command ${name}`);
169
176
  }
177
+ for (const name of FORBIDDEN_COMPAT_COMMANDS) {
178
+ assert.equal(commands.has(name), false, `compat command should not be registered: ${name}`);
179
+ }
170
180
  assert.ok(flags.has("no-skill-registry"), "missing no-skill-registry flag");
171
181
  assert.ok(hooks.has("session_start"), "missing session_start hook");
172
182
  assert.ok(hooks.has("session_shutdown"), "missing session_shutdown hook");
@@ -313,7 +323,7 @@ async function run() {
313
323
  assert.equal(bannerConfig.showRose, false);
314
324
  assert.equal(bannerConfig.showTextLogo, true);
315
325
  assert.equal(bannerConfig.color, "pink");
316
- await commands.get("gentle-ai:toggle-text-logo").handler("", ctx);
326
+ await commands.get("gentle:toggle-text-logo").handler("", ctx);
317
327
  bannerConfig = JSON.parse(await readFile(join(globalConfigHome, "banner.json"), "utf8"));
318
328
  assert.equal(bannerConfig.showTextLogo, false);
319
329
  await commands.get("gentle:banner-color").handler("cyan", ctx);
@@ -500,7 +510,7 @@ async function run() {
500
510
  assert.equal(ctx.ui.selections.length, 3);
501
511
  assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
502
512
  assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
503
- await commands.get("gentle-ai:status").handler("", ctx);
513
+ await commands.get("gentle:status").handler("", ctx);
504
514
  assert.match(ctx.ui.notifications.at(-1).message, /Global SDD assets stale: 0 file\(s\)/);
505
515
  assert.doesNotMatch(ctx.ui.notifications.at(-1).message, /install-sdd --force/);
506
516
 
@@ -543,7 +553,7 @@ async function run() {
543
553
  const commandSddCwd = await tempWorkspace();
544
554
  try {
545
555
  const ctx = createCtx(commandSddCwd, true, "command-session");
546
- await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
556
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
547
557
  assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), false);
548
558
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
549
559
  assert.equal(ctx.ui.selections.length, 3);
@@ -622,7 +632,7 @@ async function run() {
622
632
  try {
623
633
  await writeFile(globalModelsPath, "{ invalid json");
624
634
  const ctx = createCtx(invalidPreflightCwd, true, "invalid-preflight-session");
625
- await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
635
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
626
636
  assert.equal(ctx.ui.notifications.at(-1).level, "warning");
627
637
  assert.match(ctx.ui.notifications.at(-1).message, /Model routing skipped:/);
628
638
  assert.match(ctx.ui.notifications.at(-1).message, /invalid JSON or not an object/);
@@ -635,7 +645,7 @@ async function run() {
635
645
  try {
636
646
  pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
637
647
  const ctx = createCtx(engramSddCwd, true, "engram-session");
638
- await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
648
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
639
649
  assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "both"]);
640
650
  } finally {
641
651
  pi.setActiveTools(["read", "bash", "edit", "write"]);
@@ -646,7 +656,7 @@ async function run() {
646
656
  try {
647
657
  pi.setActiveTools(["read", "bash", "edit", "write", "engram_mem_save"]);
648
658
  const ctx = createCtx(directEngramToolCwd, true, "direct-engram-session");
649
- await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
659
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
650
660
  assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
651
661
  } finally {
652
662
  pi.setActiveTools(["read", "bash", "edit", "write"]);
@@ -656,7 +666,7 @@ async function run() {
656
666
  const installCwd = await tempWorkspace();
657
667
  try {
658
668
  const ctx = createCtx(installCwd, true);
659
- await commands.get("gentle-ai:install-sdd").handler("", ctx);
669
+ await commands.get("gentle:install-sdd").handler("", ctx);
660
670
  assert.match(ctx.ui.notifications.at(-1).message, /Global Gentle AI SDD assets installed/);
661
671
  assert.equal(existsSync(join(installCwd, ".pi", "agents", "sdd-apply.md")), false);
662
672
  assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
@@ -677,17 +687,17 @@ async function run() {
677
687
  await writeFile(join(staleAssetsCwd, ".pi", "chains", "sdd-full.chain.md"), "stale chain\n");
678
688
  await writeFile(join(staleAssetsCwd, ".pi", "gentle-ai", "support", "sdd-status-contract.md"), "stale status contract\n");
679
689
  const ctx = createCtx(staleAssetsCwd, true);
680
- await commands.get("gentle-ai:status").handler("", ctx);
690
+ await commands.get("gentle:status").handler("", ctx);
681
691
  assert.match(ctx.ui.notifications.at(-1).message, /Project-local SDD agent overrides: 2 file\(s\)/);
682
692
  assert.match(ctx.ui.notifications.at(-1).message, /local SDD agents shadow package assets/);
683
- await commands.get("gentle-ai:doctor").handler("", ctx);
693
+ await commands.get("gentle:doctor").handler("", ctx);
684
694
  assert.match(ctx.ui.notifications.at(-1).message, /el Gentleman doctor/);
685
695
  assert.match(ctx.ui.notifications.at(-1).message, /Sensitive-path guard active/);
686
696
  pi.setActiveTools([{ name: "engram.mem_save" }]);
687
- await commands.get("gentle-ai:doctor").handler("", ctx);
697
+ await commands.get("gentle:doctor").handler("", ctx);
688
698
  assert.match(ctx.ui.notifications.at(-1).message, /Engram memory tools active/);
689
699
  pi.setActiveTools([{ name: "engram_mem_save" }]);
690
- await commands.get("gentle-ai:doctor").handler("", ctx);
700
+ await commands.get("gentle:doctor").handler("", ctx);
691
701
  assert.match(ctx.ui.notifications.at(-1).message, /Engram memory tools not active in this session/);
692
702
  pi.setActiveTools(["read", "bash", "edit", "write"]);
693
703
  } finally {
@@ -709,7 +719,7 @@ async function run() {
709
719
  assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
710
720
  assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
711
721
 
712
- await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
722
+ await commands.get("gentle:sdd-preflight").handler("", ctx);
713
723
  assert.equal(ctx.ui.selections.length, 3, "/sdd-init preflight should be reused by later manual preflight");
714
724
  } finally {
715
725
  await rm(sddCwd, { recursive: true, force: true });
@@ -789,20 +799,6 @@ async function run() {
789
799
  assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
790
800
  assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
791
801
  assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
792
- await writeFile(
793
- join(legacyModelsCwd, ".pi", "settings.json"),
794
- JSON.stringify(
795
- {
796
- subagents: {
797
- agentOverrides: {
798
- "sdd-apply": { model: "settings/provider-model", thinking: "high" },
799
- },
800
- },
801
- },
802
- null,
803
- 2,
804
- ),
805
- );
806
802
  await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
807
803
  await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
808
804
  const emptyGlobalPreservesAgent = await readFile(
@@ -810,12 +806,12 @@ async function run() {
810
806
  "utf8",
811
807
  );
812
808
  assert.match(emptyGlobalPreservesAgent, /model: global\/provider-model/);
813
- const emptyGlobalPreservesSettings = JSON.parse(
814
- await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
809
+ const emptyGlobalPreservesProfiles = JSON.parse(
810
+ await readFile(join(legacyModelsCwd, ".pi", "subagents.json"), "utf8"),
815
811
  );
816
812
  assert.equal(
817
- emptyGlobalPreservesSettings.subagents.agentOverrides["sdd-apply"].model,
818
- "settings/provider-model",
813
+ emptyGlobalPreservesProfiles.model_profiles["sdd-apply"].model,
814
+ "global/provider-model",
819
815
  );
820
816
  await writeFile(
821
817
  globalModelsPath,
@@ -827,12 +823,12 @@ async function run() {
827
823
  "utf8",
828
824
  );
829
825
  assert.match(invalidEntryPreservesAgent, /model: global\/provider-model/);
830
- const invalidEntryPreservesSettings = JSON.parse(
831
- await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
826
+ const invalidEntryPreservesProfiles = JSON.parse(
827
+ await readFile(join(legacyModelsCwd, ".pi", "subagents.json"), "utf8"),
832
828
  );
833
829
  assert.equal(
834
- invalidEntryPreservesSettings.subagents.agentOverrides["sdd-apply"].model,
835
- "settings/provider-model",
830
+ invalidEntryPreservesProfiles.model_profiles["sdd-apply"].model,
831
+ "global/provider-model",
836
832
  );
837
833
  await writeFile(globalModelsPath, JSON.stringify({ "sdd-apply": {} }, null, 2));
838
834
  await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
@@ -841,10 +837,10 @@ async function run() {
841
837
  "utf8",
842
838
  );
843
839
  assert.doesNotMatch(explicitInheritClearsAgent, /model:/);
844
- const explicitInheritClearsSettings = JSON.parse(
845
- await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
840
+ const explicitInheritClearsProfiles = JSON.parse(
841
+ await readFile(join(legacyModelsCwd, ".pi", "subagents.json"), "utf8"),
846
842
  );
847
- assert.equal(explicitInheritClearsSettings.subagents, undefined);
843
+ assert.equal(explicitInheritClearsProfiles.model_profiles, undefined);
848
844
  } finally {
849
845
  await rm(legacyModelsCwd, { recursive: true, force: true });
850
846
  await rm(globalModelsPath, { force: true });
@@ -852,15 +848,12 @@ async function run() {
852
848
 
853
849
  const staleSettingsOnlyCwd = await tempWorkspace();
854
850
  try {
855
- await mkdir(join(staleSettingsOnlyCwd, ".pi"), { recursive: true });
856
851
  await writeFile(
857
- join(staleSettingsOnlyCwd, ".pi", "settings.json"),
852
+ globalSubagentsPath,
858
853
  JSON.stringify(
859
854
  {
860
- subagents: {
861
- agentOverrides: {
862
- worker: { model: "stale/model", thinking: "high" },
863
- },
855
+ model_profiles: {
856
+ worker: { model: "stale/model", effort: "high" },
864
857
  },
865
858
  },
866
859
  null,
@@ -869,18 +862,126 @@ async function run() {
869
862
  );
870
863
  await writeFile(globalModelsPath, JSON.stringify({ worker: {} }, null, 2));
871
864
  await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(staleSettingsOnlyCwd, true));
872
- const staleOnlyClearedSettings = JSON.parse(
873
- await readFile(join(staleSettingsOnlyCwd, ".pi", "settings.json"), "utf8"),
865
+ const staleOnlyClearedProfiles = JSON.parse(
866
+ await readFile(globalSubagentsPath, "utf8"),
874
867
  );
875
- assert.equal(staleOnlyClearedSettings.subagents, undefined);
868
+ assert.equal(staleOnlyClearedProfiles.model_profiles, undefined);
876
869
  } finally {
877
870
  await rm(staleSettingsOnlyCwd, { recursive: true, force: true });
878
871
  await rm(globalModelsPath, { force: true });
872
+ await rm(globalSubagentsPath, { force: true });
873
+ }
874
+
875
+ const legacySettingsMigrationCwd = await tempWorkspace();
876
+ try {
877
+ await mkdir(join(legacySettingsMigrationCwd, ".pi", "subagents"), { recursive: true });
878
+ await mkdir(join(globalAgentHome, "subagents"), { recursive: true });
879
+ await writeFile(
880
+ join(legacySettingsMigrationCwd, ".pi", "subagents", "local-worker.md"),
881
+ `---\nname: local-worker\ndescription: Local worker\n---\n`,
882
+ );
883
+ await writeFile(
884
+ join(legacySettingsMigrationCwd, ".pi", "subagents", "local-new.md"),
885
+ `---\nname: local-new\ndescription: Local new worker\n---\n`,
886
+ );
887
+ await writeFile(
888
+ join(globalAgentHome, "subagents", "global-worker.md"),
889
+ `---\nname: global-worker\ndescription: Global worker\n---\n`,
890
+ );
891
+ await writeFile(
892
+ join(globalAgentHome, "subagents", "global-new.md"),
893
+ `---\nname: global-new\ndescription: Global new worker\n---\n`,
894
+ );
895
+ await writeFile(
896
+ join(legacySettingsMigrationCwd, ".pi", "subagents.json"),
897
+ JSON.stringify({ model_profiles: { "local-worker": { model: "existing/local", effort: "medium" } } }, null, 2),
898
+ );
899
+ await writeFile(
900
+ globalSubagentsPath,
901
+ JSON.stringify({ model_profiles: { "global-worker": { model: "existing/global", effort: "medium" } } }, null, 2),
902
+ );
903
+ await writeFile(
904
+ join(legacySettingsMigrationCwd, ".pi", "settings.json"),
905
+ JSON.stringify(
906
+ {
907
+ theme: "keep-me",
908
+ subagents: {
909
+ history: "keep-me-too",
910
+ agentOverrides: {
911
+ "local-worker": {},
912
+ "local-new": { model: "local/new-model", thinking: "minimal" },
913
+ "global-worker": {},
914
+ "global-new": { model: "global/new-model", thinking: "xhigh" },
915
+ "unknown-project": { model: "unknown/project-model", thinking: "low" },
916
+ },
917
+ },
918
+ },
919
+ null,
920
+ 2,
921
+ ),
922
+ );
923
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(legacySettingsMigrationCwd, true));
924
+ const migratedProjectProfiles = JSON.parse(
925
+ await readFile(join(legacySettingsMigrationCwd, ".pi", "subagents.json"), "utf8"),
926
+ );
927
+ assert.equal(migratedProjectProfiles.model_profiles["local-worker"].model, "existing/local");
928
+ assert.equal(migratedProjectProfiles.model_profiles["local-worker"].effort, "medium");
929
+ assert.equal(migratedProjectProfiles.model_profiles["local-new"].model, "local/new-model");
930
+ assert.equal(migratedProjectProfiles.model_profiles["local-new"].effort, "minimal");
931
+ assert.equal(migratedProjectProfiles.model_profiles["unknown-project"].model, "unknown/project-model");
932
+ assert.equal(migratedProjectProfiles.model_profiles["unknown-project"].effort, "low");
933
+ const migratedGlobalProfiles = JSON.parse(await readFile(globalSubagentsPath, "utf8"));
934
+ assert.equal(migratedGlobalProfiles.model_profiles["global-worker"].model, "existing/global");
935
+ assert.equal(migratedGlobalProfiles.model_profiles["global-worker"].effort, "medium");
936
+ assert.equal(migratedGlobalProfiles.model_profiles["global-new"].model, "global/new-model");
937
+ assert.equal(migratedGlobalProfiles.model_profiles["global-new"].effort, "xhigh");
938
+ const migratedSettings = JSON.parse(
939
+ await readFile(join(legacySettingsMigrationCwd, ".pi", "settings.json"), "utf8"),
940
+ );
941
+ assert.equal(migratedSettings.theme, "keep-me");
942
+ assert.equal(migratedSettings.subagents.history, "keep-me-too");
943
+ assert.equal(migratedSettings.subagents.agentOverrides, undefined);
944
+ } finally {
945
+ await rm(legacySettingsMigrationCwd, { recursive: true, force: true });
946
+ await rm(globalSubagentsPath, { force: true });
947
+ }
948
+
949
+ const invalidMigrationTargetCwd = await tempWorkspace();
950
+ try {
951
+ await mkdir(join(invalidMigrationTargetCwd, ".pi", "subagents"), { recursive: true });
952
+ await writeFile(
953
+ join(invalidMigrationTargetCwd, ".pi", "subagents", "local-bad.md"),
954
+ `---\nname: local-bad\ndescription: Local bad target\n---\n`,
955
+ );
956
+ await mkdir(join(globalAgentHome, "subagents"), { recursive: true });
957
+ await writeFile(
958
+ join(globalAgentHome, "subagents", "global-bad.md"),
959
+ `---\nname: global-bad\ndescription: Global bad target\n---\n`,
960
+ );
961
+ await writeFile(join(invalidMigrationTargetCwd, ".pi", "subagents.json"), "{ invalid json");
962
+ await writeFile(globalSubagentsPath, "{ invalid global json");
963
+ await writeFile(
964
+ join(invalidMigrationTargetCwd, ".pi", "settings.json"),
965
+ JSON.stringify({ subagents: { agentOverrides: { "local-bad": { model: "legacy/model", thinking: "low" }, "global-bad": { model: "legacy/global-model", thinking: "high" } } } }, null, 2),
966
+ );
967
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(invalidMigrationTargetCwd, true));
968
+ assert.equal(await readFile(join(invalidMigrationTargetCwd, ".pi", "subagents.json"), "utf8"), "{ invalid json");
969
+ assert.equal(await readFile(globalSubagentsPath, "utf8"), "{ invalid global json");
970
+ const preservedLegacySettings = JSON.parse(
971
+ await readFile(join(invalidMigrationTargetCwd, ".pi", "settings.json"), "utf8"),
972
+ );
973
+ assert.equal(preservedLegacySettings.subagents.agentOverrides["local-bad"].model, "legacy/model");
974
+ assert.equal(preservedLegacySettings.subagents.agentOverrides["global-bad"].model, "legacy/global-model");
975
+ } finally {
976
+ await rm(invalidMigrationTargetCwd, { recursive: true, force: true });
977
+ await rm(globalSubagentsPath, { force: true });
879
978
  }
880
979
 
881
980
  const modelsCwd = await tempWorkspace();
882
981
  try {
883
982
  await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
983
+ await mkdir(join(modelsCwd, ".pi", "subagents"), { recursive: true });
984
+ await mkdir(join(globalAgentHome, "subagents"), { recursive: true });
884
985
  await mkdir(
885
986
  join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"),
886
987
  { recursive: true },
@@ -905,6 +1006,10 @@ async function run() {
905
1006
  join(modelsCwd, ".pi", "agents", "worker.md"),
906
1007
  `---\nname: worker\ndescription: Project worker\nmodel: existing/project-worker\nthinking: high\n---\n`,
907
1008
  );
1009
+ await writeFile(
1010
+ join(modelsCwd, ".pi", "subagents", "worker.md"),
1011
+ `---\nname: worker\ndescription: Project subagents worker\nmodel: existing/project-subagent-worker\nthinking: medium\n---\n`,
1012
+ );
908
1013
  await writeFile(
909
1014
  join(
910
1015
  modelsCwd,
@@ -921,6 +1026,14 @@ async function run() {
921
1026
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
922
1027
  `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
923
1028
  );
1029
+ await writeFile(
1030
+ join(modelsCwd, ".pi", "subagents", "project-special.md"),
1031
+ `---\nname: project-special\ndescription: Project subagent dir fixture\n---\n\nbody\n`,
1032
+ );
1033
+ await writeFile(
1034
+ join(globalAgentHome, "subagents", "global-special.md"),
1035
+ `---\nname: global-special\ndescription: Global subagent dir fixture\n---\n\nbody\n`,
1036
+ );
924
1037
  for (let i = 0; i < 25; i++) {
925
1038
  const name = `large-agent-${String(i).padStart(2, "0")}`;
926
1039
  await writeFile(
@@ -933,13 +1046,11 @@ async function run() {
933
1046
  `---\nname: evil\u001b]52;c;Zm9v\u0007-agent\ndescription: Escape fixture\n---\n`,
934
1047
  );
935
1048
  await writeFile(
936
- join(modelsCwd, ".pi", "settings.json"),
1049
+ join(modelsCwd, ".pi", "subagents.json"),
937
1050
  JSON.stringify(
938
1051
  {
939
- subagents: {
940
- agentOverrides: {
941
- worker: { model: "existing/model", thinking: "high" },
942
- },
1052
+ model_profiles: {
1053
+ worker: { model: "existing/model", effort: "high" },
943
1054
  },
944
1055
  },
945
1056
  null,
@@ -948,35 +1059,44 @@ async function run() {
948
1059
  );
949
1060
  await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
950
1061
  await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(modelsCwd, true));
951
- const preservedSettings = JSON.parse(
952
- await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
1062
+ const preservedProfiles = JSON.parse(
1063
+ await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
953
1064
  );
954
1065
  assert.equal(
955
- preservedSettings.subagents.agentOverrides.worker.model,
1066
+ preservedProfiles.model_profiles.worker.model,
956
1067
  "existing/model",
957
1068
  );
958
- assert.equal(
959
- preservedSettings.subagents.agentOverrides.worker.thinking,
960
- "high",
961
- );
1069
+ assert.equal(preservedProfiles.model_profiles.worker.effort, "high");
962
1070
  const preservedProjectWorker = await readFile(
963
1071
  join(modelsCwd, ".pi", "agents", "worker.md"),
964
1072
  "utf8",
965
1073
  );
966
1074
  assert.match(preservedProjectWorker, /model: existing\/project-worker/);
967
1075
  assert.match(preservedProjectWorker, /thinking: high/);
1076
+ const preservedProjectSubagentWorker = await readFile(
1077
+ join(modelsCwd, ".pi", "subagents", "worker.md"),
1078
+ "utf8",
1079
+ );
1080
+ assert.match(preservedProjectSubagentWorker, /model: existing\/project-subagent-worker/);
1081
+ assert.match(preservedProjectSubagentWorker, /thinking: medium/);
968
1082
  await writeFile(globalModelsPath, JSON.stringify({ worker: {} }, null, 2));
969
1083
  await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(modelsCwd, true));
970
- const clearedSettings = JSON.parse(
971
- await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
1084
+ const clearedProfiles = JSON.parse(
1085
+ await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
972
1086
  );
973
- assert.equal(clearedSettings.subagents, undefined);
974
- const clearedProjectWorker = await readFile(
1087
+ assert.equal(clearedProfiles.model_profiles, undefined);
1088
+ const unchangedProjectWorker = await readFile(
975
1089
  join(modelsCwd, ".pi", "agents", "worker.md"),
976
1090
  "utf8",
977
1091
  );
978
- assert.doesNotMatch(clearedProjectWorker, /model:/);
979
- assert.doesNotMatch(clearedProjectWorker, /thinking:/);
1092
+ assert.match(unchangedProjectWorker, /model: existing\/project-worker/);
1093
+ assert.match(unchangedProjectWorker, /thinking: high/);
1094
+ const clearedProjectSubagentWorker = await readFile(
1095
+ join(modelsCwd, ".pi", "subagents", "worker.md"),
1096
+ "utf8",
1097
+ );
1098
+ assert.doesNotMatch(clearedProjectSubagentWorker, /model:/);
1099
+ assert.doesNotMatch(clearedProjectSubagentWorker, /thinking:/);
980
1100
 
981
1101
  await writeFile(
982
1102
  globalModelsPath,
@@ -1059,6 +1179,8 @@ async function run() {
1059
1179
  "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
1060
1180
  worker: { model: "openai/gpt-5-mini", thinking: "low" },
1061
1181
  researcher: { model: "openai/gpt-5-mini", thinking: "low" },
1182
+ "project-special": { model: "openai/gpt-5-mini", thinking: "low" },
1183
+ "global-special": { model: "openai/gpt-5-mini", thinking: "low" },
1062
1184
  },
1063
1185
  });
1064
1186
  await commands.get("gentle:models").handler("", ctx);
@@ -1088,19 +1210,48 @@ async function run() {
1088
1210
  assert.match(applyAgent, /model: openai\/gpt-5/);
1089
1211
  assert.match(applyAgent, /thinking: high/);
1090
1212
 
1091
- const settings = JSON.parse(
1092
- await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
1213
+ const projectSubagents = JSON.parse(
1214
+ await readFile(join(modelsCwd, ".pi", "subagents.json"), "utf8"),
1215
+ );
1216
+ assert.equal(
1217
+ projectSubagents.model_profiles["sdd-apply"].model,
1218
+ "openai/gpt-5",
1219
+ );
1220
+ assert.equal(projectSubagents.model_profiles["sdd-apply"].effort, "high");
1221
+ assert.equal(
1222
+ projectSubagents.model_profiles.worker.model,
1223
+ "openai/gpt-5-mini",
1224
+ );
1225
+ assert.equal(projectSubagents.model_profiles.worker.effort, "low");
1226
+ const routedProjectSubagentWorker = await readFile(
1227
+ join(modelsCwd, ".pi", "subagents", "worker.md"),
1228
+ "utf8",
1229
+ );
1230
+ assert.match(routedProjectSubagentWorker, /model: openai\/gpt-5-mini/);
1231
+ assert.match(routedProjectSubagentWorker, /thinking: low/);
1232
+ const shadowedProjectAgentWorker = await readFile(
1233
+ join(modelsCwd, ".pi", "agents", "worker.md"),
1234
+ "utf8",
1235
+ );
1236
+ assert.match(shadowedProjectAgentWorker, /model: existing\/project-worker/);
1237
+ assert.match(shadowedProjectAgentWorker, /thinking: high/);
1238
+ assert.equal(
1239
+ projectSubagents.model_profiles["project-special"].model,
1240
+ "openai/gpt-5-mini",
1093
1241
  );
1242
+ assert.equal(projectSubagents.model_profiles["project-special"].effort, "low");
1243
+ const globalSubagents = JSON.parse(await readFile(globalSubagentsPath, "utf8"));
1094
1244
  assert.equal(
1095
- settings.subagents.agentOverrides.worker.model,
1245
+ globalSubagents.model_profiles.researcher.model,
1096
1246
  "openai/gpt-5-mini",
1097
1247
  );
1098
- assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
1248
+ assert.equal(globalSubagents.model_profiles.researcher.effort, "low");
1099
1249
  assert.equal(
1100
- settings.subagents.agentOverrides.researcher.model,
1250
+ globalSubagents.model_profiles["global-special"].model,
1101
1251
  "openai/gpt-5-mini",
1102
1252
  );
1103
- assert.equal(settings.subagents.agentOverrides.researcher.thinking, "low");
1253
+ assert.equal(globalSubagents.model_profiles["global-special"].effort, "low");
1254
+ assert.equal(existsSync(join(modelsCwd, ".pi", "settings.json")), false);
1104
1255
 
1105
1256
  const kittyE = "\x1b[101u";
1106
1257
  assert.notEqual(kittyE, "e");
@@ -1196,6 +1347,7 @@ async function run() {
1196
1347
  } finally {
1197
1348
  await rm(modelsCwd, { recursive: true, force: true });
1198
1349
  await rm(globalModelsPath, { force: true });
1350
+ await rm(globalSubagentsPath, { force: true });
1199
1351
  }
1200
1352
 
1201
1353
  const registryCwd = await tempWorkspace();