gentle-pi 0.2.5 → 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
@@ -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,7 @@ 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 = 3;
23
+ const REGISTRY_SCHEMA_VERSION = 4;
24
24
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
25
25
  const LEGACY_PROJECT_REGISTRY_DISABLED_REL_PATH =
26
26
  ".pi/extensions/skill-registry.ts.disabled";
@@ -36,23 +36,39 @@ function userSkillDirs(): string[] {
36
36
  const home = homedir();
37
37
  return [
38
38
  join(home, ".pi/agent/skills"),
39
+ join(home, ".config/agents/skills"),
39
40
  join(home, ".agents/skills"),
41
+ join(home, ".kimi/skills"),
40
42
  join(home, ".config/opencode/skills"),
43
+ join(home, ".config/kilo/skills"),
41
44
  join(home, ".claude/skills"),
42
45
  join(home, ".gemini/skills"),
46
+ join(home, ".gemini/antigravity/skills"),
43
47
  join(home, ".cursor/skills"),
44
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"),
45
54
  ];
46
55
  }
47
56
 
48
57
  function projectSkillDirs(cwd: string): string[] {
49
58
  return [
50
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"),
51
69
  join(cwd, ".pi/skills"),
52
70
  join(cwd, ".agent/skills"),
53
71
  join(cwd, ".agents/skills"),
54
- join(cwd, ".claude/skills"),
55
- join(cwd, ".gemini/skills"),
56
72
  join(cwd, ".atl/skills"),
57
73
  ];
58
74
  }
@@ -78,7 +94,7 @@ function findSkillFiles(root: string): string[] {
78
94
  }
79
95
  }
80
96
  }
81
- return out;
97
+ return out.sort();
82
98
  }
83
99
 
84
100
  function parseFrontmatter(source: string): { name?: string; description?: string; body: string } {
@@ -102,24 +118,76 @@ function parseFrontmatter(source: string): { name?: string; description?: string
102
118
  return { ...out, body };
103
119
  }
104
120
 
121
+ const FALLBACK_RULE_HEADINGS = ["Hard Rules", "Critical Rules", "Critical Patterns", "Voice Rules", "Decision Gates"];
122
+ const MAX_EXTRACTED_RULE_COUNT = 15;
123
+
105
124
  function extractCompactRulesSection(body: string): string[] {
106
- 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));
107
132
  let inSection = false;
108
133
  const rules: string[] = [];
109
- for (const raw of lines) {
134
+ for (const raw of body.split("\n")) {
110
135
  const line = raw.trimEnd();
111
- if (/^##\s+Compact Rules\s*$/i.test(line)) {
112
- inSection = true;
136
+ const heading = line.match(/^##\s+(.+?)\s*$/);
137
+ if (heading) {
138
+ inSection = wanted.has(normalizeHeading(heading[1]));
113
139
  continue;
114
140
  }
115
141
  if (!inSection) continue;
116
- if (/^##\s+/.test(line)) break;
117
- const m = line.match(/^-\s+(.+)$/);
118
- 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
+ }
119
151
  }
120
152
  return rules;
121
153
  }
122
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
+
123
191
  function deriveSkillName(file: string, frontmatterName: string | undefined): string {
124
192
  if (frontmatterName) return frontmatterName;
125
193
  return basename(join(file, ".."));
@@ -130,13 +198,19 @@ function isExcluded(name: string): boolean {
130
198
  return EXCLUDE_PREFIXES.some((p) => name.startsWith(p));
131
199
  }
132
200
 
201
+ function comparablePath(path: string): string {
202
+ const clean = normalize(path);
203
+ return clean.length > 1 ? clean.replace(/[\\/]+$/, "") : clean;
204
+ }
205
+
133
206
  function uniqueExistingDirs(dirs: string[]): string[] {
134
207
  const seen = new Set<string>();
135
208
  const out: string[] = [];
136
209
  for (const dir of dirs) {
137
- if (seen.has(dir) || !existsSync(dir)) continue;
138
- seen.add(dir);
139
- out.push(dir);
210
+ const clean = comparablePath(dir);
211
+ if (seen.has(clean) || !existsSync(clean)) continue;
212
+ seen.add(clean);
213
+ out.push(clean);
140
214
  }
141
215
  return out;
142
216
  }
@@ -155,7 +229,7 @@ function loadSkill(file: string): SkillEntry | undefined {
155
229
  return {
156
230
  name,
157
231
  path: file,
158
- description: fm.description ?? "",
232
+ description: extractTriggerDescription(fm.description ?? ""),
159
233
  rules:
160
234
  rules.length > 0
161
235
  ? rules
@@ -163,8 +237,14 @@ function loadSkill(file: string): SkillEntry | undefined {
163
237
  };
164
238
  }
165
239
 
240
+ function extractTriggerDescription(description: string): string {
241
+ const match = description.match(/\bTrigger:\s*(.+)$/i);
242
+ return match ? match[1].trim() : description;
243
+ }
244
+
166
245
  function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
167
- const projectPrefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
246
+ const cleanCwd = comparablePath(cwd);
247
+ const projectPrefix = cleanCwd.endsWith(sep) ? cleanCwd : `${cleanCwd}${sep}`;
168
248
  const buckets = new Map<string, SkillEntry[]>();
169
249
  for (const entry of entries) {
170
250
  const list = buckets.get(entry.name) ?? [];
@@ -173,7 +253,7 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
173
253
  }
174
254
  const out: SkillEntry[] = [];
175
255
  for (const [, list] of buckets) {
176
- const projectScoped = list.find((e) => e.path.startsWith(projectPrefix));
256
+ const projectScoped = list.find((e) => comparablePath(e.path).startsWith(projectPrefix));
177
257
  out.push(projectScoped ?? list[0]);
178
258
  }
179
259
  return out.sort((a, b) => a.name.localeCompare(b.name));
@@ -298,7 +378,7 @@ function quarantineLegacyProjectRegistry(cwd: string): boolean {
298
378
 
299
379
  function regenerateRegistry(cwd: string, force: boolean): RegenResult {
300
380
  const existingDirs = uniqueExistingDirs([...projectSkillDirs(cwd), ...userSkillDirs()]);
301
- const files = existingDirs.flatMap(findSkillFiles).sort();
381
+ const files = existingDirs.flatMap(findSkillFiles);
302
382
  const cachePath = join(cwd, CACHE_REL_PATH);
303
383
  const registryPath = join(cwd, REGISTRY_REL_PATH);
304
384
  const fp = fingerprint(files);
@@ -357,6 +437,15 @@ function startSkillRegistryWatcher(cwd: string, notify: (message: string) => voi
357
437
  }
358
438
  }
359
439
 
440
+ export const __testing = {
441
+ projectSkillDirs,
442
+ userSkillDirs,
443
+ extractCompactRulesSection,
444
+ extractTriggerDescription,
445
+ uniqueExistingDirs,
446
+ dedupeBySkillName,
447
+ };
448
+
360
449
  export default function (pi: ExtensionAPI) {
361
450
  pi.on("session_start", async (_event, ctx) => {
362
451
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.2.5",
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
+ });