gentle-pi 0.3.5 → 0.3.7

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.
@@ -6,16 +6,16 @@ Bind this to the parent Pi session only. Do not apply it to SDD executor phase a
6
6
 
7
7
  You are el Gentleman: a Pi-specific coding-agent harness for controlled development work.
8
8
 
9
- When the user asks who or what you are, answer in this shape:
9
+ When the user asks who or what you are, answer with this meaning, translated into the user's language:
10
10
 
11
11
  ```text
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.
12
+ I am el Gentleman: a Pi-specific coding-agent harness for controlled development, with a senior architect persona. I work with SDD/OpenSpec when the task justifies it, coordinate subagents, use phase artifacts, run commands, and edit files. I am not a generic chatbot.
13
13
  ```
14
14
 
15
15
  Rules:
16
16
 
17
17
  - Never introduce yourself as only "your assistant" or "the default assistant".
18
- - Keep the response in the user's language; in Spanish, use natural Rioplatense voseo.
18
+ - Keep the response in the user's language and follow the currently selected persona mode.
19
19
  - Mention persistent memory only when a memory package or callable memory tools are actually active.
20
20
  - Do not claim portability outside the Pi runtime.
21
21
 
@@ -45,7 +45,7 @@ el Gentleman is an ecosystem configurator and harness layer. After installation,
45
45
 
46
46
  - Small request: do it directly.
47
47
  - Substantial feature: suggest SDD organically.
48
- - User says "use sdd" / "hacelo con sdd": run the SDD flow.
48
+ - User explicitly asks to use SDD: run the SDD flow.
49
49
  - Parent session orchestrates; phase agents execute.
50
50
 
51
51
  Delegation is not optional once complexity appears. If a task crosses the triggers below, use the smallest useful subagent workflow instead of continuing as a monolithic executor.
@@ -101,7 +101,7 @@ Triggers:
101
101
  - cross-cutting behavior changes;
102
102
  - expected large diff or reviewer burden;
103
103
  - need for specs/design/tasks before safe implementation;
104
- - user explicitly says `use sdd`, `hacelo con sdd`, `/sdd-new`, `/sdd-ff`, or `/sdd-continue`.
104
+ - user explicitly asks to use SDD, or invokes `/sdd-new`, `/sdd-ff`, or `/sdd-continue`.
105
105
 
106
106
  If the request is large enough for SDD, do not jump directly to implementation. Calibrate context, create artifacts, and ask for approval at the appropriate gates.
107
107
 
@@ -181,6 +181,13 @@ proposal → design ┘
181
181
 
182
182
  Do not ask SDD setup questions on session start. The first time the user initiates an SDD process in a Pi session, run the SDD preflight once and keep those choices for the rest of that session. Runtime trigger detection is intentionally deterministic: slash SDD flows and `/sdd-init` run preflight automatically; for natural-language requests, the parent/orchestrator decides semantically whether SDD is needed and must run/reuse `/gentle-ai:sdd-preflight` before continuing.
183
183
 
184
+ **Hard gate:** `openspec/config.yaml`, existing SDD changes, installed `.pi`/global SDD assets, or a todo named "preflight" are not session preflight. They are project context only. Do not mark SDD preflight complete, start `sdd-init`, launch SDD subagents/chains, or move to explore/proposal/spec/design/tasks until this session has either:
185
+
186
+ 1. an injected `## SDD Session Preflight` block, or
187
+ 2. an explicit user answer in the current conversation covering all four preflight choices below.
188
+
189
+ If neither exists and `/gentle-ai:sdd-preflight` cannot be invoked from the current context, ask the four choices manually with `ask_user_question` before any SDD phase work. Treat missing Engram availability as a reason to ask/confirm artifact store, not as permission to assume defaults.
190
+
184
191
  The preflight captures:
185
192
 
186
193
  - execution mode: `interactive` or `auto`;
@@ -207,7 +214,7 @@ In this Pi package, the default local artifact is:
207
214
  openspec/config.yaml
208
215
  ```
209
216
 
210
- If it is missing, ask the user for the minimal information needed or run `/sdd-init` if available. Do not proceed with a substantial SDD flow while pretending project context and testing capability are known.
217
+ If it is missing, ask the user for the minimal information needed or run `/sdd-init` if available. This init guard runs after the session preflight gate above; project config presence or absence never substitutes for session preflight choices. Do not proceed with a substantial SDD flow while pretending project context, testing capability, or session preflight choices are known.
211
218
 
212
219
  ## Artifact Store Policy
213
220
 
@@ -25,6 +25,7 @@ export interface SddPreflightPreferences {
25
25
  chainedPrStrategy: SddChainedPrStrategy;
26
26
  reviewBudgetLines: number;
27
27
  engramAvailable: boolean;
28
+ prompted: boolean;
28
29
  }
29
30
 
30
31
  interface SddPreflightCallbacks {
@@ -55,6 +56,7 @@ const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
55
56
  chainedPrStrategy: "auto-forecast",
56
57
  reviewBudgetLines: 400,
57
58
  engramAvailable: false,
59
+ prompted: false,
58
60
  };
59
61
 
60
62
  const sddPreflightBySession = new Map<string, SddPreflightPreferences>();
@@ -122,7 +124,23 @@ export function installSddAssets(
122
124
  }
123
125
 
124
126
  export function isSddPreflightTrigger(text: string): boolean {
125
- return /^\/sdd-[^\s]*(?:\s|$)/i.test(text.trim());
127
+ const trimmed = text.trim();
128
+ if (/^\/sdd(?:[-:][^\s]*)?(?:\s|$)/i.test(trimmed)) return true;
129
+ if (/[??]\s*$/.test(trimmed)) return false;
130
+ if (
131
+ /\b(?:don't|do\s+not|not\s+use|never\s+use|without\s+using|sin\s+usar|no\s+(?:quiero|queremos|vamos\s+a)?\s*usar)\s+sdd\b/i.test(
132
+ trimmed,
133
+ )
134
+ ) {
135
+ return false;
136
+ }
137
+ return [
138
+ /^(?:please\s+)?(?:use|run|start)\s+(?:the\s+|an?\s+)?sdd(?:\s+(?:flow|process|workflow|plan))?\b/i,
139
+ /^(?:please\s+)?(?:do|handle|implement)\b.+\b(?:with|using)\s+(?:the\s+|an?\s+)?sdd\b/i,
140
+ /^(?:por\s+favor[\s,]+)?(?:vamos|vayamos)\s+con\s+(?:el\s+)?sdd\b/i,
141
+ /^(?:por\s+favor[\s,]+)?(?:usa|usá|usemos|corre|corré|arranca|arrancá|inicia|iniciá|empeza|empezá)\s+(?:el\s+)?sdd\b/i,
142
+ /^(?:por\s+favor[\s,]+)?(?:hacelo|hazlo|hacerlo)\s+(?:con|usando)\s+(?:el\s+)?sdd\b/i,
143
+ ].some((pattern) => pattern.test(trimmed));
126
144
  }
127
145
 
128
146
  export function sddPreflightSessionKey(ctx: ExtensionContext): string {
@@ -209,13 +227,17 @@ async function collectSddPreflightPreferences(
209
227
  : DEFAULT_SDD_PREFLIGHT.chainedPrStrategy,
210
228
  reviewBudgetLines,
211
229
  engramAvailable,
230
+ prompted: true,
212
231
  };
213
232
  }
214
233
 
215
234
  export function renderSddPreflightPrompt(prefs: SddPreflightPreferences): string {
235
+ const sourceLine = prefs.prompted
236
+ ? "The user already chose these SDD preferences for this Pi session. Reuse them unless the user explicitly changes them."
237
+ : "No interactive UI was available for SDD preflight, so these default preferences were applied for this Pi session. Ask the user before making delivery decisions that depend on them.";
216
238
  return [
217
239
  "## SDD Session Preflight",
218
- "The user already chose these SDD preferences for this Pi session. Reuse them unless the user explicitly changes them.",
240
+ sourceLine,
219
241
  `- Execution mode: ${prefs.executionMode}`,
220
242
  `- Artifact store: ${prefs.artifactStore}${prefs.engramAvailable ? "" : " (Engram unavailable in this session)"}`,
221
243
  `- Chained PR strategy: ${prefs.chainedPrStrategy}`,
@@ -254,6 +276,7 @@ export async function ensureSddPreflight(
254
276
  `Artifacts: ${prefs.artifactStore}`,
255
277
  `PR chaining: ${prefs.chainedPrStrategy}`,
256
278
  `Review budget: ${prefs.reviewBudgetLines} changed lines`,
279
+ `Preference source: ${prefs.prompted ? "user prompt" : "defaults (no interactive UI available)"}`,
257
280
  `Global SDD assets ready: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
258
281
  modelRoutingLine,
259
282
  ].join("\n"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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",
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import { dirname, extname, join, relative } from "node:path";
4
+ import test from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ renderSddPreflightPrompt,
8
+ type SddPreflightPreferences,
9
+ } from "../lib/sdd-preflight.ts";
10
+
11
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
12
+ const TEXT_EXTENSIONS = new Set([".md", ".ts", ".mjs", ".json"]);
13
+
14
+ async function collectTextFiles(dir: string): Promise<string[]> {
15
+ const entries = await readdir(dir, { withFileTypes: true });
16
+ const files: string[] = [];
17
+ for (const entry of entries) {
18
+ const path = join(dir, entry.name);
19
+ if (entry.isDirectory()) {
20
+ files.push(...(await collectTextFiles(path)));
21
+ continue;
22
+ }
23
+ if (entry.isFile() && TEXT_EXTENSIONS.has(extname(entry.name))) {
24
+ files.push(path);
25
+ }
26
+ }
27
+ return files;
28
+ }
29
+
30
+ const SPANISH_PREFLIGHT_COPY = [
31
+ /Antes de continuar con SDD/i,
32
+ /Antes de seguir con SDD/i,
33
+ /una opci[oó]n por grupo/i,
34
+ /usar recomendad[oa]/i,
35
+ /\bRitmo\b/i,
36
+ /\bArtefactos\b/i,
37
+ /\bPreguntarme\b/i,
38
+ /l[ií]neas cambiadas/i,
39
+ /\bhacelo\b/i,
40
+ /\bSoy el Gentleman\b/i,
41
+ ];
42
+
43
+ test("orchestrator keeps conversation language separate from generated artifact language", async () => {
44
+ const orchestrator = await readFile(join(ROOT, "assets/orchestrator.md"), "utf8");
45
+
46
+ assert.match(
47
+ orchestrator,
48
+ /User-facing conversation should stay in the user's language/,
49
+ );
50
+ assert.match(
51
+ orchestrator,
52
+ /Generated artifacts[\s\S]*default to English, regardless of the user's conversation language/,
53
+ );
54
+ });
55
+
56
+ test("rendered SDD preflight prompt is English artifact copy", () => {
57
+ const prefs: SddPreflightPreferences = {
58
+ executionMode: "interactive",
59
+ artifactStore: "openspec",
60
+ chainedPrStrategy: "ask-always",
61
+ reviewBudgetLines: 400,
62
+ engramAvailable: false,
63
+ prompted: true,
64
+ };
65
+ const prompt = renderSddPreflightPrompt(prefs);
66
+
67
+ assert.match(prompt, /The user already chose these SDD preferences/);
68
+ assert.match(prompt, /Review budget: 400 changed lines/);
69
+ for (const pattern of SPANISH_PREFLIGHT_COPY) {
70
+ assert.doesNotMatch(prompt, pattern);
71
+ }
72
+ });
73
+
74
+ test("persistent harness prompt assets do not hardcode Spanish SDD artifact copy", async () => {
75
+ const files = [
76
+ ...(await collectTextFiles(join(ROOT, "assets"))),
77
+ ...(await collectTextFiles(join(ROOT, "prompts"))),
78
+ ];
79
+ const failures: string[] = [];
80
+
81
+ for (const file of files) {
82
+ const text = await readFile(file, "utf8");
83
+ for (const pattern of SPANISH_PREFLIGHT_COPY) {
84
+ if (pattern.test(text)) {
85
+ failures.push(`${relative(ROOT, file)} matched ${pattern}`);
86
+ }
87
+ }
88
+ }
89
+
90
+ assert.deepEqual(failures, []);
91
+ });
@@ -177,6 +177,20 @@ async function run() {
177
177
  const promptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
178
178
  assert.match(promptResult.systemPrompt, /base/);
179
179
  assert.match(promptResult.systemPrompt, /el Gentleman/);
180
+ assert.match(promptResult.systemPrompt, /openspec\/config\.yaml.*not session preflight/s);
181
+ assert.match(promptResult.systemPrompt, /Do not mark SDD preflight complete/);
182
+ await mkdir(join(promptCwd, ".pi", "gentle-ai"), { recursive: true });
183
+ await writeFile(
184
+ join(promptCwd, ".pi", "gentle-ai", "persona.json"),
185
+ '{"mode":"neutral"}\n',
186
+ );
187
+ const neutralPromptResult = await promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
188
+ assert.match(neutralPromptResult.systemPrompt, /Do not use slang or regional expressions/);
189
+ assert.doesNotMatch(
190
+ neutralPromptResult.systemPrompt,
191
+ /When the user writes Spanish, answer in natural Rioplatense Spanish with voseo/,
192
+ "neutral persona prompt must not include unconditional voseo instructions after reload",
193
+ );
180
194
  const subagentPromptResult = await promptHook(
181
195
  { agentName: "worker", systemPrompt: "worker base" },
182
196
  createCtx(promptCwd),
@@ -284,10 +298,26 @@ async function run() {
284
298
  );
285
299
  assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
286
300
 
301
+ assert.deepEqual(
302
+ await inputHook({ text: "vamos con sdd", source: "interactive" }, ctx),
303
+ { action: "continue" },
304
+ );
305
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
306
+ assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), false);
307
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
308
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-sync.md")), true);
309
+ assert.equal(existsSync(join(globalAgentHome, "chains", "sdd-full.chain.md")), true);
310
+ assert.equal(ctx.ui.selections.length, 3);
311
+ assert.equal(ctx.ui.selections[0].label, "SDD execution mode");
312
+ assert.equal(ctx.ui.selections[1].label, "SDD artifact store");
313
+ assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
314
+ assert.equal(ctx.ui.selections[2].label, "SDD PR chaining");
315
+ assert.match(ctx.ui.notifications.at(-1).message, /Preference source: user prompt/);
287
316
  assert.deepEqual(
288
317
  await inputHook({ text: "please use sdd for this change", source: "interactive" }, ctx),
289
318
  { action: "continue" },
290
319
  );
320
+ assert.equal(ctx.ui.selections.length, 3, "natural SDD trigger should reuse session choices");
291
321
  assert.deepEqual(
292
322
  await inputHook({ text: "/sdd", source: "interactive" }, ctx),
293
323
  { action: "continue" },
@@ -300,7 +330,7 @@ async function run() {
300
330
  await inputHook({ text: "/sdd:plan", source: "interactive" }, ctx),
301
331
  { action: "continue" },
302
332
  );
303
- assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
333
+ assert.equal(ctx.ui.selections.length, 3);
304
334
 
305
335
  assert.deepEqual(
306
336
  await inputHook({ text: "/sdd-plan this change", source: "interactive" }, ctx),
@@ -338,6 +368,22 @@ async function run() {
338
368
  await rm(globalModelsPath, { force: true });
339
369
  }
340
370
 
371
+ for (const [index, text] of ["/sdd", "/sdd plan", "/sdd:plan", "/sdd-plan this change"].entries()) {
372
+ const slashSddCwd = await tempWorkspace();
373
+ try {
374
+ const ctx = createCtx(slashSddCwd, true, `slash-sdd-session-${index}`);
375
+ const inputHook = hooks.get("input")[0];
376
+ assert.deepEqual(await inputHook({ text, source: "interactive" }, ctx), {
377
+ action: "continue",
378
+ });
379
+ assert.equal(existsSync(join(slashSddCwd, ".pi", "agents", "sdd-apply.md")), false);
380
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
381
+ assert.equal(ctx.ui.selections.length, 3, `${text} should run canonical preflight`);
382
+ } finally {
383
+ await rm(slashSddCwd, { recursive: true, force: true });
384
+ }
385
+ }
386
+
341
387
  const commandSddCwd = await tempWorkspace();
342
388
  try {
343
389
  const ctx = createCtx(commandSddCwd, true, "command-session");
@@ -396,6 +442,26 @@ async function run() {
396
442
  await rm(sddAgentGuardCwd, { recursive: true, force: true });
397
443
  }
398
444
 
445
+ const noUiSddAgentCwd = await tempWorkspace();
446
+ try {
447
+ const ctx = createCtx(noUiSddAgentCwd, false, "no-ui-sdd-agent-session");
448
+ const promptHook = hooks.get("before_agent_start")[0];
449
+ const promptResult = await promptHook(
450
+ {
451
+ agentName: "sdd-proposal",
452
+ systemPrompt: "You are the SDD proposal executor for Gentle AI.",
453
+ },
454
+ ctx,
455
+ );
456
+ assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
457
+ assert.match(promptResult.systemPrompt, /No interactive UI was available/);
458
+ assert.equal(ctx.ui.selections.length, 0);
459
+ assert.equal(existsSync(join(noUiSddAgentCwd, ".pi", "agents", "sdd-apply.md")), false);
460
+ assert.equal(existsSync(join(globalAgentHome, "agents", "sdd-apply.md")), true);
461
+ } finally {
462
+ await rm(noUiSddAgentCwd, { recursive: true, force: true });
463
+ }
464
+
399
465
  const invalidPreflightCwd = await tempWorkspace();
400
466
  try {
401
467
  await writeFile(globalModelsPath, "{ invalid json");