gentle-pi 0.3.3 → 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 +77 -16
- package/assets/agents/sdd-apply.md +4 -5
- package/assets/agents/sdd-archive.md +127 -9
- 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 +143 -9
- package/assets/agents/sdd-sync.md +104 -0
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +4 -5
- package/assets/chains/sdd-full.chain.md +11 -2
- package/assets/chains/sdd-verify.chain.md +11 -2
- package/assets/orchestrator.md +14 -13
- package/extensions/gentle-ai.ts +114 -21
- package/extensions/skill-registry.ts +62 -103
- package/lib/openspec-deltas.ts +156 -0
- package/lib/openspec-guardrails.ts +99 -0
- package/lib/sdd-preflight.ts +11 -5
- package/package.json +1 -1
- package/scripts/verify-package-files.mjs +12 -0
- 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/openspec-deltas.test.ts +209 -0
- package/tests/openspec-guardrails.test.ts +71 -0
- package/tests/runtime-harness.mjs +84 -18
- package/tests/skill-registry.test.ts +93 -55
package/extensions/gentle-ai.ts
CHANGED
|
@@ -33,6 +33,68 @@ 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 gentlePiAgentHome(): string {
|
|
37
|
+
return process.env.GENTLE_PI_AGENT_HOME ?? join(homedir(), ".pi", "agent");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sddGlobalAssetDriftCount(): number {
|
|
41
|
+
let stale = 0;
|
|
42
|
+
for (const [assetSubdir, installedSubdir] of [
|
|
43
|
+
["agents", "agents"],
|
|
44
|
+
["chains", "chains"],
|
|
45
|
+
] as const) {
|
|
46
|
+
const assetDir = join(ASSETS_DIR, assetSubdir);
|
|
47
|
+
if (!existsSync(assetDir)) continue;
|
|
48
|
+
for (const entry of readdirSync(assetDir, { withFileTypes: true })) {
|
|
49
|
+
if (!entry.isFile()) continue;
|
|
50
|
+
const installedPath = join(gentlePiAgentHome(), installedSubdir, entry.name);
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(installedPath)) {
|
|
53
|
+
stale += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
readFileSync(join(assetDir, entry.name), "utf8") !==
|
|
58
|
+
readFileSync(installedPath, "utf8")
|
|
59
|
+
) {
|
|
60
|
+
stale += 1;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
stale += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return stale;
|
|
68
|
+
}
|
|
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
|
+
|
|
36
98
|
let orchestratorPromptCache: string | null = null;
|
|
37
99
|
function getOrchestratorPrompt(): string {
|
|
38
100
|
if (orchestratorPromptCache === null) {
|
|
@@ -128,6 +190,7 @@ const SDD_AGENT_NAMES = [
|
|
|
128
190
|
"sdd-tasks",
|
|
129
191
|
"sdd-apply",
|
|
130
192
|
"sdd-verify",
|
|
193
|
+
"sdd-sync",
|
|
131
194
|
"sdd-archive",
|
|
132
195
|
] as const;
|
|
133
196
|
const SDD_AGENT_NAME_SET = new Set<string>(SDD_AGENT_NAMES);
|
|
@@ -181,7 +244,18 @@ function readStringPath(value: unknown, path: string[]): string | undefined {
|
|
|
181
244
|
}
|
|
182
245
|
|
|
183
246
|
function isSddAgentStartEvent(event: unknown): boolean {
|
|
184
|
-
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 [
|
|
185
259
|
readStringPath(event, ["agentName"]),
|
|
186
260
|
readStringPath(event, ["agent"]),
|
|
187
261
|
readStringPath(event, ["name"]),
|
|
@@ -189,14 +263,12 @@ function isSddAgentStartEvent(event: unknown): boolean {
|
|
|
189
263
|
readStringPath(event, ["subagent", "name"]),
|
|
190
264
|
]
|
|
191
265
|
.filter((value): value is string => value !== undefined)
|
|
192
|
-
.map((value) => value.trim())
|
|
193
|
-
|
|
266
|
+
.map((value) => value.trim())
|
|
267
|
+
.filter((value) => value.length > 0);
|
|
268
|
+
}
|
|
194
269
|
|
|
195
|
-
|
|
196
|
-
return
|
|
197
|
-
const phase = name.replace(/^sdd-/, "");
|
|
198
|
-
return new RegExp(`\\bSDD ${phase} executor\\b`, "i").test(systemPrompt);
|
|
199
|
-
});
|
|
270
|
+
function isNamedAgentStartEvent(event: unknown): boolean {
|
|
271
|
+
return readAgentStartNames(event).length > 0;
|
|
200
272
|
}
|
|
201
273
|
|
|
202
274
|
function evaluateDeniedCommand(
|
|
@@ -517,13 +589,13 @@ async function listAgentsFromDirAsync(
|
|
|
517
589
|
|
|
518
590
|
function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
519
591
|
const builtinDirs = [
|
|
592
|
+
join(gentlePiAgentHome(), "agents"),
|
|
520
593
|
join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
|
|
521
594
|
join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
|
|
522
595
|
join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
|
|
523
596
|
];
|
|
524
597
|
const agents = [
|
|
525
598
|
...builtinDirs.flatMap((dir) => listAgentsFromDir(dir, "builtin")),
|
|
526
|
-
...listAgentsFromDir(join(homedir(), ".pi", "agent", "agents"), "user"),
|
|
527
599
|
...listAgentsFromDir(join(homedir(), ".agents"), "user"),
|
|
528
600
|
...listAgentsFromDir(join(cwd, ".agents"), "project"),
|
|
529
601
|
...listAgentsFromDir(join(cwd, ".pi", "agents"), "project"),
|
|
@@ -542,6 +614,7 @@ function listDiscoverableAgents(cwd: string): AgentEntry[] {
|
|
|
542
614
|
|
|
543
615
|
async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
|
|
544
616
|
const builtinDirs = [
|
|
617
|
+
join(gentlePiAgentHome(), "agents"),
|
|
545
618
|
join(PACKAGE_ROOT, "..", "pi-subagents", "agents"),
|
|
546
619
|
join(cwd, ".pi", "npm", "node_modules", "pi-subagents", "agents"),
|
|
547
620
|
join(homedir(), ".local", "lib", "node_modules", "pi-subagents", "agents"),
|
|
@@ -551,7 +624,6 @@ async function listDiscoverableAgentsAsync(cwd: string): Promise<AgentEntry[]> {
|
|
|
551
624
|
agents.push(...(await listAgentsFromDirAsync(dir, "builtin")));
|
|
552
625
|
}
|
|
553
626
|
const otherDirs: Array<[string, AgentSource]> = [
|
|
554
|
-
[join(homedir(), ".pi", "agent", "agents"), "user"],
|
|
555
627
|
[join(homedir(), ".agents"), "user"],
|
|
556
628
|
[join(cwd, ".agents"), "project"],
|
|
557
629
|
[join(cwd, ".pi", "agents"), "project"],
|
|
@@ -1204,6 +1276,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1204
1276
|
|
|
1205
1277
|
pi.on("session_start", async (_event, ctx) => {
|
|
1206
1278
|
try {
|
|
1279
|
+
const installResult = installSddAssets(ctx.cwd, false);
|
|
1207
1280
|
const modelResult = await applySavedModelConfig(ctx);
|
|
1208
1281
|
if (ctx.hasUI && modelResult.invalidPath) {
|
|
1209
1282
|
ctx.ui.notify(
|
|
@@ -1214,7 +1287,7 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1214
1287
|
}
|
|
1215
1288
|
if (ctx.hasUI && modelResult.updated > 0) {
|
|
1216
1289
|
ctx.ui.notify(
|
|
1217
|
-
`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).`,
|
|
1218
1291
|
"info",
|
|
1219
1292
|
);
|
|
1220
1293
|
}
|
|
@@ -1239,13 +1312,21 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1239
1312
|
});
|
|
1240
1313
|
|
|
1241
1314
|
pi.on("before_agent_start", async (event, ctx) => {
|
|
1242
|
-
|
|
1315
|
+
const isSddAgent = isSddAgentStartEvent(event);
|
|
1316
|
+
const isNamedAgent = isNamedAgentStartEvent(event);
|
|
1317
|
+
if (isSddAgent && !getSddPreflightPreferences(ctx)) {
|
|
1243
1318
|
await runSddPreflight(ctx);
|
|
1244
1319
|
}
|
|
1245
1320
|
const prefs = getSddPreflightPreferences(ctx);
|
|
1246
|
-
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))}`;
|
|
1247
1328
|
return {
|
|
1248
|
-
systemPrompt: `${event.systemPrompt}
|
|
1329
|
+
systemPrompt: `${event.systemPrompt}${gentlePrompt}${sddPrompt}`,
|
|
1249
1330
|
};
|
|
1250
1331
|
});
|
|
1251
1332
|
|
|
@@ -1258,12 +1339,12 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1258
1339
|
|
|
1259
1340
|
pi.registerCommand("gentle-ai:install-sdd", {
|
|
1260
1341
|
description:
|
|
1261
|
-
"
|
|
1342
|
+
"Repair or refresh global Gentle AI SDD subagent and chain assets.",
|
|
1262
1343
|
handler: async (args, ctx) => {
|
|
1263
1344
|
const force = args.includes("--force");
|
|
1264
1345
|
const result = installSddAssets(ctx.cwd, force);
|
|
1265
1346
|
ctx.ui.notify(
|
|
1266
|
-
`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.`,
|
|
1267
1348
|
"info",
|
|
1268
1349
|
);
|
|
1269
1350
|
},
|
|
@@ -1330,26 +1411,38 @@ export default function gentleAi(pi: ExtensionAPI): void {
|
|
|
1330
1411
|
description: "Show Gentle AI package status for this project.",
|
|
1331
1412
|
handler: async (_args, ctx) => {
|
|
1332
1413
|
const agentsInstalled = existsSync(
|
|
1333
|
-
join(
|
|
1414
|
+
join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
|
|
1334
1415
|
);
|
|
1335
1416
|
const chainsInstalled = existsSync(
|
|
1336
|
-
join(
|
|
1417
|
+
join(gentlePiAgentHome(), "chains", "sdd-full.chain.md"),
|
|
1337
1418
|
);
|
|
1338
1419
|
const openspecConfigured = existsSync(
|
|
1339
1420
|
join(ctx.cwd, "openspec", "config.yaml"),
|
|
1340
1421
|
);
|
|
1422
|
+
const staleSddAssets = sddGlobalAssetDriftCount();
|
|
1423
|
+
const staleLocalOverrides = sddLocalOverrideDriftCount(ctx.cwd);
|
|
1341
1424
|
const modelConfig = await readModelConfigAsync(ctx.cwd);
|
|
1342
1425
|
ctx.ui.notify(
|
|
1343
1426
|
[
|
|
1344
1427
|
"el Gentleman package is active.",
|
|
1345
1428
|
`Persona: ${readPersonaMode(ctx.cwd)}`,
|
|
1346
|
-
`SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
1347
|
-
`SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
1429
|
+
`Global SDD agents: ${agentsInstalled ? "installed" : "not installed"}`,
|
|
1430
|
+
`Global SDD chains: ${chainsInstalled ? "installed" : "not installed"}`,
|
|
1431
|
+
`Global SDD assets stale: ${staleSddAssets} file(s)${
|
|
1432
|
+
staleSddAssets > 0
|
|
1433
|
+
? " — run /gentle-ai:install-sdd --force to refresh intentionally"
|
|
1434
|
+
: ""
|
|
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
|
+
}`,
|
|
1348
1441
|
`OpenSpec config: ${openspecConfigured ? "present" : "missing"}`,
|
|
1349
1442
|
`Global model config: ${existsSync(modelConfigPath(ctx.cwd)) ? "present" : "missing"}`,
|
|
1350
1443
|
...describeModelConfig(ctx.cwd, modelConfig),
|
|
1351
1444
|
].join("\n"),
|
|
1352
|
-
"info",
|
|
1445
|
+
staleSddAssets > 0 || staleLocalOverrides > 0 ? "warning" : "info",
|
|
1353
1446
|
);
|
|
1354
1447
|
},
|
|
1355
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
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
export interface RequirementBlock {
|
|
2
|
+
name: string;
|
|
3
|
+
content: string;
|
|
4
|
+
start: number;
|
|
5
|
+
end: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DeltaSpec {
|
|
9
|
+
added: RequirementBlock[];
|
|
10
|
+
modified: RequirementBlock[];
|
|
11
|
+
removed: RequirementBlock[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type DeltaOperation = keyof DeltaSpec;
|
|
15
|
+
|
|
16
|
+
const REQUIREMENT_HEADING = /^### Requirement:\s*(.+?)\s*$/gm;
|
|
17
|
+
const DELTA_SECTION_HEADING = /^##\s+(ADDED|MODIFIED|REMOVED)\s+Requirements\s*$/gim;
|
|
18
|
+
|
|
19
|
+
function normalizeMarkdown(markdown: string): string {
|
|
20
|
+
return markdown.replace(/\r\n/g, "\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nextTopLevelSection(markdown: string, from: number): number {
|
|
24
|
+
const match = /^##\s+/gm;
|
|
25
|
+
match.lastIndex = from;
|
|
26
|
+
const next = match.exec(markdown);
|
|
27
|
+
return next?.index ?? markdown.length;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cleanRequirementContent(content: string): string {
|
|
31
|
+
return content.trimEnd().replace(/\n\s*---\s*$/m, "").trimEnd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function operationKey(label: string): DeltaOperation {
|
|
35
|
+
switch (label.toUpperCase()) {
|
|
36
|
+
case "ADDED":
|
|
37
|
+
return "added";
|
|
38
|
+
case "MODIFIED":
|
|
39
|
+
return "modified";
|
|
40
|
+
case "REMOVED":
|
|
41
|
+
return "removed";
|
|
42
|
+
default:
|
|
43
|
+
throw new Error(`Unsupported delta operation: ${label}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseRequirementBlocks(markdown: string): RequirementBlock[] {
|
|
48
|
+
const source = normalizeMarkdown(markdown);
|
|
49
|
+
const matches = [...source.matchAll(REQUIREMENT_HEADING)];
|
|
50
|
+
return matches.map((match, index) => {
|
|
51
|
+
const start = match.index ?? 0;
|
|
52
|
+
const end = matches[index + 1]?.index ?? nextTopLevelSection(source, start + match[0].length);
|
|
53
|
+
return {
|
|
54
|
+
name: match[1].trim(),
|
|
55
|
+
content: cleanRequirementContent(source.slice(start, end)),
|
|
56
|
+
start,
|
|
57
|
+
end,
|
|
58
|
+
};
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseDeltaSpec(markdown: string): DeltaSpec {
|
|
63
|
+
const source = normalizeMarkdown(markdown);
|
|
64
|
+
const sectionMatches = [...source.matchAll(DELTA_SECTION_HEADING)];
|
|
65
|
+
const delta: DeltaSpec = { added: [], modified: [], removed: [] };
|
|
66
|
+
const seen = new Map<string, string>();
|
|
67
|
+
|
|
68
|
+
for (const [index, match] of sectionMatches.entries()) {
|
|
69
|
+
const sectionStart = (match.index ?? 0) + match[0].length;
|
|
70
|
+
const sectionEnd = sectionMatches[index + 1]?.index ?? source.length;
|
|
71
|
+
const key = operationKey(match[1]);
|
|
72
|
+
const section = source.slice(sectionStart, sectionEnd);
|
|
73
|
+
const blocks = parseRequirementBlocks(section);
|
|
74
|
+
delta[key].push(...blocks);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [operation, blocks] of Object.entries(delta) as [DeltaOperation, RequirementBlock[]][]) {
|
|
78
|
+
for (const block of blocks) {
|
|
79
|
+
const previous = seen.get(block.name);
|
|
80
|
+
if (previous) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Duplicate delta operation for requirement "${block.name}" (${previous} and ${operation})`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
seen.set(block.name, operation);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return delta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function requirementMap(blocks: RequirementBlock[]): Map<string, RequirementBlock> {
|
|
93
|
+
const out = new Map<string, RequirementBlock>();
|
|
94
|
+
for (const block of blocks) {
|
|
95
|
+
if (out.has(block.name)) throw new Error(`Duplicate canonical requirement "${block.name}"`);
|
|
96
|
+
out.set(block.name, block);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function requireCanonicalBlock(
|
|
102
|
+
canonical: Map<string, RequirementBlock>,
|
|
103
|
+
name: string,
|
|
104
|
+
operation: string,
|
|
105
|
+
): RequirementBlock {
|
|
106
|
+
const block = canonical.get(name);
|
|
107
|
+
if (!block) throw new Error(`Missing canonical requirement "${name}" for ${operation}`);
|
|
108
|
+
return block;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appendAddedRequirements(markdown: string, added: RequirementBlock[]): string {
|
|
112
|
+
if (added.length === 0) return markdown;
|
|
113
|
+
const addition = added.map((block) => block.content.trim()).join("\n\n---\n\n");
|
|
114
|
+
const requirementsHeading = /^## Requirements\s*$/m.exec(markdown);
|
|
115
|
+
if (!requirementsHeading) return `${markdown.trimEnd()}\n\n## Requirements\n\n${addition}\n`;
|
|
116
|
+
|
|
117
|
+
const sectionStart = requirementsHeading.index + requirementsHeading[0].length;
|
|
118
|
+
const sectionEnd = nextTopLevelSection(markdown, sectionStart);
|
|
119
|
+
const before = markdown.slice(0, sectionEnd).trimEnd();
|
|
120
|
+
const after = markdown.slice(sectionEnd).replace(/^\n+/, "");
|
|
121
|
+
return after
|
|
122
|
+
? `${before}\n\n---\n\n${addition}\n\n${after}`
|
|
123
|
+
: `${before}\n\n---\n\n${addition}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function applyDeltaSpec(canonicalMarkdown: string, deltaMarkdown: string): string {
|
|
127
|
+
let result = normalizeMarkdown(canonicalMarkdown);
|
|
128
|
+
const delta = parseDeltaSpec(deltaMarkdown);
|
|
129
|
+
const canonical = requirementMap(parseRequirementBlocks(result));
|
|
130
|
+
|
|
131
|
+
for (const block of delta.added) {
|
|
132
|
+
if (canonical.has(block.name)) {
|
|
133
|
+
throw new Error(`Cannot add existing canonical requirement "${block.name}"`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const replacements: Array<{ start: number; end: number; content: string }> = [];
|
|
138
|
+
for (const block of delta.modified) {
|
|
139
|
+
const target = requireCanonicalBlock(canonical, block.name, "MODIFIED");
|
|
140
|
+
replacements.push({ start: target.start, end: target.end, content: block.content.trimEnd() });
|
|
141
|
+
}
|
|
142
|
+
for (const block of delta.removed) {
|
|
143
|
+
const target = requireCanonicalBlock(canonical, block.name, "REMOVED");
|
|
144
|
+
replacements.push({ start: target.start, end: target.end, content: "" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const replacement of replacements.sort((a, b) => b.start - a.start)) {
|
|
148
|
+
const prefix = result.slice(0, replacement.start).trimEnd();
|
|
149
|
+
const suffix = result.slice(replacement.end).replace(/^\n+/, "");
|
|
150
|
+
result = replacement.content
|
|
151
|
+
? `${prefix}\n\n${replacement.content}\n\n${suffix}`.trimEnd() + "\n"
|
|
152
|
+
: `${prefix}\n\n${suffix}`.trimEnd() + "\n";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return appendAddedRequirements(result, delta.added).trimEnd() + "\n";
|
|
156
|
+
}
|