gentle-pi 0.2.5 → 0.2.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.
- package/README.md +32 -3
- package/assets/orchestrator.md +2 -0
- package/extensions/gentle-ai.ts +6 -2
- package/extensions/skill-registry.ts +138 -19
- package/package.json +6 -3
- package/tests/runtime-harness.mjs +192 -0
- package/tests/skill-registry.test.ts +128 -0
package/README.md
CHANGED
|
@@ -178,25 +178,45 @@ It scans common roots such as:
|
|
|
178
178
|
|
|
179
179
|
```text
|
|
180
180
|
./skills
|
|
181
|
+
.opencode/skills
|
|
182
|
+
.claude/skills
|
|
183
|
+
.gemini/skills
|
|
184
|
+
.cursor/skills
|
|
185
|
+
.github/skills
|
|
186
|
+
.codex/skills
|
|
187
|
+
.qwen/skills
|
|
188
|
+
.kiro/skills
|
|
189
|
+
.openclaw/skills
|
|
181
190
|
.pi/skills
|
|
182
191
|
.agent/skills
|
|
183
192
|
.agents/skills
|
|
184
|
-
.
|
|
185
|
-
|
|
193
|
+
.atl/skills
|
|
194
|
+
~/.pi/agent/skills
|
|
195
|
+
~/.config/agents/skills
|
|
196
|
+
~/.agents/skills
|
|
197
|
+
~/.kimi/skills
|
|
186
198
|
~/.config/opencode/skills
|
|
199
|
+
~/.config/kilo/skills
|
|
187
200
|
~/.claude/skills
|
|
188
201
|
~/.gemini/skills
|
|
202
|
+
~/.gemini/antigravity/skills
|
|
189
203
|
~/.cursor/skills
|
|
190
204
|
~/.copilot/skills
|
|
205
|
+
~/.codex/skills
|
|
206
|
+
~/.codeium/windsurf/skills
|
|
207
|
+
~/.qwen/skills
|
|
208
|
+
~/.kiro/skills
|
|
209
|
+
~/.openclaw/skills
|
|
191
210
|
```
|
|
192
211
|
|
|
193
212
|
Behavior:
|
|
194
213
|
|
|
195
214
|
- `.atl/` is added to `.gitignore` when needed;
|
|
196
215
|
- the registry refreshes on session start;
|
|
216
|
+
- startup refresh is skipped when Pi starts with `--no-skills` / `-ns`, `--no-skill-registry`, or `GENTLE_PI_NO_SKILL_REGISTRY=1`;
|
|
197
217
|
- `/skill-registry:refresh` forces regeneration;
|
|
198
218
|
- a best-effort watcher refreshes when skill files change;
|
|
199
|
-
-
|
|
219
|
+
- `## Compact Rules` wins when present; otherwise the registry extracts compact rules from `## Hard Rules`, `## Critical Rules`, `## Critical Patterns`, `## Voice Rules`, and `## Decision Gates` using bullets, numbered lists, or simple tables.
|
|
200
220
|
|
|
201
221
|
Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony.
|
|
202
222
|
|
|
@@ -266,6 +286,14 @@ Saved at:
|
|
|
266
286
|
| `/gentle-ai:install-sdd --force` | Force-refreshes installed SDD assets. |
|
|
267
287
|
| `/skill-registry:refresh` | Regenerates `.atl/skill-registry.md`. |
|
|
268
288
|
|
|
289
|
+
Startup flag:
|
|
290
|
+
|
|
291
|
+
```text
|
|
292
|
+
pi --no-skill-registry
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Use it when you want skills available normally but do not want Gentle AI to refresh/watch `.atl/skill-registry.md` on startup. `pi -ns` / `pi --no-skills` also skip the registry startup work because Pi is already disabling skill loading.
|
|
296
|
+
|
|
269
297
|
Compatibility aliases:
|
|
270
298
|
|
|
271
299
|
```text
|
|
@@ -331,6 +359,7 @@ pi install .
|
|
|
331
359
|
Validate before publishing:
|
|
332
360
|
|
|
333
361
|
```bash
|
|
362
|
+
pnpm test
|
|
334
363
|
bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js
|
|
335
364
|
node --experimental-strip-types --check extensions/gentle-ai.ts
|
|
336
365
|
node --experimental-strip-types --check extensions/sdd-init.ts
|
package/assets/orchestrator.md
CHANGED
|
@@ -31,6 +31,8 @@ User-facing conversation should stay in the user's language and follow the curre
|
|
|
31
31
|
|
|
32
32
|
Subagent-facing prompts should be written in English by default, even when the user speaks Spanish. Translate the user's request into concise English before delegation. This keeps token usage lower and gives built-in/project subagents a consistent operating language without changing the user-facing persona.
|
|
33
33
|
|
|
34
|
+
Generated artifacts — whether by the parent inline or by subagents — (code, UI copy, comments, identifiers, commit messages, filenames, PR descriptions) default to English, regardless of the user's conversation language. Override only when the user explicitly requests another language for that artifact, or when extending a project whose existing convention is non-English.
|
|
35
|
+
|
|
34
36
|
Exceptions:
|
|
35
37
|
|
|
36
38
|
- Preserve exact user quotes, UI copy, error messages, filenames, commands, and domain terms in their original language when they are evidence.
|
package/extensions/gentle-ai.ts
CHANGED
|
@@ -471,15 +471,19 @@ class SddModelPanel implements OverlayComponent {
|
|
|
471
471
|
private query = "";
|
|
472
472
|
private readonly draft: AgentModelConfig;
|
|
473
473
|
private readonly rows: string[];
|
|
474
|
+
private readonly modelOptions: string[];
|
|
475
|
+
private readonly done: (result: ModelPanelResult) => void;
|
|
474
476
|
|
|
475
477
|
constructor(
|
|
476
478
|
initialConfig: AgentModelConfig,
|
|
477
|
-
|
|
479
|
+
modelOptions: string[],
|
|
478
480
|
agents: string[],
|
|
479
|
-
|
|
481
|
+
done: (result: ModelPanelResult) => void,
|
|
480
482
|
) {
|
|
481
483
|
this.draft = { ...initialConfig };
|
|
482
484
|
this.rows = [SET_ALL_AGENTS, ...agents];
|
|
485
|
+
this.modelOptions = modelOptions;
|
|
486
|
+
this.done = done;
|
|
483
487
|
}
|
|
484
488
|
|
|
485
489
|
invalidate(): void {}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
writeFileSync,
|
|
11
11
|
} from "node:fs";
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
|
-
import { basename, join, relative } from "node:path";
|
|
13
|
+
import { basename, join, normalize, relative, sep } from "node:path";
|
|
14
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
|
|
16
16
|
const REGISTRY_REL_PATH = ".atl/skill-registry.md";
|
|
@@ -20,7 +20,9 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
|
|
|
20
20
|
const EXCLUDE_PREFIXES = ["sdd-"];
|
|
21
21
|
const ATL_IGNORE_ENTRY = ".atl/";
|
|
22
22
|
const WATCH_DEBOUNCE_MS = 500;
|
|
23
|
-
const REGISTRY_SCHEMA_VERSION =
|
|
23
|
+
const REGISTRY_SCHEMA_VERSION = 4;
|
|
24
|
+
const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
|
|
25
|
+
const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
|
|
24
26
|
const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
|
|
25
27
|
const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
|
|
26
28
|
".pi/extensions/skill-registry.ts.disabled";
|
|
@@ -36,23 +38,39 @@ function userSkillDirs(): string[] {
|
|
|
36
38
|
const home = homedir();
|
|
37
39
|
return [
|
|
38
40
|
join(home, ".pi/agent/skills"),
|
|
41
|
+
join(home, ".config/agents/skills"),
|
|
39
42
|
join(home, ".agents/skills"),
|
|
43
|
+
join(home, ".kimi/skills"),
|
|
40
44
|
join(home, ".config/opencode/skills"),
|
|
45
|
+
join(home, ".config/kilo/skills"),
|
|
41
46
|
join(home, ".claude/skills"),
|
|
42
47
|
join(home, ".gemini/skills"),
|
|
48
|
+
join(home, ".gemini/antigravity/skills"),
|
|
43
49
|
join(home, ".cursor/skills"),
|
|
44
50
|
join(home, ".copilot/skills"),
|
|
51
|
+
join(home, ".codex/skills"),
|
|
52
|
+
join(home, ".codeium/windsurf/skills"),
|
|
53
|
+
join(home, ".qwen/skills"),
|
|
54
|
+
join(home, ".kiro/skills"),
|
|
55
|
+
join(home, ".openclaw/skills"),
|
|
45
56
|
];
|
|
46
57
|
}
|
|
47
58
|
|
|
48
59
|
function projectSkillDirs(cwd: string): string[] {
|
|
49
60
|
return [
|
|
50
61
|
join(cwd, "skills"),
|
|
62
|
+
join(cwd, ".opencode/skills"),
|
|
63
|
+
join(cwd, ".claude/skills"),
|
|
64
|
+
join(cwd, ".gemini/skills"),
|
|
65
|
+
join(cwd, ".cursor/skills"),
|
|
66
|
+
join(cwd, ".github/skills"),
|
|
67
|
+
join(cwd, ".codex/skills"),
|
|
68
|
+
join(cwd, ".qwen/skills"),
|
|
69
|
+
join(cwd, ".kiro/skills"),
|
|
70
|
+
join(cwd, ".openclaw/skills"),
|
|
51
71
|
join(cwd, ".pi/skills"),
|
|
52
72
|
join(cwd, ".agent/skills"),
|
|
53
73
|
join(cwd, ".agents/skills"),
|
|
54
|
-
join(cwd, ".claude/skills"),
|
|
55
|
-
join(cwd, ".gemini/skills"),
|
|
56
74
|
join(cwd, ".atl/skills"),
|
|
57
75
|
];
|
|
58
76
|
}
|
|
@@ -78,7 +96,7 @@ function findSkillFiles(root: string): string[] {
|
|
|
78
96
|
}
|
|
79
97
|
}
|
|
80
98
|
}
|
|
81
|
-
return out;
|
|
99
|
+
return out.sort();
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
|
|
@@ -102,24 +120,76 @@ function parseFrontmatter(source: string): { name?: string; description?: string
|
|
|
102
120
|
return { ...out, body };
|
|
103
121
|
}
|
|
104
122
|
|
|
123
|
+
const FALLBACK_RULE_HEADINGS = ["Hard Rules", "Critical Rules", "Critical Patterns", "Voice Rules", "Decision Gates"];
|
|
124
|
+
const MAX_EXTRACTED_RULE_COUNT = 15;
|
|
125
|
+
|
|
105
126
|
function extractCompactRulesSection(body: string): string[] {
|
|
106
|
-
const
|
|
127
|
+
const compactRules = extractRulesFromHeadings(body, ["Compact Rules"]);
|
|
128
|
+
if (compactRules.length > 0) return compactRules;
|
|
129
|
+
return extractRulesFromHeadings(body, FALLBACK_RULE_HEADINGS);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function extractRulesFromHeadings(body: string, headings: string[]): string[] {
|
|
133
|
+
const wanted = new Set(headings.map(normalizeHeading));
|
|
107
134
|
let inSection = false;
|
|
108
135
|
const rules: string[] = [];
|
|
109
|
-
for (const raw of
|
|
136
|
+
for (const raw of body.split("\n")) {
|
|
110
137
|
const line = raw.trimEnd();
|
|
111
|
-
|
|
112
|
-
|
|
138
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
139
|
+
if (heading) {
|
|
140
|
+
inSection = wanted.has(normalizeHeading(heading[1]));
|
|
113
141
|
continue;
|
|
114
142
|
}
|
|
115
143
|
if (!inSection) continue;
|
|
116
|
-
if (/^##\s+/.test(line))
|
|
117
|
-
|
|
118
|
-
|
|
144
|
+
if (/^##\s+/.test(line)) {
|
|
145
|
+
inSection = false;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const rule = extractRuleLine(line);
|
|
149
|
+
if (rule) {
|
|
150
|
+
rules.push(rule);
|
|
151
|
+
if (rules.length >= MAX_EXTRACTED_RULE_COUNT) return rules;
|
|
152
|
+
}
|
|
119
153
|
}
|
|
120
154
|
return rules;
|
|
121
155
|
}
|
|
122
156
|
|
|
157
|
+
function extractRuleLine(line: string): string | undefined {
|
|
158
|
+
const trimmed = line.trim();
|
|
159
|
+
if (!trimmed) return undefined;
|
|
160
|
+
const bullet = trimmed.match(/^-\s+(.+)$/);
|
|
161
|
+
if (bullet) return bullet[1].trim();
|
|
162
|
+
const ordered = trimmed.match(/^\d+[.)]\s+(.+)$/);
|
|
163
|
+
if (ordered) return ordered[1].trim();
|
|
164
|
+
if (trimmed.startsWith("|") && trimmed.endsWith("|")) return extractRuleTableRow(trimmed);
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function extractRuleTableRow(line: string): string | undefined {
|
|
169
|
+
const cells = line
|
|
170
|
+
.slice(1, -1)
|
|
171
|
+
.split("|")
|
|
172
|
+
.map((cell) => cell.trim());
|
|
173
|
+
if (cells.length < 2) return undefined;
|
|
174
|
+
if (isTableSeparator(cells) || isTableHeader(cells) || !cells[0] || !cells[1]) return undefined;
|
|
175
|
+
return `${cells[0]}: ${cells[1]}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isTableSeparator(cells: string[]): boolean {
|
|
179
|
+
return cells.every((cell) => cell.replace(/[\s:-]/g, "") === "");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isTableHeader(cells: string[]): boolean {
|
|
183
|
+
if (cells.length < 2) return false;
|
|
184
|
+
const first = normalizeHeading(cells[0]);
|
|
185
|
+
const second = normalizeHeading(cells[1]);
|
|
186
|
+
return (first === "rule" && second === "requirement") || (first === "target" && second === "test pattern");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeHeading(heading: string): string {
|
|
190
|
+
return heading.trim().toLowerCase();
|
|
191
|
+
}
|
|
192
|
+
|
|
123
193
|
function deriveSkillName(file: string, frontmatterName: string | undefined): string {
|
|
124
194
|
if (frontmatterName) return frontmatterName;
|
|
125
195
|
return basename(join(file, ".."));
|
|
@@ -130,13 +200,19 @@ function isExcluded(name: string): boolean {
|
|
|
130
200
|
return EXCLUDE_PREFIXES.some((p) => name.startsWith(p));
|
|
131
201
|
}
|
|
132
202
|
|
|
203
|
+
function comparablePath(path: string): string {
|
|
204
|
+
const clean = normalize(path);
|
|
205
|
+
return clean.length > 1 ? clean.replace(/[\\/]+$/, "") : clean;
|
|
206
|
+
}
|
|
207
|
+
|
|
133
208
|
function uniqueExistingDirs(dirs: string[]): string[] {
|
|
134
209
|
const seen = new Set<string>();
|
|
135
210
|
const out: string[] = [];
|
|
136
211
|
for (const dir of dirs) {
|
|
137
|
-
|
|
138
|
-
seen.
|
|
139
|
-
|
|
212
|
+
const clean = comparablePath(dir);
|
|
213
|
+
if (seen.has(clean) || !existsSync(clean)) continue;
|
|
214
|
+
seen.add(clean);
|
|
215
|
+
out.push(clean);
|
|
140
216
|
}
|
|
141
217
|
return out;
|
|
142
218
|
}
|
|
@@ -155,7 +231,7 @@ function loadSkill(file: string): SkillEntry | undefined {
|
|
|
155
231
|
return {
|
|
156
232
|
name,
|
|
157
233
|
path: file,
|
|
158
|
-
description: fm.description ?? "",
|
|
234
|
+
description: extractTriggerDescription(fm.description ?? ""),
|
|
159
235
|
rules:
|
|
160
236
|
rules.length > 0
|
|
161
237
|
? rules
|
|
@@ -163,8 +239,14 @@ function loadSkill(file: string): SkillEntry | undefined {
|
|
|
163
239
|
};
|
|
164
240
|
}
|
|
165
241
|
|
|
242
|
+
function extractTriggerDescription(description: string): string {
|
|
243
|
+
const match = description.match(/\bTrigger:\s*(.+)$/i);
|
|
244
|
+
return match ? match[1].trim() : description;
|
|
245
|
+
}
|
|
246
|
+
|
|
166
247
|
function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
|
|
167
|
-
const
|
|
248
|
+
const cleanCwd = comparablePath(cwd);
|
|
249
|
+
const projectPrefix = cleanCwd.endsWith(sep) ? cleanCwd : `${cleanCwd}${sep}`;
|
|
168
250
|
const buckets = new Map<string, SkillEntry[]>();
|
|
169
251
|
for (const entry of entries) {
|
|
170
252
|
const list = buckets.get(entry.name) ?? [];
|
|
@@ -173,7 +255,7 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
|
|
|
173
255
|
}
|
|
174
256
|
const out: SkillEntry[] = [];
|
|
175
257
|
for (const [, list] of buckets) {
|
|
176
|
-
const projectScoped = list.find((e) => e.path.startsWith(projectPrefix));
|
|
258
|
+
const projectScoped = list.find((e) => comparablePath(e.path).startsWith(projectPrefix));
|
|
177
259
|
out.push(projectScoped ?? list[0]);
|
|
178
260
|
}
|
|
179
261
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -298,7 +380,7 @@ function quarantineLegacyProjectRegistry(cwd: string): boolean {
|
|
|
298
380
|
|
|
299
381
|
function regenerateRegistry(cwd: string, force: boolean): RegenResult {
|
|
300
382
|
const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
|
|
301
|
-
const files = existingDirs.flatMap(findSkillFiles)
|
|
383
|
+
const files = existingDirs.flatMap(findSkillFiles);
|
|
302
384
|
const cachePath = join(cwd, CACHE_REL_PATH);
|
|
303
385
|
const registryPath = join(cwd, REGISTRY_REL_PATH);
|
|
304
386
|
const fp = fingerprint(files);
|
|
@@ -330,6 +412,26 @@ function regenerateRegistry(cwd: string, force: boolean): RegenResult {
|
|
|
330
412
|
|
|
331
413
|
const watchedCwds = new Set<string>();
|
|
332
414
|
|
|
415
|
+
function isTruthyEnv(value: string | undefined): boolean {
|
|
416
|
+
return value === "1" || value === "true" || value === "yes" || value === "on";
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function hasCliArg(args: string[], ...names: string[]): boolean {
|
|
420
|
+
return args.some((arg) => names.includes(arg));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function shouldSkipSkillRegistryStartup(
|
|
424
|
+
pi: Pick<ExtensionAPI, "getFlag">,
|
|
425
|
+
argv = process.argv.slice(2),
|
|
426
|
+
env = process.env,
|
|
427
|
+
): boolean {
|
|
428
|
+
return (
|
|
429
|
+
pi.getFlag(NO_SKILL_REGISTRY_FLAG) === true ||
|
|
430
|
+
isTruthyEnv(env[NO_SKILL_REGISTRY_ENV]) ||
|
|
431
|
+
hasCliArg(argv, "--no-skills", "-ns")
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
333
435
|
function startSkillRegistryWatcher(cwd: string, notify: (message: string) => void): void {
|
|
334
436
|
if (watchedCwds.has(cwd)) return;
|
|
335
437
|
watchedCwds.add(cwd);
|
|
@@ -357,8 +459,25 @@ function startSkillRegistryWatcher(cwd: string, notify: (message: string) => voi
|
|
|
357
459
|
}
|
|
358
460
|
}
|
|
359
461
|
|
|
462
|
+
export const __testing = {
|
|
463
|
+
projectSkillDirs,
|
|
464
|
+
userSkillDirs,
|
|
465
|
+
extractCompactRulesSection,
|
|
466
|
+
extractTriggerDescription,
|
|
467
|
+
uniqueExistingDirs,
|
|
468
|
+
dedupeBySkillName,
|
|
469
|
+
shouldSkipSkillRegistryStartup,
|
|
470
|
+
};
|
|
471
|
+
|
|
360
472
|
export default function (pi: ExtensionAPI) {
|
|
473
|
+
pi.registerFlag(NO_SKILL_REGISTRY_FLAG, {
|
|
474
|
+
description: "Skip the Gentle AI skill registry refresh and watcher on startup.",
|
|
475
|
+
type: "boolean",
|
|
476
|
+
default: false,
|
|
477
|
+
});
|
|
478
|
+
|
|
361
479
|
pi.on("session_start", async (_event, ctx) => {
|
|
480
|
+
if (shouldSkipSkillRegistryStartup(pi)) return;
|
|
362
481
|
try {
|
|
363
482
|
ensureAtlIgnored(ctx.cwd);
|
|
364
483
|
const quarantinedLegacy = quarantineLegacyProjectRegistry(ctx.cwd);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.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",
|
|
@@ -28,11 +28,14 @@
|
|
|
28
28
|
"prompts/",
|
|
29
29
|
"skills/",
|
|
30
30
|
"scripts/",
|
|
31
|
+
"tests/",
|
|
31
32
|
"README.md"
|
|
32
33
|
],
|
|
33
34
|
"scripts": {
|
|
34
|
-
"
|
|
35
|
-
"
|
|
35
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts && pnpm run test:harness",
|
|
36
|
+
"test:harness": "node --experimental-strip-types tests/runtime-harness.mjs",
|
|
37
|
+
"prepack": "pnpm test && node scripts/verify-package-files.mjs",
|
|
38
|
+
"prepublishOnly": "pnpm test && node scripts/verify-package-files.mjs"
|
|
36
39
|
},
|
|
37
40
|
"pi": {
|
|
38
41
|
"image": "https://cdn.jsdelivr.net/npm/gentle-pi/assets/gentle-logo-only.png",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
9
|
+
const EXTENSIONS = [
|
|
10
|
+
"extensions/gentle-ai.ts",
|
|
11
|
+
"extensions/skill-registry.ts",
|
|
12
|
+
"extensions/sdd-init.ts",
|
|
13
|
+
"extensions/startup-banner.ts",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const EXPECTED_COMMANDS = [
|
|
17
|
+
"gentle-ai:install-sdd",
|
|
18
|
+
"gentle:models",
|
|
19
|
+
"gentle-ai:models",
|
|
20
|
+
"gentleman:models",
|
|
21
|
+
"gentle:persona",
|
|
22
|
+
"gentle-ai:persona",
|
|
23
|
+
"gentleman:persona",
|
|
24
|
+
"gentle-ai:status",
|
|
25
|
+
"sdd-init",
|
|
26
|
+
"skill-registry:refresh",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function createPi() {
|
|
30
|
+
const hooks = new Map();
|
|
31
|
+
const commands = new Map();
|
|
32
|
+
const flags = new Map();
|
|
33
|
+
const flagValues = new Map([["no-skill-registry", true]]);
|
|
34
|
+
|
|
35
|
+
const pi = {
|
|
36
|
+
on(name, handler) {
|
|
37
|
+
const list = hooks.get(name) ?? [];
|
|
38
|
+
list.push(handler);
|
|
39
|
+
hooks.set(name, list);
|
|
40
|
+
},
|
|
41
|
+
registerCommand(name, definition) {
|
|
42
|
+
commands.set(name, definition);
|
|
43
|
+
},
|
|
44
|
+
registerFlag(name, definition) {
|
|
45
|
+
flags.set(name, definition);
|
|
46
|
+
},
|
|
47
|
+
getFlag(name) {
|
|
48
|
+
return flagValues.get(name) ?? false;
|
|
49
|
+
},
|
|
50
|
+
setFlag(name, value) {
|
|
51
|
+
flagValues.set(name, value);
|
|
52
|
+
},
|
|
53
|
+
getCommands() {
|
|
54
|
+
return Array.from(commands, ([name, definition]) => ({ name, ...definition }));
|
|
55
|
+
},
|
|
56
|
+
getAllTools() {
|
|
57
|
+
return [
|
|
58
|
+
{ name: "read" },
|
|
59
|
+
{ name: "bash" },
|
|
60
|
+
{ name: "edit" },
|
|
61
|
+
{ name: "write" },
|
|
62
|
+
];
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return { pi, hooks, commands, flags };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createUi() {
|
|
70
|
+
const notifications = [];
|
|
71
|
+
return {
|
|
72
|
+
notifications,
|
|
73
|
+
notify(message, level = "info") {
|
|
74
|
+
notifications.push({ message, level });
|
|
75
|
+
},
|
|
76
|
+
async confirm() {
|
|
77
|
+
return false;
|
|
78
|
+
},
|
|
79
|
+
async select(_label, options) {
|
|
80
|
+
return options[0];
|
|
81
|
+
},
|
|
82
|
+
async input(_label, placeholder) {
|
|
83
|
+
return placeholder;
|
|
84
|
+
},
|
|
85
|
+
custom() {
|
|
86
|
+
return Promise.resolve({ type: "cancel" });
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function createCtx(cwd, hasUI = false) {
|
|
92
|
+
return {
|
|
93
|
+
cwd,
|
|
94
|
+
hasUI,
|
|
95
|
+
ui: createUi(),
|
|
96
|
+
modelRegistry: {
|
|
97
|
+
async getAvailable() {
|
|
98
|
+
return [];
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function tempWorkspace() {
|
|
105
|
+
return mkdtemp(join(tmpdir(), "gentle-pi-runtime-"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function loadExtensions(pi) {
|
|
109
|
+
for (const [index, rel] of EXTENSIONS.entries()) {
|
|
110
|
+
const mod = await import(`${pathToFileURL(join(ROOT, rel)).href}?runtime-harness=${index}`);
|
|
111
|
+
assert.equal(typeof mod.default, "function", `${rel} must export a default function`);
|
|
112
|
+
mod.default(pi);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function run() {
|
|
117
|
+
const { pi, hooks, commands, flags } = createPi();
|
|
118
|
+
await loadExtensions(pi);
|
|
119
|
+
|
|
120
|
+
for (const name of EXPECTED_COMMANDS) {
|
|
121
|
+
assert.ok(commands.has(name), `missing command ${name}`);
|
|
122
|
+
}
|
|
123
|
+
assert.ok(flags.has("no-skill-registry"), "missing no-skill-registry flag");
|
|
124
|
+
assert.ok(hooks.has("session_start"), "missing session_start hook");
|
|
125
|
+
assert.ok(hooks.has("before_agent_start"), "missing before_agent_start hook");
|
|
126
|
+
assert.ok(hooks.has("tool_call"), "missing tool_call hook");
|
|
127
|
+
|
|
128
|
+
const promptCwd = await tempWorkspace();
|
|
129
|
+
try {
|
|
130
|
+
const promptHook = hooks.get("before_agent_start")[0];
|
|
131
|
+
const promptResult = promptHook({ systemPrompt: "base" }, createCtx(promptCwd));
|
|
132
|
+
assert.match(promptResult.systemPrompt, /base/);
|
|
133
|
+
assert.match(promptResult.systemPrompt, /el Gentleman/);
|
|
134
|
+
} finally {
|
|
135
|
+
await rm(promptCwd, { recursive: true, force: true });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const toolCwd = await tempWorkspace();
|
|
139
|
+
try {
|
|
140
|
+
const toolHook = hooks.get("tool_call")[0];
|
|
141
|
+
assert.equal(await toolHook({ toolName: "bash", input: { command: "git status" } }, createCtx(toolCwd)), undefined);
|
|
142
|
+
const denied = await toolHook({ toolName: "bash", input: { command: "rm -rf /" } }, createCtx(toolCwd));
|
|
143
|
+
assert.equal(denied.block, true);
|
|
144
|
+
assert.match(denied.reason, /destructive/);
|
|
145
|
+
const needsConfirm = await toolHook({ toolName: "bash", input: { command: "git push" } }, createCtx(toolCwd));
|
|
146
|
+
assert.equal(needsConfirm.block, true);
|
|
147
|
+
assert.match(needsConfirm.reason, /confirmation/);
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(toolCwd, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const noUiCwd = await tempWorkspace();
|
|
153
|
+
try {
|
|
154
|
+
for (const handler of hooks.get("session_start")) {
|
|
155
|
+
await handler({ reason: "startup" }, createCtx(noUiCwd, false));
|
|
156
|
+
}
|
|
157
|
+
} finally {
|
|
158
|
+
await rm(noUiCwd, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const installCwd = await tempWorkspace();
|
|
162
|
+
try {
|
|
163
|
+
const ctx = createCtx(installCwd, true);
|
|
164
|
+
await commands.get("gentle-ai:install-sdd").handler("", ctx);
|
|
165
|
+
assert.match(ctx.ui.notifications.at(-1).message, /SDD assets installed/);
|
|
166
|
+
} finally {
|
|
167
|
+
await rm(installCwd, { recursive: true, force: true });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sddCwd = await tempWorkspace();
|
|
171
|
+
try {
|
|
172
|
+
const ctx = createCtx(sddCwd, true);
|
|
173
|
+
await commands.get("sdd-init").handler("", ctx);
|
|
174
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Wrote openspec\/config\.yaml/);
|
|
175
|
+
} finally {
|
|
176
|
+
await rm(sddCwd, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const registryCwd = await tempWorkspace();
|
|
180
|
+
try {
|
|
181
|
+
const ctx = createCtx(registryCwd, true);
|
|
182
|
+
await commands.get("skill-registry:refresh").handler("", ctx);
|
|
183
|
+
assert.match(ctx.ui.notifications.at(-1).message, /Skill registry:/);
|
|
184
|
+
} finally {
|
|
185
|
+
await rm(registryCwd, { recursive: true, force: true });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
run().catch((error) => {
|
|
190
|
+
console.error(error);
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { __testing } from "../extensions/skill-registry.ts";
|
|
7
|
+
|
|
8
|
+
test("project skill dirs include supported workspace roots", () => {
|
|
9
|
+
const cwd = "/repo";
|
|
10
|
+
const dirs = __testing.projectSkillDirs(cwd);
|
|
11
|
+
for (const want of [
|
|
12
|
+
"skills",
|
|
13
|
+
".opencode/skills",
|
|
14
|
+
".claude/skills",
|
|
15
|
+
".gemini/skills",
|
|
16
|
+
".cursor/skills",
|
|
17
|
+
".github/skills",
|
|
18
|
+
".codex/skills",
|
|
19
|
+
".qwen/skills",
|
|
20
|
+
".kiro/skills",
|
|
21
|
+
".openclaw/skills",
|
|
22
|
+
".pi/skills",
|
|
23
|
+
".agent/skills",
|
|
24
|
+
".agents/skills",
|
|
25
|
+
".atl/skills",
|
|
26
|
+
]) {
|
|
27
|
+
assert.ok(dirs.includes(join(cwd, want)), `missing ${want}`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Compact Rules are preferred over fallback sections", () => {
|
|
32
|
+
const rules = __testing.extractCompactRulesSection(`## Compact Rules
|
|
33
|
+
|
|
34
|
+
- Explicit compact rule.
|
|
35
|
+
|
|
36
|
+
## Hard Rules
|
|
37
|
+
|
|
38
|
+
- Hard rule should not be copied.
|
|
39
|
+
`);
|
|
40
|
+
|
|
41
|
+
assert.deepEqual(rules, ["Explicit compact rule."]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("LLM-first and legacy sections extract bullets, ordered lists, and tables", () => {
|
|
45
|
+
const rules = __testing.extractCompactRulesSection(`## Hard Rules
|
|
46
|
+
|
|
47
|
+
- Prefer focused tests.
|
|
48
|
+
|
|
49
|
+
## Critical Rules
|
|
50
|
+
|
|
51
|
+
1. Link an approved issue.
|
|
52
|
+
2. Keep PRs within review budget.
|
|
53
|
+
|
|
54
|
+
## Voice Rules
|
|
55
|
+
|
|
56
|
+
| Rule | Requirement |
|
|
57
|
+
|------|-------------|
|
|
58
|
+
| Be warm | Sound like a teammate. |
|
|
59
|
+
|
|
60
|
+
## Decision Gates
|
|
61
|
+
|
|
62
|
+
| Target | Test pattern |
|
|
63
|
+
|---|---|
|
|
64
|
+
| File operations | Use t.TempDir(). |
|
|
65
|
+
`);
|
|
66
|
+
|
|
67
|
+
assert.deepEqual(rules, [
|
|
68
|
+
"Prefer focused tests.",
|
|
69
|
+
"Link an approved issue.",
|
|
70
|
+
"Keep PRs within review budget.",
|
|
71
|
+
"Be warm: Sound like a teammate.",
|
|
72
|
+
"File operations: Use t.TempDir().",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("description trigger text is extracted when present", () => {
|
|
77
|
+
assert.equal(
|
|
78
|
+
__testing.extractTriggerDescription("Write comments. Trigger: PR feedback, issue replies."),
|
|
79
|
+
"PR feedback, issue replies.",
|
|
80
|
+
);
|
|
81
|
+
assert.equal(__testing.extractTriggerDescription("No explicit trigger."), "No explicit trigger.");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("fallback extraction is capped at 15 rules", () => {
|
|
85
|
+
const body = `## Hard Rules
|
|
86
|
+
|
|
87
|
+
${Array.from({ length: 16 }, (_, i) => `- Rule ${String(i + 1).padStart(2, "0")}.`).join("\n")}
|
|
88
|
+
`;
|
|
89
|
+
const rules = __testing.extractCompactRulesSection(body);
|
|
90
|
+
|
|
91
|
+
assert.equal(rules.length, 15);
|
|
92
|
+
assert.equal(rules.at(-1), "Rule 15.");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("project-scoped duplicate wins over user duplicate", () => {
|
|
96
|
+
const cwd = join(tmpdir(), `gentle-pi-registry-${Date.now()}`);
|
|
97
|
+
const projectPath = join(cwd, ".opencode/skills/dup/SKILL.md");
|
|
98
|
+
const userPath = join(cwd + "-home", ".config/opencode/skills/dup/SKILL.md");
|
|
99
|
+
const entries = [
|
|
100
|
+
{ name: "dup", path: userPath, description: "user", rules: ["User rule."] },
|
|
101
|
+
{ name: "dup", path: projectPath, description: "project", rules: ["Project rule."] },
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const [chosen] = __testing.dedupeBySkillName(entries, cwd);
|
|
105
|
+
assert.equal(chosen.path, projectPath);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("uniqueExistingDirs normalizes duplicates and ignores missing roots", () => {
|
|
109
|
+
const root = join(tmpdir(), `gentle-pi-existing-${Date.now()}`);
|
|
110
|
+
const existing = join(root, "skills");
|
|
111
|
+
mkdirSync(existing, { recursive: true });
|
|
112
|
+
|
|
113
|
+
assert.deepEqual(__testing.uniqueExistingDirs([existing, join(root, "skills/"), join(root, "missing")]), [existing]);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("startup skip honors no skill registry controls", () => {
|
|
117
|
+
const enabled = { getFlag: () => true };
|
|
118
|
+
const disabled = { getFlag: () => false };
|
|
119
|
+
|
|
120
|
+
assert.equal(__testing.shouldSkipSkillRegistryStartup(enabled, [], {}), true);
|
|
121
|
+
assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, ["--no-skills"], {}), true);
|
|
122
|
+
assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, ["-ns"], {}), true);
|
|
123
|
+
assert.equal(
|
|
124
|
+
__testing.shouldSkipSkillRegistryStartup(disabled, [], { GENTLE_PI_NO_SKILL_REGISTRY: "1" }),
|
|
125
|
+
true,
|
|
126
|
+
);
|
|
127
|
+
assert.equal(__testing.shouldSkipSkillRegistryStartup(disabled, [], {}), false);
|
|
128
|
+
});
|