gentle-pi 0.2.8 → 0.3.0
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 +17 -3
- package/assets/orchestrator.md +13 -4
- package/extensions/gentle-ai.ts +48 -71
- package/extensions/sdd-init.ts +7 -0
- package/lib/sdd-preflight.ts +253 -0
- package/package.json +2 -1
- package/scripts/verify-package-files.mjs +1 -0
- package/tests/runtime-harness.mjs +176 -2
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ Then start Pi in a project:
|
|
|
81
81
|
pi
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
`gentle-pi` waits until the first SDD request in a session, then runs a one-time SDD preflight and installs local SDD assets without overwriting your edits.
|
|
85
85
|
|
|
86
86
|
## Quick start
|
|
87
87
|
|
|
@@ -147,9 +147,9 @@ For substantial work, the parent session coordinates the flow and each phase wri
|
|
|
147
147
|
- verification reports;
|
|
148
148
|
- archive notes for future agents.
|
|
149
149
|
|
|
150
|
-
##
|
|
150
|
+
## SDD preflight and project files
|
|
151
151
|
|
|
152
|
-
|
|
152
|
+
`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
153
|
|
|
154
154
|
```text
|
|
155
155
|
.pi/agents/sdd-*.md
|
|
@@ -158,12 +158,26 @@ On Pi `session_start`, `gentle-pi` copies these assets if they are missing:
|
|
|
158
158
|
.pi/gentle-ai/support/strict-tdd-verify.md
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
+
The preflight choices are reused for later SDD flows in the same session:
|
|
162
|
+
|
|
163
|
+
- execution mode: `interactive` or `auto`;
|
|
164
|
+
- artifact store: `openspec`, or `engram`/`both` when callable memory tools are available;
|
|
165
|
+
- PR chaining strategy: `auto-forecast`, `ask-always`, `single-pr-default`, or `force-chained`;
|
|
166
|
+
- review budget line threshold.
|
|
167
|
+
|
|
161
168
|
It does **not** overwrite existing files unless you explicitly run:
|
|
162
169
|
|
|
163
170
|
```text
|
|
164
171
|
/gentle-ai:install-sdd --force
|
|
165
172
|
```
|
|
166
173
|
|
|
174
|
+
Manual preflight commands:
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
/gentle-ai:sdd-preflight
|
|
178
|
+
/gentle:sdd-preflight
|
|
179
|
+
```
|
|
180
|
+
|
|
167
181
|
## Skill registry
|
|
168
182
|
|
|
169
183
|
`gentle-pi` keeps a local registry at:
|
package/assets/orchestrator.md
CHANGED
|
@@ -177,16 +177,25 @@ proposal → spec ─┬→ tasks → apply → verify → archive
|
|
|
177
177
|
proposal → design ┘
|
|
178
178
|
```
|
|
179
179
|
|
|
180
|
-
##
|
|
180
|
+
## Lazy SDD Preflight
|
|
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
|
+
|
|
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
|
-
|
|
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.
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -14,6 +14,14 @@ import type {
|
|
|
14
14
|
ToolCallEventResult,
|
|
15
15
|
} from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
17
|
+
import {
|
|
18
|
+
ensureSddPreflight,
|
|
19
|
+
getSddPreflightPreferences,
|
|
20
|
+
installSddAssets,
|
|
21
|
+
isSddPreflightTrigger,
|
|
22
|
+
renderSddPreflightPrompt,
|
|
23
|
+
type SddPreflightPreferences,
|
|
24
|
+
} from "../lib/sdd-preflight.ts";
|
|
17
25
|
|
|
18
26
|
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
19
27
|
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
@@ -182,62 +190,6 @@ async function confirmCommand(
|
|
|
182
190
|
};
|
|
183
191
|
}
|
|
184
192
|
|
|
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
193
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
242
194
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
243
195
|
}
|
|
@@ -294,7 +246,7 @@ function normalizeRoutingEntry(value: unknown): AgentRoutingEntry | undefined {
|
|
|
294
246
|
return { model, thinking };
|
|
295
247
|
}
|
|
296
248
|
|
|
297
|
-
function readModelConfig(cwd: string): AgentModelConfig {
|
|
249
|
+
export function readModelConfig(cwd: string): AgentModelConfig {
|
|
298
250
|
const path = modelConfigPath(cwd);
|
|
299
251
|
if (!existsSync(path)) return {};
|
|
300
252
|
try {
|
|
@@ -465,7 +417,7 @@ function updateBuiltinModelOverride(
|
|
|
465
417
|
return true;
|
|
466
418
|
}
|
|
467
419
|
|
|
468
|
-
function applyModelConfig(
|
|
420
|
+
export function applyModelConfig(
|
|
469
421
|
cwd: string,
|
|
470
422
|
config: AgentModelConfig,
|
|
471
423
|
): { updated: number; skipped: number } {
|
|
@@ -953,18 +905,16 @@ async function handlePersonaCommand(ctx: ExtensionContext): Promise<void> {
|
|
|
953
905
|
}
|
|
954
906
|
|
|
955
907
|
export default function gentleAi(pi: ExtensionAPI): void {
|
|
908
|
+
function runSddPreflight(ctx: ExtensionContext): Promise<SddPreflightPreferences> {
|
|
909
|
+
return ensureSddPreflight(ctx, {
|
|
910
|
+
pi,
|
|
911
|
+
installAssets: (cwd) => installSddAssets(cwd, false),
|
|
912
|
+
applyModelConfig: (cwd) => applyModelConfig(cwd, readModelConfig(cwd)),
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
|
|
956
916
|
pi.on("session_start", (_event, ctx) => {
|
|
957
|
-
const result = installSddAssets(ctx.cwd, false);
|
|
958
917
|
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
|
-
);
|
|
967
|
-
}
|
|
968
918
|
if (ctx.hasUI && modelResult.updated > 0) {
|
|
969
919
|
ctx.ui.notify(
|
|
970
920
|
`el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
|
|
@@ -973,9 +923,21 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
973
923
|
}
|
|
974
924
|
});
|
|
975
925
|
|
|
976
|
-
pi.on("
|
|
977
|
-
|
|
978
|
-
|
|
926
|
+
pi.on("input", async (event, ctx) => {
|
|
927
|
+
if (typeof event.text !== "string" || !isSddPreflightTrigger(event.text)) {
|
|
928
|
+
return { action: "continue" };
|
|
929
|
+
}
|
|
930
|
+
await runSddPreflight(ctx);
|
|
931
|
+
return { action: "continue" };
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
935
|
+
const prefs = getSddPreflightPreferences(ctx);
|
|
936
|
+
const sddPrompt = prefs ? `\n\n${renderSddPreflightPrompt(prefs)}` : "";
|
|
937
|
+
return {
|
|
938
|
+
systemPrompt: `${event.systemPrompt}\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}${sddPrompt}`,
|
|
939
|
+
};
|
|
940
|
+
});
|
|
979
941
|
|
|
980
942
|
pi.on("tool_call", async (event, ctx) => {
|
|
981
943
|
if (event.toolName !== "bash") return undefined;
|
|
@@ -997,6 +959,21 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
997
959
|
},
|
|
998
960
|
});
|
|
999
961
|
|
|
962
|
+
pi.registerCommand("gentle-ai:sdd-preflight", {
|
|
963
|
+
description:
|
|
964
|
+
"Run or reuse the lazy SDD preflight for this Pi session.",
|
|
965
|
+
handler: async (_args, ctx) => {
|
|
966
|
+
await runSddPreflight(ctx);
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
pi.registerCommand("gentle:sdd-preflight", {
|
|
971
|
+
description: "Compatibility alias for /gentle-ai:sdd-preflight.",
|
|
972
|
+
handler: async (_args, ctx) => {
|
|
973
|
+
await runSddPreflight(ctx);
|
|
974
|
+
},
|
|
975
|
+
});
|
|
976
|
+
|
|
1000
977
|
pi.registerCommand("gentle:models", {
|
|
1001
978
|
description: "Configure per-agent models for el Gentleman.",
|
|
1002
979
|
handler: async (_args, ctx) => {
|
package/extensions/sdd-init.ts
CHANGED
|
@@ -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 { applyModelConfig, readModelConfig } 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,11 @@ 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: (cwd) => applyModelConfig(cwd, readModelConfig(cwd)),
|
|
782
|
+
});
|
|
776
783
|
const configPath = join(ctx.cwd, CONFIG_REL_PATH);
|
|
777
784
|
if (existsSync(configPath)) {
|
|
778
785
|
ctx.ui.notify(
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
7
|
+
const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
|
|
8
|
+
|
|
9
|
+
export type SddExecutionMode = "interactive" | "auto";
|
|
10
|
+
export type SddArtifactStore = "openspec" | "engram" | "both";
|
|
11
|
+
export type SddChainedPrStrategy =
|
|
12
|
+
| "auto-forecast"
|
|
13
|
+
| "ask-always"
|
|
14
|
+
| "single-pr-default"
|
|
15
|
+
| "force-chained";
|
|
16
|
+
|
|
17
|
+
export interface SddPreflightPreferences {
|
|
18
|
+
executionMode: SddExecutionMode;
|
|
19
|
+
artifactStore: SddArtifactStore;
|
|
20
|
+
chainedPrStrategy: SddChainedPrStrategy;
|
|
21
|
+
reviewBudgetLines: number;
|
|
22
|
+
engramAvailable: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface SddPreflightCallbacks {
|
|
26
|
+
pi: ExtensionAPI;
|
|
27
|
+
installAssets?: (cwd: string) => {
|
|
28
|
+
agents: number;
|
|
29
|
+
chains: number;
|
|
30
|
+
support: number;
|
|
31
|
+
skipped: number;
|
|
32
|
+
};
|
|
33
|
+
applyModelConfig?: (cwd: string) => { updated: number; skipped: number };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_SDD_PREFLIGHT: SddPreflightPreferences = {
|
|
37
|
+
executionMode: "interactive",
|
|
38
|
+
artifactStore: "openspec",
|
|
39
|
+
chainedPrStrategy: "auto-forecast",
|
|
40
|
+
reviewBudgetLines: 400,
|
|
41
|
+
engramAvailable: false,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const sddPreflightBySession = new Map<string, SddPreflightPreferences>();
|
|
45
|
+
const sddPreflightInFlight = new Map<string, Promise<SddPreflightPreferences>>();
|
|
46
|
+
|
|
47
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
48
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function copyDirectoryFiles(
|
|
52
|
+
sourceDir: string,
|
|
53
|
+
targetDir: string,
|
|
54
|
+
force: boolean,
|
|
55
|
+
): { copied: number; skipped: number } {
|
|
56
|
+
if (!existsSync(sourceDir)) return { copied: 0, skipped: 0 };
|
|
57
|
+
mkdirSync(targetDir, { recursive: true });
|
|
58
|
+
let copied = 0;
|
|
59
|
+
let skipped = 0;
|
|
60
|
+
for (const entry of readdirSync(sourceDir, { withFileTypes: true })) {
|
|
61
|
+
const sourcePath = join(sourceDir, entry.name);
|
|
62
|
+
const targetPath = join(targetDir, entry.name);
|
|
63
|
+
if (entry.isDirectory()) {
|
|
64
|
+
const child = copyDirectoryFiles(sourcePath, targetPath, force);
|
|
65
|
+
copied += child.copied;
|
|
66
|
+
skipped += child.skipped;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!entry.isFile()) continue;
|
|
70
|
+
if (!force && existsSync(targetPath)) {
|
|
71
|
+
skipped += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
writeFileSync(targetPath, readFileSync(sourcePath));
|
|
75
|
+
copied += 1;
|
|
76
|
+
}
|
|
77
|
+
return { copied, skipped };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function installSddAssets(
|
|
81
|
+
cwd: string,
|
|
82
|
+
force: boolean,
|
|
83
|
+
): { agents: number; chains: number; support: number; skipped: number } {
|
|
84
|
+
const agents = copyDirectoryFiles(
|
|
85
|
+
join(ASSETS_DIR, "agents"),
|
|
86
|
+
join(cwd, ".pi", "agents"),
|
|
87
|
+
force,
|
|
88
|
+
);
|
|
89
|
+
const chains = copyDirectoryFiles(
|
|
90
|
+
join(ASSETS_DIR, "chains"),
|
|
91
|
+
join(cwd, ".pi", "chains"),
|
|
92
|
+
force,
|
|
93
|
+
);
|
|
94
|
+
const support = copyDirectoryFiles(
|
|
95
|
+
join(ASSETS_DIR, "support"),
|
|
96
|
+
join(cwd, ".pi", "gentle-ai", "support"),
|
|
97
|
+
force,
|
|
98
|
+
);
|
|
99
|
+
return {
|
|
100
|
+
agents: agents.copied,
|
|
101
|
+
chains: chains.copied,
|
|
102
|
+
support: support.copied,
|
|
103
|
+
skipped: agents.skipped + chains.skipped + support.skipped,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function isSddPreflightTrigger(text: string): boolean {
|
|
108
|
+
return /^\/sdd-[^\s]*(?:\s|$)/i.test(text.trim());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function sddPreflightSessionKey(ctx: ExtensionContext): string {
|
|
112
|
+
const manager = (ctx as unknown as { sessionManager?: unknown }).sessionManager;
|
|
113
|
+
if (isRecord(manager)) {
|
|
114
|
+
const getSessionFile = manager.getSessionFile;
|
|
115
|
+
if (typeof getSessionFile === "function") {
|
|
116
|
+
const value = getSessionFile.call(manager);
|
|
117
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
118
|
+
}
|
|
119
|
+
const getSessionId = manager.getSessionId;
|
|
120
|
+
if (typeof getSessionId === "function") {
|
|
121
|
+
const value = getSessionId.call(manager);
|
|
122
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return ctx.cwd;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hasWritableEngramTool(pi: ExtensionAPI): boolean {
|
|
129
|
+
try {
|
|
130
|
+
const getActiveTools = (pi as unknown as { getActiveTools?: () => unknown[] })
|
|
131
|
+
.getActiveTools;
|
|
132
|
+
if (typeof getActiveTools !== "function") return false;
|
|
133
|
+
const tools = getActiveTools.call(pi);
|
|
134
|
+
return tools.some((tool) => {
|
|
135
|
+
const name =
|
|
136
|
+
typeof tool === "string"
|
|
137
|
+
? tool
|
|
138
|
+
: isRecord(tool) && typeof tool.name === "string"
|
|
139
|
+
? tool.name
|
|
140
|
+
: "";
|
|
141
|
+
return (
|
|
142
|
+
name === "mem_save" ||
|
|
143
|
+
name === "engram_mem_save" ||
|
|
144
|
+
name.endsWith(".mem_save") ||
|
|
145
|
+
name.endsWith(".engram_mem_save")
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
} catch {
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function normalizeSddReviewBudget(value: string): number {
|
|
154
|
+
const parsed = Number.parseInt(value.trim(), 10);
|
|
155
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : 400;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function collectSddPreflightPreferences(
|
|
159
|
+
ctx: ExtensionContext,
|
|
160
|
+
engramAvailable: boolean,
|
|
161
|
+
): Promise<SddPreflightPreferences> {
|
|
162
|
+
if (!ctx.hasUI) return { ...DEFAULT_SDD_PREFLIGHT, engramAvailable };
|
|
163
|
+
const executionMode = await ctx.ui.select("SDD execution mode", [
|
|
164
|
+
"interactive",
|
|
165
|
+
"auto",
|
|
166
|
+
]);
|
|
167
|
+
const artifactOptions = engramAvailable
|
|
168
|
+
? ["openspec", "engram", "both"]
|
|
169
|
+
: ["openspec"];
|
|
170
|
+
const artifactStore = await ctx.ui.select("SDD artifact store", artifactOptions);
|
|
171
|
+
const chainedPrStrategy = await ctx.ui.select("SDD PR chaining", [
|
|
172
|
+
"auto-forecast",
|
|
173
|
+
"ask-always",
|
|
174
|
+
"single-pr-default",
|
|
175
|
+
"force-chained",
|
|
176
|
+
]);
|
|
177
|
+
const reviewBudgetLines = normalizeSddReviewBudget(
|
|
178
|
+
(await ctx.ui.input("SDD review budget lines", "400")) ?? "400",
|
|
179
|
+
);
|
|
180
|
+
return {
|
|
181
|
+
executionMode:
|
|
182
|
+
executionMode === "auto" ? "auto" : DEFAULT_SDD_PREFLIGHT.executionMode,
|
|
183
|
+
artifactStore:
|
|
184
|
+
artifactStore === "engram" || artifactStore === "both"
|
|
185
|
+
? artifactStore
|
|
186
|
+
: DEFAULT_SDD_PREFLIGHT.artifactStore,
|
|
187
|
+
chainedPrStrategy:
|
|
188
|
+
chainedPrStrategy === "ask-always" ||
|
|
189
|
+
chainedPrStrategy === "single-pr-default" ||
|
|
190
|
+
chainedPrStrategy === "force-chained"
|
|
191
|
+
? chainedPrStrategy
|
|
192
|
+
: DEFAULT_SDD_PREFLIGHT.chainedPrStrategy,
|
|
193
|
+
reviewBudgetLines,
|
|
194
|
+
engramAvailable,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function renderSddPreflightPrompt(prefs: SddPreflightPreferences): string {
|
|
199
|
+
return [
|
|
200
|
+
"## SDD Session Preflight",
|
|
201
|
+
"The user already chose these SDD preferences for this Pi session. Reuse them unless the user explicitly changes them.",
|
|
202
|
+
`- Execution mode: ${prefs.executionMode}`,
|
|
203
|
+
`- Artifact store: ${prefs.artifactStore}${prefs.engramAvailable ? "" : " (Engram unavailable in this session)"}`,
|
|
204
|
+
`- Chained PR strategy: ${prefs.chainedPrStrategy}`,
|
|
205
|
+
`- Review budget: ${prefs.reviewBudgetLines} changed lines`,
|
|
206
|
+
"- If task/workload forecasts conflict with these preferences, pause before sdd-apply and ask the user for a delivery decision.",
|
|
207
|
+
].join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function ensureSddPreflight(
|
|
211
|
+
ctx: ExtensionContext,
|
|
212
|
+
callbacks: SddPreflightCallbacks,
|
|
213
|
+
): Promise<SddPreflightPreferences> {
|
|
214
|
+
const sessionKey = sddPreflightSessionKey(ctx);
|
|
215
|
+
const existing = sddPreflightBySession.get(sessionKey);
|
|
216
|
+
if (existing) return existing;
|
|
217
|
+
const inFlight = sddPreflightInFlight.get(sessionKey);
|
|
218
|
+
if (inFlight) return inFlight;
|
|
219
|
+
const promise = (async () => {
|
|
220
|
+
const engramAvailable = hasWritableEngramTool(callbacks.pi);
|
|
221
|
+
const prefs = await collectSddPreflightPreferences(ctx, engramAvailable);
|
|
222
|
+
const result = callbacks.installAssets?.(ctx.cwd) ?? installSddAssets(ctx.cwd, false);
|
|
223
|
+
const modelResult = callbacks.applyModelConfig?.(ctx.cwd) ?? { updated: 0, skipped: 0 };
|
|
224
|
+
if (ctx.hasUI) {
|
|
225
|
+
ctx.ui.notify(
|
|
226
|
+
[
|
|
227
|
+
"Gentle AI SDD preflight complete.",
|
|
228
|
+
`Mode: ${prefs.executionMode}`,
|
|
229
|
+
`Artifacts: ${prefs.artifactStore}`,
|
|
230
|
+
`PR chaining: ${prefs.chainedPrStrategy}`,
|
|
231
|
+
`Review budget: ${prefs.reviewBudgetLines} changed lines`,
|
|
232
|
+
`Assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} skipped.`,
|
|
233
|
+
`Model-routed agents updated: ${modelResult.updated}`,
|
|
234
|
+
].join("\n"),
|
|
235
|
+
"info",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
sddPreflightBySession.set(sessionKey, prefs);
|
|
239
|
+
return prefs;
|
|
240
|
+
})();
|
|
241
|
+
sddPreflightInFlight.set(sessionKey, promise);
|
|
242
|
+
try {
|
|
243
|
+
return await promise;
|
|
244
|
+
} finally {
|
|
245
|
+
sddPreflightInFlight.delete(sessionKey);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function getSddPreflightPreferences(
|
|
250
|
+
ctx: ExtensionContext,
|
|
251
|
+
): SddPreflightPreferences | undefined {
|
|
252
|
+
return sddPreflightBySession.get(sddPreflightSessionKey(ctx));
|
|
253
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"assets/",
|
|
27
27
|
"extensions/",
|
|
28
|
+
"lib/",
|
|
28
29
|
"prompts/",
|
|
29
30
|
"skills/",
|
|
30
31
|
"scripts/",
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
3
4
|
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
5
|
import { tmpdir } from "node:os";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
7
|
+
import { discoverAndLoadExtensions } from "@earendil-works/pi-coding-agent";
|
|
6
8
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
9
|
|
|
8
10
|
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
@@ -15,6 +17,8 @@ const EXTENSIONS = [
|
|
|
15
17
|
|
|
16
18
|
const EXPECTED_COMMANDS = [
|
|
17
19
|
"gentle-ai:install-sdd",
|
|
20
|
+
"gentle-ai:sdd-preflight",
|
|
21
|
+
"gentle:sdd-preflight",
|
|
18
22
|
"gentle:models",
|
|
19
23
|
"gentle-ai:models",
|
|
20
24
|
"gentleman:models",
|
|
@@ -31,6 +35,7 @@ function createPi() {
|
|
|
31
35
|
const commands = new Map();
|
|
32
36
|
const flags = new Map();
|
|
33
37
|
const flagValues = new Map([["no-skill-registry", true]]);
|
|
38
|
+
let activeTools = ["read", "bash", "edit", "write"];
|
|
34
39
|
|
|
35
40
|
const pi = {
|
|
36
41
|
on(name, handler) {
|
|
@@ -53,12 +58,19 @@ function createPi() {
|
|
|
53
58
|
getCommands() {
|
|
54
59
|
return Array.from(commands, ([name, definition]) => ({ name, ...definition }));
|
|
55
60
|
},
|
|
61
|
+
getActiveTools() {
|
|
62
|
+
return activeTools;
|
|
63
|
+
},
|
|
64
|
+
setActiveTools(value) {
|
|
65
|
+
activeTools = value;
|
|
66
|
+
},
|
|
56
67
|
getAllTools() {
|
|
57
68
|
return [
|
|
58
69
|
{ name: "read" },
|
|
59
70
|
{ name: "bash" },
|
|
60
71
|
{ name: "edit" },
|
|
61
72
|
{ name: "write" },
|
|
73
|
+
{ name: "mem_save" },
|
|
62
74
|
];
|
|
63
75
|
},
|
|
64
76
|
};
|
|
@@ -68,15 +80,18 @@ function createPi() {
|
|
|
68
80
|
|
|
69
81
|
function createUi() {
|
|
70
82
|
const notifications = [];
|
|
83
|
+
const selections = [];
|
|
71
84
|
return {
|
|
72
85
|
notifications,
|
|
86
|
+
selections,
|
|
73
87
|
notify(message, level = "info") {
|
|
74
88
|
notifications.push({ message, level });
|
|
75
89
|
},
|
|
76
90
|
async confirm() {
|
|
77
91
|
return false;
|
|
78
92
|
},
|
|
79
|
-
async select(
|
|
93
|
+
async select(label, options) {
|
|
94
|
+
selections.push({ label, options });
|
|
80
95
|
return options[0];
|
|
81
96
|
},
|
|
82
97
|
async input(_label, placeholder) {
|
|
@@ -88,11 +103,19 @@ function createUi() {
|
|
|
88
103
|
};
|
|
89
104
|
}
|
|
90
105
|
|
|
91
|
-
function createCtx(cwd, hasUI = false) {
|
|
106
|
+
function createCtx(cwd, hasUI = false, sessionId = "session-1") {
|
|
92
107
|
return {
|
|
93
108
|
cwd,
|
|
94
109
|
hasUI,
|
|
95
110
|
ui: createUi(),
|
|
111
|
+
sessionManager: {
|
|
112
|
+
getSessionFile() {
|
|
113
|
+
return join(cwd, `${sessionId}.jsonl`);
|
|
114
|
+
},
|
|
115
|
+
getSessionId() {
|
|
116
|
+
return sessionId;
|
|
117
|
+
},
|
|
118
|
+
},
|
|
96
119
|
modelRegistry: {
|
|
97
120
|
async getAvailable() {
|
|
98
121
|
return [];
|
|
@@ -122,9 +145,17 @@ async function run() {
|
|
|
122
145
|
}
|
|
123
146
|
assert.ok(flags.has("no-skill-registry"), "missing no-skill-registry flag");
|
|
124
147
|
assert.ok(hooks.has("session_start"), "missing session_start hook");
|
|
148
|
+
assert.ok(hooks.has("input"), "missing input hook");
|
|
125
149
|
assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
|
|
126
150
|
assert.ok(hooks.has("tool_call"), "missing tool_call hook");
|
|
127
151
|
|
|
152
|
+
const discovered = await discoverAndLoadExtensions(["./extensions"], ROOT);
|
|
153
|
+
assert.deepEqual(
|
|
154
|
+
discovered.errors,
|
|
155
|
+
[],
|
|
156
|
+
"declared extension directory must load without invalid helper modules",
|
|
157
|
+
);
|
|
158
|
+
|
|
128
159
|
const promptCwd = await tempWorkspace();
|
|
129
160
|
try {
|
|
130
161
|
const promptHook = hooks.get("before_agent_start")[0];
|
|
@@ -154,10 +185,146 @@ async function run() {
|
|
|
154
185
|
for (const handler of hooks.get("session_start")) {
|
|
155
186
|
await handler({ reason: "startup" }, createCtx(noUiCwd, false));
|
|
156
187
|
}
|
|
188
|
+
assert.equal(
|
|
189
|
+
existsSync(join(noUiCwd, ".pi", "agents", "sdd-apply.md")),
|
|
190
|
+
false,
|
|
191
|
+
"session_start must not install SDD agents before first SDD intent",
|
|
192
|
+
);
|
|
193
|
+
assert.equal(
|
|
194
|
+
existsSync(join(noUiCwd, ".pi", "chains", "sdd-full.chain.md")),
|
|
195
|
+
false,
|
|
196
|
+
"session_start must not install SDD chains before first SDD intent",
|
|
197
|
+
);
|
|
157
198
|
} finally {
|
|
158
199
|
await rm(noUiCwd, { recursive: true, force: true });
|
|
159
200
|
}
|
|
160
201
|
|
|
202
|
+
const lazySddCwd = await tempWorkspace();
|
|
203
|
+
try {
|
|
204
|
+
await mkdir(join(lazySddCwd, ".pi", "gentle-ai"), { recursive: true });
|
|
205
|
+
await writeFile(
|
|
206
|
+
join(lazySddCwd, ".pi", "gentle-ai", "models.json"),
|
|
207
|
+
JSON.stringify({ "sdd-apply": { model: "openai/gpt-5", thinking: "high" } }, null, 2),
|
|
208
|
+
);
|
|
209
|
+
const ctx = createCtx(lazySddCwd, true);
|
|
210
|
+
const inputHook = hooks.get("input")[0];
|
|
211
|
+
assert.deepEqual(
|
|
212
|
+
await inputHook({ text: "hola, solo mirando", source: "interactive" }, ctx),
|
|
213
|
+
{ action: "continue" },
|
|
214
|
+
);
|
|
215
|
+
assert.deepEqual(
|
|
216
|
+
await inputHook({ text: "what is SDD?", source: "interactive" }, ctx),
|
|
217
|
+
{ action: "continue" },
|
|
218
|
+
);
|
|
219
|
+
assert.deepEqual(
|
|
220
|
+
await inputHook({ text: "what can I do with SDD?", source: "interactive" }, ctx),
|
|
221
|
+
{ action: "continue" },
|
|
222
|
+
);
|
|
223
|
+
assert.deepEqual(
|
|
224
|
+
await inputHook({ text: "how do I use SDD?", source: "interactive" }, ctx),
|
|
225
|
+
{ action: "continue" },
|
|
226
|
+
);
|
|
227
|
+
assert.deepEqual(
|
|
228
|
+
await inputHook({ text: "Can I use SDD?", source: "interactive" }, ctx),
|
|
229
|
+
{ action: "continue" },
|
|
230
|
+
);
|
|
231
|
+
assert.deepEqual(
|
|
232
|
+
await inputHook({ text: "don't use sdd for this", source: "interactive" }, ctx),
|
|
233
|
+
{ action: "continue" },
|
|
234
|
+
);
|
|
235
|
+
assert.deepEqual(
|
|
236
|
+
await inputHook({ text: "sin usar SDD por ahora", source: "interactive" }, ctx),
|
|
237
|
+
{ action: "continue" },
|
|
238
|
+
);
|
|
239
|
+
assert.deepEqual(
|
|
240
|
+
await inputHook({ text: "let's not use SDD for this", source: "interactive" }, ctx),
|
|
241
|
+
{ action: "continue" },
|
|
242
|
+
);
|
|
243
|
+
assert.deepEqual(
|
|
244
|
+
await inputHook({ text: "never use SDD here", source: "interactive" }, ctx),
|
|
245
|
+
{ action: "continue" },
|
|
246
|
+
);
|
|
247
|
+
assert.deepEqual(
|
|
248
|
+
await inputHook({ text: "no quiero usar SDD por ahora", source: "interactive" }, ctx),
|
|
249
|
+
{ action: "continue" },
|
|
250
|
+
);
|
|
251
|
+
assert.deepEqual(
|
|
252
|
+
await inputHook({ text: "I use SDD sometimes", source: "interactive" }, ctx),
|
|
253
|
+
{ action: "continue" },
|
|
254
|
+
);
|
|
255
|
+
assert.deepEqual(
|
|
256
|
+
await inputHook({ text: "I'm using SDD in another repo", source: "interactive" }, ctx),
|
|
257
|
+
{ action: "continue" },
|
|
258
|
+
);
|
|
259
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
|
|
260
|
+
|
|
261
|
+
assert.deepEqual(
|
|
262
|
+
await inputHook({ text: "please use sdd for this change", source: "interactive" }, ctx),
|
|
263
|
+
{ action: "continue" },
|
|
264
|
+
);
|
|
265
|
+
assert.deepEqual(
|
|
266
|
+
await inputHook({ text: "/sdd", source: "interactive" }, ctx),
|
|
267
|
+
{ action: "continue" },
|
|
268
|
+
);
|
|
269
|
+
assert.deepEqual(
|
|
270
|
+
await inputHook({ text: "/sdd plan", source: "interactive" }, ctx),
|
|
271
|
+
{ action: "continue" },
|
|
272
|
+
);
|
|
273
|
+
assert.deepEqual(
|
|
274
|
+
await inputHook({ text: "/sdd:plan", source: "interactive" }, ctx),
|
|
275
|
+
{ action: "continue" },
|
|
276
|
+
);
|
|
277
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), false);
|
|
278
|
+
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
await inputHook({ text: "/sdd-plan this change", source: "interactive" }, ctx),
|
|
281
|
+
{ action: "continue" },
|
|
282
|
+
);
|
|
283
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "agents", "sdd-apply.md")), true);
|
|
284
|
+
assert.equal(existsSync(join(lazySddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
|
|
285
|
+
const lazyAppliedAgent = await readFile(
|
|
286
|
+
join(lazySddCwd, ".pi", "agents", "sdd-apply.md"),
|
|
287
|
+
"utf8",
|
|
288
|
+
);
|
|
289
|
+
assert.match(lazyAppliedAgent, /model: openai\/gpt-5/);
|
|
290
|
+
assert.match(lazyAppliedAgent, /thinking: high/);
|
|
291
|
+
assert.equal(ctx.ui.selections.length, 3);
|
|
292
|
+
assert.deepEqual(ctx.ui.selections[1].options, ["openspec"]);
|
|
293
|
+
assert.match(ctx.ui.notifications.at(-1).message, /SDD preflight complete/);
|
|
294
|
+
|
|
295
|
+
await inputHook({ text: "/sdd-plan another change", source: "interactive" }, ctx);
|
|
296
|
+
assert.equal(ctx.ui.selections.length, 3, "preflight should run only once per session");
|
|
297
|
+
const promptHook = hooks.get("before_agent_start")[0];
|
|
298
|
+
const promptResult = promptHook({ systemPrompt: "base" }, ctx);
|
|
299
|
+
assert.match(promptResult.systemPrompt, /SDD Session Preflight/);
|
|
300
|
+
assert.match(promptResult.systemPrompt, /Execution mode: interactive/);
|
|
301
|
+
} finally {
|
|
302
|
+
await rm(lazySddCwd, { recursive: true, force: true });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const commandSddCwd = await tempWorkspace();
|
|
306
|
+
try {
|
|
307
|
+
const ctx = createCtx(commandSddCwd, true, "command-session");
|
|
308
|
+
await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
|
|
309
|
+
assert.equal(existsSync(join(commandSddCwd, ".pi", "agents", "sdd-apply.md")), true);
|
|
310
|
+
assert.equal(ctx.ui.selections.length, 3);
|
|
311
|
+
await commands.get("gentle:sdd-preflight").handler("", ctx);
|
|
312
|
+
assert.equal(ctx.ui.selections.length, 3, "manual preflight command should reuse session choices");
|
|
313
|
+
} finally {
|
|
314
|
+
await rm(commandSddCwd, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const engramSddCwd = await tempWorkspace();
|
|
318
|
+
try {
|
|
319
|
+
pi.setActiveTools(["read", "bash", "edit", "write", "mem_save"]);
|
|
320
|
+
const ctx = createCtx(engramSddCwd, true, "engram-session");
|
|
321
|
+
await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
|
|
322
|
+
assert.deepEqual(ctx.ui.selections[1].options, ["openspec", "engram", "both"]);
|
|
323
|
+
} finally {
|
|
324
|
+
pi.setActiveTools(["read", "bash", "edit", "write"]);
|
|
325
|
+
await rm(engramSddCwd, { recursive: true, force: true });
|
|
326
|
+
}
|
|
327
|
+
|
|
161
328
|
const installCwd = await tempWorkspace();
|
|
162
329
|
try {
|
|
163
330
|
const ctx = createCtx(installCwd, true);
|
|
@@ -171,7 +338,14 @@ async function run() {
|
|
|
171
338
|
try {
|
|
172
339
|
const ctx = createCtx(sddCwd, true);
|
|
173
340
|
await commands.get("sdd-init").handler("", ctx);
|
|
341
|
+
assert.equal(existsSync(join(sddCwd, ".pi", "agents", "sdd-apply.md")), true);
|
|
342
|
+
assert.equal(existsSync(join(sddCwd, ".pi", "chains", "sdd-full.chain.md")), true);
|
|
343
|
+
assert.equal(ctx.ui.selections.length, 3);
|
|
344
|
+
assert.match(ctx.ui.notifications[0].message, /SDD preflight complete/);
|
|
174
345
|
assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
|
|
346
|
+
|
|
347
|
+
await commands.get("gentle-ai:sdd-preflight").handler("", ctx);
|
|
348
|
+
assert.equal(ctx.ui.selections.length, 3, "/sdd-init preflight should be reused by later manual preflight");
|
|
175
349
|
} finally {
|
|
176
350
|
await rm(sddCwd, { recursive: true, force: true });
|
|
177
351
|
}
|