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.
@@ -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
- if (candidates.some((value) => SDD_AGENT_NAME_SET.has(value))) return true;
266
+ .map((value) => value.trim())
267
+ .filter((value) => value.length > 0);
268
+ }
194
269
 
195
- const systemPrompt = readStringPath(event, ["systemPrompt"]) ?? "";
196
- return SDD_AGENT_NAMES.some((name) => {
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
- if (isSddAgentStartEvent(event) && !getSddPreflightPreferences(ctx)) {
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 = 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))}`;
1247
1328
  return {
1248
- systemPrompt: `${event.systemPrompt}\n\n${buildGentlePrompt(readPersonaMode(ctx.cwd))}${sddPrompt}`,
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
- "Install Gentle AI SDD subagent and chain assets into this project.",
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} 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(ctx.cwd, ".pi", "agents", "sdd-apply.md"),
1414
+ join(gentlePiAgentHome(), "agents", "sdd-apply.md"),
1334
1415
  );
1335
1416
  const chainsInstalled = existsSync(
1336
- join(ctx.cwd, ".pi", "chains", "sdd-full.chain.md"),
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 = "## 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
 
@@ -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
+ }