gentle-pi 0.1.3 → 0.1.12

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
@@ -1,66 +1,226 @@
1
- # Gentle Pi
1
+ # el Gentleman
2
2
 
3
- `gentle-pi` is an opinionated Pi package for controlled autonomy: explicit Gentle Pi identity, SDD/OpenSpec workflow, strict TDD guidance, subagent-ready phase assets, review workload discipline, and a senior-architect persona.
3
+ `gentle-pi` installs **el Gentleman** into Pi: a controlled-development harness with a senior architect persona, SDD/OpenSpec phase discipline, strict TDD evidence, subagent orchestration, and review-workload protection.
4
4
 
5
- It does not include persistent memory. Install a separate memory package, such as a future Engram package, when you want long-term recall.
5
+ Install it when you want Pi to stop behaving like a generic chatbot and start acting like a disciplined development harness.
6
6
 
7
- ## Install
7
+ ## Quick start
8
8
 
9
9
  ```bash
10
10
  pi install npm:gentle-pi
11
11
  ```
12
12
 
13
- For local development from this repo:
13
+ Recommended companion packages:
14
14
 
15
15
  ```bash
16
- pi install ./pi-packages/gentle-ai
16
+ pi install npm:pi-subagents
17
+ pi install npm:pi-intercom
18
+ ```
19
+
20
+ Then start Pi in your project:
21
+
22
+ ```bash
23
+ pi
24
+ ```
25
+
26
+ On session start, `gentle-pi` automatically installs SDD assets into the project without overwriting local edits.
27
+
28
+ ## What you get
29
+
30
+ | Capability | What it does |
31
+ |---|---|
32
+ | el Gentleman identity | Answers as a Pi-specific harness, not a generic assistant. Defaults to the `gentleman` persona and can switch to `neutral`. |
33
+ | SDD/OpenSpec routing | Small work stays inline, context-heavy work delegates, large/risky work uses SDD artifacts. |
34
+ | SDD phase agents | Installs `sdd-init`, `sdd-explore`, `sdd-proposal`, `sdd-spec`, `sdd-design`, `sdd-tasks`, `sdd-apply`, `sdd-verify`, and `sdd-archive`. |
35
+ | Strict TDD support | Preserves RED → GREEN → TRIANGULATE → REFACTOR evidence and verify-time compliance checks. |
36
+ | Review workload guard | Forecasts large diffs and recommends chained PRs or explicit `size:exception`. |
37
+ | Model assignment UI | Opens a modal to assign Pi models to project, user, and built-in agents, with SDD agents shown first. |
38
+ | Foundation skills | Adds PR, issue, chained-PR, comment, docs, work-unit, and Judgment Day skills. |
39
+ | Safety policy | Blocks destructive shell actions unless there is explicit user approval. |
40
+
41
+ ## Core commands
42
+
43
+ ```text
44
+ /gentle-ai:status Show package, SDD asset, OpenSpec, and model config status.
45
+ /gentleman:models Open the per-agent model assignment modal.
46
+ /gentle-ai:models Compatibility alias that points to /gentleman:models.
47
+ /gentleman:persona Switch between gentleman and neutral personas.
48
+ /gentle-ai:persona Compatibility alias that points to /gentleman:persona.
49
+ /sdd-init Bootstrap or refresh openspec/config.yaml.
50
+ /gentle-ai:install-sdd Reinstall SDD assets without overwriting local files.
51
+ /gentle-ai:install-sdd --force
52
+ Force-refresh installed SDD assets.
53
+ ```
54
+
55
+ ## Switch persona
56
+
57
+ Default persona: `gentleman`.
58
+
59
+ Run:
60
+
61
+ ```text
62
+ /gentleman:persona
63
+ ```
64
+
65
+ Available modes:
66
+
67
+ | Persona | Behavior |
68
+ |---|---|
69
+ | `gentleman` | Teaching-oriented senior architect persona with Rioplatense Spanish/voseo when the user writes Spanish. |
70
+ | `neutral` | Same senior architect discipline and teaching philosophy, but with warm professional language and no regional expressions. |
71
+
72
+ Saved config:
73
+
74
+ ```text
75
+ .pi/gentle-ai/persona.json
76
+ ```
77
+
78
+ Run `/reload` or start a new Pi session after switching if the current session already injected the previous persona.
79
+
80
+ ## Assign models to agents
81
+
82
+ Run:
83
+
84
+ ```text
85
+ /gentleman:models
86
+ ```
87
+
88
+ This opens a modal similar to Gentle-AI's model picker. It discovers Pi subagents from project, user, and built-in sources, with SDD agents sorted first:
89
+
90
+ ```text
91
+ Assign Models to Agents
92
+
93
+ Current assignments:
94
+
95
+ ▸ Set all agents mixed
96
+ sdd-init inherit
97
+ sdd-explore openai-codex/gpt-5.5
98
+ sdd-proposal openai-codex/gpt-5.5
99
+ sdd-spec anthropic/claude-sonnet-4
100
+ sdd-design anthropic/claude-sonnet-4
101
+ sdd-tasks anthropic/claude-sonnet-4
102
+ sdd-apply anthropic/claude-sonnet-4
103
+ sdd-verify google/gemini-3-pro
104
+ sdd-archive inherit
105
+ delegate inherit
106
+ my-custom-agent anthropic/claude-sonnet-4
107
+
108
+ Continue
109
+ ← Back
110
+
111
+ j/k: navigate • enter: change model / confirm • i: inherit • c: custom • ctrl+s: save • esc: back
17
112
  ```
18
113
 
19
- ## What it loads
114
+ Model choices come from Pi itself via the active model registry. The modal also supports custom model IDs for advanced setups.
115
+
116
+ The modal covers:
117
+
118
+ - project agents from `.pi/agents/` and `.agents/`;
119
+ - user agents from `~/.pi/agent/agents/` and `~/.agents/`;
120
+ - built-in `pi-subagents` agents.
20
121
 
21
- - `extensions/gentle-ai.ts` injects the parent-session Gentle AI orchestrator, auto-installs SDD assets non-destructively, registers `/gentle-ai:*` commands, and blocks high-risk shell commands.
22
- - `extensions/sdd-init.ts` — registers `/sdd-init` to bootstrap `openspec/config.yaml`.
23
- - `extensions/skill-registry.ts` — registers `/skill-registry:refresh` and maintains `.atl/skill-registry.md` from compact skill rules.
24
- - `skills/gentle-ai` — compact rules for Gentle AI orchestration.
25
- - `skills/branch-pr`, `skills/chained-pr`, `skills/work-unit-commits`, `skills/judgment-day`, `skills/cognitive-doc-design`, `skills/comment-writer`, and `skills/issue-creation` — foundation skills ported from Gentle-AI.
26
- - `prompts/` — reusable prompt templates with Gentle-prefixed names (`/gcl`, `/gis`, `/gpr`, `/gwr`) to avoid collisions with project prompts.
27
- - `assets/orchestrator.md` — parent-session SDD orchestration contract adapted from Gentle-AI.
28
- - `assets/support` — strict TDD apply/verify support files copied to `.pi/gentle-ai/support/`.
29
- - `assets/agents` and `assets/chains` — SDD assets auto-installed into projects that use `pi-subagents`.
122
+ Small recommendation in English:
30
123
 
31
- ## First run
124
+ | Agent kind | Recommended model shape |
125
+ |---|---|
126
+ | Exploration, proposal, archive | Fast and cheap is usually enough. |
127
+ | Spec, design, tasks | Strong reasoning model, because these phases shape the implementation. |
128
+ | Apply | Strong coding model with good tool-use reliability. |
129
+ | Verify / review agents | Strongest fresh-context model you can afford; verification benefits from independence. |
130
+ | Tiny utility agents | Inherit the active/default model unless they become a bottleneck. |
131
+
132
+ Saved config:
133
+
134
+ ```text
135
+ .pi/gentle-ai/models.json
136
+ ```
32
137
 
33
- After installing, open Pi and start working. The package auto-installs SDD subagent assets into the current project when the session starts, without overwriting existing files.
138
+ Applied configuration:
139
+
140
+ ```text
141
+ .pi/agents/*.md # project/user markdown agents
142
+ .pi/settings.json # project overrides for built-in pi-subagents agents
143
+ ```
144
+
145
+ Use `Inherit active/default model` to remove an agent override.
146
+
147
+ ## Installed project files
148
+
149
+ `gentle-pi` installs these project-local assets on session start:
150
+
151
+ ```text
152
+ .pi/agents/sdd-*.md
153
+ .pi/chains/sdd-*.chain.md
154
+ .pi/gentle-ai/support/strict-tdd.md
155
+ .pi/gentle-ai/support/strict-tdd-verify.md
156
+ ```
34
157
 
35
- Useful diagnostics/recovery commands:
158
+ Existing files are skipped. Your local edits remain unless you explicitly run:
36
159
 
37
160
  ```text
38
- /gentle-ai:status
39
- /sdd-init
40
161
  /gentle-ai:install-sdd --force
41
162
  ```
42
163
 
43
- `/gentle-ai:install-sdd` is a recovery command. The normal path is automatic. It copies SDD agents to `.pi/agents/` and chains to `.pi/chains/`; existing files are skipped unless `--force` is passed.
164
+ ## Package contents
165
+
166
+ | Path | Purpose |
167
+ |---|---|
168
+ | `extensions/gentle-ai.ts` | Injects el Gentleman, installs assets, provides commands, applies model config, blocks unsafe shell commands. |
169
+ | `extensions/sdd-init.ts` | Registers `/sdd-init` for OpenSpec project initialization. |
170
+ | `extensions/skill-registry.ts` | Registers `/skill-registry:refresh` and maintains `.atl/skill-registry.md`. |
171
+ | `assets/orchestrator.md` | Parent-session orchestration contract. |
172
+ | `assets/agents/` | SDD phase agents copied into `.pi/agents/`. |
173
+ | `assets/chains/` | SDD chains copied into `.pi/chains/`. |
174
+ | `assets/support/` | Strict TDD apply/verify support docs. |
175
+ | `skills/` | el Gentleman and foundation skills. |
176
+ | `prompts/` | Gentle-prefixed prompt templates: `/gcl`, `/gis`, `/gpr`, `/gwr`. |
177
+
178
+ ## Foundation skills
179
+
180
+ Included skills:
181
+
182
+ - `gentle-ai` — el Gentleman harness discipline.
183
+ - `branch-pr` — issue-first PR creation.
184
+ - `chained-pr` — split oversized PRs into reviewable chains.
185
+ - `work-unit-commits` — commit by deliverable work unit.
186
+ - `judgment-day` — blind dual review and re-judgment flow.
187
+ - `cognitive-doc-design` — documentation that reduces reviewer load.
188
+ - `comment-writer` — concise collaboration comments.
189
+ - `issue-creation` — issue-first GitHub workflow.
44
190
 
45
- ## Optional companion packages
191
+ ## Memory is separate
46
192
 
47
- Recommended with this package:
193
+ This package intentionally does **not** configure persistent memory.
194
+
195
+ Use a separate memory package, for example:
48
196
 
49
197
  ```bash
50
- pi install npm:pi-subagents
51
- pi install npm:pi-intercom
198
+ pi install npm:gentle-engram
52
199
  ```
53
200
 
54
- - `pi-subagents` runs the installed SDD agents and chains.
55
- - `pi-intercom` lets child agents ask the parent for decisions while running.
201
+ el Gentleman may mention memory only when a memory package or callable memory tool is actually active.
202
+
203
+ ## Local development
56
204
 
57
- Persistent memory should remain separate; this package intentionally does not configure Engram or any other memory backend.
205
+ From this repo:
206
+
207
+ ```bash
208
+ pi install ./pi-packages/gentle-ai
209
+ ```
210
+
211
+ Validate before publishing:
212
+
213
+ ```bash
214
+ node --experimental-strip-types --check pi-packages/gentle-ai/extensions/gentle-ai.ts
215
+ cd pi-packages/gentle-ai
216
+ npm pack --dry-run
217
+ ```
58
218
 
59
219
  ## Design principles
60
220
 
61
221
  - Concepts before code.
62
- - Artifacts over floating context.
63
- - Strict TDD when tests exist, including evidence in apply and compliance checks in verify.
64
- - One orchestrator, focused subagents.
222
+ - Artifacts over floating chat context.
223
+ - Strict TDD evidence when tests exist.
224
+ - One parent orchestrator, focused subagents.
65
225
  - Review workload is a first-class constraint.
66
- - Safety policy blocks destructive actions unless the user explicitly approves a safer path.
226
+ - Human control beats agent momentum.
@@ -1,15 +1,15 @@
1
- # Gentle Pi Orchestrator
1
+ # el Gentleman Orchestrator
2
2
 
3
3
  Bind this to the parent Pi session only. Do not apply it to SDD executor phase agents.
4
4
 
5
5
  ## Identity Contract
6
6
 
7
- You are Gentle Pi: a Pi-specific coding-agent harness for controlled development work.
7
+ You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
8
8
 
9
9
  When the user asks who or what you are, answer in this shape:
10
10
 
11
11
  ```text
12
- Soy Gentle Pi: un harness específico de Pi para desarrollo controlado, con persona de arquitecto senior. Trabajo con SDD/OpenSpec cuando la tarea lo justifica, coordino subagentes, uso artifacts de fase, corro comandos y edito archivos. No soy un chatbot genérico.
12
+ Soy el Gentleman: un harness específico de Pi para desarrollo controlado, con persona de arquitecto senior. Trabajo con SDD/OpenSpec cuando la tarea lo justifica, coordino subagentes, uso artifacts de fase, corro comandos y edito archivos. No soy un chatbot genérico.
13
13
  ```
14
14
 
15
15
  Rules:
@@ -27,7 +27,7 @@ Keep synthesis short by default: decision, outcome, next action. Expand only whe
27
27
 
28
28
  ## Mental Model
29
29
 
30
- Gentle Pi is an ecosystem configurator and harness layer. After installation, the user should not memorize workflows or manually wire agents. The package should get out of the way:
30
+ el Gentleman is an ecosystem configurator and harness layer. After installation, the user should not memorize workflows or manually wire agents. The package should get out of the way:
31
31
 
32
32
  - Small request: do it directly.
33
33
  - Substantial feature: suggest SDD organically.
@@ -1,30 +1,51 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- import type { ExtensionAPI, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI, ExtensionContext, ToolCallEventResult } from "@earendil-works/pi-coding-agent";
6
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
5
7
 
6
8
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
7
9
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
8
10
  const ORCHESTRATOR_PROMPT = readFileSync(join(ASSETS_DIR, "orchestrator.md"), "utf8").trim();
9
11
 
10
- const GENTLE_AI_PROMPT = `## Gentle Pi Identity and Harness
11
- You are Gentle Pi: a Pi-specific coding-agent harness for controlled development work.
12
+ type PersonaMode = "gentleman" | "neutral";
13
+
14
+ const PERSONA_OPTIONS = ["gentleman", "neutral"] as const;
15
+
16
+ const GENTLEMAN_PERSONA_PROMPT = `Persona:
17
+ - Be direct, technical, and concise.
18
+ - When the user writes Spanish, answer in natural Rioplatense Spanish with voseo.
19
+ - Act as a senior architect and teacher: concepts before code, no shortcuts.
20
+ - Treat AI as a tool directed by the human; never present yourself as a default chatbot.
21
+ - Push back when the user asks for code without enough context or understanding.
22
+ - Correct errors directly, explain why, and show the better path.`;
23
+
24
+ const NEUTRAL_PERSONA_PROMPT = `Persona:
25
+ - Be direct, technical, concise, warm, and professional.
26
+ - Always respond in the same language the user writes in.
27
+ - Do not use slang or regional expressions.
28
+ - Act as a senior architect and teacher: concepts before code, no shortcuts.
29
+ - Treat AI as a tool directed by the human; never present yourself as a default chatbot.
30
+ - Push back when the user asks for code without enough context or understanding.
31
+ - Correct errors directly, explain why, and show the better path.`;
32
+
33
+ function buildGentlePrompt(persona: PersonaMode): string {
34
+ const personaPrompt = persona === "neutral" ? NEUTRAL_PERSONA_PROMPT : GENTLEMAN_PERSONA_PROMPT;
35
+ return `## el Gentleman Identity and Harness
36
+ You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
12
37
 
13
38
  Identity contract:
14
- - If the user asks who or what you are, answer as Gentle Pi, not as a generic assistant.
39
+ - If the user asks who or what you are, answer as el Gentleman, not as a generic assistant.
15
40
  - Say you are a Pi-specific coding-agent harness with senior architect persona.
16
41
  - Mention SDD/OpenSpec phase artifacts and subagents as core capabilities.
17
42
  - Mention memory only when memory packages or callable memory tools are actually active; never invent persistent memory.
18
43
  - Do not claim portability outside the Pi runtime.
19
44
 
20
- Persona:
21
- - Be direct, technical, and concise.
22
- - When the user writes Spanish, answer in natural Rioplatense Spanish with voseo.
23
- - Act as a senior architect and teacher: concepts before code, no shortcuts.
24
- - Treat AI as a tool directed by the human; never present yourself as a default chatbot.
45
+ ${personaPrompt}
25
46
 
26
47
  Harness principles:
27
- - Gentle Pi is not prompt engineering. It is runtime discipline around powerful agents.
48
+ - el Gentleman is not prompt engineering. It is runtime discipline around powerful agents.
28
49
  - Prefer SDD/OpenSpec artifacts over floating chat context for non-trivial work.
29
50
  - Clarify scope, constraints, acceptance criteria, and non-goals before implementation.
30
51
  - Use subagents when available for exploration, planning, implementation, and review, while keeping one parent session responsible for orchestration.
@@ -34,6 +55,7 @@ Harness principles:
34
55
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
35
56
 
36
57
  ${ORCHESTRATOR_PROMPT}`;
58
+ }
37
59
 
38
60
  const DENIED_BASH_PATTERNS: RegExp[] = [
39
61
  /\brm\s+-rf\s+(?:\/|~|\$HOME|\.\.?)(?:\s|$)/,
@@ -52,6 +74,34 @@ const CONFIRM_BASH_PATTERNS: RegExp[] = [
52
74
  /\bpi\s+remove\b/,
53
75
  ];
54
76
 
77
+ const SDD_AGENT_NAMES = [
78
+ "sdd-init",
79
+ "sdd-explore",
80
+ "sdd-proposal",
81
+ "sdd-spec",
82
+ "sdd-design",
83
+ "sdd-tasks",
84
+ "sdd-apply",
85
+ "sdd-verify",
86
+ "sdd-archive",
87
+ ] as const;
88
+
89
+ type SddAgentName = (typeof SDD_AGENT_NAMES)[number];
90
+ type AgentModelConfig = Record<string, string>;
91
+ type AgentSource = "project" | "user" | "builtin";
92
+
93
+ interface AgentEntry {
94
+ name: string;
95
+ source: AgentSource;
96
+ filePath?: string;
97
+ }
98
+
99
+ const KEEP_CURRENT = "Keep current";
100
+ const INHERIT_MODEL = "Inherit active/default model";
101
+ const CUSTOM_MODEL = "Custom model id";
102
+
103
+ const MODEL_CONTROL_OPTIONS = [KEEP_CURRENT, INHERIT_MODEL, CUSTOM_MODEL] as const;
104
+
55
105
  function evaluateCommand(command: string): ToolCallEventResult | undefined {
56
106
  for (const pattern of DENIED_BASH_PATTERNS) {
57
107
  if (pattern.test(command)) {
@@ -112,19 +162,436 @@ function installSddAssets(
112
162
  };
113
163
  }
114
164
 
165
+ function isRecord(value: unknown): value is Record<string, unknown> {
166
+ return typeof value === "object" && value !== null && !Array.isArray(value);
167
+ }
168
+
169
+ function modelConfigPath(cwd: string): string {
170
+ return join(cwd, ".pi", "gentle-ai", "models.json");
171
+ }
172
+
173
+ function personaConfigPath(cwd: string): string {
174
+ return join(cwd, ".pi", "gentle-ai", "persona.json");
175
+ }
176
+
177
+ function readPersonaMode(cwd: string): PersonaMode {
178
+ const path = personaConfigPath(cwd);
179
+ if (!existsSync(path)) return "gentleman";
180
+ try {
181
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
182
+ if (!isRecord(parsed)) return "gentleman";
183
+ return parsed.mode === "neutral" ? "neutral" : "gentleman";
184
+ } catch {
185
+ return "gentleman";
186
+ }
187
+ }
188
+
189
+ function writePersonaMode(cwd: string, mode: PersonaMode): void {
190
+ const path = personaConfigPath(cwd);
191
+ mkdirSync(dirname(path), { recursive: true });
192
+ writeFileSync(path, `${JSON.stringify({ mode }, null, 2)}\n`);
193
+ }
194
+
195
+ function readModelConfig(cwd: string): AgentModelConfig {
196
+ const path = modelConfigPath(cwd);
197
+ if (!existsSync(path)) return {};
198
+ try {
199
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
200
+ if (!isRecord(parsed)) return {};
201
+ const config: AgentModelConfig = {};
202
+ for (const [name, value] of Object.entries(parsed)) {
203
+ if (typeof value === "string" && value.trim().length > 0) {
204
+ config[name] = value.trim();
205
+ }
206
+ }
207
+ return config;
208
+ } catch {
209
+ return {};
210
+ }
211
+ }
212
+
213
+ function writeModelConfig(cwd: string, config: AgentModelConfig): void {
214
+ const path = modelConfigPath(cwd);
215
+ mkdirSync(dirname(path), { recursive: true });
216
+ writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`);
217
+ }
218
+
219
+ function updateFrontmatterModel(content: string, model: string | undefined): string {
220
+ if (!content.startsWith("---\n")) return content;
221
+ const endIndex = content.indexOf("\n---", 4);
222
+ if (endIndex === -1) return content;
223
+ const frontmatter = content.slice(4, endIndex);
224
+ const body = content.slice(endIndex);
225
+ const lines = frontmatter.split("\n").filter((line) => !line.startsWith("model:"));
226
+ if (model !== undefined) {
227
+ const descriptionIndex = lines.findIndex((line) => line.startsWith("description:"));
228
+ const insertIndex = descriptionIndex >= 0 ? descriptionIndex + 1 : Math.min(1, lines.length);
229
+ lines.splice(insertIndex, 0, `model: ${model}`);
230
+ }
231
+ return `---\n${lines.join("\n")}${body}`;
232
+ }
233
+
234
+ function parseAgentName(filePath: string): string | undefined {
235
+ let content: string;
236
+ try {
237
+ content = readFileSync(filePath, "utf8");
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim();
242
+ if (!name) return undefined;
243
+ const packageName = content.match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim();
244
+ return packageName ? `${packageName}.${name}` : name;
245
+ }
246
+
247
+ function listAgentFilesRecursive(dir: string): string[] {
248
+ if (!existsSync(dir)) return [];
249
+ const files: string[] = [];
250
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
251
+ const path = join(dir, entry.name);
252
+ if (entry.isDirectory()) files.push(...listAgentFilesRecursive(path));
253
+ else if (entry.isFile() && entry.name.endsWith(".md") && !entry.name.endsWith(".chain.md")) files.push(path);
254
+ }
255
+ return files;
256
+ }
257
+
258
+ function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
259
+ return listAgentFilesRecursive(dir)
260
+ .map((filePath) => {
261
+ const name = parseAgentName(filePath);
262
+ return name ? { name, source, filePath } : undefined;
263
+ })
264
+ .filter((entry): entry is AgentEntry => entry !== undefined);
265
+ }
266
+
267
+ function listDiscoverableAgents(cwd: string): AgentEntry[] {
268
+ const builtinDirs = [
269
+ join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
270
+ join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
271
+ join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
272
+ ];
273
+ const agents = [
274
+ ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
275
+ ...listAgentsFromDir(join(homedir(), ".pi", "agent", "agents"), "user"),
276
+ ...listAgentsFromDir(join(homedir(), ".agents"), "user"),
277
+ ...listAgentsFromDir(join(cwd, ".agents"), "project"),
278
+ ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
279
+ ];
280
+ const byName = new Map<string, AgentEntry>();
281
+ for (const agent of agents) byName.set(agent.name, agent);
282
+ const discovered = Array.from(byName.values());
283
+ const sddFirst = SDD_AGENT_NAMES
284
+ .map((name) => discovered.find((agent) => agent.name === name))
285
+ .filter((agent): agent is AgentEntry => agent !== undefined);
286
+ const rest = discovered
287
+ .filter((agent) => !SDD_AGENT_NAMES.includes(agent.name as SddAgentName))
288
+ .sort((left, right) => left.name.localeCompare(right.name));
289
+ return [...sddFirst, ...rest];
290
+ }
291
+
292
+ function projectSettingsPath(cwd: string): string {
293
+ return join(cwd, ".pi", "settings.json");
294
+ }
295
+
296
+ function updateBuiltinModelOverride(cwd: string, name: string, model: string | undefined): boolean {
297
+ const path = projectSettingsPath(cwd);
298
+ let settings: Record<string, unknown> = {};
299
+ if (existsSync(path)) {
300
+ try {
301
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
302
+ if (isRecord(parsed)) settings = parsed;
303
+ } catch {
304
+ settings = {};
305
+ }
306
+ }
307
+ const subagents = isRecord(settings.subagents) ? { ...settings.subagents } : {};
308
+ const agentOverrides = isRecord(subagents.agentOverrides) ? { ...subagents.agentOverrides } : {};
309
+ const current = isRecord(agentOverrides[name]) ? { ...agentOverrides[name] } : {};
310
+ if (model === undefined) delete current.model;
311
+ else current.model = model;
312
+ if (Object.keys(current).length > 0) agentOverrides[name] = current;
313
+ else delete agentOverrides[name];
314
+ if (Object.keys(agentOverrides).length > 0) subagents.agentOverrides = agentOverrides;
315
+ else delete subagents.agentOverrides;
316
+ if (Object.keys(subagents).length > 0) settings.subagents = subagents;
317
+ else delete settings.subagents;
318
+ mkdirSync(dirname(path), { recursive: true });
319
+ writeFileSync(path, `${JSON.stringify(settings, null, "\t")}\n`);
320
+ return true;
321
+ }
322
+
323
+ function applyModelConfig(cwd: string, config: AgentModelConfig): { updated: number; skipped: number } {
324
+ let updated = 0;
325
+ let skipped = 0;
326
+ for (const agent of listDiscoverableAgents(cwd)) {
327
+ const model = config[agent.name];
328
+ if (agent.source === "builtin") {
329
+ if (updateBuiltinModelOverride(cwd, agent.name, model)) updated += 1;
330
+ else skipped += 1;
331
+ continue;
332
+ }
333
+ if (!agent.filePath || !existsSync(agent.filePath)) {
334
+ skipped += 1;
335
+ continue;
336
+ }
337
+ const original = readFileSync(agent.filePath, "utf8");
338
+ const next = updateFrontmatterModel(original, model);
339
+ if (next === original) {
340
+ skipped += 1;
341
+ continue;
342
+ }
343
+ writeFileSync(agent.filePath, next);
344
+ updated += 1;
345
+ }
346
+ return { updated, skipped };
347
+ }
348
+
349
+ function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
350
+ return listDiscoverableAgents(cwd).map((agent) => `${agent.name}: ${config[agent.name] ?? "inherit"}`);
351
+ }
352
+
353
+ async function getPiModelOptions(ctx: ExtensionContext): Promise<string[]> {
354
+ const models = await ctx.modelRegistry.getAvailable();
355
+ const modelIds = models
356
+ .map((model) => `${model.provider}/${model.id}`)
357
+ .sort((left, right) => left.localeCompare(right));
358
+ return [...MODEL_CONTROL_OPTIONS, ...modelIds];
359
+ }
360
+
361
+ interface OverlayComponent {
362
+ render(width: number): string[];
363
+ handleInput(data: string): void;
364
+ invalidate(): void;
365
+ }
366
+
367
+ type ModelPanelResult =
368
+ | { type: "save"; config: AgentModelConfig }
369
+ | { type: "custom"; agent: string | "all" }
370
+ | { type: "cancel" };
371
+
372
+ const SET_ALL_AGENTS = "Set all agents";
373
+
374
+ class SddModelPanel implements OverlayComponent {
375
+ private cursor = 0;
376
+ private mode: "agents" | "models" = "agents";
377
+ private selectedRow = SET_ALL_AGENTS;
378
+ private modelCursor = 0;
379
+ private query = "";
380
+ private readonly draft: AgentModelConfig;
381
+ private readonly rows: string[];
382
+
383
+ constructor(
384
+ initialConfig: AgentModelConfig,
385
+ private readonly modelOptions: string[],
386
+ agents: string[],
387
+ private readonly done: (result: ModelPanelResult) => void,
388
+ ) {
389
+ this.draft = { ...initialConfig };
390
+ this.rows = [SET_ALL_AGENTS, ...agents];
391
+ }
392
+
393
+ invalidate(): void {}
394
+
395
+ handleInput(data: string): void {
396
+ if (this.mode === "models") {
397
+ this.handleModelInput(data);
398
+ return;
399
+ }
400
+ this.handleAgentInput(data);
401
+ }
402
+
403
+ render(width: number): string[] {
404
+ return this.mode === "models" ? this.renderModelPicker(width) : this.renderAgentList(width);
405
+ }
406
+
407
+ private handleAgentInput(data: string): void {
408
+ const maxCursor = this.rows.length + 1;
409
+ if (matchesKey(data, "ctrl+c") || matchesKey(data, "escape")) {
410
+ this.done({ type: "cancel" });
411
+ return;
412
+ }
413
+ if (matchesKey(data, "ctrl+s")) {
414
+ this.done({ type: "save", config: this.draft });
415
+ return;
416
+ }
417
+ if (matchesKey(data, "down") || data === "j") {
418
+ this.cursor = Math.min(maxCursor, this.cursor + 1);
419
+ return;
420
+ }
421
+ if (matchesKey(data, "up") || data === "k") {
422
+ this.cursor = Math.max(0, this.cursor - 1);
423
+ return;
424
+ }
425
+ if (data === "i") {
426
+ this.applySelection(undefined);
427
+ return;
428
+ }
429
+ if (data === "c") {
430
+ const row = this.rows[this.cursor];
431
+ if (row === SET_ALL_AGENTS) this.done({ type: "custom", agent: "all" });
432
+ else if (row) this.done({ type: "custom", agent: row });
433
+ return;
434
+ }
435
+ if (!matchesKey(data, "return")) return;
436
+ if (this.cursor === this.rows.length) {
437
+ this.done({ type: "save", config: this.draft });
438
+ return;
439
+ }
440
+ if (this.cursor === this.rows.length + 1) {
441
+ this.done({ type: "cancel" });
442
+ return;
443
+ }
444
+ this.selectedRow = this.rows[this.cursor] ?? SET_ALL_AGENTS;
445
+ this.mode = "models";
446
+ this.modelCursor = 0;
447
+ this.query = "";
448
+ }
449
+
450
+ private handleModelInput(data: string): void {
451
+ const options = this.filteredModelOptions();
452
+ if (matchesKey(data, "ctrl+c")) {
453
+ this.done({ type: "cancel" });
454
+ return;
455
+ }
456
+ if (matchesKey(data, "escape")) {
457
+ this.mode = "agents";
458
+ this.query = "";
459
+ return;
460
+ }
461
+ if (matchesKey(data, "backspace")) {
462
+ this.query = this.query.slice(0, -1);
463
+ this.modelCursor = Math.min(this.modelCursor, Math.max(0, this.filteredModelOptions().length - 1));
464
+ return;
465
+ }
466
+ if (matchesKey(data, "down") || data === "j") {
467
+ this.modelCursor = Math.min(Math.max(0, options.length - 1), this.modelCursor + 1);
468
+ return;
469
+ }
470
+ if (matchesKey(data, "up") || data === "k") {
471
+ this.modelCursor = Math.max(0, this.modelCursor - 1);
472
+ return;
473
+ }
474
+ if (matchesKey(data, "return")) {
475
+ const selected = options[this.modelCursor];
476
+ if (!selected) return;
477
+ if (selected === CUSTOM_MODEL) {
478
+ this.done({ type: "custom", agent: this.selectedRow === SET_ALL_AGENTS ? "all" : this.selectedRow });
479
+ return;
480
+ }
481
+ if (selected === KEEP_CURRENT) {
482
+ this.mode = "agents";
483
+ return;
484
+ }
485
+ this.applySelection(selected === INHERIT_MODEL ? undefined : selected);
486
+ this.mode = "agents";
487
+ return;
488
+ }
489
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
490
+ this.query += data;
491
+ this.modelCursor = 0;
492
+ }
493
+ }
494
+
495
+ private applySelection(model: string | undefined): void {
496
+ const row = this.rows[this.cursor];
497
+ if (row === SET_ALL_AGENTS) {
498
+ for (const name of this.rows.slice(1)) {
499
+ if (model === undefined) delete this.draft[name];
500
+ else this.draft[name] = model;
501
+ }
502
+ return;
503
+ }
504
+ if (!row) return;
505
+ if (model === undefined) delete this.draft[row];
506
+ else this.draft[row] = model;
507
+ }
508
+
509
+ private filteredModelOptions(): string[] {
510
+ const query = this.query.trim().toLowerCase();
511
+ if (!query) return this.modelOptions;
512
+ return this.modelOptions.filter((option) => option.toLowerCase().includes(query));
513
+ }
514
+
515
+ private renderAgentList(width: number): string[] {
516
+ const lines: string[] = [];
517
+ const line = (text = "") => truncateToWidth(text, Math.max(1, width), "…", true);
518
+ lines.push(line("Assign Models to Agents"));
519
+ lines.push("");
520
+ lines.push(line("Current assignments:"));
521
+ lines.push("");
522
+ for (let i = 0; i < this.rows.length; i++) {
523
+ const row = this.rows[i] ?? SET_ALL_AGENTS;
524
+ const focused = i === this.cursor;
525
+ const label = row === SET_ALL_AGENTS ? this.renderSetAllLabel(row) : this.renderAgentLabel(row);
526
+ lines.push(line(`${focused ? "▸" : " "} ${label}`));
527
+ }
528
+ lines.push("");
529
+ lines.push(line(`${this.cursor === this.rows.length ? "▸" : " "} Continue`));
530
+ lines.push(line(`${this.cursor === this.rows.length + 1 ? "▸" : " "} ← Back`));
531
+ lines.push("");
532
+ lines.push(line("j/k: navigate • enter: change model / confirm • i: inherit • c: custom • ctrl+s: save • esc: back"));
533
+ return lines;
534
+ }
535
+
536
+ private renderModelPicker(width: number): string[] {
537
+ const lines: string[] = [];
538
+ const options = this.filteredModelOptions();
539
+ const line = (text = "") => truncateToWidth(text, Math.max(1, width), "…", true);
540
+ lines.push(line(`Select model for ${this.selectedRow}`));
541
+ lines.push("");
542
+ lines.push(line(`◎ ${this.query || "search..."}`));
543
+ lines.push("");
544
+ const maxVisible = 12;
545
+ const start = Math.max(0, Math.min(this.modelCursor - Math.floor(maxVisible / 2), Math.max(0, options.length - maxVisible)));
546
+ const end = Math.min(options.length, start + maxVisible);
547
+ for (let i = start; i < end; i++) {
548
+ const focused = i === this.modelCursor;
549
+ lines.push(line(`${focused ? "▸" : " "} ${options[i]}`));
550
+ }
551
+ if (options.length === 0) lines.push(line(" No matching models"));
552
+ lines.push("");
553
+ lines.push(line("j/k: navigate • type: search • enter: select • esc: back"));
554
+ return lines;
555
+ }
556
+
557
+ private renderSetAllLabel(row: string): string {
558
+ const values = this.rows.slice(1).map((name) => this.draft[name] ?? "inherit");
559
+ const first = values[0] ?? "inherit";
560
+ const allSame = values.every((value) => value === first);
561
+ return `${row.padEnd(20)} ${allSame ? first : "mixed"}`;
562
+ }
563
+
564
+ private renderAgentLabel(row: string): string {
565
+ return `${row.padEnd(20)} ${this.draft[row] ?? "inherit"}`;
566
+ }
567
+ }
568
+
569
+ async function showSddModelPanel(ctx: ExtensionContext, config: AgentModelConfig): Promise<ModelPanelResult> {
570
+ const modelOptions = await getPiModelOptions(ctx);
571
+ const agents = listDiscoverableAgents(ctx.cwd).map((agent) => agent.name);
572
+ return ctx.ui.custom<ModelPanelResult>((_tui, _theme, _keybindings, done) => new SddModelPanel(config, modelOptions, agents, done), {
573
+ overlay: true,
574
+ overlayOptions: { anchor: "center", width: "70%", minWidth: 72, maxHeight: "85%" },
575
+ });
576
+ }
577
+
115
578
  export default function gentleAi(pi: ExtensionAPI): void {
116
579
  pi.on("session_start", (_event, ctx) => {
117
580
  const result = installSddAssets(ctx.cwd, false);
581
+ const modelResult = applyModelConfig(ctx.cwd, readModelConfig(ctx.cwd));
118
582
  if (ctx.hasUI && (result.agents > 0 || result.chains > 0 || result.support > 0)) {
119
583
  ctx.ui.notify(
120
584
  `Gentle AI SDD assets auto-installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s).`,
121
585
  "info",
122
586
  );
123
587
  }
588
+ if (ctx.hasUI && modelResult.updated > 0) {
589
+ ctx.ui.notify(`el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`, "info");
590
+ }
124
591
  });
125
592
 
126
- pi.on("before_agent_start", (event) => ({
127
- systemPrompt: `${event.systemPrompt}\n\n${GENTLE_AI_PROMPT}`,
593
+ pi.on("before_agent_start", (event, ctx) => ({
594
+ systemPrompt: `${event.systemPrompt}\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`,
128
595
  }));
129
596
 
130
597
  pi.on("tool_call", (event) => {
@@ -144,18 +611,91 @@ export default function gentleAi(pi: ExtensionAPI): void {
144
611
  },
145
612
  });
146
613
 
614
+ pi.registerCommand("gentleman:models", {
615
+ description: "Configure per-agent models for el Gentleman.",
616
+ handler: async (_args, ctx) => {
617
+ let config = readModelConfig(ctx.cwd);
618
+ let result = await showSddModelPanel(ctx, config);
619
+ while (result.type === "custom") {
620
+ const current = result.agent === "all" ? "inherit" : (config[result.agent] ?? "inherit");
621
+ const custom = await ctx.ui.input(
622
+ `${result.agent === "all" ? "all agents" : result.agent} custom model id`,
623
+ current === "inherit" ? "provider/model" : current,
624
+ );
625
+ if (custom === undefined) return;
626
+ const trimmed = custom.trim();
627
+ if (trimmed.length > 0) {
628
+ if (result.agent === "all") {
629
+ config = Object.fromEntries(listDiscoverableAgents(ctx.cwd).map((agent) => [agent.name, trimmed]));
630
+ } else {
631
+ config = { ...config, [result.agent]: trimmed };
632
+ }
633
+ }
634
+ result = await showSddModelPanel(ctx, config);
635
+ }
636
+ if (result.type !== "save") return;
637
+ writeModelConfig(ctx.cwd, result.config);
638
+ const applyResult = applyModelConfig(ctx.cwd, result.config);
639
+ ctx.ui.notify(
640
+ [
641
+ "el Gentleman model config saved.",
642
+ `Config: ${modelConfigPath(ctx.cwd)}`,
643
+ `Agents updated: ${applyResult.updated}`,
644
+ ...describeModelConfig(ctx.cwd, result.config),
645
+ ].join("\n"),
646
+ "info",
647
+ );
648
+ },
649
+ });
650
+
651
+ pi.registerCommand("gentle-ai:models", {
652
+ description: "Alias for /gentleman:models.",
653
+ handler: async (_args, ctx) => {
654
+ ctx.ui.notify("Use /gentleman:models to configure per-agent models.", "info");
655
+ },
656
+ });
657
+
658
+ pi.registerCommand("gentleman:persona", {
659
+ description: "Switch el Gentleman persona between gentleman and neutral.",
660
+ handler: async (_args, ctx) => {
661
+ const current = readPersonaMode(ctx.cwd);
662
+ const selected = await ctx.ui.select(`el Gentleman persona (current: ${current})`, [...PERSONA_OPTIONS]);
663
+ if (selected !== "gentleman" && selected !== "neutral") return;
664
+ writePersonaMode(ctx.cwd, selected);
665
+ ctx.ui.notify(
666
+ [
667
+ `el Gentleman persona set to: ${selected}`,
668
+ `Config: ${personaConfigPath(ctx.cwd)}`,
669
+ "Run /reload or start a new Pi session for already-injected prompts to refresh.",
670
+ ].join("\n"),
671
+ "info",
672
+ );
673
+ },
674
+ });
675
+
676
+ pi.registerCommand("gentle-ai:persona", {
677
+ description: "Alias for /gentleman:persona.",
678
+ handler: async (_args, ctx) => {
679
+ ctx.ui.notify("Use /gentleman:persona to switch between gentleman and neutral personas.", "info");
680
+ },
681
+ });
682
+
147
683
  pi.registerCommand("gentle-ai:status", {
148
684
  description: "Show Gentle AI package status for this project.",
149
685
  handler: async (_args, ctx) => {
150
686
  const agentsInstalled = existsSync(join(ctx.cwd, ".pi", "agents", "sdd-apply.md"));
151
687
  const chainsInstalled = existsSync(join(ctx.cwd, ".pi", "chains", "sdd-full.chain.md"));
152
688
  const openspecConfigured = existsSync(join(ctx.cwd, "openspec", "config.yaml"));
689
+ const modelConfig = readModelConfig(ctx.cwd);
153
690
  ctx.ui.notify(
154
691
  [
155
- "Gentle AI package is active.",
692
+ "el Gentleman package is active.",
693
+ `Persona: ${readPersonaMode(ctx.cwd)}`,
156
694
  `SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
157
695
  `SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
158
696
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
697
+ `Model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
698
+ ...describeModelConfig(ctx.cwd, modelConfig),
159
699
  ].join("\n"),
160
700
  "info",
161
701
  );
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.1.3",
4
- "description": "Opinionated Gentle Pi harness package: SDD/OpenSpec workflow, strict TDD guidance, subagent assets, and safety defaults.",
3
+ "version": "0.1.12",
4
+ "description": "Opinionated el Gentleman harness package for Pi: SDD/OpenSpec workflow, strict TDD guidance, subagent assets, and safety defaults.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "keywords": [
@@ -36,6 +36,9 @@
36
36
  "./skills"
37
37
  ]
38
38
  },
39
+ "dependencies": {
40
+ "@earendil-works/pi-tui": "*"
41
+ },
39
42
  "peerDependencies": {
40
43
  "@earendil-works/pi-coding-agent": "*"
41
44
  },
@@ -3,13 +3,13 @@ name: gentle-ai
3
3
  description: "Use Gentle AI harness discipline for Pi work: clarify first, preserve OpenSpec artifacts, use strict TDD where available, delegate through subagents when useful, and protect review workload."
4
4
  ---
5
5
 
6
- # Gentle Pi Harness
6
+ # el Gentleman Harness
7
7
 
8
8
  Use this skill when work is non-trivial, risky, multi-step, or likely to benefit from SDD/OpenSpec artifacts.
9
9
 
10
10
  ## Identity Rule
11
11
 
12
- When asked who or what you are, answer as Gentle Pi: a Pi-specific coding-agent harness with senior architect persona, SDD/OpenSpec artifacts, and subagent coordination. Do not answer as a generic assistant.
12
+ When asked who or what you are, answer as el Gentleman: a Pi-specific coding-agent harness with senior architect persona, SDD/OpenSpec artifacts, and subagent coordination. Do not answer as a generic assistant.
13
13
 
14
14
  ## Compact Rules
15
15
 
@@ -20,7 +20,7 @@ When asked who or what you are, answer as Gentle Pi: a Pi-specific coding-agent
20
20
  - Prefer fresh-context reviewers for adversarial review and forked workers only after direction is approved.
21
21
  - Keep writes single-threaded unless the user explicitly approves isolated parallel worktrees.
22
22
  - Forecast review workload before large changes; ask before producing oversized or multi-area diffs.
23
- - Never claim persistent memory is available because of Gentle Pi itself; memory is provided by separate packages/tools when active.
23
+ - Never claim persistent memory is available because of el Gentleman itself; memory is provided by separate packages/tools when active.
24
24
 
25
25
  ## Work Routing
26
26