gentle-pi 0.2.4 → 0.2.6
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 +23 -3
- package/extensions/skill-registry.ts +180 -22
- package/extensions/startup-banner.ts +369 -25
- package/package.json +5 -3
- package/tests/skill-registry.test.ts +114 -0
package/README.md
CHANGED
|
@@ -178,16 +178,35 @@ 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:
|
|
@@ -196,7 +215,7 @@ Behavior:
|
|
|
196
215
|
- the registry refreshes on session start;
|
|
197
216
|
- `/skill-registry:refresh` forces regeneration;
|
|
198
217
|
- a best-effort watcher refreshes when skill files change;
|
|
199
|
-
-
|
|
218
|
+
- `## 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
219
|
|
|
201
220
|
Skill discovery is a guardrail, not a workflow router: it helps Pi load the right skill without forcing extra ceremony.
|
|
202
221
|
|
|
@@ -331,6 +350,7 @@ pi install .
|
|
|
331
350
|
Validate before publishing:
|
|
332
351
|
|
|
333
352
|
```bash
|
|
353
|
+
pnpm test
|
|
334
354
|
bun build extensions/skill-registry.ts --target=node --format=esm --outfile=/tmp/skill-registry.js
|
|
335
355
|
node --experimental-strip-types --check extensions/gentle-ai.ts
|
|
336
356
|
node --experimental-strip-types --check extensions/sdd-init.ts
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
renameSync,
|
|
8
|
+
statSync,
|
|
9
|
+
watch,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
3
12
|
import { homedir } from "node:os";
|
|
4
|
-
import { basename, join, relative } from "node:path";
|
|
13
|
+
import { basename, join, normalize, relative, sep } from "node:path";
|
|
5
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
15
|
|
|
7
16
|
const REGISTRY_REL_PATH = ".atl/skill-registry.md";
|
|
@@ -11,7 +20,10 @@ const EXCLUDE_NAMES = new Set(["_shared", "skill-registry"]);
|
|
|
11
20
|
const EXCLUDE_PREFIXES = ["sdd-"];
|
|
12
21
|
const ATL_IGNORE_ENTRY = ".atl/";
|
|
13
22
|
const WATCH_DEBOUNCE_MS = 500;
|
|
14
|
-
const REGISTRY_SCHEMA_VERSION =
|
|
23
|
+
const REGISTRY_SCHEMA_VERSION = 4;
|
|
24
|
+
const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
|
|
25
|
+
const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
|
|
26
|
+
".pi/extensions/skill-registry.ts.disabled";
|
|
15
27
|
|
|
16
28
|
interface SkillEntry {
|
|
17
29
|
name: string;
|
|
@@ -24,23 +36,39 @@ function userSkillDirs(): string[] {
|
|
|
24
36
|
const home = homedir();
|
|
25
37
|
return [
|
|
26
38
|
join(home, ".pi/agent/skills"),
|
|
39
|
+
join(home, ".config/agents/skills"),
|
|
27
40
|
join(home, ".agents/skills"),
|
|
41
|
+
join(home, ".kimi/skills"),
|
|
28
42
|
join(home, ".config/opencode/skills"),
|
|
43
|
+
join(home, ".config/kilo/skills"),
|
|
29
44
|
join(home, ".claude/skills"),
|
|
30
45
|
join(home, ".gemini/skills"),
|
|
46
|
+
join(home, ".gemini/antigravity/skills"),
|
|
31
47
|
join(home, ".cursor/skills"),
|
|
32
48
|
join(home, ".copilot/skills"),
|
|
49
|
+
join(home, ".codex/skills"),
|
|
50
|
+
join(home, ".codeium/windsurf/skills"),
|
|
51
|
+
join(home, ".qwen/skills"),
|
|
52
|
+
join(home, ".kiro/skills"),
|
|
53
|
+
join(home, ".openclaw/skills"),
|
|
33
54
|
];
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
function projectSkillDirs(cwd: string): string[] {
|
|
37
58
|
return [
|
|
38
59
|
join(cwd, "skills"),
|
|
60
|
+
join(cwd, ".opencode/skills"),
|
|
61
|
+
join(cwd, ".claude/skills"),
|
|
62
|
+
join(cwd, ".gemini/skills"),
|
|
63
|
+
join(cwd, ".cursor/skills"),
|
|
64
|
+
join(cwd, ".github/skills"),
|
|
65
|
+
join(cwd, ".codex/skills"),
|
|
66
|
+
join(cwd, ".qwen/skills"),
|
|
67
|
+
join(cwd, ".kiro/skills"),
|
|
68
|
+
join(cwd, ".openclaw/skills"),
|
|
39
69
|
join(cwd, ".pi/skills"),
|
|
40
70
|
join(cwd, ".agent/skills"),
|
|
41
71
|
join(cwd, ".agents/skills"),
|
|
42
|
-
join(cwd, ".claude/skills"),
|
|
43
|
-
join(cwd, ".gemini/skills"),
|
|
44
72
|
join(cwd, ".atl/skills"),
|
|
45
73
|
];
|
|
46
74
|
}
|
|
@@ -66,7 +94,7 @@ function findSkillFiles(root: string): string[] {
|
|
|
66
94
|
}
|
|
67
95
|
}
|
|
68
96
|
}
|
|
69
|
-
return out;
|
|
97
|
+
return out.sort();
|
|
70
98
|
}
|
|
71
99
|
|
|
72
100
|
function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
|
|
@@ -90,24 +118,76 @@ function parseFrontmatter(source: string): { name?: string; description?: string
|
|
|
90
118
|
return { ...out, body };
|
|
91
119
|
}
|
|
92
120
|
|
|
121
|
+
const FALLBACK_RULE_HEADINGS = ["Hard Rules", "Critical Rules", "Critical Patterns", "Voice Rules", "Decision Gates"];
|
|
122
|
+
const MAX_EXTRACTED_RULE_COUNT = 15;
|
|
123
|
+
|
|
93
124
|
function extractCompactRulesSection(body: string): string[] {
|
|
94
|
-
const
|
|
125
|
+
const compactRules = extractRulesFromHeadings(body, ["Compact Rules"]);
|
|
126
|
+
if (compactRules.length > 0) return compactRules;
|
|
127
|
+
return extractRulesFromHeadings(body, FALLBACK_RULE_HEADINGS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractRulesFromHeadings(body: string, headings: string[]): string[] {
|
|
131
|
+
const wanted = new Set(headings.map(normalizeHeading));
|
|
95
132
|
let inSection = false;
|
|
96
133
|
const rules: string[] = [];
|
|
97
|
-
for (const raw of
|
|
134
|
+
for (const raw of body.split("\n")) {
|
|
98
135
|
const line = raw.trimEnd();
|
|
99
|
-
|
|
100
|
-
|
|
136
|
+
const heading = line.match(/^##\s+(.+?)\s*$/);
|
|
137
|
+
if (heading) {
|
|
138
|
+
inSection = wanted.has(normalizeHeading(heading[1]));
|
|
101
139
|
continue;
|
|
102
140
|
}
|
|
103
141
|
if (!inSection) continue;
|
|
104
|
-
if (/^##\s+/.test(line))
|
|
105
|
-
|
|
106
|
-
|
|
142
|
+
if (/^##\s+/.test(line)) {
|
|
143
|
+
inSection = false;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const rule = extractRuleLine(line);
|
|
147
|
+
if (rule) {
|
|
148
|
+
rules.push(rule);
|
|
149
|
+
if (rules.length >= MAX_EXTRACTED_RULE_COUNT) return rules;
|
|
150
|
+
}
|
|
107
151
|
}
|
|
108
152
|
return rules;
|
|
109
153
|
}
|
|
110
154
|
|
|
155
|
+
function extractRuleLine(line: string): string | undefined {
|
|
156
|
+
const trimmed = line.trim();
|
|
157
|
+
if (!trimmed) return undefined;
|
|
158
|
+
const bullet = trimmed.match(/^-\s+(.+)$/);
|
|
159
|
+
if (bullet) return bullet[1].trim();
|
|
160
|
+
const ordered = trimmed.match(/^\d+[.)]\s+(.+)$/);
|
|
161
|
+
if (ordered) return ordered[1].trim();
|
|
162
|
+
if (trimmed.startsWith("|") && trimmed.endsWith("|")) return extractRuleTableRow(trimmed);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractRuleTableRow(line: string): string | undefined {
|
|
167
|
+
const cells = line
|
|
168
|
+
.slice(1, -1)
|
|
169
|
+
.split("|")
|
|
170
|
+
.map((cell) => cell.trim());
|
|
171
|
+
if (cells.length < 2) return undefined;
|
|
172
|
+
if (isTableSeparator(cells) || isTableHeader(cells) || !cells[0] || !cells[1]) return undefined;
|
|
173
|
+
return `${cells[0]}: ${cells[1]}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function isTableSeparator(cells: string[]): boolean {
|
|
177
|
+
return cells.every((cell) => cell.replace(/[\s:-]/g, "") === "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isTableHeader(cells: string[]): boolean {
|
|
181
|
+
if (cells.length < 2) return false;
|
|
182
|
+
const first = normalizeHeading(cells[0]);
|
|
183
|
+
const second = normalizeHeading(cells[1]);
|
|
184
|
+
return (first === "rule" && second === "requirement") || (first === "target" && second === "test pattern");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function normalizeHeading(heading: string): string {
|
|
188
|
+
return heading.trim().toLowerCase();
|
|
189
|
+
}
|
|
190
|
+
|
|
111
191
|
function deriveSkillName(file: string, frontmatterName: string | undefined): string {
|
|
112
192
|
if (frontmatterName) return frontmatterName;
|
|
113
193
|
return basename(join(file, ".."));
|
|
@@ -118,13 +198,19 @@ function isExcluded(name: string): boolean {
|
|
|
118
198
|
return EXCLUDE_PREFIXES.some((p) => name.startsWith(p));
|
|
119
199
|
}
|
|
120
200
|
|
|
201
|
+
function comparablePath(path: string): string {
|
|
202
|
+
const clean = normalize(path);
|
|
203
|
+
return clean.length > 1 ? clean.replace(/[\\/]+$/, "") : clean;
|
|
204
|
+
}
|
|
205
|
+
|
|
121
206
|
function uniqueExistingDirs(dirs: string[]): string[] {
|
|
122
207
|
const seen = new Set<string>();
|
|
123
208
|
const out: string[] = [];
|
|
124
209
|
for (const dir of dirs) {
|
|
125
|
-
|
|
126
|
-
seen.
|
|
127
|
-
|
|
210
|
+
const clean = comparablePath(dir);
|
|
211
|
+
if (seen.has(clean) || !existsSync(clean)) continue;
|
|
212
|
+
seen.add(clean);
|
|
213
|
+
out.push(clean);
|
|
128
214
|
}
|
|
129
215
|
return out;
|
|
130
216
|
}
|
|
@@ -143,7 +229,7 @@ function loadSkill(file: string): SkillEntry | undefined {
|
|
|
143
229
|
return {
|
|
144
230
|
name,
|
|
145
231
|
path: file,
|
|
146
|
-
description: fm.description ?? "",
|
|
232
|
+
description: extractTriggerDescription(fm.description ?? ""),
|
|
147
233
|
rules:
|
|
148
234
|
rules.length > 0
|
|
149
235
|
? rules
|
|
@@ -151,8 +237,14 @@ function loadSkill(file: string): SkillEntry | undefined {
|
|
|
151
237
|
};
|
|
152
238
|
}
|
|
153
239
|
|
|
240
|
+
function extractTriggerDescription(description: string): string {
|
|
241
|
+
const match = description.match(/\bTrigger:\s*(.+)$/i);
|
|
242
|
+
return match ? match[1].trim() : description;
|
|
243
|
+
}
|
|
244
|
+
|
|
154
245
|
function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
|
|
155
|
-
const
|
|
246
|
+
const cleanCwd = comparablePath(cwd);
|
|
247
|
+
const projectPrefix = cleanCwd.endsWith(sep) ? cleanCwd : `${cleanCwd}${sep}`;
|
|
156
248
|
const buckets = new Map<string, SkillEntry[]>();
|
|
157
249
|
for (const entry of entries) {
|
|
158
250
|
const list = buckets.get(entry.name) ?? [];
|
|
@@ -161,7 +253,7 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
|
|
|
161
253
|
}
|
|
162
254
|
const out: SkillEntry[] = [];
|
|
163
255
|
for (const [, list] of buckets) {
|
|
164
|
-
const projectScoped = list.find((e) => e.path.startsWith(projectPrefix));
|
|
256
|
+
const projectScoped = list.find((e) => comparablePath(e.path).startsWith(projectPrefix));
|
|
165
257
|
out.push(projectScoped ?? list[0]);
|
|
166
258
|
}
|
|
167
259
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
@@ -188,7 +280,7 @@ function renderRegistry(cwd: string, sources: string[], entries: SkillEntry[]):
|
|
|
188
280
|
const lines: string[] = [];
|
|
189
281
|
lines.push(`# Skill Registry — ${projectName}`);
|
|
190
282
|
lines.push("");
|
|
191
|
-
lines.push("<!-- Auto-generated by
|
|
283
|
+
lines.push("<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->");
|
|
192
284
|
lines.push("");
|
|
193
285
|
lines.push(`Last updated: ${today}`);
|
|
194
286
|
lines.push("");
|
|
@@ -243,9 +335,50 @@ function ensureAtlIgnored(cwd: string): void {
|
|
|
243
335
|
writeFileSync(gitignorePath, `${existing}${prefix}${header}${ATL_IGNORE_ENTRY}\n`);
|
|
244
336
|
}
|
|
245
337
|
|
|
338
|
+
function isGeneratedLegacyProjectRegistry(source: string): boolean {
|
|
339
|
+
return (
|
|
340
|
+
source.includes("Auto-generated by .pi/extensions/skill-registry.ts") &&
|
|
341
|
+
source.includes("const REGISTRY_REL_PATH = \".atl/skill-registry.md\"") &&
|
|
342
|
+
source.includes("function projectSkillDirs(cwd: string): string[]") &&
|
|
343
|
+
source.includes("function regenerateRegistry(cwd: string, force: boolean)") &&
|
|
344
|
+
(!source.includes('join(cwd, "skills")') ||
|
|
345
|
+
source.includes("const dirs = [...userSkillDirs(), ...projectSkillDirs(cwd)]") ||
|
|
346
|
+
source.includes("if (rules.length === 0) return undefined"))
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function nextLegacyDisabledPath(cwd: string): string {
|
|
351
|
+
const base = join(cwd, LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH);
|
|
352
|
+
if (!existsSync(base)) return base;
|
|
353
|
+
for (let i = 1; i < 100; i++) {
|
|
354
|
+
const candidate = `${base}.${i}`;
|
|
355
|
+
if (!existsSync(candidate)) return candidate;
|
|
356
|
+
}
|
|
357
|
+
return `${base}.${Date.now()}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function quarantineLegacyProjectRegistry(cwd: string): boolean {
|
|
361
|
+
const legacyPath = join(cwd, LEGACY_PROJECT_REGISTRY_REL_PATH);
|
|
362
|
+
if (!existsSync(legacyPath)) return false;
|
|
363
|
+
let source = "";
|
|
364
|
+
try {
|
|
365
|
+
source = readFileSync(legacyPath, "utf8");
|
|
366
|
+
} catch {
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
if (!isGeneratedLegacyProjectRegistry(source)) return false;
|
|
370
|
+
const disabledPath = nextLegacyDisabledPath(cwd);
|
|
371
|
+
try {
|
|
372
|
+
renameSync(legacyPath, disabledPath);
|
|
373
|
+
return true;
|
|
374
|
+
} catch {
|
|
375
|
+
return false;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
246
379
|
function regenerateRegistry(cwd: string, force: boolean): RegenResult {
|
|
247
380
|
const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
|
|
248
|
-
const files = existingDirs.flatMap(findSkillFiles)
|
|
381
|
+
const files = existingDirs.flatMap(findSkillFiles);
|
|
249
382
|
const cachePath = join(cwd, CACHE_REL_PATH);
|
|
250
383
|
const registryPath = join(cwd, REGISTRY_REL_PATH);
|
|
251
384
|
const fp = fingerprint(files);
|
|
@@ -304,17 +437,42 @@ function startSkillRegistryWatcher(cwd: string, notify: (message: string) => voi
|
|
|
304
437
|
}
|
|
305
438
|
}
|
|
306
439
|
|
|
440
|
+
export const __testing = {
|
|
441
|
+
projectSkillDirs,
|
|
442
|
+
userSkillDirs,
|
|
443
|
+
extractCompactRulesSection,
|
|
444
|
+
extractTriggerDescription,
|
|
445
|
+
uniqueExistingDirs,
|
|
446
|
+
dedupeBySkillName,
|
|
447
|
+
};
|
|
448
|
+
|
|
307
449
|
export default function (pi: ExtensionAPI) {
|
|
308
450
|
pi.on("session_start", async (_event, ctx) => {
|
|
309
451
|
try {
|
|
310
452
|
ensureAtlIgnored(ctx.cwd);
|
|
311
|
-
const
|
|
453
|
+
const quarantinedLegacy = quarantineLegacyProjectRegistry(ctx.cwd);
|
|
454
|
+
const result = regenerateRegistry(ctx.cwd, quarantinedLegacy);
|
|
312
455
|
if (result.regenerated && ctx.hasUI) {
|
|
313
456
|
ctx.ui.notify(`Skill registry refreshed (${result.skillCount} skills)`, "info");
|
|
314
457
|
}
|
|
458
|
+
if (quarantinedLegacy && ctx.hasUI) {
|
|
459
|
+
ctx.ui.notify(
|
|
460
|
+
"Disabled stale project-local skill registry extension; using package registry with project skills first.",
|
|
461
|
+
"warning",
|
|
462
|
+
);
|
|
463
|
+
}
|
|
315
464
|
startSkillRegistryWatcher(ctx.cwd, (message) => {
|
|
316
465
|
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
317
466
|
});
|
|
467
|
+
if (quarantinedLegacy) {
|
|
468
|
+
setTimeout(() => {
|
|
469
|
+
try {
|
|
470
|
+
regenerateRegistry(ctx.cwd, true);
|
|
471
|
+
} catch {
|
|
472
|
+
// Best-effort same-session self-heal in case the stale extension already ran.
|
|
473
|
+
}
|
|
474
|
+
}, WATCH_DEBOUNCE_MS);
|
|
475
|
+
}
|
|
318
476
|
} catch (error) {
|
|
319
477
|
if (ctx.hasUI) {
|
|
320
478
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -63,6 +63,9 @@ function padLines(lines: string[]): { lines: string[]; width: number } {
|
|
|
63
63
|
|
|
64
64
|
type CellType =
|
|
65
65
|
| "banner"
|
|
66
|
+
| "logo-tip"
|
|
67
|
+
| "logo-fresh"
|
|
68
|
+
| "logo-ink"
|
|
66
69
|
| "rose"
|
|
67
70
|
| "label"
|
|
68
71
|
| "value"
|
|
@@ -70,6 +73,280 @@ type CellType =
|
|
|
70
73
|
| "accent"
|
|
71
74
|
| "none";
|
|
72
75
|
type LayoutCell = { char: string; type: CellType };
|
|
76
|
+
type LogoCellType = Extract<
|
|
77
|
+
CellType,
|
|
78
|
+
"banner" | "logo-tip" | "logo-fresh" | "logo-ink"
|
|
79
|
+
>;
|
|
80
|
+
|
|
81
|
+
const LOGO_CELL_TYPES: ReadonlySet<CellType> = new Set<CellType>([
|
|
82
|
+
"banner",
|
|
83
|
+
"logo-tip",
|
|
84
|
+
"logo-fresh",
|
|
85
|
+
"logo-ink",
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
function isLogoCellType(type: CellType): type is LogoCellType {
|
|
89
|
+
return LOGO_CELL_TYPES.has(type);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Span = { start: number; end: number };
|
|
93
|
+
|
|
94
|
+
function computeLogoBounds(lines: string[]): Span {
|
|
95
|
+
let start = Number.POSITIVE_INFINITY;
|
|
96
|
+
let end = Number.NEGATIVE_INFINITY;
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
for (let i = 0; i < line.length; i++) {
|
|
99
|
+
if (line[i] !== " ") {
|
|
100
|
+
if (i < start) start = i;
|
|
101
|
+
if (i > end) end = i;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isFinite(start) || !Number.isFinite(end)) {
|
|
106
|
+
return { start: 0, end: 0 };
|
|
107
|
+
}
|
|
108
|
+
return { start, end };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildLetterSpans(bounds: Span, weights: number[]): Span[] {
|
|
112
|
+
const spanWidth = Math.max(1, bounds.end - bounds.start + 1);
|
|
113
|
+
const total = weights.reduce((a, b) => a + b, 0);
|
|
114
|
+
let cursor = bounds.start;
|
|
115
|
+
return weights.map((w, i) => {
|
|
116
|
+
const remaining = bounds.end - cursor + 1;
|
|
117
|
+
const raw = Math.max(1, Math.round((w / total) * spanWidth));
|
|
118
|
+
const width =
|
|
119
|
+
i === weights.length - 1
|
|
120
|
+
? remaining
|
|
121
|
+
: Math.min(raw, remaining - (weights.length - i - 1));
|
|
122
|
+
const s = cursor;
|
|
123
|
+
const e = s + width - 1;
|
|
124
|
+
cursor = e + 1;
|
|
125
|
+
return { start: s, end: e };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const LOGO_BOUNDS = computeLogoBounds(TEXT_LOGO);
|
|
130
|
+
const LETTER_WEIGHTS = [14, 10, 11, 10, 9, 11, 6, 13, 12]; // G E N T L E - P I
|
|
131
|
+
const LETTER_SPANS = buildLetterSpans(LOGO_BOUNDS, LETTER_WEIGHTS);
|
|
132
|
+
|
|
133
|
+
function letterIndexAtX(x: number): number {
|
|
134
|
+
for (let i = 0; i < LETTER_SPANS.length; i++) {
|
|
135
|
+
const s = LETTER_SPANS[i];
|
|
136
|
+
if (x >= s.start && x <= s.end) return i;
|
|
137
|
+
}
|
|
138
|
+
if (x < LETTER_SPANS[0].start) return 0;
|
|
139
|
+
return LETTER_SPANS.length - 1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
type Point = { x: number; y: number };
|
|
143
|
+
|
|
144
|
+
function pointKey(x: number, y: number): string {
|
|
145
|
+
return `${x}:${y}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function buildLetterStrokeMap(letterIdx: number): { orderMap: Map<string, number>; maxOrder: number } {
|
|
149
|
+
const span = LETTER_SPANS[letterIdx];
|
|
150
|
+
const points: Point[] = [];
|
|
151
|
+
const pointSet = new Set<string>();
|
|
152
|
+
|
|
153
|
+
for (let y = 0; y < TEXT_LOGO.length; y++) {
|
|
154
|
+
const line = TEXT_LOGO[y] ?? "";
|
|
155
|
+
for (let x = span.start; x <= Math.min(span.end, line.length - 1); x++) {
|
|
156
|
+
if (line[x] !== " ") {
|
|
157
|
+
points.push({ x, y });
|
|
158
|
+
pointSet.add(pointKey(x, y));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const neighbors8 = [
|
|
164
|
+
[-1, -1], [0, -1], [1, -1],
|
|
165
|
+
[-1, 0], [1, 0],
|
|
166
|
+
[-1, 1], [0, 1], [1, 1],
|
|
167
|
+
] as const;
|
|
168
|
+
|
|
169
|
+
const visited = new Set<string>();
|
|
170
|
+
const components: Point[][] = [];
|
|
171
|
+
|
|
172
|
+
for (const p of points) {
|
|
173
|
+
const k = pointKey(p.x, p.y);
|
|
174
|
+
if (visited.has(k)) continue;
|
|
175
|
+
|
|
176
|
+
const stack = [p];
|
|
177
|
+
const comp: Point[] = [];
|
|
178
|
+
visited.add(k);
|
|
179
|
+
|
|
180
|
+
while (stack.length > 0) {
|
|
181
|
+
const cur = stack.pop()!;
|
|
182
|
+
comp.push(cur);
|
|
183
|
+
for (const [dx, dy] of neighbors8) {
|
|
184
|
+
const nk = pointKey(cur.x + dx, cur.y + dy);
|
|
185
|
+
if (!visited.has(nk) && pointSet.has(nk)) {
|
|
186
|
+
visited.add(nk);
|
|
187
|
+
stack.push({ x: cur.x + dx, y: cur.y + dy });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
components.push(comp);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
components.sort((a, b) => {
|
|
196
|
+
const ax = Math.min(...a.map((p) => p.x));
|
|
197
|
+
const bx = Math.min(...b.map((p) => p.x));
|
|
198
|
+
if (ax !== bx) return ax - bx;
|
|
199
|
+
const ay = Math.min(...a.map((p) => p.y));
|
|
200
|
+
const by = Math.min(...b.map((p) => p.y));
|
|
201
|
+
return ay - by;
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const orderMap = new Map<string, number>();
|
|
205
|
+
let order = 0;
|
|
206
|
+
|
|
207
|
+
for (const comp of components) {
|
|
208
|
+
const compSet = new Set(comp.map((p) => pointKey(p.x, p.y)));
|
|
209
|
+
const compMap = new Map(comp.map((p) => [pointKey(p.x, p.y), p]));
|
|
210
|
+
|
|
211
|
+
let current = comp.reduce((best, p) =>
|
|
212
|
+
p.x < best.x || (p.x === best.x && p.y < best.y) ? p : best,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
let dirX = 1;
|
|
216
|
+
let dirY = 0;
|
|
217
|
+
|
|
218
|
+
while (compSet.size > 0) {
|
|
219
|
+
const ck = pointKey(current.x, current.y);
|
|
220
|
+
if (compSet.has(ck)) {
|
|
221
|
+
compSet.delete(ck);
|
|
222
|
+
orderMap.set(ck, order++);
|
|
223
|
+
}
|
|
224
|
+
if (compSet.size === 0) break;
|
|
225
|
+
|
|
226
|
+
const candidates: Point[] = [];
|
|
227
|
+
for (let dy = -2; dy <= 2; dy++) {
|
|
228
|
+
for (let dx = -2; dx <= 2; dx++) {
|
|
229
|
+
if (dx === 0 && dy === 0) continue;
|
|
230
|
+
const nk = pointKey(current.x + dx, current.y + dy);
|
|
231
|
+
if (compSet.has(nk)) {
|
|
232
|
+
const point = compMap.get(nk);
|
|
233
|
+
if (point) candidates.push(point);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let next: Point | null = null;
|
|
239
|
+
if (candidates.length > 0) {
|
|
240
|
+
candidates.sort((a, b) => {
|
|
241
|
+
const adx = a.x - current.x;
|
|
242
|
+
const ady = a.y - current.y;
|
|
243
|
+
const bdx = b.x - current.x;
|
|
244
|
+
const bdy = b.y - current.y;
|
|
245
|
+
|
|
246
|
+
const aDist = Math.hypot(adx, ady);
|
|
247
|
+
const bDist = Math.hypot(bdx, bdy);
|
|
248
|
+
const aTurn = Math.abs(adx * dirY - ady * dirX);
|
|
249
|
+
const bTurn = Math.abs(bdx * dirY - bdy * dirX);
|
|
250
|
+
|
|
251
|
+
const aScore = aDist * 3.8 + aTurn * 1.3 + Math.abs(ady) * 0.12;
|
|
252
|
+
const bScore = bDist * 3.8 + bTurn * 1.3 + Math.abs(bdy) * 0.12;
|
|
253
|
+
return aScore - bScore;
|
|
254
|
+
});
|
|
255
|
+
next = candidates[0];
|
|
256
|
+
} else {
|
|
257
|
+
let best: Point | null = null;
|
|
258
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
259
|
+
for (const k of compSet) {
|
|
260
|
+
const p = compMap.get(k);
|
|
261
|
+
if (!p) continue;
|
|
262
|
+
const dx = p.x - current.x;
|
|
263
|
+
const dy = p.y - current.y;
|
|
264
|
+
const score = Math.hypot(dx, dy) + Math.abs(dy) * 0.16;
|
|
265
|
+
if (score < bestScore) {
|
|
266
|
+
bestScore = score;
|
|
267
|
+
best = p;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
next = best;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (!next) break;
|
|
274
|
+
dirX = next.x - current.x;
|
|
275
|
+
dirY = next.y - current.y;
|
|
276
|
+
current = next;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { orderMap, maxOrder: Math.max(1, order - 1) };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const LETTER_STROKES = LETTER_SPANS.map((_, i) => buildLetterStrokeMap(i));
|
|
284
|
+
const WRITING_START_TICK = 6;
|
|
285
|
+
const LETTER_TICKS = LETTER_STROKES.map((s) =>
|
|
286
|
+
Math.max(5, Math.ceil(((s.maxOrder + 8) / 11) * 0.48)),
|
|
287
|
+
);
|
|
288
|
+
const LETTER_START_TICKS = LETTER_TICKS.map((_, i) =>
|
|
289
|
+
WRITING_START_TICK + LETTER_TICKS.slice(0, i).reduce((a, b) => a + b, 0),
|
|
290
|
+
);
|
|
291
|
+
const WRITING_END_TICK = WRITING_START_TICK + LETTER_TICKS.reduce((a, b) => a + b, 0);
|
|
292
|
+
|
|
293
|
+
function buildPenLogoLine(
|
|
294
|
+
line: string,
|
|
295
|
+
rowIdx: number,
|
|
296
|
+
_totalRows: number,
|
|
297
|
+
tick: number,
|
|
298
|
+
): LayoutCell[] {
|
|
299
|
+
const out: LayoutCell[] = [];
|
|
300
|
+
|
|
301
|
+
for (let x = 0; x < line.length; x++) {
|
|
302
|
+
const ch = line[x] ?? " ";
|
|
303
|
+
if (ch === " ") {
|
|
304
|
+
out.push({ char: " ", type: "none" });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const letterIdx = letterIndexAtX(x);
|
|
309
|
+
const stroke = LETTER_STROKES[letterIdx];
|
|
310
|
+
const startTick = LETTER_START_TICKS[letterIdx];
|
|
311
|
+
const duration = LETTER_TICKS[letterIdx];
|
|
312
|
+
const progress = (tick - startTick) / Math.max(1, duration);
|
|
313
|
+
|
|
314
|
+
if (progress < 0) {
|
|
315
|
+
out.push({ char: " ", type: "none" });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const head = progress * (stroke.maxOrder + 7);
|
|
320
|
+
const rawOrder = stroke.orderMap.get(pointKey(x, rowIdx));
|
|
321
|
+
if (rawOrder === undefined) {
|
|
322
|
+
out.push({ char: " ", type: "none" });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let order = rawOrder;
|
|
327
|
+
// v1: ajuste SOLO para la primera letra (G), con más curvatura caligráfica.
|
|
328
|
+
if (letterIdx === 0) {
|
|
329
|
+
const s = LETTER_SPANS[0];
|
|
330
|
+
const w = Math.max(1, s.end - s.start + 1);
|
|
331
|
+
const localX = x - s.start;
|
|
332
|
+
const curveBias =
|
|
333
|
+
Math.sin((localX / w) * Math.PI * 1.35 + rowIdx * 0.26) * 2.2 +
|
|
334
|
+
Math.cos((localX / w) * Math.PI * 0.72 - rowIdx * 0.20) * 1.3;
|
|
335
|
+
order = rawOrder + curveBias;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (head < order) {
|
|
339
|
+
out.push({ char: " ", type: "none" });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const age = head - order;
|
|
344
|
+
if (age < 1.2) out.push({ char: ch, type: "logo-tip" });
|
|
345
|
+
else if (age < 4.9) out.push({ char: ch, type: "logo-fresh" });
|
|
346
|
+
else out.push({ char: ch, type: "logo-ink" });
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
73
350
|
|
|
74
351
|
class LayoutBuilder {
|
|
75
352
|
lines: LayoutCell[][] = [];
|
|
@@ -106,10 +383,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
106
383
|
|
|
107
384
|
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
108
385
|
|
|
109
|
-
const finishIntro = () => {
|
|
110
|
-
ctx.ui.setHeader(undefined);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
386
|
const roseBase = padLines(normalizeAscii(ROSE_LARGE_RAW));
|
|
114
387
|
const logoBase = padLines(TEXT_LOGO);
|
|
115
388
|
|
|
@@ -177,12 +450,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
177
450
|
|
|
178
451
|
state.timer = setInterval(() => {
|
|
179
452
|
tick++;
|
|
180
|
-
if (tick >
|
|
453
|
+
if (tick > WRITING_END_TICK + 22) {
|
|
181
454
|
if (state.timer) {
|
|
182
455
|
clearInterval(state.timer);
|
|
183
456
|
state.timer = null;
|
|
184
457
|
}
|
|
185
|
-
finishIntro();
|
|
186
458
|
return;
|
|
187
459
|
}
|
|
188
460
|
try {
|
|
@@ -193,15 +465,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
193
465
|
state.timer = null;
|
|
194
466
|
}
|
|
195
467
|
}
|
|
196
|
-
},
|
|
468
|
+
}, 25);
|
|
197
469
|
|
|
198
470
|
return {
|
|
199
471
|
render(width: number): string[] {
|
|
200
|
-
const flashStartTick =
|
|
201
|
-
const roseOpacity = Math.min(1, tick /
|
|
472
|
+
const flashStartTick = 10;
|
|
473
|
+
const roseOpacity = Math.min(1, tick / 10);
|
|
202
474
|
const flashPhase =
|
|
203
475
|
tick >= flashStartTick
|
|
204
|
-
? Math.max(0, 1 - (tick - flashStartTick) /
|
|
476
|
+
? Math.max(0, 1 - (tick - flashStartTick) / 12)
|
|
205
477
|
: 0;
|
|
206
478
|
const frame = Math.floor(tick / 2);
|
|
207
479
|
|
|
@@ -243,16 +515,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
243
515
|
b.addRow();
|
|
244
516
|
b.add("rose", roseLine);
|
|
245
517
|
b.add("none", " ");
|
|
246
|
-
|
|
518
|
+
if (logoI >= 0 && logoI < logoBase.lines.length) {
|
|
519
|
+
b.lines[b.lines.length - 1].push(
|
|
520
|
+
...buildPenLogoLine(
|
|
521
|
+
logoLine,
|
|
522
|
+
logoI,
|
|
523
|
+
logoBase.lines.length,
|
|
524
|
+
tick,
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
} else {
|
|
528
|
+
b.add("none", " ".repeat(logoBase.width));
|
|
529
|
+
}
|
|
247
530
|
b.center(width);
|
|
248
531
|
}
|
|
249
532
|
} else {
|
|
250
533
|
const showBanner = width >= logoBase.width + 2;
|
|
251
534
|
const showRose = width >= roseBase.width + 2;
|
|
252
535
|
if (showBanner) {
|
|
253
|
-
for (
|
|
536
|
+
for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
|
|
537
|
+
const logoLine = logoBase.lines[logoI];
|
|
254
538
|
b.addRow();
|
|
255
|
-
b.
|
|
539
|
+
b.lines[b.lines.length - 1].push(
|
|
540
|
+
...buildPenLogoLine(
|
|
541
|
+
logoLine,
|
|
542
|
+
logoI,
|
|
543
|
+
logoBase.lines.length,
|
|
544
|
+
tick,
|
|
545
|
+
),
|
|
546
|
+
);
|
|
256
547
|
b.center(width);
|
|
257
548
|
}
|
|
258
549
|
if (showRose) {
|
|
@@ -357,9 +648,46 @@ export default function (pi: ExtensionAPI) {
|
|
|
357
648
|
const out: string[] = [];
|
|
358
649
|
const layout = b.lines;
|
|
359
650
|
|
|
651
|
+
const logoRows = layout
|
|
652
|
+
.map((row, idx) => ({
|
|
653
|
+
idx,
|
|
654
|
+
hasLogo: (row || []).some((c) => isLogoCellType(c.type)),
|
|
655
|
+
}))
|
|
656
|
+
.filter((r) => r.hasLogo)
|
|
657
|
+
.map((r) => r.idx);
|
|
658
|
+
const sparkleY =
|
|
659
|
+
logoRows.length > 0
|
|
660
|
+
? logoRows[Math.floor(logoRows.length / 2)]
|
|
661
|
+
: -1;
|
|
662
|
+
const logoLastX = Math.max(
|
|
663
|
+
-1,
|
|
664
|
+
...layout.map((row) => {
|
|
665
|
+
let last = -1;
|
|
666
|
+
for (let i = 0; i < (row || []).length; i++) {
|
|
667
|
+
const cell = row?.[i];
|
|
668
|
+
if (cell && isLogoCellType(cell.type) && cell.char !== " ") {
|
|
669
|
+
last = i;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
return last;
|
|
673
|
+
}),
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const glintStartTick = WRITING_END_TICK + 3;
|
|
677
|
+
const glintEndTick = WRITING_END_TICK + 12;
|
|
678
|
+
const glintActive = tick >= glintStartTick && tick <= glintEndTick;
|
|
679
|
+
const glintHead =
|
|
680
|
+
((tick - glintStartTick) /
|
|
681
|
+
Math.max(1, glintEndTick - glintStartTick)) *
|
|
682
|
+
(LOGO_BOUNDS.end - LOGO_BOUNDS.start + 1);
|
|
683
|
+
const sparkleActive =
|
|
684
|
+
tick >= WRITING_END_TICK + 13 && tick <= WRITING_END_TICK + 20;
|
|
685
|
+
|
|
360
686
|
for (let y = 0; y < layout.length; y++) {
|
|
361
687
|
const row = layout[y] || [];
|
|
362
|
-
const
|
|
688
|
+
const firstLogoX = row.findIndex(
|
|
689
|
+
(c) => isLogoCellType(c.type) && c.char !== " ",
|
|
690
|
+
);
|
|
363
691
|
let line = "";
|
|
364
692
|
|
|
365
693
|
for (let x = 0; x < row.length; x++) {
|
|
@@ -389,20 +717,37 @@ export default function (pi: ExtensionAPI) {
|
|
|
389
717
|
continue;
|
|
390
718
|
}
|
|
391
719
|
|
|
392
|
-
if (cell.type
|
|
393
|
-
|
|
394
|
-
|
|
720
|
+
if (isLogoCellType(cell.type)) {
|
|
721
|
+
const localLogoX = firstLogoX >= 0 ? x - firstLogoX : x;
|
|
722
|
+
const glintOnCell =
|
|
723
|
+
glintActive &&
|
|
724
|
+
localLogoX >= glintHead - 2 &&
|
|
725
|
+
localLogoX <= glintHead + 1;
|
|
726
|
+
const sparkleOnCell =
|
|
727
|
+
sparkleActive &&
|
|
728
|
+
y === sparkleY &&
|
|
729
|
+
(x === logoLastX || x === logoLastX - 1);
|
|
730
|
+
|
|
731
|
+
if (sparkleOnCell) {
|
|
732
|
+
line += `\x1b[1m` + rgb(255, 255, 255, "✦") + `\x1b[22m`;
|
|
395
733
|
continue;
|
|
396
734
|
}
|
|
397
|
-
const localX = firstBannerX >= 0 ? x - firstBannerX : x;
|
|
398
|
-
const sweep = Math.floor((tick - 16) * 2.2);
|
|
399
|
-
const isFlashing =
|
|
400
|
-
tick >= 16 && localX >= sweep - 4 && localX <= sweep + 2;
|
|
401
735
|
|
|
402
|
-
if (
|
|
403
|
-
line += `\x1b[1m
|
|
736
|
+
if (glintOnCell) {
|
|
737
|
+
line += `\x1b[1m` + rgb(255, 245, 252, cell.char) + `\x1b[22m`;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
if (cell.type === "logo-tip") {
|
|
742
|
+
line += `\x1b[1m` + rgb(255, 205, 238, cell.char) + `\x1b[22m`;
|
|
743
|
+
} else if (cell.type === "logo-fresh") {
|
|
744
|
+
line += cell.char === "▒"
|
|
745
|
+
? rgb(110, 36, 70, cell.char)
|
|
746
|
+
: rgb(255, 138, 206, cell.char);
|
|
404
747
|
} else {
|
|
405
|
-
line +=
|
|
748
|
+
line += cell.char === "▒"
|
|
749
|
+
? rgb(95, 30, 60, cell.char)
|
|
750
|
+
: rgb(255, 120, 198, cell.char);
|
|
406
751
|
}
|
|
407
752
|
continue;
|
|
408
753
|
}
|
|
@@ -435,7 +780,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
435
780
|
clearInterval(state.timer);
|
|
436
781
|
state.timer = null;
|
|
437
782
|
}
|
|
438
|
-
finishIntro();
|
|
439
783
|
},
|
|
440
784
|
};
|
|
441
785
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gentle-pi",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
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,13 @@
|
|
|
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",
|
|
36
|
+
"prepack": "pnpm test && node scripts/verify-package-files.mjs",
|
|
37
|
+
"prepublishOnly": "pnpm test && node scripts/verify-package-files.mjs"
|
|
36
38
|
},
|
|
37
39
|
"pi": {
|
|
38
40
|
"image": "https://cdn.jsdelivr.net/npm/gentle-pi/assets/gentle-logo-only.png",
|
|
@@ -0,0 +1,114 @@
|
|
|
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
|
+
});
|