gentle-pi 0.2.6 → 0.2.8

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
@@ -213,6 +213,7 @@ Behavior:
213
213
 
214
214
  - `.atl/` is added to `.gitignore` when needed;
215
215
  - the registry refreshes on session start;
216
+ - startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`;
216
217
  - `/skill-registry:refresh` forces regeneration;
217
218
  - a best-effort watcher refreshes when skill files change;
218
219
  - `## Compact Rules` wins when present; otherwise the registry extracts compact rules from `## Hard Rules`, `## Critical Rules`, `## Critical Patterns`, `## Voice Rules`, and `## Decision Gates` using bullets, numbered lists, or simple tables.
@@ -245,7 +246,7 @@ Saved at:
245
246
 
246
247
  Run `/reload` or start a new Pi session after switching persona.
247
248
 
248
- ## Model assignment
249
+ ## Model and effort assignment
249
250
 
250
251
  ```text
251
252
  /gentle:models
@@ -257,15 +258,15 @@ The modal discovers:
257
258
  - user agents in `~/.pi/agent/agents/` and `~/.agents/`;
258
259
  - built-in agents from `pi-subagents`.
259
260
 
260
- Recommended model shape:
261
+ Recommended model/effort shape:
261
262
 
262
- | Agent kind | Recommended model |
263
- | -------------------------- | ---------------------------------------------------- |
264
- | Explore, proposal, archive | Fast and cheap is usually enough. |
265
- | Spec, design, tasks | Strong reasoning model. |
266
- | Apply | Strong coding and tool-use model. |
267
- | Verify / review | Strong fresh-context model. |
268
- | Tiny utilities | Inherit active/default model unless they bottleneck. |
263
+ | Agent kind | Recommended model | Recommended effort (`thinking`) |
264
+ | -------------------------- | ---------------------------------------------------- | ------------------------------- |
265
+ | Explore, proposal, archive | Fast and cheap is usually enough. | `off` to `low` |
266
+ | Spec, design, tasks | Strong reasoning model. | `medium` to `high` |
267
+ | Apply | Strong coding and tool-use model. | `medium` to `high` |
268
+ | Verify / review | Strong fresh-context model. | `high` |
269
+ | Tiny utilities | Inherit active/default model unless they bottleneck. | `inherit` |
269
270
 
270
271
  Saved at:
271
272
 
@@ -273,18 +274,42 @@ Saved at:
273
274
  .pi/gentle-ai/models.json
274
275
  ```
275
276
 
277
+ Config shape (per agent):
278
+
279
+ ```json
280
+ {
281
+ "sdd-design": {
282
+ "model": "anthropic/claude-sonnet-4",
283
+ "thinking": "high"
284
+ },
285
+ "sdd-archive": {
286
+ "model": "openai/gpt-5-mini"
287
+ }
288
+ }
289
+ ```
290
+
291
+ Legacy string entries are still accepted and treated as `model`-only config.
292
+
276
293
  ## Commands
277
294
 
278
295
  | Command | What it does |
279
296
  | -------------------------------- | ------------------------------------------------------------ |
280
297
  | `/gentle-ai:status` | Shows package, SDD asset, OpenSpec, and model config status. |
281
- | `/gentle:models` | Opens model assignment UI. |
298
+ | `/gentle:models` | Opens model + effort assignment UI. |
282
299
  | `/gentle:persona` | Switches persona mode. |
283
300
  | `/sdd-init` | Initializes or refreshes `openspec/config.yaml`. |
284
301
  | `/gentle-ai:install-sdd` | Reinstalls SDD assets without overwriting local files. |
285
302
  | `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets. |
286
303
  | `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
287
304
 
305
+ Startup flag:
306
+
307
+ ```text
308
+ pi --no-skill-registry
309
+ ```
310
+
311
+ 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.
312
+
288
313
  Compatibility aliases:
289
314
 
290
315
  ```text
@@ -31,6 +31,8 @@ User-facing conversation should stay in the user's language and follow the curre
31
31
 
32
32
  Subagent-facing prompts should be written in English by default, even when the user speaks Spanish. Translate the user's request into concise English before delegation. This keeps token usage lower and gives built-in/project subagents a consistent operating language without changing the user-facing persona.
33
33
 
34
+ Generated artifacts — whether by the parent inline or by subagents — (code, UI copy, comments, identifiers, commit messages, filenames, PR descriptions) default to English, regardless of the user's conversation language. Override only when the user explicitly requests another language for that artifact, or when extending a project whose existing convention is non-English.
35
+
34
36
  Exceptions:
35
37
 
36
38
  - Preserve exact user quotes, UI copy, error messages, filenames, commands, and domain terms in their original language when they are evidence.
@@ -101,7 +101,12 @@ const SDD_AGENT_NAMES = [
101
101
  ] as const;
102
102
 
103
103
  type SddAgentName = (typeof SDD_AGENT_NAMES)[number];
104
- type AgentModelConfig = Record<string, string>;
104
+ type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
105
+ interface AgentRoutingEntry {
106
+ model?: string;
107
+ thinking?: ThinkingLevel;
108
+ }
109
+ type AgentModelConfig = Record<string, AgentRoutingEntry>;
105
110
  type AgentSource = "project" | "user" | "builtin";
106
111
 
107
112
  interface AgentEntry {
@@ -113,6 +118,16 @@ interface AgentEntry {
113
118
  const KEEP_CURRENT = "Keep current";
114
119
  const INHERIT_MODEL = "Inherit active/default model";
115
120
  const CUSTOM_MODEL = "Custom model id";
121
+ const INHERIT_THINKING = "Inherit effort";
122
+ const THINKING_OPTIONS: (ThinkingLevel | typeof INHERIT_THINKING)[] = [
123
+ INHERIT_THINKING,
124
+ "off",
125
+ "minimal",
126
+ "low",
127
+ "medium",
128
+ "high",
129
+ "xhigh",
130
+ ];
116
131
 
117
132
  const MODEL_CONTROL_OPTIONS = [
118
133
  KEEP_CURRENT,
@@ -253,6 +268,32 @@ function writePersonaMode(cwd: string, mode: PersonaMode): void {
253
268
  writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`);
254
269
  }
255
270
 
271
+ function isThinkingLevel(value: unknown): value is ThinkingLevel {
272
+ return (
273
+ value === "off" ||
274
+ value === "minimal" ||
275
+ value === "low" ||
276
+ value === "medium" ||
277
+ value === "high" ||
278
+ value === "xhigh"
279
+ );
280
+ }
281
+
282
+ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
283
+ if (typeof value === "string") {
284
+ const model = value.trim();
285
+ return model.length > 0 ? { model } : undefined;
286
+ }
287
+ if (!isRecord(value)) return undefined;
288
+ const model =
289
+ typeof value.model === "string" && value.model.trim().length > 0
290
+ ? value.model.trim()
291
+ : undefined;
292
+ const thinking = isThinkingLevel(value.thinking) ? value.thinking : undefined;
293
+ if (!model && !thinking) return undefined;
294
+ return { model, thinking };
295
+ }
296
+
256
297
  function readModelConfig(cwd: string): AgentModelConfig {
257
298
  const path = modelConfigPath(cwd);
258
299
  if (!existsSync(path)) return {};
@@ -261,9 +302,8 @@ function readModelConfig(cwd: string): AgentModelConfig {
261
302
  if (!isRecord(parsed)) return {};
262
303
  const config: AgentModelConfig = {};
263
304
  for (const [name, value] of Object.entries(parsed)) {
264
- if (typeof value === "string" && value.trim().length > 0) {
265
- config[name] = value.trim();
266
- }
305
+ const entry = normalizeRoutingEntry(value);
306
+ if (entry) config[name] = entry;
267
307
  }
268
308
  return config;
269
309
  } catch {
@@ -274,12 +314,23 @@ function readModelConfig(cwd: string): AgentModelConfig {
274
314
  function writeModelConfig(cwd: string, config: AgentModelConfig): void {
275
315
  const path = modelConfigPath(cwd);
276
316
  mkdirSync(dirname(path), { recursive: true });
277
- writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
317
+ const cleaned: AgentModelConfig = {};
318
+ for (const [name, value] of Object.entries(config)) {
319
+ const entry = normalizeRoutingEntry(value);
320
+ if (entry) cleaned[name] = entry;
321
+ }
322
+ writeFileSync(path, `${JSON.stringify(cleaned, null, 2)}\n`);
323
+ }
324
+
325
+ function cloneModelConfig(config: AgentModelConfig): AgentModelConfig {
326
+ return Object.fromEntries(
327
+ Object.entries(config).map(([name, entry]) => [name, { ...entry }]),
328
+ );
278
329
  }
279
330
 
280
- function updateFrontmatterModel(
331
+ function updateFrontmatterRouting(
281
332
  content: string,
282
- model: string | undefined,
333
+ entry: AgentRoutingEntry | undefined,
283
334
  ): string {
284
335
  if (!content.startsWith("---\n")) return content;
285
336
  const endIndex = content.indexOf("\n---", 4);
@@ -288,14 +339,19 @@ function updateFrontmatterModel(
288
339
  const body = content.slice(endIndex);
289
340
  const lines = frontmatter
290
341
  .split("\n")
291
- .filter((line) => !line.startsWith("model:"));
292
- if (model !== undefined) {
342
+ .filter(
343
+ (line) => !line.startsWith("model:") && !line.startsWith("thinking:"),
344
+ );
345
+ const toInsert: string[] = [];
346
+ if (entry?.model) toInsert.push(`model: ${entry.model}`);
347
+ if (entry?.thinking) toInsert.push(`thinking: ${entry.thinking}`);
348
+ if (toInsert.length > 0) {
293
349
  const descriptionIndex = lines.findIndex((line) =>
294
350
  line.startsWith("description:"),
295
351
  );
296
352
  const insertIndex =
297
353
  descriptionIndex >= 0 ? descriptionIndex + 1 : Math.min(1, lines.length);
298
- lines.splice(insertIndex, 0, `model: ${model}`);
354
+ lines.splice(insertIndex, 0, ...toInsert);
299
355
  }
300
356
  return `---\n${lines.join("\n")}${body}`;
301
357
  }
@@ -372,7 +428,7 @@ function projectSettingsPath(cwd: string): string {
372
428
  function updateBuiltinModelOverride(
373
429
  cwd: string,
374
430
  name: string,
375
- model: string | undefined,
431
+ entry: AgentRoutingEntry | undefined,
376
432
  ): boolean {
377
433
  const path = projectSettingsPath(cwd);
378
434
  let settings: Record<string, unknown> = {};
@@ -393,8 +449,10 @@ function updateBuiltinModelOverride(
393
449
  const current = isRecord(agentOverrides[name])
394
450
  ? { ...agentOverrides[name] }
395
451
  : {};
396
- if (model === undefined) delete current.model;
397
- else current.model = model;
452
+ if (entry?.model === undefined) delete current.model;
453
+ else current.model = entry.model;
454
+ if (entry?.thinking === undefined) delete current.thinking;
455
+ else current.thinking = entry.thinking;
398
456
  if (Object.keys(current).length > 0) agentOverrides[name] = current;
399
457
  else delete agentOverrides[name];
400
458
  if (Object.keys(agentOverrides).length > 0)
@@ -414,9 +472,9 @@ function applyModelConfig(
414
472
  let updated = 0;
415
473
  let skipped = 0;
416
474
  for (const agent of listDiscoverableAgents(cwd)) {
417
- const model = config[agent.name];
475
+ const entry = config[agent.name];
418
476
  if (agent.source === "builtin") {
419
- if (updateBuiltinModelOverride(cwd, agent.name, model)) updated += 1;
477
+ if (updateBuiltinModelOverride(cwd, agent.name, entry)) updated += 1;
420
478
  else skipped += 1;
421
479
  continue;
422
480
  }
@@ -425,7 +483,7 @@ function applyModelConfig(
425
483
  continue;
426
484
  }
427
485
  const original = readFileSync(agent.filePath, "utf8");
428
- const next = updateFrontmatterModel(original, model);
486
+ const next = updateFrontmatterRouting(original, entry);
429
487
  if (next === original) {
430
488
  skipped += 1;
431
489
  continue;
@@ -437,9 +495,12 @@ function applyModelConfig(
437
495
  }
438
496
 
439
497
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
440
- return listDiscoverableAgents(cwd).map(
441
- (agent) => `${agent.name}: ${config[agent.name] ?? "inherit"}`,
442
- );
498
+ return listDiscoverableAgents(cwd).map((agent) => {
499
+ const entry = config[agent.name];
500
+ const model = entry?.model ?? "inherit";
501
+ const thinking = entry?.thinking ?? "inherit";
502
+ return `${agent.name}: model=${model}, effort=${thinking}`;
503
+ });
443
504
  }
444
505
 
445
506
  async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
@@ -458,28 +519,33 @@ interface OverlayComponent {
458
519
 
459
520
  type ModelPanelResult =
460
521
  | { type: "save"; config: AgentModelConfig }
461
- | { type: "custom"; agent: string | "all" }
522
+ | { type: "custom"; agent: string | "all"; config: AgentModelConfig }
462
523
  | { type: "cancel" };
463
524
 
464
525
  const SET_ALL_AGENTS = "Set all agents";
465
526
 
466
527
  class SddModelPanel implements OverlayComponent {
467
528
  private cursor = 0;
468
- private mode: "agents" | "models" = "agents";
529
+ private mode: "agents" | "models" | "effort" = "agents";
469
530
  private selectedRow = SET_ALL_AGENTS;
470
531
  private modelCursor = 0;
532
+ private effortCursor = 0;
471
533
  private query = "";
472
534
  private readonly draft: AgentModelConfig;
473
535
  private readonly rows: string[];
536
+ private readonly modelOptions: string[];
537
+ private readonly done: (result: ModelPanelResult) => void;
474
538
 
475
539
  constructor(
476
540
  initialConfig: AgentModelConfig,
477
- private readonly modelOptions: string[],
541
+ modelOptions: string[],
478
542
  agents: string[],
479
- private readonly done: (result: ModelPanelResult) => void,
543
+ done: (result: ModelPanelResult) => void,
480
544
  ) {
481
- this.draft = { ...initialConfig };
545
+ this.draft = cloneModelConfig(initialConfig);
482
546
  this.rows = [SET_ALL_AGENTS, ...agents];
547
+ this.modelOptions = modelOptions;
548
+ this.done = done;
483
549
  }
484
550
 
485
551
  invalidate(): void {}
@@ -489,13 +555,17 @@ class SddModelPanel implements OverlayComponent {
489
555
  this.handleModelInput(data);
490
556
  return;
491
557
  }
558
+ if (this.mode === "effort") {
559
+ this.handleEffortInput(data);
560
+ return;
561
+ }
492
562
  this.handleAgentInput(data);
493
563
  }
494
564
 
495
565
  render(width: number): string[] {
496
- return this.mode === "models"
497
- ? this.renderModelPicker(width)
498
- : this.renderAgentList(width);
566
+ if (this.mode === "models") return this.renderModelPicker(width);
567
+ if (this.mode === "effort") return this.renderEffortPicker(width);
568
+ return this.renderAgentList(width);
499
569
  }
500
570
 
501
571
  private handleAgentInput(data: string): void {
@@ -517,13 +587,21 @@ class SddModelPanel implements OverlayComponent {
517
587
  return;
518
588
  }
519
589
  if (data === "i") {
520
- this.applySelection(undefined);
590
+ this.applyInherit();
591
+ return;
592
+ }
593
+ if (data === "e") {
594
+ this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
595
+ this.mode = "effort";
596
+ this.effortCursor = 0;
521
597
  return;
522
598
  }
523
599
  if (data === "c") {
524
600
  const row = this.rows[this.cursor];
525
- if (row === SET_ALL_AGENTS) this.done({ type: "custom", agent: "all" });
526
- else if (row) this.done({ type: "custom", agent: row });
601
+ if (row === SET_ALL_AGENTS)
602
+ this.done({ type: "custom", agent: "all", config: this.draft });
603
+ else if (row)
604
+ this.done({ type: "custom", agent: row, config: this.draft });
527
605
  return;
528
606
  }
529
607
  if (!matchesKey(data, "return")) return;
@@ -578,6 +656,7 @@ class SddModelPanel implements OverlayComponent {
578
656
  this.done({
579
657
  type: "custom",
580
658
  agent: this.selectedRow === SET_ALL_AGENTS ? "all" : this.selectedRow,
659
+ config: this.draft,
581
660
  });
582
661
  return;
583
662
  }
@@ -585,7 +664,9 @@ class SddModelPanel implements OverlayComponent {
585
664
  this.mode = "agents";
586
665
  return;
587
666
  }
588
- this.applySelection(selected === INHERIT_MODEL ? undefined : selected);
667
+ this.applyModelSelection(
668
+ selected === INHERIT_MODEL ? undefined : selected,
669
+ );
589
670
  this.mode = "agents";
590
671
  return;
591
672
  }
@@ -595,18 +676,52 @@ class SddModelPanel implements OverlayComponent {
595
676
  }
596
677
  }
597
678
 
598
- private applySelection(model: string | undefined): void {
679
+ private applyModelSelection(model: string | undefined): void {
599
680
  const row = this.rows[this.cursor];
600
681
  if (row === SET_ALL_AGENTS) {
601
- for (const name of this.rows.slice(1)) {
602
- if (model === undefined) delete this.draft[name];
603
- else this.draft[name] = model;
604
- }
682
+ for (const name of this.rows.slice(1)) this.setModel(name, model);
605
683
  return;
606
684
  }
607
685
  if (!row) return;
608
- if (model === undefined) delete this.draft[row];
609
- else this.draft[row] = model;
686
+ this.setModel(row, model);
687
+ }
688
+
689
+ private applyThinkingSelection(thinking: ThinkingLevel | undefined): void {
690
+ const row = this.selectedRow;
691
+ if (row === SET_ALL_AGENTS) {
692
+ for (const name of this.rows.slice(1)) this.setThinking(name, thinking);
693
+ return;
694
+ }
695
+ this.setThinking(row, thinking);
696
+ }
697
+
698
+ private applyInherit(): void {
699
+ const row = this.rows[this.cursor];
700
+ if (row === SET_ALL_AGENTS) {
701
+ for (const name of this.rows.slice(1)) this.clearEntry(name);
702
+ return;
703
+ }
704
+ if (row) this.clearEntry(row);
705
+ }
706
+
707
+ private setModel(name: string, model: string | undefined): void {
708
+ const current = this.draft[name] ?? {};
709
+ if (model === undefined) delete current.model;
710
+ else current.model = model;
711
+ if (!current.model && !current.thinking) delete this.draft[name];
712
+ else this.draft[name] = current;
713
+ }
714
+
715
+ private setThinking(name: string, thinking: ThinkingLevel | undefined): void {
716
+ const current = this.draft[name] ?? {};
717
+ if (thinking === undefined) delete current.thinking;
718
+ else current.thinking = thinking;
719
+ if (!current.model && !current.thinking) delete this.draft[name];
720
+ else this.draft[name] = current;
721
+ }
722
+
723
+ private clearEntry(name: string): void {
724
+ delete this.draft[name];
610
725
  }
611
726
 
612
727
  private filteredModelOptions(): string[] {
@@ -644,7 +759,7 @@ class SddModelPanel implements OverlayComponent {
644
759
  lines.push("");
645
760
  lines.push(
646
761
  line(
647
- "j/k: navigate • enter: change model / confirm • i: inherit • c: custom • ctrl+s: save • esc: back",
762
+ "j/k: navigate • enter: change model / confirm • e: change effort • i: inherit all • c: custom model • ctrl+s: save • esc: back",
648
763
  ),
649
764
  );
650
765
  return lines;
@@ -680,17 +795,70 @@ class SddModelPanel implements OverlayComponent {
680
795
  return lines;
681
796
  }
682
797
 
798
+ private handleEffortInput(data: string): void {
799
+ if (matchesKey(data, "ctrl+c")) {
800
+ this.done({ type: "cancel" });
801
+ return;
802
+ }
803
+ if (matchesKey(data, "escape")) {
804
+ this.mode = "agents";
805
+ return;
806
+ }
807
+ if (matchesKey(data, "down") || data === "j") {
808
+ this.effortCursor = Math.min(
809
+ Math.max(0, THINKING_OPTIONS.length - 1),
810
+ this.effortCursor + 1,
811
+ );
812
+ return;
813
+ }
814
+ if (matchesKey(data, "up") || data === "k") {
815
+ this.effortCursor = Math.max(0, this.effortCursor - 1);
816
+ return;
817
+ }
818
+ if (!matchesKey(data, "return")) return;
819
+ const selected = THINKING_OPTIONS[this.effortCursor];
820
+ if (selected === INHERIT_THINKING) this.applyThinkingSelection(undefined);
821
+ else this.applyThinkingSelection(selected);
822
+ this.mode = "agents";
823
+ }
824
+
825
+ private renderEffortPicker(width: number): string[] {
826
+ const lines: string[] = [];
827
+ const line = (text = "") =>
828
+ truncateToWidth(text, Math.max(1, width), "…", true);
829
+ lines.push(line(`Select effort for ${this.selectedRow}`));
830
+ lines.push("");
831
+ for (let i = 0; i < THINKING_OPTIONS.length; i++) {
832
+ const focused = i === this.effortCursor;
833
+ lines.push(line(`${focused ? "▸" : " "} ${THINKING_OPTIONS[i]}`));
834
+ }
835
+ lines.push("");
836
+ lines.push(line("j/k: navigate • enter: select • esc: back"));
837
+ return lines;
838
+ }
839
+
683
840
  private renderSetAllLabel(row: string): string {
684
- const values = this.rows
841
+ const models = this.rows
842
+ .slice(1)
843
+ .map((name) => this.draft[name]?.model ?? "inherit");
844
+ const efforts = this.rows
685
845
  .slice(1)
686
- .map((name) => this.draft[name] ?? "inherit");
687
- const first = values[0] ?? "inherit";
688
- const allSame = values.every((value) => value === first);
689
- return `${row.padEnd(20)} ${allSame ? first : "mixed"}`;
846
+ .map((name) => this.draft[name]?.thinking ?? "inherit");
847
+ const firstModel = models[0] ?? "inherit";
848
+ const firstEffort = efforts[0] ?? "inherit";
849
+ const modelLabel = models.every((value) => value === firstModel)
850
+ ? firstModel
851
+ : "mixed";
852
+ const effortLabel = efforts.every((value) => value === firstEffort)
853
+ ? firstEffort
854
+ : "mixed";
855
+ return `${row.padEnd(20)} model=${modelLabel}, effort=${effortLabel}`;
690
856
  }
691
857
 
692
858
  private renderAgentLabel(row: string): string {
693
- return `${row.padEnd(20)} ${this.draft[row] ?? "inherit"}`;
859
+ const model = this.draft[row]?.model ?? "inherit";
860
+ const effort = this.draft[row]?.thinking ?? "inherit";
861
+ return `${row.padEnd(20)} model=${model}, effort=${effort}`;
694
862
  }
695
863
  }
696
864
 
@@ -719,8 +887,11 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
719
887
  let config = readModelConfig(ctx.cwd);
720
888
  let result = await showSddModelPanel(ctx, config);
721
889
  while (result.type === "custom") {
890
+ config = cloneModelConfig(result.config);
722
891
  const current =
723
- result.agent === "all" ? "inherit" : (config[result.agent] ?? "inherit");
892
+ result.agent === "all"
893
+ ? "inherit"
894
+ : (config[result.agent]?.model ?? "inherit");
724
895
  const custom = await ctx.ui.input(
725
896
  `${result.agent === "all" ? "all agents" : result.agent} custom model id`,
726
897
  current === "inherit" ? "provider/model" : current,
@@ -729,11 +900,22 @@ async function handleModelsCommand(ctx: ExtensionContext): Promise<void> {
729
900
  const trimmed = custom.trim();
730
901
  if (trimmed.length > 0) {
731
902
  if (result.agent === "all") {
732
- config = Object.fromEntries(
733
- listDiscoverableAgents(ctx.cwd).map((agent) => [agent.name, trimmed]),
734
- );
903
+ const next: AgentModelConfig = { ...config };
904
+ for (const agent of listDiscoverableAgents(ctx.cwd)) {
905
+ next[agent.name] = {
906
+ ...(next[agent.name] ?? {}),
907
+ model: trimmed,
908
+ };
909
+ }
910
+ config = next;
735
911
  } else {
736
- config = { ...config, [result.agent]: trimmed };
912
+ config = {
913
+ ...config,
914
+ [result.agent]: {
915
+ ...(config[result.agent] ?? {}),
916
+ model: trimmed,
917
+ },
918
+ };
737
919
  }
738
920
  }
739
921
  result = await showSddModelPanel(ctx, config);
@@ -21,6 +21,8 @@ const EXCLUDE_PREFIXES = ["sdd-"];
21
21
  const ATL_IGNORE_ENTRY = ".atl/";
22
22
  const WATCH_DEBOUNCE_MS = 500;
23
23
  const REGISTRY_SCHEMA_VERSION = 4;
24
+ const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
25
+ const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
24
26
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
25
27
  const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
26
28
  ".pi/extensions/skill-registry.ts.disabled";
@@ -410,6 +412,26 @@ function regenerateRegistry(cwd: string, force: boolean): RegenResult {
410
412
 
411
413
  const watchedCwds = new Set<string>();
412
414
 
415
+ function isTruthyEnv(value: string | undefined): boolean {
416
+ return value === "1" || value === "true" || value === "yes" || value === "on";
417
+ }
418
+
419
+ function hasCliArg(args: string[], ...names: string[]): boolean {
420
+ return args.some((arg) => names.includes(arg));
421
+ }
422
+
423
+ function shouldSkipSkillRegistryStartup(
424
+ pi: Pick<ExtensionAPI, "getFlag">,
425
+ argv = process.argv.slice(2),
426
+ env = process.env,
427
+ ): boolean {
428
+ return (
429
+ pi.getFlag(NO_SKILL_REGISTRY_FLAG) === true ||
430
+ isTruthyEnv(env[NO_SKILL_REGISTRY_ENV]) ||
431
+ hasCliArg(argv, "--no-skills", "-ns")
432
+ );
433
+ }
434
+
413
435
  function startSkillRegistryWatcher(cwd: string, notify: (message: string) => void): void {
414
436
  if (watchedCwds.has(cwd)) return;
415
437
  watchedCwds.add(cwd);
@@ -444,10 +466,18 @@ export const __testing = {
444
466
  extractTriggerDescription,
445
467
  uniqueExistingDirs,
446
468
  dedupeBySkillName,
469
+ shouldSkipSkillRegistryStartup,
447
470
  };
448
471
 
449
472
  export default function (pi: ExtensionAPI) {
473
+ pi.registerFlag(NO_SKILL_REGISTRY_FLAG, {
474
+ description: "Skip the Gentle AI skill registry refresh and watcher on startup.",
475
+ type: "boolean",
476
+ default: false,
477
+ });
478
+
450
479
  pi.on("session_start", async (_event, ctx) => {
480
+ if (shouldSkipSkillRegistryStartup(pi)) return;
451
481
  try {
452
482
  ensureAtlIgnored(ctx.cwd);
453
483
  const quarantinedLegacy = quarantineLegacyProjectRegistry(ctx.cwd);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
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",
@@ -32,7 +32,8 @@
32
32
  "README.md"
33
33
  ],
34
34
  "scripts": {
35
- "test": "node --experimental-strip-types --test tests/*.test.ts",
35
+ "test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
36
+ "test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
36
37
  "prepack": "pnpm test && node scripts/verify-package-files.mjs",
37
38
  "prepublishOnly": "pnpm test && node scripts/verify-package-files.mjs"
38
39
  },
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env node
2
+ import assert from "node:assert/strict";
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
+
8
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
9
+ const EXTENSIONS = [
10
+ "extensions/gentle-ai.ts",
11
+ "extensions/skill-registry.ts",
12
+ "extensions/sdd-init.ts",
13
+ "extensions/startup-banner.ts",
14
+ ];
15
+
16
+ const EXPECTED_COMMANDS = [
17
+ "gentle-ai:install-sdd",
18
+ "gentle:models",
19
+ "gentle-ai:models",
20
+ "gentleman:models",
21
+ "gentle:persona",
22
+ "gentle-ai:persona",
23
+ "gentleman:persona",
24
+ "gentle-ai:status",
25
+ "sdd-init",
26
+ "skill-registry:refresh",
27
+ ];
28
+
29
+ function createPi() {
30
+ const hooks = new Map();
31
+ const commands = new Map();
32
+ const flags = new Map();
33
+ const flagValues = new Map([["no-skill-registry", true]]);
34
+
35
+ const pi = {
36
+ on(name, handler) {
37
+ const list = hooks.get(name) ?? [];
38
+ list.push(handler);
39
+ hooks.set(name, list);
40
+ },
41
+ registerCommand(name, definition) {
42
+ commands.set(name, definition);
43
+ },
44
+ registerFlag(name, definition) {
45
+ flags.set(name, definition);
46
+ },
47
+ getFlag(name) {
48
+ return flagValues.get(name) ?? false;
49
+ },
50
+ setFlag(name, value) {
51
+ flagValues.set(name, value);
52
+ },
53
+ getCommands() {
54
+ return Array.from(commands, ([name, definition]) => ({ name, ...definition }));
55
+ },
56
+ getAllTools() {
57
+ return [
58
+ { name: "read" },
59
+ { name: "bash" },
60
+ { name: "edit" },
61
+ { name: "write" },
62
+ ];
63
+ },
64
+ };
65
+
66
+ return { pi, hooks, commands, flags };
67
+ }
68
+
69
+ function createUi() {
70
+ const notifications = [];
71
+ return {
72
+ notifications,
73
+ notify(message, level = "info") {
74
+ notifications.push({ message, level });
75
+ },
76
+ async confirm() {
77
+ return false;
78
+ },
79
+ async select(_label, options) {
80
+ return options[0];
81
+ },
82
+ async input(_label, placeholder) {
83
+ return placeholder;
84
+ },
85
+ custom() {
86
+ return Promise.resolve({ type: "cancel" });
87
+ },
88
+ };
89
+ }
90
+
91
+ function createCtx(cwd, hasUI = false) {
92
+ return {
93
+ cwd,
94
+ hasUI,
95
+ ui: createUi(),
96
+ modelRegistry: {
97
+ async getAvailable() {
98
+ return [];
99
+ },
100
+ },
101
+ };
102
+ }
103
+
104
+ async function tempWorkspace() {
105
+ return mkdtemp(join(tmpdir(), "gentle-pi-runtime-"));
106
+ }
107
+
108
+ async function loadExtensions(pi) {
109
+ for (const [index, rel] of EXTENSIONS.entries()) {
110
+ const mod = await import(`${pathToFileURL(join(ROOT, rel)).href}?runtime-harness=${index}`);
111
+ assert.equal(typeof mod.default, "function", `${rel} must export a default function`);
112
+ mod.default(pi);
113
+ }
114
+ }
115
+
116
+ async function run() {
117
+ const { pi, hooks, commands, flags } = createPi();
118
+ await loadExtensions(pi);
119
+
120
+ for (const name of EXPECTED_COMMANDS) {
121
+ assert.ok(commands.has(name), `missing command ${name}`);
122
+ }
123
+ assert.ok(flags.has("no-skill-registry"), "missing no-skill-registry flag");
124
+ assert.ok(hooks.has("session_start"), "missing session_start hook");
125
+ assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
126
+ assert.ok(hooks.has("tool_call"), "missing tool_call hook");
127
+
128
+ const promptCwd = await tempWorkspace();
129
+ try {
130
+ const promptHook = hooks.get("before_agent_start")[0];
131
+ const promptResult = promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
132
+ assert.match(promptResult.systemPrompt, /base/);
133
+ assert.match(promptResult.systemPrompt, /el Gentleman/);
134
+ } finally {
135
+ await rm(promptCwd, { recursive: true, force: true });
136
+ }
137
+
138
+ const toolCwd = await tempWorkspace();
139
+ try {
140
+ const toolHook = hooks.get("tool_call")[0];
141
+ assert.equal(await toolHook({ toolName: "bash", input: { command: "git status" } }, createCtx(toolCwd)), undefined);
142
+ const denied = await toolHook({ toolName: "bash", input: { command: "rm -rf /" } }, createCtx(toolCwd));
143
+ assert.equal(denied.block, true);
144
+ assert.match(denied.reason, /destructive/);
145
+ const needsConfirm = await toolHook({ toolName: "bash", input: { command: "git push" } }, createCtx(toolCwd));
146
+ assert.equal(needsConfirm.block, true);
147
+ assert.match(needsConfirm.reason, /confirmation/);
148
+ } finally {
149
+ await rm(toolCwd, { recursive: true, force: true });
150
+ }
151
+
152
+ const noUiCwd = await tempWorkspace();
153
+ try {
154
+ for (const handler of hooks.get("session_start")) {
155
+ await handler({ reason: "startup" }, createCtx(noUiCwd, false));
156
+ }
157
+ } finally {
158
+ await rm(noUiCwd, { recursive: true, force: true });
159
+ }
160
+
161
+ const installCwd = await tempWorkspace();
162
+ try {
163
+ const ctx = createCtx(installCwd, true);
164
+ await commands.get("gentle-ai:install-sdd").handler("", ctx);
165
+ assert.match(ctx.ui.notifications.at(-1).message, /SDD assets installed/);
166
+ } finally {
167
+ await rm(installCwd, { recursive: true, force: true });
168
+ }
169
+
170
+ const sddCwd = await tempWorkspace();
171
+ try {
172
+ const ctx = createCtx(sddCwd, true);
173
+ await commands.get("sdd-init").handler("", ctx);
174
+ assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
175
+ } finally {
176
+ await rm(sddCwd, { recursive: true, force: true });
177
+ }
178
+
179
+ const modelsCwd = await tempWorkspace();
180
+ try {
181
+ await mkdir(join(modelsCwd, ".pi", "agents"), { recursive: true });
182
+ await mkdir(
183
+ join(modelsCwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
184
+ { recursive: true },
185
+ );
186
+ await writeFile(
187
+ join(
188
+ modelsCwd,
189
+ ".pi",
190
+ "npm",
191
+ "node_modules",
192
+ "pi-subagents",
193
+ "agents",
194
+ "worker.md",
195
+ ),
196
+ `---\nname: worker\ndescription: Builtin worker\n---\n`,
197
+ );
198
+ await writeFile(
199
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
200
+ `---\nname: sdd-apply\ndescription: Apply phase\n---\n\nbody\n`,
201
+ );
202
+ await mkdir(join(modelsCwd, ".pi", "gentle-ai"), { recursive: true });
203
+ await writeFile(
204
+ join(modelsCwd, ".pi", "gentle-ai", "models.json"),
205
+ JSON.stringify({ "sdd-apply": "openai/gpt-5" }, null, 2),
206
+ );
207
+
208
+ const ctx = createCtx(modelsCwd, true);
209
+ await hooks.get("session_start")[0]({ reason: "startup" }, ctx);
210
+ const legacyAppliedAgent = await readFile(
211
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
212
+ "utf8",
213
+ );
214
+ assert.match(legacyAppliedAgent, /model: openai\/gpt-5/);
215
+ assert.doesNotMatch(legacyAppliedAgent, /thinking:/);
216
+
217
+ ctx.ui.custom = () =>
218
+ Promise.resolve({
219
+ type: "save",
220
+ config: {
221
+ "sdd-apply": { model: "openai/gpt-5", thinking: "high" },
222
+ worker: { model: "openai/gpt-5-mini", thinking: "low" },
223
+ },
224
+ });
225
+ await commands.get("gentle:models").handler("", ctx);
226
+
227
+ const savedConfig = JSON.parse(
228
+ await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
229
+ );
230
+ assert.deepEqual(savedConfig["sdd-apply"], {
231
+ model: "openai/gpt-5",
232
+ thinking: "high",
233
+ });
234
+
235
+ const applyAgent = await readFile(
236
+ join(modelsCwd, ".pi", "agents", "sdd-apply.md"),
237
+ "utf8",
238
+ );
239
+ assert.match(applyAgent, /model: openai\/gpt-5/);
240
+ assert.match(applyAgent, /thinking: high/);
241
+
242
+ const settings = JSON.parse(
243
+ await readFile(join(modelsCwd, ".pi", "settings.json"), "utf8"),
244
+ );
245
+ assert.equal(
246
+ settings.subagents.agentOverrides.worker.model,
247
+ "openai/gpt-5-mini",
248
+ );
249
+ assert.equal(settings.subagents.agentOverrides.worker.thinking, "low");
250
+
251
+ let customPanelCalls = 0;
252
+ ctx.ui.input = async () => "custom/provider-model";
253
+ ctx.ui.custom = (factory) =>
254
+ new Promise((resolve) => {
255
+ customPanelCalls += 1;
256
+ const panel = factory(null, null, null, resolve);
257
+ if (customPanelCalls === 1) {
258
+ panel.handleInput("e"); // effort picker for all agents
259
+ for (let i = 0; i < 4; i++) panel.handleInput("j"); // medium
260
+ panel.handleInput("\r");
261
+ panel.handleInput("c"); // custom model from the same unsaved draft
262
+ return;
263
+ }
264
+ panel.handleInput("\u0013"); // ctrl+s saves the draft reopened after custom model input
265
+ });
266
+ await commands.get("gentle:models").handler("", ctx);
267
+
268
+ const customSavedConfig = JSON.parse(
269
+ await readFile(join(modelsCwd, ".pi", "gentle-ai", "models.json"), "utf8"),
270
+ );
271
+ assert.deepEqual(customSavedConfig["sdd-apply"], {
272
+ model: "custom/provider-model",
273
+ thinking: "medium",
274
+ });
275
+ } finally {
276
+ await rm(modelsCwd, { recursive: true, force: true });
277
+ }
278
+
279
+ const registryCwd = await tempWorkspace();
280
+ try {
281
+ const ctx = createCtx(registryCwd, true);
282
+ await commands.get("skill-registry:refresh").handler("", ctx);
283
+ assert.match(ctx.ui.notifications.at(-1).message, /Skill registry:/);
284
+ } finally {
285
+ await rm(registryCwd, { recursive: true, force: true });
286
+ }
287
+ }
288
+
289
+ run().catch((error) => {
290
+ console.error(error);
291
+ process.exitCode = 1;
292
+ });
@@ -112,3 +112,17 @@ test("uniqueExistingDirs normalizes duplicates and ignores missing roots", () =>
112
112
 
113
113
  assert.deepEqual(__testing.uniqueExistingDirs([existing, join(root, "skills/"), join(root, "missing")]), [existing]);
114
114
  });
115
+
116
+ test("startup skip honors no skill registry controls", () => {
117
+ const enabled = { getFlag: () => true };
118
+ const disabled = { getFlag: () => false };
119
+
120
+ assert.equal(__testing.shouldSkipSkillRegistryStartup(enabled, [], {}), true);
121
+ assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, ["--no-skills"], {}), true);
122
+ assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, ["-ns"], {}), true);
123
+ assert.equal(
124
+ __testing.shouldSkipSkillRegistryStartup(disabled, [], { GENTLE_PI_NO_SKILL_REGISTRY: "1" }),
125
+ true,
126
+ );
127
+ assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, [], {}), false);
128
+ });