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.
- package/README.md +14 -15
- package/assets/agents/sdd-apply.md +4 -5
- package/assets/agents/sdd-archive.md +2 -3
- package/assets/agents/sdd-design.md +2 -4
- package/assets/agents/sdd-explore.md +2 -4
- package/assets/agents/sdd-init.md +2 -4
- package/assets/agents/sdd-onboard.md +2 -4
- package/assets/agents/sdd-proposal.md +2 -4
- package/assets/agents/sdd-spec.md +2 -3
- package/assets/agents/sdd-sync.md +2 -3
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +4 -5
- package/assets/orchestrator.md +14 -13
- package/extensions/gentle-ai.ts +83 -27
- package/extensions/skill-registry.ts +62 -103
- package/lib/sdd-preflight.ts +11 -5
- package/package.json +1 -1
- package/skills/gentle-ai/SKILL.md +1 -1
- package/skills/judgment-day/SKILL.md +1 -1
- package/skills/judgment-day/references/prompts-and-formats.md +6 -6
- package/skills/skill-registry/SKILL.md +51 -0
- package/tests/runtime-harness.mjs +70 -21
- package/tests/skill-registry.test.ts +93 -55
package/extensions/gentle-ai.ts
CHANGED
|
@@ -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
|
|
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",
|
|
40
|
-
["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(
|
|
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
|
-
|
|
266
|
+
.map((value) => value.trim())
|
|
267
|
+
.filter((value) => value.length > 0);
|
|
268
|
+
}
|
|
225
269
|
|
|
226
|
-
|
|
227
|
-
return
|
|
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
|
-
|
|
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 =
|
|
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}
|
|
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
|
-
"
|
|
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}
|
|
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(
|
|
1414
|
+
join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
|
|
1365
1415
|
);
|
|
1366
1416
|
const chainsInstalled = existsSync(
|
|
1367
|
-
join(
|
|
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 =
|
|
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 = "##
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
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
|
|
251
|
-
|
|
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
|
|
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("
|
|
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(
|
|
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
|
-
|
|
407
|
-
|
|
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
|
|
package/lib/sdd-preflight.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
103
|
+
join(agentHome, "agents"),
|
|
98
104
|
force,
|
|
99
105
|
);
|
|
100
106
|
const chains = copyDirectoryFiles(
|
|
101
107
|
join(ASSETS_DIR, "chains"),
|
|
102
|
-
join(
|
|
108
|
+
join(agentHome, "chains"),
|
|
103
109
|
force,
|
|
104
110
|
);
|
|
105
111
|
const support = copyDirectoryFiles(
|
|
106
112
|
join(ASSETS_DIR, "support"),
|
|
107
|
-
join(
|
|
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
|
-
`
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
##
|
|
12
|
-
{matching
|
|
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
|
-
##
|
|
48
|
-
{matching
|
|
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.
|