gentle-pi 0.3.4 → 0.3.5

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.
@@ -33,17 +33,21 @@ import {
33
33
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
34
34
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
35
35
 
36
- function sddAssetDriftCount(cwd: string): number {
36
+ function gentlePiAgentHome(): string {
37
+ return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent");
38
+ }
39
+
40
+ function sddGlobalAssetDriftCount(): number {
37
41
  let stale = 0;
38
42
  for (const [assetSubdir, installedSubdir] of [
39
- ["agents", join(".pi", "agents")],
40
- ["chains", join(".pi", "chains")],
43
+ ["agents", "agents"],
44
+ ["chains", "chains"],
41
45
  ] as const) {
42
46
  const assetDir = join(ASSETS_DIR, assetSubdir);
43
47
  if (!existsSync(assetDir)) continue;
44
48
  for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
45
49
  if (!entry.isFile()) continue;
46
- const installedPath = join(cwd, installedSubdir, entry.name);
50
+ const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name);
47
51
  try {
48
52
  if (!existsSync(installedPath)) {
49
53
  stale += 1;
@@ -63,6 +67,34 @@ function sddAssetDriftCount(cwd: string): number {
63
67
  return stale;
64
68
  }
65
69
 
70
+ function sddLocalOverrideDriftCount(cwd: string): number {
71
+ let stale = 0;
72
+ for (const [assetSubdir, installedSubdir] of [
73
+ ["agents", join(".pi", "agents")],
74
+ ["chains", join(".pi", "chains")],
75
+ ] as const) {
76
+ const assetDir = join(ASSETS_DIR, assetSubdir);
77
+ const installedDir = join(cwd, installedSubdir);
78
+ if (!existsSync(assetDir) || !existsSync(installedDir)) continue;
79
+ for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
80
+ if (!entry.isFile()) continue;
81
+ const installedPath = join(installedDir, entry.name);
82
+ if (!existsSync(installedPath)) continue;
83
+ try {
84
+ if (
85
+ readFileSync(join(assetDir, entry.name), "utf8") !==
86
+ readFileSync(installedPath, "utf8")
87
+ ) {
88
+ stale += 1;
89
+ }
90
+ } catch {
91
+ stale += 1;
92
+ }
93
+ }
94
+ }
95
+ return stale;
96
+ }
97
+
66
98
  let orchestratorPromptCache: string | null = null;
67
99
  function getOrchestratorPrompt(): string {
68
100
  if (orchestratorPromptCache === null) {
@@ -212,7 +244,18 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
212
244
  }
213
245
 
214
246
  function isSddAgentStartEvent(event: unknown): boolean {
215
- const candidates = [
247
+ const candidates = readAgentStartNames(event);
248
+ if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
249
+
250
+ const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
251
+ return SDD_AGENT_NAMES.some((name) => {
252
+ const phase = name.replace(/^sdd-/, "");
253
+ return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
254
+ });
255
+ }
256
+
257
+ function readAgentStartNames(event: unknown): string[] {
258
+ return [
216
259
  readStringPath(event, ["agentName"]),
217
260
  readStringPath(event, ["agent"]),
218
261
  readStringPath(event, ["name"]),
@@ -220,14 +263,12 @@ function isSddAgentStartEvent(event: unknown): boolean {
220
263
  readStringPath(event, ["subagent", "name"]),
221
264
  ]
222
265
  .filter((value): value is string => value !== undefined)
223
- .map((value) => value.trim());
224
- if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
266
+ .map((value) => value.trim())
267
+ .filter((value) => value.length > 0);
268
+ }
225
269
 
226
- const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
227
- return SDD_AGENT_NAMES.some((name) => {
228
- const phase = name.replace(/^sdd-/, "");
229
- return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
230
- });
270
+ function isNamedAgentStartEvent(event: unknown): boolean {
271
+ return readAgentStartNames(event).length > 0;
231
272
  }
232
273
 
233
274
  function evaluateDeniedCommand(
@@ -548,13 +589,13 @@ async function listAgentsFromDirAsync(
548
589
 
549
590
  function listDiscoverableAgents(cwd: string): AgentEntry[] {
550
591
  const builtinDirs = [
592
+ join(gentlePiAgentHome(), "agents"),
551
593
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
552
594
  join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
553
595
  join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
554
596
  ];
555
597
  const agents = [
556
598
  ...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
557
- ...listAgentsFromDir(join(homedir(), ".pi", "agent", "agents"), "user"),
558
599
  ...listAgentsFromDir(join(homedir(), ".agents"), "user"),
559
600
  ...listAgentsFromDir(join(cwd, ".agents"), "project"),
560
601
  ...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
@@ -573,6 +614,7 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
573
614
 
574
615
  async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
575
616
  const builtinDirs = [
617
+ join(gentlePiAgentHome(), "agents"),
576
618
  join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
577
619
  join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
578
620
  join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
@@ -582,7 +624,6 @@ async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
582
624
  agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
583
625
  }
584
626
  const otherDirs: Array<[string, AgentSource]> = [
585
- [join(homedir(), ".pi", "agent", "agents"), "user"],
586
627
  [join(homedir(), ".agents"), "user"],
587
628
  [join(cwd, ".agents"), "project"],
588
629
  [join(cwd, ".pi", "agents"), "project"],
@@ -1235,6 +1276,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1235
1276
 
1236
1277
  pi.on("session_start", async (_event, ctx) => {
1237
1278
  try {
1279
+ const installResult = installSddAssets(ctx.cwd, false);
1238
1280
  const modelResult = await applySavedModelConfig(ctx);
1239
1281
  if (ctx.hasUI && modelResult.invalidPath) {
1240
1282
  ctx.ui.notify(
@@ -1245,7 +1287,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
1245
1287
  }
1246
1288
  if (ctx.hasUI && modelResult.updated > 0) {
1247
1289
  ctx.ui.notify(
1248
- `el Gentleman applied SDD model config to ${modelResult.updated} agent(s).`,
1290
+ `el Gentleman applied SDD model config to ${modelResult.updated} agent(s). Global SDD assets ready: ${installResult.agents} new agent(s), ${installResult.chains} new chain(s), ${installResult.support} new support file(s).`,
1249
1291
  "info",
1250
1292
  );
1251
1293
  }
@@ -1270,13 +1312,21 @@ export default function gentleAi(pi: ExtensionAPI): void {
1270
1312
  });
1271
1313
 
1272
1314
  pi.on("before_agent_start", async (event, ctx) => {
1273
- if (isSddAgentStartEvent(event) && !getSddPreflightPreferences(ctx)) {
1315
+ const isSddAgent = isSddAgentStartEvent(event);
1316
+ const isNamedAgent = isNamedAgentStartEvent(event);
1317
+ if (isSddAgent && !getSddPreflightPreferences(ctx)) {
1274
1318
  await runSddPreflight(ctx);
1275
1319
  }
1276
1320
  const prefs = getSddPreflightPreferences(ctx);
1277
- const sddPrompt = prefs ? `\n\n${renderSddPreflightPrompt(prefs)}` : "";
1321
+ const sddPrompt =
1322
+ prefs && (!isNamedAgent || isSddAgent)
1323
+ ? `\n\n${renderSddPreflightPrompt(prefs)}`
1324
+ : "";
1325
+ const gentlePrompt = isNamedAgent || isSddAgent
1326
+ ? ""
1327
+ : `\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}`;
1278
1328
  return {
1279
- systemPrompt: `${event.systemPrompt}\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}${sddPrompt}`,
1329
+ systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}`,
1280
1330
  };
1281
1331
  });
1282
1332
 
@@ -1289,12 +1339,12 @@ export default function gentleAi(pi: ExtensionAPI): void {
1289
1339
 
1290
1340
  pi.registerCommand("gentle-ai:install-sdd", {
1291
1341
  description:
1292
- "Install Gentle AI SDD subagent and chain assets into this project.",
1342
+ "Repair or refresh global Gentle AI SDD subagent and chain assets.",
1293
1343
  handler: async (args, ctx) => {
1294
1344
  const force = args.includes("--force");
1295
1345
  const result = installSddAssets(ctx.cwd, force);
1296
1346
  ctx.ui.notify(
1297
- `Gentle AI SDD assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} skipped.`,
1347
+ `Global Gentle AI SDD assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
1298
1348
  "info",
1299
1349
  );
1300
1350
  },
@@ -1361,32 +1411,38 @@ export default function gentleAi(pi: ExtensionAPI): void {
1361
1411
  description: "Show Gentle AI package status for this project.",
1362
1412
  handler: async (_args, ctx) => {
1363
1413
  const agentsInstalled = existsSync(
1364
- join(ctx.cwd, ".pi", "agents", "sdd-apply.md"),
1414
+ join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
1365
1415
  );
1366
1416
  const chainsInstalled = existsSync(
1367
- join(ctx.cwd, ".pi", "chains", "sdd-full.chain.md"),
1417
+ join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
1368
1418
  );
1369
1419
  const openspecConfigured = existsSync(
1370
1420
  join(ctx.cwd, "openspec", "config.yaml"),
1371
1421
  );
1372
- const staleSddAssets = sddAssetDriftCount(ctx.cwd);
1422
+ const staleSddAssets = sddGlobalAssetDriftCount();
1423
+ const staleLocalOverrides = sddLocalOverrideDriftCount(ctx.cwd);
1373
1424
  const modelConfig = await readModelConfigAsync(ctx.cwd);
1374
1425
  ctx.ui.notify(
1375
1426
  [
1376
1427
  "el Gentleman package is active.",
1377
1428
  `Persona: ${readPersonaMode(ctx.cwd)}`,
1378
- `SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
1379
- `SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
1380
- `SDD assets stale: ${staleSddAssets} file(s)${
1429
+ `Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
1430
+ `Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
1431
+ `Global SDD assets stale: ${staleSddAssets} file(s)${
1381
1432
  staleSddAssets > 0
1382
1433
  ? " — run /gentle-ai:install-sdd --force to refresh intentionally"
1383
1434
  : ""
1384
1435
  }`,
1436
+ `Project-local SDD override drift: ${staleLocalOverrides} file(s)${
1437
+ staleLocalOverrides > 0
1438
+ ? " — run /gentle-ai:install-sdd --force only if you intentionally want to replace local overrides"
1439
+ : ""
1440
+ }`,
1385
1441
  `OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
1386
1442
  `Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
1387
1443
  ...describeModelConfig(ctx.cwd, modelConfig),
1388
1444
  ].join("\n"),
1389
- staleSddAssets > 0 ? "warning" : "info",
1445
+ staleSddAssets > 0 || staleLocalOverrides > 0 ? "warning" : "info",
1390
1446
  );
1391
1447
  },
1392
1448
  });
@@ -15,12 +15,12 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
15
 
16
16
  const REGISTRY_REL_PATH = ".atl/skill-registry.md";
17
17
  const CACHE_REL_PATH = ".atl/.skill-registry.cache.json";
18
- const SECTION_MARKER = "## Selected skills and compact rules";
18
+ const SECTION_MARKER = "## Skills";
19
19
  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 = 4;
23
+ const REGISTRY_SCHEMA_VERSION = 5;
24
24
  const NO_SKILL_REGISTRY_FLAG = "no-skill-registry";
25
25
  const NO_SKILL_REGISTRY_ENV = "GENTLE_PI_NO_SKILL_REGISTRY";
26
26
  const LEGACY_PROJECT_REGISTRY_REL_PATH = ".pi/extensions/skill-registry.ts";
@@ -39,7 +39,7 @@ interface SkillEntry {
39
39
  name: string;
40
40
  path: string;
41
41
  description: string;
42
- rules: string[];
42
+ scope?: string;
43
43
  }
44
44
 
45
45
  function userSkillDirs(): string[] {
@@ -114,12 +114,28 @@ function parseFrontmatter(source: string): { name?: string; description?: string
114
114
  const fm = source.slice(4, end);
115
115
  const body = source.slice(end + 4).replace(/^\n/, "");
116
116
  const out: { name?: string; description?: string } = {};
117
- for (const line of fm.split("\n")) {
117
+ const lines = fm.split("\n");
118
+ for (let i = 0; i < lines.length; i++) {
119
+ const line = lines[i];
118
120
  const m = line.match(/^(\w+):\s*(.*)$/);
119
121
  if (!m) continue;
120
122
  const key = m[1];
121
123
  let value = m[2].trim();
122
- if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
124
+ if (value === ">" || value === ">-" || value === "|" || value === "|-") {
125
+ const block: string[] = [];
126
+ while (i + 1 < lines.length) {
127
+ const next = lines[i + 1];
128
+ if (next.trim() === "") {
129
+ block.push("");
130
+ i++;
131
+ continue;
132
+ }
133
+ if (!next.startsWith(" ") && !next.startsWith("\t")) break;
134
+ block.push(next.trim());
135
+ i++;
136
+ }
137
+ value = block.join(" ").trim();
138
+ } else if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
123
139
  value = value.slice(1, -1);
124
140
  }
125
141
  if (key === "name") out.name = value;
@@ -128,76 +144,6 @@ function parseFrontmatter(source: string): { name?: string; description?: string
128
144
  return { ...out, body };
129
145
  }
130
146
 
131
- const FALLBACK_RULE_HEADINGS = ["Hard Rules", "Critical Rules", "Critical Patterns", "Voice Rules", "Decision Gates"];
132
- const MAX_EXTRACTED_RULE_COUNT = 15;
133
-
134
- function extractCompactRulesSection(body: string): string[] {
135
- const compactRules = extractRulesFromHeadings(body, ["Compact Rules"]);
136
- if (compactRules.length > 0) return compactRules;
137
- return extractRulesFromHeadings(body, FALLBACK_RULE_HEADINGS);
138
- }
139
-
140
- function extractRulesFromHeadings(body: string, headings: string[]): string[] {
141
- const wanted = new Set(headings.map(normalizeHeading));
142
- let inSection = false;
143
- const rules: string[] = [];
144
- for (const raw of body.split("\n")) {
145
- const line = raw.trimEnd();
146
- const heading = line.match(/^##\s+(.+?)\s*$/);
147
- if (heading) {
148
- inSection = wanted.has(normalizeHeading(heading[1]));
149
- continue;
150
- }
151
- if (!inSection) continue;
152
- if (/^##\s+/.test(line)) {
153
- inSection = false;
154
- continue;
155
- }
156
- const rule = extractRuleLine(line);
157
- if (rule) {
158
- rules.push(rule);
159
- if (rules.length >= MAX_EXTRACTED_RULE_COUNT) return rules;
160
- }
161
- }
162
- return rules;
163
- }
164
-
165
- function extractRuleLine(line: string): string | undefined {
166
- const trimmed = line.trim();
167
- if (!trimmed) return undefined;
168
- const bullet = trimmed.match(/^-\s+(.+)$/);
169
- if (bullet) return bullet[1].trim();
170
- const ordered = trimmed.match(/^\d+[.)]\s+(.+)$/);
171
- if (ordered) return ordered[1].trim();
172
- if (trimmed.startsWith("|") && trimmed.endsWith("|")) return extractRuleTableRow(trimmed);
173
- return undefined;
174
- }
175
-
176
- function extractRuleTableRow(line: string): string | undefined {
177
- const cells = line
178
- .slice(1, -1)
179
- .split("|")
180
- .map((cell) => cell.trim());
181
- if (cells.length < 2) return undefined;
182
- if (isTableSeparator(cells) || isTableHeader(cells) || !cells[0] || !cells[1]) return undefined;
183
- return `${cells[0]}: ${cells[1]}`;
184
- }
185
-
186
- function isTableSeparator(cells: string[]): boolean {
187
- return cells.every((cell) => cell.replace(/[\s:-]/g, "") === "");
188
- }
189
-
190
- function isTableHeader(cells: string[]): boolean {
191
- if (cells.length < 2) return false;
192
- const first = normalizeHeading(cells[0]);
193
- const second = normalizeHeading(cells[1]);
194
- return (first === "rule" && second === "requirement") || (first === "target" && second === "test pattern");
195
- }
196
-
197
- function normalizeHeading(heading: string): string {
198
- return heading.trim().toLowerCase();
199
- }
200
-
201
147
  function deriveSkillName(file: string, frontmatterName: string | undefined): string {
202
148
  if (frontmatterName) return frontmatterName;
203
149
  return basename(join(file, ".."));
@@ -235,21 +181,15 @@ async function loadSkill(file: string): Promise<SkillEntry | undefined> {
235
181
  const fm = parseFrontmatter(source);
236
182
  const name = deriveSkillName(file, fm.name);
237
183
  if (isExcluded(name)) return undefined;
238
- const rules = extractCompactRulesSection(fm.body);
239
184
  return {
240
185
  name,
241
186
  path: file,
242
- description: extractTriggerDescription(fm.description ?? ""),
243
- rules:
244
- rules.length > 0
245
- ? rules
246
- : ["No compact rules declared; delegators should load the full skill file before direct work, or pass an explicit fallback path only when Project Standards cannot be injected."],
187
+ description: normalizeSkillDescription(fm.description ?? ""),
247
188
  };
248
189
  }
249
190
 
250
- function extractTriggerDescription(description: string): string {
251
- const match = description.match(/\bTrigger:\s*(.+)$/i);
252
- return match ? match[1].trim() : description;
191
+ function normalizeSkillDescription(description: string): string {
192
+ return description.replace(/\s+/g, " ").trim();
253
193
  }
254
194
 
255
195
  function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
@@ -269,6 +209,26 @@ function dedupeBySkillName(entries: SkillEntry[], cwd: string): SkillEntry[] {
269
209
  return out.sort((a, b) => a.name.localeCompare(b.name));
270
210
  }
271
211
 
212
+ function scopeForPath(cwd: string, path: string): string {
213
+ const cleanCwd = comparablePath(cwd);
214
+ const projectPrefix = cleanCwd.endsWith(sep) ? cleanCwd : `${cleanCwd}${sep}`;
215
+ return comparablePath(path).startsWith(projectPrefix) ? "project" : "user";
216
+ }
217
+
218
+ function markdownCell(value: string): string {
219
+ const trimmed = value.replace(/\n/g, " ").replace(/\|/g, "\\|").trim();
220
+ return trimmed.length > 0 ? trimmed : "—";
221
+ }
222
+
223
+ function isCacheFile(value: unknown): value is { fingerprint: string } {
224
+ return (
225
+ typeof value === "object" &&
226
+ value !== null &&
227
+ "fingerprint" in value &&
228
+ typeof value.fingerprint === "string"
229
+ );
230
+ }
231
+
272
232
  async function fingerprint(files: string[]): Promise<string> {
273
233
  const lines: string[] = [`schema:${REGISTRY_SCHEMA_VERSION}`];
274
234
  for (const file of files) {
@@ -301,24 +261,24 @@ function renderRegistry(cwd: string, sources: string[], entries: SkillEntry[]):
301
261
  lines.push("");
302
262
  lines.push("## Contract");
303
263
  lines.push("");
304
- lines.push("**Delegator use only.** Any agent that launches subagents reads this registry to resolve compact rules, then injects matching rule text into subagent prompts under `## Project Standards (auto-resolved)`.");
264
+ lines.push("**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.");
305
265
  lines.push("");
306
- lines.push("Subagents still read their assigned executor/phase skill. During normal runtime, they do **not** independently discover or load additional project/user `SKILL.md` files or this registry; project/user rules arrive pre-digested. Explicit fallback loading is degraded self-healing and must be reported in `skill_resolution` as `fallback-registry` or `fallback-path`.");
266
+ lines.push("`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.");
307
267
  lines.push("");
308
268
  lines.push(SECTION_MARKER);
309
269
  lines.push("");
270
+ lines.push("| Skill | Trigger / description | Scope | Path |");
271
+ lines.push("| --- | --- | --- | --- |");
310
272
  for (const entry of entries) {
311
- lines.push(`### ${entry.name}`);
312
- lines.push(`- Path: ${entry.path}`);
313
- if (entry.description) {
314
- lines.push(`- Trigger: ${entry.description}`);
315
- }
316
- lines.push("- Rules:");
317
- for (const rule of entry.rules) {
318
- lines.push(` - ${rule}`);
319
- }
320
- lines.push("");
273
+ lines.push(`| \`${markdownCell(entry.name)}\` | ${markdownCell(entry.description)} | ${markdownCell(entry.scope ?? scopeForPath(cwd, entry.path))} | \`${markdownCell(entry.path)}\` |`);
321
274
  }
275
+ lines.push("");
276
+ lines.push("## Loading protocol");
277
+ lines.push("");
278
+ lines.push("1. Match task context and target files against the `Trigger / description` column.");
279
+ lines.push("2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.");
280
+ lines.push("3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.");
281
+ lines.push("4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.");
322
282
  return `${lines.join("\n").trimEnd()}\n`;
323
283
  }
324
284
 
@@ -403,11 +363,8 @@ async function regenerateRegistry(
403
363
  let cached: string | undefined;
404
364
  if (await pathExists(cachePath)) {
405
365
  try {
406
- cached = (
407
- JSON.parse(await readFile(cachePath, "utf8")) as {
408
- fingerprint?: string;
409
- }
410
- ).fingerprint;
366
+ const parsed: unknown = JSON.parse(await readFile(cachePath, "utf8"));
367
+ cached = isCacheFile(parsed) ? parsed.fingerprint : undefined;
411
368
  } catch {
412
369
  cached = undefined;
413
370
  }
@@ -496,10 +453,12 @@ async function startSkillRegistryWatcher(
496
453
  export const __testing = {
497
454
  projectSkillDirs,
498
455
  userSkillDirs,
499
- extractCompactRulesSection,
500
- extractTriggerDescription,
501
456
  uniqueExistingDirs,
502
457
  dedupeBySkillName,
458
+ scopeForPath,
459
+ normalizeSkillDescription,
460
+ parseFrontmatter,
461
+ renderRegistry,
503
462
  shouldSkipSkillRegistryStartup,
504
463
  };
505
464
 
@@ -1,4 +1,5 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
2
3
  import { dirname, join } from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
@@ -6,6 +7,10 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
6
7
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
7
8
  const ASSETS_DIR = join(PACKAGE_ROOT, "assets");
8
9
 
10
+ function gentlePiAgentHome(): string {
11
+ return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent");
12
+ }
13
+
9
14
  export type SddExecutionMode = "interactive" | "auto";
10
15
  export type SddArtifactStore = "openspec" | "engram" | "both";
11
16
  export type SddChainedPrStrategy =
@@ -89,22 +94,23 @@ function copyDirectoryFiles(
89
94
  }
90
95
 
91
96
  export function installSddAssets(
92
- cwd: string,
97
+ _cwd: string,
93
98
  force: boolean,
94
99
  ): { agents: number; chains: number; support: number; skipped: number } {
100
+ const agentHome = gentlePiAgentHome();
95
101
  const agents = copyDirectoryFiles(
96
102
  join(ASSETS_DIR, "agents"),
97
- join(cwd, ".pi", "agents"),
103
+ join(agentHome, "agents"),
98
104
  force,
99
105
  );
100
106
  const chains = copyDirectoryFiles(
101
107
  join(ASSETS_DIR, "chains"),
102
- join(cwd, ".pi", "chains"),
108
+ join(agentHome, "chains"),
103
109
  force,
104
110
  );
105
111
  const support = copyDirectoryFiles(
106
112
  join(ASSETS_DIR, "support"),
107
- join(cwd, ".pi", "gentle-ai", "support"),
113
+ join(agentHome, "gentle-ai", "support"),
108
114
  force,
109
115
  );
110
116
  return {
@@ -248,7 +254,7 @@ export async function ensureSddPreflight(
248
254
  `Artifacts: ${prefs.artifactStore}`,
249
255
  `PR chaining: ${prefs.chainedPrStrategy}`,
250
256
  `Review budget: ${prefs.reviewBudgetLines} changed lines`,
251
- `Assets installed: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} skipped.`,
257
+ `Global SDD assets ready: ${result.agents} agent(s), ${result.chains} chain(s), ${result.support} support file(s), ${result.skipped} already present.`,
252
258
  modelRoutingLine,
253
259
  ].join("\n"),
254
260
  modelResult.invalidPath ? "warning" : "info",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gentle-pi",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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",
@@ -55,4 +55,4 @@ Hard delegation triggers:
55
55
  - **Incident rule**: after wrong cwd, accidental worktree/repo mutation, merge recovery, confusing test command, or environment workaround, run fresh audit.
56
56
  - **Long-session rule**: after roughly 20 tool calls, 5 exploratory reads, or 2 non-mechanical edits with no delegation and accumulating complexity, pause and choose a subagent or justify not doing so.
57
57
 
58
- The package auto-installs SDD agents and chains into the project when a Pi session starts. Use `/gentle-ai:install-sdd --force` only for recovery or intentional overwrite.
58
+ The package ensures SDD agents and chains are available as global Pi runtime assets. Project-local SDD files are overrides/debug copies only. Use `/gentle-ai:install-sdd --force` only for recovery or intentional global refresh.
@@ -13,7 +13,7 @@ Load this skill only when the user explicitly asks for Judgment Day, dual/advers
13
13
 
14
14
  ## Hard Rules
15
15
 
16
- - Resolve project skills before launching agents: read skill registry, match compact rules by target files/task, and inject the same `Project Standards` block into both judge prompts and fix prompts.
16
+ - Resolve project skills before launching agents: read skill registry, match indexed paths by target files/task, and pass the same `Skills to load before work` block into both judge prompts and fix prompts.
17
17
  - Launch **two blind judges in parallel** with identical target and criteria; never review the code yourself.
18
18
  - Wait for both judges before synthesis; never accept a partial verdict.
19
19
  - Classify warnings as `WARNING (real)` only if normal intended use can trigger them; otherwise downgrade to INFO as `WARNING (theoretical)`.
@@ -8,8 +8,8 @@ You are an adversarial code reviewer. Your ONLY job is to find problems.
8
8
  ## Target
9
9
  {files, feature, architecture, component}
10
10
 
11
- ## Project Standards (auto-resolved)
12
- {matching compact rules, if available}
11
+ ## Skills to load before work
12
+ {matching SKILL.md paths, if available}
13
13
 
14
14
  ## Review Criteria
15
15
  - Correctness: logical errors and behavior mismatches
@@ -33,7 +33,7 @@ WARNING rule: normal intended use can trigger it → `WARNING (real)`; contrived
33
33
 
34
34
  If clean: `VERDICT: CLEAN — No issues found.`
35
35
 
36
- Always end with: `Skill Resolution: {injected|fallback-registry|fallback-path|none} — {details}`.
36
+ Always end with: `Skill Resolution: {paths-injected|fallback-registry|fallback-path|none} — {details}`.
37
37
  ```
38
38
 
39
39
  ## Fix Agent Prompt
@@ -44,8 +44,8 @@ You are a surgical fix agent. Apply ONLY the confirmed issues listed below.
44
44
  ## Confirmed Issues to Fix
45
45
  {confirmed findings table}
46
46
 
47
- ## Project Standards (auto-resolved)
48
- {matching compact rules, if available}
47
+ ## Skills to load before work
48
+ {matching SKILL.md paths, if available}
49
49
 
50
50
  ## Instructions
51
51
  - Fix only confirmed issues.
@@ -54,7 +54,7 @@ You are a surgical fix agent. Apply ONLY the confirmed issues listed below.
54
54
  - If fixing a repeated pattern in touched files, fix all occurrences of that same pattern.
55
55
  - Return changed file, line, and fix summary.
56
56
 
57
- End with: `Skill Resolution: {injected|fallback-registry|fallback-path|none} — {details}`.
57
+ End with: `Skill Resolution: {paths-injected|fallback-registry|fallback-path|none} — {details}`.
58
58
  ```
59
59
 
60
60
  ## Verdict Table
@@ -0,0 +1,51 @@
1
+ ---
2
+ name: skill-registry
3
+ description: "Trigger: update skills, skill registry, actualizar skills, after skill changes. Index available skills by trigger and path."
4
+ license: MIT
5
+ metadata:
6
+ author: gentleman-programming
7
+ version: "1.0"
8
+ ---
9
+
10
+ ## Activation Contract
11
+
12
+ Use this skill after installing, removing, creating, moving, or renaming skills, or when a delegator needs a fresh skill index.
13
+
14
+ ## Hard Rules
15
+
16
+ - The registry is an index, not a compiler or summary. `SKILL.md` remains the source of truth.
17
+ - Do not generate or inject compact rules by default; preserve author intent by passing exact skill paths to subagents.
18
+ - Always write `.atl/skill-registry.md` regardless of SDD persistence mode.
19
+ - Save the registry to Engram as `topic_key: skill-registry` when available, with `capture_prompt: false`.
20
+ - Skip `sdd-*`, `_shared`, and `skill-registry`; deduplicate by skill name, preferring project-level skills over user-level skills.
21
+ - Add `.atl/` to `.gitignore` when possible unless explicitly disabled.
22
+
23
+ ## Decision Gates
24
+
25
+ | Situation | Action |
26
+ | --- | --- |
27
+ | Same skill exists globally and in project | Keep the project-level skill |
28
+ | Same skill exists in multiple global locations | Keep the first source in scan order |
29
+ | No skills found | Write an empty registry so agents stop searching blindly |
30
+ | Agent will delegate work | Select matching registry rows and pass their `SKILL.md` paths |
31
+
32
+ ## Execution Steps
33
+
34
+ 1. Scan all known user and project skill directories for `*/SKILL.md`.
35
+ 2. Read frontmatter only as needed to extract `name` and `description` trigger text.
36
+ 3. Render `.atl/skill-registry.md` with scanned sources, registry contract, skill name, trigger/description, scope, and exact path.
37
+ 4. Persist to Engram when available using `title: skill-registry`, `topic_key: skill-registry`, `type: config`, and `capture_prompt: false`.
38
+ 5. Return the registry path, skill count, cache status, and whether Engram was updated.
39
+
40
+ ## Output Contract
41
+
42
+ Return:
43
+ - Project name and `.atl/skill-registry.md` path.
44
+ - Number of indexed skills.
45
+ - Whether the cache was hit or regenerated.
46
+ - Any skipped or duplicate skills when relevant.
47
+
48
+ ## References
49
+
50
+ - `docs/skill-style-guide.md` — how skills should be authored before indexing.
51
+ - `skills/_shared/skill-resolver.md` — how delegators use the index.