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 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
- .claude/skills
185
- .gemini/skills
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
- - skills without `## Compact Rules` are still listed, but delegators should inject project/user compact rules into subagents whenever possible.
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 { existsSync, mkdirSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
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 = 2;
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 lines = body.split("\n");
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 lines) {
134
+ for (const raw of body.split("\n")) {
98
135
  const line = raw.trimEnd();
99
- if (/^##\s+Compact Rules\s*$/i.test(line)) {
100
- inSection = true;
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)) break;
105
- const m = line.match(/^-\s+(.+)$/);
106
- if (m) rules.push(m[1].trim());
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
- if (seen.has(dir) || !existsSync(dir)) continue;
126
- seen.add(dir);
127
- out.push(dir);
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 projectPrefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
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 .pi/extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->");
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).sort();
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 result = regenerateRegistry(ctx.cwd, false);
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 > 90) {
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
- }, 50);
468
+ }, 25);
197
469
 
198
470
  return {
199
471
  render(width: number): string[] {
200
- const flashStartTick = 16;
201
- const roseOpacity = Math.min(1, tick / 16);
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) / 20)
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
- b.add("banner", logoLine);
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 (const logoLine of logoBase.lines) {
536
+ for (let logoI = 0; logoI < logoBase.lines.length; logoI++) {
537
+ const logoLine = logoBase.lines[logoI];
254
538
  b.addRow();
255
- b.add("banner", logoLine);
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 firstBannerX = row.findIndex((c) => c.type === "banner");
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 === "banner") {
393
- if (cell.char === "▒") {
394
- line += rgb(95, 30, 60, cell.char);
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 (isFlashing) {
403
- line += `\x1b[1m\x1b[38;2;255;255;255m${cell.char}\x1b[0m`;
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 += rgb(255, 120, 198, cell.char);
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.4",
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
- "prepack": "node scripts/verify-package-files.mjs",
35
- "prepublishOnly": "node scripts/verify-package-files.mjs"
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
+ });