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 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
- .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:
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
- - skills without `## Compact Rules` are still listed, but delegators should inject project/user compact rules into subagents whenever possible.
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
@@ -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.
@@ -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
- private readonly modelOptions: string[],
479
+ modelOptions: string[],
478
480
  agents: string[],
479
- private readonly done: (result: ModelPanelResult) => void,
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 = 3;
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 lines = body.split("\n");
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 lines) {
136
+ for (const raw of body.split("\n")) {
110
137
  const line = raw.trimEnd();
111
- if (/^##\s+Compact Rules\s*$/i.test(line)) {
112
- inSection = true;
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)) break;
117
- const m = line.match(/^-\s+(.+)$/);
118
- if (m) rules.push(m[1].trim());
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
- if (seen.has(dir) || !existsSync(dir)) continue;
138
- seen.add(dir);
139
- out.push(dir);
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 projectPrefix = cwd.endsWith("/") ? cwd : `${cwd}/`;
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).sort();
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.5",
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
- "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 && 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
+ });