gentle-pi 0.10.1 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,7 +68,7 @@ pi install npm:gentle-pi
68
68
  Recommended companion packages:
69
69
 
70
70
  ```bash
71
- pi install npm:pi-subagents
71
+ pi install npm:pi-subagents-j0k3r
72
72
  pi install npm:pi-intercom
73
73
  pi install npm:gentle-engram
74
74
  pi install npm:pi-web-access
@@ -346,7 +346,7 @@ The modal discovers:
346
346
 
347
347
  - project agents in `.pi/agents/` and `.agents/`;
348
348
  - user agents in `~/.pi/agent/agents/` and `~/.agents/`;
349
- - built-in agents from `pi-subagents`.
349
+ - built-in agents from `pi-subagents-j0k3r` when present.
350
350
 
351
351
  Recommended model/effort shape:
352
352
 
@@ -82,7 +82,15 @@ Examples:
82
82
  - run tests/builds and summarize results;
83
83
  - fresh-context review.
84
84
 
85
- Use `pi-subagents` when available. Prefer background/async for long exploration, implementation, tests, or review when the parent has independent work.
85
+ Use `pi-subagents-j0k3r` when available. Prefer background/async for long exploration, implementation, tests, or review when the parent has independent work.
86
+
87
+ ### Pi Subagent Model Routing
88
+
89
+ For generic Pi subagents (`delegate`, `worker`, `scout`, `reviewer`, `context-builder`, `oracle`, `planner`, `researcher`, or other non-SDD agents), do not pass the `model` parameter by default. Let `pi-subagents` resolve model and thinking from `.pi/settings.json`, `.pi/subagents.json`, global subagent config, and runtime defaults.
90
+
91
+ SDD model assignment tables apply only to SDD/Judgment-Day phase agents. They must not be used for generic Pi delegation.
92
+
93
+ Only pass `model` for generic subagents when the user explicitly requests a model override for that launch.
86
94
 
87
95
  Default balanced pattern for bounded implementation:
88
96
 
@@ -734,7 +734,9 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
734
734
  if (!isRecord(value)) return undefined;
735
735
  const model = normalizeModelId(value.model);
736
736
  const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
737
- if (!model && !thinking) return undefined;
737
+ if (!model && !thinking) {
738
+ return Object.keys(value).length === 0 ? {} : undefined;
739
+ }
738
740
  return { model, thinking };
739
741
  }
740
742
 
@@ -987,13 +989,38 @@ async function listAgentsFromDirAsync(
987
989
  return entries;
988
990
  }
989
991
 
990
- function listDiscoverableAgents(cwd: string): AgentEntry[] {
991
- const globalAgentDir = join(gentlePiAgentHome(), "agents");
992
- const builtinDirs = [
992
+ function builtinAgentDirs(cwd: string): string[] {
993
+ return [
994
+ join(PACKAGE_ROOT, "..", "pi-subagents-j0k3r", "agents"),
995
+ join(cwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"),
996
+ join(homedir(), ".local", "lib", "node_modules", "pi-subagents-j0k3r", "agents"),
993
997
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
994
998
  join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
995
999
  join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
996
1000
  ];
1001
+ }
1002
+
1003
+ function listBuiltinAgentNames(cwd: string): Set<string> {
1004
+ return new Set(
1005
+ builtinAgentDirs(cwd).flatMap((dir) =>
1006
+ listAgentsFromDir(dir, "builtin").map((agent) => agent.name),
1007
+ ),
1008
+ );
1009
+ }
1010
+
1011
+ async function listBuiltinAgentNamesAsync(cwd: string): Promise<Set<string>> {
1012
+ const names = new Set<string>();
1013
+ for (const dir of builtinAgentDirs(cwd)) {
1014
+ for (const agent of await listAgentsFromDirAsync(dir, "builtin")) {
1015
+ names.add(agent.name);
1016
+ }
1017
+ }
1018
+ return names;
1019
+ }
1020
+
1021
+ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1022
+ const globalAgentDir = join(gentlePiAgentHome(), "agents");
1023
+ const builtinDirs = builtinAgentDirs(cwd);
997
1024
  const agents = [
998
1025
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
999
1026
  ...listAgentsFromDir(globalAgentDir, "user"),
@@ -1008,11 +1035,7 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
1008
1035
 
1009
1036
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
1010
1037
  const globalAgentDir = join(gentlePiAgentHome(), "agents");
1011
- const builtinDirs = [
1012
- join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
1013
- join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
1014
- join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
1015
- ];
1038
+ const builtinDirs = builtinAgentDirs(cwd);
1016
1039
  const agents: AgentEntry[] = [];
1017
1040
  for (const dir of builtinDirs) {
1018
1041
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
@@ -1045,6 +1068,10 @@ function projectSettingsPath(cwd: string): string {
1045
1068
  return join(cwd, ".pi", "settings.json");
1046
1069
  }
1047
1070
 
1071
+ function isClearRoutingEntry(entry: AgentRoutingEntry): boolean {
1072
+ return entry.model === undefined && entry.thinking === undefined;
1073
+ }
1074
+
1048
1075
  function updateBuiltinModelOverride(
1049
1076
  cwd: string,
1050
1077
  name: string,
@@ -1131,13 +1158,28 @@ export function applyModelConfig(
1131
1158
  ): { updated: number; skipped: number } {
1132
1159
  let updated = 0;
1133
1160
  let skipped = 0;
1161
+ const builtinNames = listBuiltinAgentNames(cwd);
1162
+ const seenAgents = new Set<string>();
1134
1163
  for (const agent of listDiscoverableAgents(cwd)) {
1164
+ seenAgents.add(agent.name);
1135
1165
  const entry = config[agent.name];
1136
1166
  if (agent.source === "builtin") {
1167
+ if (entry === undefined) {
1168
+ skipped += 1;
1169
+ continue;
1170
+ }
1137
1171
  if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
1138
1172
  else skipped += 1;
1139
1173
  continue;
1140
1174
  }
1175
+ if (entry === undefined) {
1176
+ skipped += 1;
1177
+ continue;
1178
+ }
1179
+ if (builtinNames.has(agent.name) || isClearRoutingEntry(entry)) {
1180
+ if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
1181
+ else skipped += 1;
1182
+ }
1141
1183
  if (!agent.filePath || !existsSync(agent.filePath)) {
1142
1184
  skipped += 1;
1143
1185
  continue;
@@ -1151,6 +1193,12 @@ export function applyModelConfig(
1151
1193
  writeFileSync(agent.filePath, next);
1152
1194
  updated += 1;
1153
1195
  }
1196
+ for (const [name, entry] of Object.entries(config)) {
1197
+ if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1198
+ if (updateBuiltinModelOverride(cwd, name, entry)) updated += 1;
1199
+ else skipped += 1;
1200
+ }
1201
+ }
1154
1202
  return { updated, skipped };
1155
1203
  }
1156
1204
 
@@ -1160,14 +1208,30 @@ export async function applyModelConfigAsync(
1160
1208
  ): Promise<{ updated: number; skipped: number }> {
1161
1209
  let updated = 0;
1162
1210
  let skipped = 0;
1211
+ const builtinNames = await listBuiltinAgentNamesAsync(cwd);
1212
+ const seenAgents = new Set<string>();
1163
1213
  for (const agent of await listDiscoverableAgentsAsync(cwd)) {
1214
+ seenAgents.add(agent.name);
1164
1215
  const entry = config[agent.name];
1165
1216
  if (agent.source === "builtin") {
1217
+ if (entry === undefined) {
1218
+ skipped += 1;
1219
+ continue;
1220
+ }
1166
1221
  if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
1167
1222
  updated += 1;
1168
1223
  else skipped += 1;
1169
1224
  continue;
1170
1225
  }
1226
+ if (entry === undefined) {
1227
+ skipped += 1;
1228
+ continue;
1229
+ }
1230
+ if (builtinNames.has(agent.name) || isClearRoutingEntry(entry)) {
1231
+ if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
1232
+ updated += 1;
1233
+ else skipped += 1;
1234
+ }
1171
1235
  if (!agent.filePath || !(await pathExists(agent.filePath))) {
1172
1236
  skipped += 1;
1173
1237
  continue;
@@ -1181,6 +1245,13 @@ export async function applyModelConfigAsync(
1181
1245
  await writeFile(agent.filePath, next);
1182
1246
  updated += 1;
1183
1247
  }
1248
+ for (const [name, entry] of Object.entries(config)) {
1249
+ if (!seenAgents.has(name) && isClearRoutingEntry(entry)) {
1250
+ if (await updateBuiltinModelOverrideAsync(cwd, name, entry))
1251
+ updated += 1;
1252
+ else skipped += 1;
1253
+ }
1254
+ }
1184
1255
  return { updated, skipped };
1185
1256
  }
1186
1257
 
@@ -1500,7 +1571,7 @@ class SddModelPanel implements OverlayComponent {
1500
1571
  const current = this.draft[name] ?? {};
1501
1572
  if (model === undefined) delete current.model;
1502
1573
  else current.model = model;
1503
- if (!current.model && !current.thinking) delete this.draft[name];
1574
+ if (!current.model && !current.thinking) this.draft[name] = {};
1504
1575
  else this.draft[name] = current;
1505
1576
  }
1506
1577
 
@@ -1508,12 +1579,12 @@ class SddModelPanel implements OverlayComponent {
1508
1579
  const current = this.draft[name] ?? {};
1509
1580
  if (thinking === undefined) delete current.thinking;
1510
1581
  else current.thinking = thinking;
1511
- if (!current.model && !current.thinking) delete this.draft[name];
1582
+ if (!current.model && !current.thinking) this.draft[name] = {};
1512
1583
  else this.draft[name] = current;
1513
1584
  }
1514
1585
 
1515
1586
  private clearEntry(name: string): void {
1516
- delete this.draft[name];
1587
+ this.draft[name] = {};
1517
1588
  }
1518
1589
 
1519
1590
  private filteredModelOptions(): string[] {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "Turn Pi into el Gentleman: a senior-architect development harness with SDD/OpenSpec, subagents, strict TDD evidence, review guardrails, and skill discovery.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -197,6 +197,10 @@ async function run() {
197
197
  const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
198
198
  assert.match(promptResult.systemPrompt, /base/);
199
199
  assert.match(promptResult.systemPrompt, /el Gentleman/);
200
+ assert.match(promptResult.systemPrompt, /do not pass the `model` parameter by default/);
201
+ assert.match(promptResult.systemPrompt, /SDD model assignment tables apply only to SDD\/Judgment-Day phase agents/);
202
+ assert.doesNotMatch(promptResult.systemPrompt, /Every Agent tool call MUST include `model`/);
203
+ assert.doesNotMatch(promptResult.systemPrompt, /default\s*\|\s*sonnet\s*\|\s*Non-SDD general delegation/);
200
204
  assert.match(promptResult.systemPrompt, /openspec\/config\.yaml.*not session preflight/s);
201
205
  assert.match(promptResult.systemPrompt, /Do not mark SDD preflight complete/);
202
206
  await writeFile(
@@ -765,21 +769,102 @@ async function run() {
765
769
  assert.equal(await readFile(globalModelsPath, "utf8"), "{ invalid json");
766
770
  assert.equal(legacyCtx.ui.notifications.at(-1).level, "warning");
767
771
  assert.match(legacyCtx.ui.notifications.at(-1).message, /cannot open model config/);
772
+ await writeFile(
773
+ join(legacyModelsCwd, ".pi", "settings.json"),
774
+ JSON.stringify(
775
+ {
776
+ subagents: {
777
+ agentOverrides: {
778
+ "sdd-apply": { model: "settings/provider-model", thinking: "high" },
779
+ },
780
+ },
781
+ },
782
+ null,
783
+ 2,
784
+ ),
785
+ );
768
786
  await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
769
787
  await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
770
- const emptyGlobalSuppressesLegacyAgent = await readFile(
788
+ const emptyGlobalPreservesAgent = await readFile(
789
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
790
+ "utf8",
791
+ );
792
+ assert.match(emptyGlobalPreservesAgent, /model: global\/provider-model/);
793
+ const emptyGlobalPreservesSettings = JSON.parse(
794
+ await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
795
+ );
796
+ assert.equal(
797
+ emptyGlobalPreservesSettings.subagents.agentOverrides["sdd-apply"].model,
798
+ "settings/provider-model",
799
+ );
800
+ await writeFile(
801
+ globalModelsPath,
802
+ JSON.stringify({ "sdd-apply": { model: "bad\nmodel: injected" } }, null, 2),
803
+ );
804
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
805
+ const invalidEntryPreservesAgent = await readFile(
771
806
  join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
772
807
  "utf8",
773
808
  );
774
- assert.doesNotMatch(emptyGlobalSuppressesLegacyAgent, /model:/);
809
+ assert.match(invalidEntryPreservesAgent, /model: global\/provider-model/);
810
+ const invalidEntryPreservesSettings = JSON.parse(
811
+ await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
812
+ );
813
+ assert.equal(
814
+ invalidEntryPreservesSettings.subagents.agentOverrides["sdd-apply"].model,
815
+ "settings/provider-model",
816
+ );
817
+ await writeFile(globalModelsPath, JSON.stringify({ "sdd-apply": {} }, null, 2));
818
+ await hooks.get("session_start")[0]({ reason: "startup" }, legacyCtx);
819
+ const explicitInheritClearsAgent = await readFile(
820
+ join(legacyModelsCwd, ".pi", "agents", "sdd-apply.md"),
821
+ "utf8",
822
+ );
823
+ assert.doesNotMatch(explicitInheritClearsAgent, /model:/);
824
+ const explicitInheritClearsSettings = JSON.parse(
825
+ await readFile(join(legacyModelsCwd, ".pi", "settings.json"), "utf8"),
826
+ );
827
+ assert.equal(explicitInheritClearsSettings.subagents, undefined);
775
828
  } finally {
776
829
  await rm(legacyModelsCwd, { recursive: true, force: true });
777
830
  await rm(globalModelsPath, { force: true });
778
831
  }
779
832
 
833
+ const staleSettingsOnlyCwd = await tempWorkspace();
834
+ try {
835
+ await mkdir(join(staleSettingsOnlyCwd, ".pi"), { recursive: true });
836
+ await writeFile(
837
+ join(staleSettingsOnlyCwd, ".pi", "settings.json"),
838
+ JSON.stringify(
839
+ {
840
+ subagents: {
841
+ agentOverrides: {
842
+ worker: { model: "stale/model", thinking: "high" },
843
+ },
844
+ },
845
+ },
846
+ null,
847
+ 2,
848
+ ),
849
+ );
850
+ await writeFile(globalModelsPath, JSON.stringify({ worker: {} }, null, 2));
851
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(staleSettingsOnlyCwd, true));
852
+ const staleOnlyClearedSettings = JSON.parse(
853
+ await readFile(join(staleSettingsOnlyCwd, ".pi", "settings.json"), "utf8"),
854
+ );
855
+ assert.equal(staleOnlyClearedSettings.subagents, undefined);
856
+ } finally {
857
+ await rm(staleSettingsOnlyCwd, { recursive: true, force: true });
858
+ await rm(globalModelsPath, { force: true });
859
+ }
860
+
780
861
  const modelsCwd = await tempWorkspace();
781
862
  try {
782
863
  await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
864
+ await mkdir(
865
+ join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents-j0k3r", "agents"),
866
+ { recursive: true },
867
+ );
783
868
  await mkdir(
784
869
  join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
785
870
  { recursive: true },
@@ -790,12 +875,28 @@ async function run() {
790
875
  ".pi",
791
876
  "npm",
792
877
  "node_modules",
793
- "pi-subagents",
878
+ "pi-subagents-j0k3r",
794
879
  "agents",
795
880
  "worker.md",
796
881
  ),
797
882
  `---\nname: worker\ndescription: Builtin worker\n---\n`,
798
883
  );
884
+ await writeFile(
885
+ join(modelsCwd, ".pi", "agents", "worker.md"),
886
+ `---\nname: worker\ndescription: Project worker\nmodel: existing/project-worker\nthinking: high\n---\n`,
887
+ );
888
+ await writeFile(
889
+ join(
890
+ modelsCwd,
891
+ ".pi",
892
+ "npm",
893
+ "node_modules",
894
+ "pi-subagents",
895
+ "agents",
896
+ "researcher.md",
897
+ ),
898
+ `---\nname: researcher\ndescription: Legacy builtin researcher\n---\n`,
899
+ );
799
900
  await writeFile(
800
901
  join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
801
902
  `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
@@ -811,6 +912,52 @@ async function run() {
811
912
  join(modelsCwd, ".pi", "agents", "escape-agent.md"),
812
913
  `---\nname: evil\u001b]52;c;Zm9v\u0007-agent\ndescription: Escape fixture\n---\n`,
813
914
  );
915
+ await writeFile(
916
+ join(modelsCwd, ".pi", "settings.json"),
917
+ JSON.stringify(
918
+ {
919
+ subagents: {
920
+ agentOverrides: {
921
+ worker: { model: "existing/model", thinking: "high" },
922
+ },
923
+ },
924
+ },
925
+ null,
926
+ 2,
927
+ ),
928
+ );
929
+ await writeFile(globalModelsPath, JSON.stringify({}, null, 2));
930
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(modelsCwd, true));
931
+ const preservedSettings = JSON.parse(
932
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
933
+ );
934
+ assert.equal(
935
+ preservedSettings.subagents.agentOverrides.worker.model,
936
+ "existing/model",
937
+ );
938
+ assert.equal(
939
+ preservedSettings.subagents.agentOverrides.worker.thinking,
940
+ "high",
941
+ );
942
+ const preservedProjectWorker = await readFile(
943
+ join(modelsCwd, ".pi", "agents", "worker.md"),
944
+ "utf8",
945
+ );
946
+ assert.match(preservedProjectWorker, /model: existing\/project-worker/);
947
+ assert.match(preservedProjectWorker, /thinking: high/);
948
+ await writeFile(globalModelsPath, JSON.stringify({ worker: {} }, null, 2));
949
+ await hooks.get("session_start")[0]({ reason: "startup" }, createCtx(modelsCwd, true));
950
+ const clearedSettings = JSON.parse(
951
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
952
+ );
953
+ assert.equal(clearedSettings.subagents, undefined);
954
+ const clearedProjectWorker = await readFile(
955
+ join(modelsCwd, ".pi", "agents", "worker.md"),
956
+ "utf8",
957
+ );
958
+ assert.doesNotMatch(clearedProjectWorker, /model:/);
959
+ assert.doesNotMatch(clearedProjectWorker, /thinking:/);
960
+
814
961
  await writeFile(
815
962
  globalModelsPath,
816
963
  JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
@@ -891,6 +1038,7 @@ async function run() {
891
1038
  config: {
892
1039
  "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
893
1040
  worker: { model: "openai/gpt-5-mini", thinking: "low" },
1041
+ researcher: { model: "openai/gpt-5-mini", thinking: "low" },
894
1042
  },
895
1043
  });
896
1044
  await commands.get("gentle:models").handler("", ctx);
@@ -928,6 +1076,11 @@ async function run() {
928
1076
  "openai/gpt-5-mini",
929
1077
  );
930
1078
  assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
1079
+ assert.equal(
1080
+ settings.subagents.agentOverrides.researcher.model,
1081
+ "openai/gpt-5-mini",
1082
+ );
1083
+ assert.equal(settings.subagents.agentOverrides.researcher.thinking, "low");
931
1084
 
932
1085
  const kittyE = "\x1b[101u";
933
1086
  assert.notEqual(kittyE, "e");