gentle-pi 0.2.8 → 0.3.1

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
@@ -49,6 +49,7 @@ Most coding-agent sessions fail for operational reasons, not model reasons:
49
49
  | **Rose startup intro** | Adds a pink rose fade-in, compact project/runtime panel, and visible startup collaboration credit for @aporcelli's `pi-gentle-startup` ideas. |
50
50
  | **Work routing discipline** | Small tasks stay inline. Context-heavy exploration can be delegated. Large or risky changes go through SDD/OpenSpec. |
51
51
  | **SDD/OpenSpec assets** | Installs phase agents and chains for `init`, `explore`, `proposal`, `spec`, `design`, `tasks`, `apply`, `verify`, and `archive`. |
52
+ | **Lazy SDD preflight** | Asks once per session for SDD mode, artifact store, PR chaining strategy, and review budget before the first SDD flow. |
52
53
  | **Subagent orchestration** | Keeps one parent session responsible while child agents explore, implement, test, or review with focused context. |
53
54
  | **Strict TDD support** | When project config declares a test command, apply/verify phases must record RED → GREEN → TRIANGULATE → REFACTOR evidence. |
54
55
  | **Reviewer protection** | Surfaces review workload risk before a task turns into an oversized PR. |
@@ -81,23 +82,24 @@ Then start Pi in a project:
81
82
  pi
82
83
  ```
83
84
 
84
- On session start, `gentle-pi` installs local SDD assets without overwriting your edits.
85
+ `gentle-pi` waits until the first SDD flow in a session, then runs a one-time SDD preflight and installs local SDD assets without overwriting your edits. Slash SDD commands trigger this automatically; for natural-language requests, el Gentleman decides when SDD is needed and runs the explicit preflight first.
85
86
 
86
87
  ## Quick start
87
88
 
88
89
  ```text
89
- /gentle-ai:status Check package, SDD assets, OpenSpec, and model config.
90
- /sdd-init Create or refresh openspec/config.yaml.
91
- /gentle:models Assign models to SDD/custom agents.
92
- /gentle:persona Switch between gentleman and neutral persona modes.
90
+ /gentle-ai:status Check package, SDD assets, OpenSpec, and model config.
91
+ /gentle-ai:sdd-preflight Run or reuse the session SDD preflight explicitly.
92
+ /sdd-init Create or refresh openspec/config.yaml.
93
+ /gentle:models Assign models to SDD/custom agents.
94
+ /gentle:persona Switch between gentleman and neutral persona modes.
93
95
  ```
94
96
 
95
97
  Typical flow:
96
98
 
97
99
  1. Open Pi in your repo.
98
100
  2. Run `/gentle-ai:status`.
99
- 3. Run `/sdd-init` once per project, or when test/project capabilities change.
100
- 4. For a substantial change, ask Pi to use SDD.
101
+ 3. Run `/sdd-init` once per project, or when test/project capabilities change. This also runs the session SDD preflight.
102
+ 4. For a substantial change, ask Pi to use SDD. Natural-language requests are classified by the parent agent, not by brittle runtime regexes.
101
103
  5. Review the phase artifacts instead of trusting floating chat context.
102
104
 
103
105
  ## How the harness decides what to do
@@ -147,9 +149,9 @@ For substantial work, the parent session coordinates the flow and each phase wri
147
149
  - verification reports;
148
150
  - archive notes for future agents.
149
151
 
150
- ## Project files installed
152
+ ## SDD preflight and project files
151
153
 
152
- On Pi `session_start`, `gentle-pi` copies these assets if they are missing:
154
+ `gentle-pi` does not interrupt every new session. Slash SDD flows such as `/sdd-*`, `/sdd-init`, and the explicit `/gentle-ai:sdd-preflight` command run a lazy preflight, ask for session-scoped SDD preferences, and then copy these assets if they are missing. For natural-language requests, the parent agent decides whether the work should use SDD and must run/reuse `/gentle-ai:sdd-preflight` before continuing.
153
155
 
154
156
  ```text
155
157
  .pi/agents/sdd-*.md
@@ -158,12 +160,26 @@ On Pi `session_start`, `gentle-pi` copies these assets if they are missing:
158
160
  .pi/gentle-ai/support/strict-tdd-verify.md
159
161
  ```
160
162
 
163
+ The preflight choices are reused for later SDD flows in the same session:
164
+
165
+ - execution mode: `interactive` or `auto`;
166
+ - artifact store: `openspec`, or `engram`/`both` when callable memory tools are available;
167
+ - PR chaining strategy: `auto-forecast`, `ask-always`, `single-pr-default`, or `force-chained`;
168
+ - review budget line threshold.
169
+
161
170
  It does **not** overwrite existing files unless you explicitly run:
162
171
 
163
172
  ```text
164
173
  /gentle-ai:install-sdd --force
165
174
  ```
166
175
 
176
+ Manual preflight commands:
177
+
178
+ ```text
179
+ /gentle-ai:sdd-preflight
180
+ /gentle:sdd-preflight
181
+ ```
182
+
167
183
  ## Skill registry
168
184
 
169
185
  `gentle-pi` keeps a local registry at:
@@ -177,16 +177,25 @@ proposal → spec ─┬→ tasks → apply → verify → archive
177
177
  proposal → design ┘
178
178
  ```
179
179
 
180
- ## Automatic Setup Expectations
180
+ ## Lazy SDD Preflight
181
181
 
182
- On startup, the package should ensure SDD assets are present for `pi-subagents` without the user needing to remember setup commands. If assets are missing, install them non-destructively into:
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
+
184
+ The preflight captures:
185
+
186
+ - execution mode: `interactive` or `auto`;
187
+ - artifact store: `openspec`, `engram`, or `both` when callable memory tools are available;
188
+ - chained PR strategy: `auto-forecast`, `ask-always`, `single-pr-default`, or `force-chained`;
189
+ - review budget in changed lines.
190
+
191
+ During that lazy preflight, the package should ensure SDD assets are present for `pi-subagents` without the user needing to remember setup commands. If assets are missing, install them non-destructively into:
183
192
 
184
193
  ```text
185
194
  .pi/agents/sdd-*.md
186
195
  .pi/chains/sdd-*.chain.md
187
196
  ```
188
197
 
189
- Manual commands are recovery/debug paths, not the happy path.
198
+ Manual install commands are recovery/debug paths, not the happy path. `/gentle-ai:sdd-preflight` and `/gentle:sdd-preflight` are the explicit preflight commands for agent/orchestrator use. If the user explicitly changes SDD preferences later in the same session, follow the new instruction.
190
199
 
191
200
  ## Init Guard
192
201
 
@@ -220,7 +229,7 @@ When Engram or another callable memory package is available, the parent owns mem
220
229
 
221
230
  ## Execution Mode
222
231
 
223
- For substantial SDD flows, choose or ask once per change:
232
+ Use the session's SDD preflight choice:
224
233
 
225
234
  - `interactive`: default, pause between major phases and ask whether to continue.
226
235
  - `auto`: run phases back-to-back when the user explicitly wants speed and trusts the flow.
@@ -5,6 +5,13 @@ import {
5
5
  readFileSync,
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
+ import {
9
+ access,
10
+ mkdir,
11
+ readFile,
12
+ readdir,
13
+ writeFile,
14
+ } from "node:fs/promises";
8
15
  import { homedir } from "node:os";
9
16
  import { dirname, join } from "node:path";
10
17
  import { fileURLToPath } from "node:url";
@@ -14,13 +21,37 @@ import type {
14
21
  ToolCallEventResult,
15
22
  } from "@earendil-works/pi-coding-agent";
16
23
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
24
+ import {
25
+ ensureSddPreflight,
26
+ getSddPreflightPreferences,
27
+ installSddAssets,
28
+ isSddPreflightTrigger,
29
+ renderSddPreflightPrompt,
30
+ type SddPreflightPreferences,
31
+ } from "../lib/sdd-preflight.ts";
17
32
 
18
33
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
19
34
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
20
- const ORCHESTRATOR_PROMPT = readFileSync(
21
- join(ASSETS_DIR, "orchestrator.md"),
22
- "utf8",
23
- ).trim();
35
+
36
+ let orchestratorPromptCache: string | null = null;
37
+ function getOrchestratorPrompt(): string {
38
+ if (orchestratorPromptCache === null) {
39
+ orchestratorPromptCache = readFileSync(
40
+ join(ASSETS_DIR, "orchestrator.md"),
41
+ "utf8",
42
+ ).trim();
43
+ }
44
+ return orchestratorPromptCache;
45
+ }
46
+
47
+ async function pathExists(path: string): Promise<boolean> {
48
+ try {
49
+ await access(path);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
24
55
 
25
56
  type PersonaMode = "gentleman" | "neutral";
26
57
 
@@ -68,7 +99,7 @@ Harness principles:
68
99
  - Protect the human reviewer: avoid oversized changes, surface review workload risk, and ask before turning one task into a large multi-area change.
69
100
  - Never claim persistent memory is available because of this package. Memory is provided by separate packages or MCP tools when installed and callable.
70
101
 
71
- ${ORCHESTRATOR_PROMPT}`;
102
+ ${getOrchestratorPrompt()}`;
72
103
  }
73
104
 
74
105
  const DENIED_BASH_PATTERNS: RegExp[] = [
@@ -182,62 +213,6 @@ async function confirmCommand(
182
213
  };
183
214
  }
184
215
 
185
- function copyDirectoryFiles(
186
- sourceDir: string,
187
- targetDir: string,
188
- force: boolean,
189
- ): { copied: number; skipped: number } {
190
- if (!existsSync(sourceDir)) return { copied: 0, skipped: 0 };
191
- mkdirSync(targetDir, { recursive: true });
192
- let copied = 0;
193
- let skipped = 0;
194
- for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
195
- const sourcePath = join(sourceDir, entry.name);
196
- const targetPath = join(targetDir, entry.name);
197
- if (entry.isDirectory()) {
198
- const child = copyDirectoryFiles(sourcePath, targetPath, force);
199
- copied += child.copied;
200
- skipped += child.skipped;
201
- continue;
202
- }
203
- if (!entry.isFile()) continue;
204
- if (!force && existsSync(targetPath)) {
205
- skipped += 1;
206
- continue;
207
- }
208
- writeFileSync(targetPath, readFileSync(sourcePath));
209
- copied += 1;
210
- }
211
- return { copied, skipped };
212
- }
213
-
214
- function installSddAssets(
215
- cwd: string,
216
- force: boolean,
217
- ): { agents: number; chains: number; support: number; skipped: number } {
218
- const agents = copyDirectoryFiles(
219
- join(ASSETS_DIR, "agents"),
220
- join(cwd, ".pi", "agents"),
221
- force,
222
- );
223
- const chains = copyDirectoryFiles(
224
- join(ASSETS_DIR, "chains"),
225
- join(cwd, ".pi", "chains"),
226
- force,
227
- );
228
- const support = copyDirectoryFiles(
229
- join(ASSETS_DIR, "support"),
230
- join(cwd, ".pi", "gentle-ai", "support"),
231
- force,
232
- );
233
- return {
234
- agents: agents.copied,
235
- chains: chains.copied,
236
- support: support.copied,
237
- skipped: agents.skipped + chains.skipped + support.skipped,
238
- };
239
- }
240
-
241
216
  function isRecord(value: unknown): value is Record<string, unknown> {
242
217
  return typeof value === "object" && value !== null && !Array.isArray(value);
243
218
  }
@@ -294,7 +269,7 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
294
269
  return { model, thinking };
295
270
  }
296
271
 
297
- function readModelConfig(cwd: string): AgentModelConfig {
272
+ export function readModelConfig(cwd: string): AgentModelConfig {
298
273
  const path = modelConfigPath(cwd);
299
274
  if (!existsSync(path)) return {};
300
275
  try {
@@ -311,6 +286,25 @@ function readModelConfig(cwd: string): AgentModelConfig {
311
286
  }
312
287
  }
313
288
 
289
+ export async function readModelConfigAsync(
290
+ cwd: string,
291
+ ): Promise<AgentModelConfig> {
292
+ const path = modelConfigPath(cwd);
293
+ if (!(await pathExists(path))) return {};
294
+ try {
295
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
296
+ if (!isRecord(parsed)) return {};
297
+ const config: AgentModelConfig = {};
298
+ for (const [name, value] of Object.entries(parsed)) {
299
+ const entry = normalizeRoutingEntry(value);
300
+ if (entry) config[name] = entry;
301
+ }
302
+ return config;
303
+ } catch {
304
+ return {};
305
+ }
306
+ }
307
+
314
308
  function writeModelConfig(cwd: string, config: AgentModelConfig): void {
315
309
  const path = modelConfigPath(cwd);
316
310
  mkdirSync(dirname(path), { recursive: true });
@@ -371,6 +365,23 @@ function parseAgentName(filePath: string): string | undefined {
371
365
  return packageName ? `${packageName}.${name}` : name;
372
366
  }
373
367
 
368
+ async function parseAgentNameAsync(
369
+ filePath: string,
370
+ ): Promise<string | undefined> {
371
+ let content: string;
372
+ try {
373
+ content = await readFile(filePath, "utf8");
374
+ } catch {
375
+ return undefined;
376
+ }
377
+ const name = content.match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]?.trim();
378
+ if (!name) return undefined;
379
+ const packageName = content
380
+ .match(/^package:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]
381
+ ?.trim();
382
+ return packageName ? `${packageName}.${name}` : name;
383
+ }
384
+
374
385
  function listAgentFilesRecursive(dir: string): string[] {
375
386
  if (!existsSync(dir)) return [];
376
387
  const files: string[] = [];
@@ -387,6 +398,30 @@ function listAgentFilesRecursive(dir: string): string[] {
387
398
  return files;
388
399
  }
389
400
 
401
+ async function listAgentFilesRecursiveAsync(dir: string): Promise<string[]> {
402
+ if (!(await pathExists(dir))) return [];
403
+ const files: string[] = [];
404
+ let entries;
405
+ try {
406
+ entries = await readdir(dir, { withFileTypes: true });
407
+ } catch {
408
+ return files;
409
+ }
410
+ for (const entry of entries) {
411
+ const path = join(dir, entry.name);
412
+ if (entry.isDirectory()) {
413
+ files.push(...(await listAgentFilesRecursiveAsync(path)));
414
+ } else if (
415
+ entry.isFile() &&
416
+ entry.name.endsWith(".md") &&
417
+ !entry.name.endsWith(".chain.md")
418
+ ) {
419
+ files.push(path);
420
+ }
421
+ }
422
+ return files;
423
+ }
424
+
390
425
  function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
391
426
  return listAgentFilesRecursive(dir)
392
427
  .map((filePath): AgentEntry | undefined => {
@@ -396,6 +431,19 @@ function listAgentsFromDir(dir: string, source: AgentSource): AgentEntry[] {
396
431
  .filter((entry): entry is AgentEntry => entry !== undefined);
397
432
  }
398
433
 
434
+ async function listAgentsFromDirAsync(
435
+ dir: string,
436
+ source: AgentSource,
437
+ ): Promise<AgentEntry[]> {
438
+ const filePaths = await listAgentFilesRecursiveAsync(dir);
439
+ const entries: AgentEntry[] = [];
440
+ for (const filePath of filePaths) {
441
+ const name = await parseAgentNameAsync(filePath);
442
+ if (name) entries.push({ name, source, filePath });
443
+ }
444
+ return entries;
445
+ }
446
+
399
447
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
400
448
  const builtinDirs = [
401
449
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
@@ -421,6 +469,37 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
421
469
  return [...sddFirst, ...rest];
422
470
  }
423
471
 
472
+ async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
473
+ const builtinDirs = [
474
+ join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
475
+ join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
476
+ join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
477
+ ];
478
+ const agents: AgentEntry[] = [];
479
+ for (const dir of builtinDirs) {
480
+ agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
481
+ }
482
+ const otherDirs: Array<[string, AgentSource]> = [
483
+ [join(homedir(), ".pi", "agent", "agents"), "user"],
484
+ [join(homedir(), ".agents"), "user"],
485
+ [join(cwd, ".agents"), "project"],
486
+ [join(cwd, ".pi", "agents"), "project"],
487
+ ];
488
+ for (const [dir, source] of otherDirs) {
489
+ agents.push(...(await listAgentsFromDirAsync(dir, source)));
490
+ }
491
+ const byName = new Map<string, AgentEntry>();
492
+ for (const agent of agents) byName.set(agent.name, agent);
493
+ const discovered = Array.from(byName.values());
494
+ const sddFirst = SDD_AGENT_NAMES.map((name) =>
495
+ discovered.find((agent) => agent.name === name),
496
+ ).filter((agent): agent is AgentEntry => agent !== undefined);
497
+ const rest = discovered
498
+ .filter((agent) => !SDD_AGENT_NAMES.includes(agent.name as SddAgentName))
499
+ .sort((left, right) => left.name.localeCompare(right.name));
500
+ return [...sddFirst, ...rest];
501
+ }
502
+
424
503
  function projectSettingsPath(cwd: string): string {
425
504
  return join(cwd, ".pi", "settings.json");
426
505
  }
@@ -465,7 +544,47 @@ function updateBuiltinModelOverride(
465
544
  return true;
466
545
  }
467
546
 
468
- function applyModelConfig(
547
+ async function updateBuiltinModelOverrideAsync(
548
+ cwd: string,
549
+ name: string,
550
+ entry: AgentRoutingEntry | undefined,
551
+ ): Promise<boolean> {
552
+ const path = projectSettingsPath(cwd);
553
+ let settings: Record<string, unknown> = {};
554
+ if (await pathExists(path)) {
555
+ try {
556
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
557
+ if (isRecord(parsed)) settings = parsed;
558
+ } catch {
559
+ settings = {};
560
+ }
561
+ }
562
+ const subagents = isRecord(settings.subagents)
563
+ ? { ...settings.subagents }
564
+ : {};
565
+ const agentOverrides = isRecord(subagents.agentOverrides)
566
+ ? { ...subagents.agentOverrides }
567
+ : {};
568
+ const current = isRecord(agentOverrides[name])
569
+ ? { ...agentOverrides[name] }
570
+ : {};
571
+ if (entry?.model === undefined) delete current.model;
572
+ else current.model = entry.model;
573
+ if (entry?.thinking === undefined) delete current.thinking;
574
+ else current.thinking = entry.thinking;
575
+ if (Object.keys(current).length > 0) agentOverrides[name] = current;
576
+ else delete agentOverrides[name];
577
+ if (Object.keys(agentOverrides).length > 0)
578
+ subagents.agentOverrides = agentOverrides;
579
+ else delete subagents.agentOverrides;
580
+ if (Object.keys(subagents).length > 0) settings.subagents = subagents;
581
+ else delete settings.subagents;
582
+ await mkdir(dirname(path), { recursive: true });
583
+ await writeFile(path, `${JSON.stringify(settings, null, "\t")}\n`);
584
+ return true;
585
+ }
586
+
587
+ export function applyModelConfig(
469
588
  cwd: string,
470
589
  config: AgentModelConfig,
471
590
  ): { updated: number; skipped: number } {
@@ -494,6 +613,36 @@ function applyModelConfig(
494
613
  return { updated, skipped };
495
614
  }
496
615
 
616
+ export async function applyModelConfigAsync(
617
+ cwd: string,
618
+ config: AgentModelConfig,
619
+ ): Promise<{ updated: number; skipped: number }> {
620
+ let updated = 0;
621
+ let skipped = 0;
622
+ for (const agent of await listDiscoverableAgentsAsync(cwd)) {
623
+ const entry = config[agent.name];
624
+ if (agent.source === "builtin") {
625
+ if (await updateBuiltinModelOverrideAsync(cwd, agent.name, entry))
626
+ updated += 1;
627
+ else skipped += 1;
628
+ continue;
629
+ }
630
+ if (!agent.filePath || !(await pathExists(agent.filePath))) {
631
+ skipped += 1;
632
+ continue;
633
+ }
634
+ const original = await readFile(agent.filePath, "utf8");
635
+ const next = updateFrontmatterRouting(original, entry);
636
+ if (next === original) {
637
+ skipped += 1;
638
+ continue;
639
+ }
640
+ await writeFile(agent.filePath, next);
641
+ updated += 1;
642
+ }
643
+ return { updated, skipped };
644
+ }
645
+
497
646
  function describeModelConfig(cwd: string, config: AgentModelConfig): string[] {
498
647
  return listDiscoverableAgents(cwd).map((agent) => {
499
648
  const entry = config[agent.name];
@@ -953,29 +1102,52 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
953
1102
  }
954
1103
 
955
1104
  export default function gentleAi(pi: ExtensionAPI): void {
956
- pi.on("session_start", (_event, ctx) => {
957
- const result = installSddAssets(ctx.cwd, false);
958
- const modelResult = applyModelConfig(ctx.cwd, readModelConfig(ctx.cwd));
959
- if (
960
- ctx.hasUI &&
961
- (result.agents > 0 || result.chains > 0 || result.support > 0)
962
- ) {
963
- ctx.ui.notify(
964
- `Gentle AI SDD assets auto-installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s).`,
965
- "info",
966
- );
1105
+ function runSddPreflight(ctx: ExtensionContext): Promise<SddPreflightPreferences> {
1106
+ return ensureSddPreflight(ctx, {
1107
+ pi,
1108
+ installAssets: (cwd) => installSddAssets(cwd, false),
1109
+ applyModelConfig: async (cwd) =>
1110
+ applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
1111
+ });
1112
+ }
1113
+
1114
+ pi.on("session_start", async (_event, ctx) => {
1115
+ try {
1116
+ const config = await readModelConfigAsync(ctx.cwd);
1117
+ const modelResult = await applyModelConfigAsync(ctx.cwd, config);
1118
+ if (ctx.hasUI && modelResult.updated > 0) {
1119
+ ctx.ui.notify(
1120
+ `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
1121
+ "info",
1122
+ );
1123
+ }
1124
+ } catch (error) {
1125
+ if (ctx.hasUI) {
1126
+ const message =
1127
+ error instanceof Error ? error.message : String(error);
1128
+ ctx.ui.notify(
1129
+ `el Gentleman model config sweep failed: ${message}`,
1130
+ "warning",
1131
+ );
1132
+ }
967
1133
  }
968
- if (ctx.hasUI && modelResult.updated > 0) {
969
- ctx.ui.notify(
970
- `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
971
- "info",
972
- );
1134
+ });
1135
+
1136
+ pi.on("input", async (event, ctx) => {
1137
+ if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
1138
+ return { action: "continue" };
973
1139
  }
1140
+ await runSddPreflight(ctx);
1141
+ return { action: "continue" };
974
1142
  });
975
1143
 
976
- pi.on("before_agent_start", (event, ctx) => ({
977
- systemPrompt: `${event.systemPrompt}\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`,
978
- }));
1144
+ pi.on("before_agent_start", (event, ctx) => {
1145
+ const prefs = getSddPreflightPreferences(ctx);
1146
+ const sddPrompt = prefs ? `\n\n${renderSddPreflightPrompt(prefs)}` : "";
1147
+ return {
1148
+ systemPrompt: `${event.systemPrompt}\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}${sddPrompt}`,
1149
+ };
1150
+ });
979
1151
 
980
1152
  pi.on("tool_call", async (event, ctx) => {
981
1153
  if (event.toolName !== "bash") return undefined;
@@ -997,6 +1169,21 @@ export default function gentleAi(pi: ExtensionAPI): void {
997
1169
  },
998
1170
  });
999
1171
 
1172
+ pi.registerCommand("gentle-ai:sdd-preflight", {
1173
+ description:
1174
+ "Run or reuse the lazy SDD preflight for this Pi session.",
1175
+ handler: async (_args, ctx) => {
1176
+ await runSddPreflight(ctx);
1177
+ },
1178
+ });
1179
+
1180
+ pi.registerCommand("gentle:sdd-preflight", {
1181
+ description: "Compatibility alias for /gentle-ai:sdd-preflight.",
1182
+ handler: async (_args, ctx) => {
1183
+ await runSddPreflight(ctx);
1184
+ },
1185
+ });
1186
+
1000
1187
  pi.registerCommand("gentle:models", {
1001
1188
  description: "Configure per-agent models for el Gentleman.",
1002
1189
  handler: async (_args, ctx) => {
@@ -6,6 +6,8 @@ import {
6
6
  writeFileSync,
7
7
  } from "node:fs";
8
8
  import { basename, dirname, join, relative } from "node:path";
9
+ import { applyModelConfigAsync, readModelConfigAsync } from "./gentle-ai.ts";
10
+ import { ensureSddPreflight, installSddAssets } from "../lib/sdd-preflight.ts";
9
11
  type ExtensionAPI = any;
10
12
 
11
13
  const CONFIG_REL_PATH = "openspec/config.yaml";
@@ -773,6 +775,12 @@ export default function (pi: ExtensionAPI) {
773
775
  description:
774
776
  "Auto-detect project stack and bootstrap openspec/config.yaml for SDD.",
775
777
  handler: async (_args: unknown, ctx: any) => {
778
+ await ensureSddPreflight(ctx, {
779
+ pi,
780
+ installAssets: (cwd) => installSddAssets(cwd, false),
781
+ applyModelConfig: async (cwd) =>
782
+ applyModelConfigAsync(cwd, await readModelConfigAsync(cwd)),
783
+ });
776
784
  const configPath = join(ctx.cwd, CONFIG_REL_PATH);
777
785
  if (existsSync(configPath)) {
778
786
  ctx.ui.notify(