Phase 14: filter deferred pending on before_agent_start — registry + disk fallback
filterPendingReinjectForConsume applies fresh registered check at consume time; loose skills pass when requireRegistered is false and tracked SKILL.md exists. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+50
-1
@@ -26,6 +26,13 @@ function notifyWarning(ctx: ExtensionContext | undefined, message: string): void
|
|||||||
ctx.ui.notify(message, "warning");
|
ctx.ui.notify(message, "warning");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyInfo(ctx: ExtensionContext | undefined, message: string): void {
|
||||||
|
if (!ctx?.hasUI) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.ui.notify(message, "info");
|
||||||
|
}
|
||||||
|
|
||||||
function notifySkippedSkill(ctx: ExtensionContext | undefined, skillName: string, reason: string): void {
|
function notifySkippedSkill(ctx: ExtensionContext | undefined, skillName: string, reason: string): void {
|
||||||
notifyWarning(ctx, `skill-reinject: skipped "${skillName}" — ${reason}`);
|
notifyWarning(ctx, `skill-reinject: skipped "${skillName}" — ${reason}`);
|
||||||
}
|
}
|
||||||
@@ -278,6 +285,42 @@ export function clearPendingReinjectOnUserPrompt(
|
|||||||
return hadPending;
|
return hadPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defer consume stage: fresh registry filter on before_agent_start (SPEC §6.5.1, Phase 14 / B-002).
|
||||||
|
* Loose fallback when requireRegistered is false and tracked SKILL.md still exists on disk.
|
||||||
|
*/
|
||||||
|
export function filterPendingReinjectForConsume(
|
||||||
|
pendingNames: readonly string[],
|
||||||
|
state: ExtensionState,
|
||||||
|
settings: SkillReinjectSettings,
|
||||||
|
registeredSkills: readonly Pick<Skill, "name">[],
|
||||||
|
ctx?: ExtensionContext,
|
||||||
|
): string[] {
|
||||||
|
const registered = registeredSkillNames(registeredSkills);
|
||||||
|
const resolved: string[] = [];
|
||||||
|
for (const name of pendingNames) {
|
||||||
|
const tracked = state.skills.find((skill) => skill.name === name);
|
||||||
|
if (!tracked) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (registered.has(name)) {
|
||||||
|
resolved.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (settings.requireRegistered) {
|
||||||
|
notifySkippedSkill(ctx, name, "no longer registered");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existsSync(tracked.filePath)) {
|
||||||
|
notifyInfo(ctx, `skill-reinject: re-injected "${name}" from disk`);
|
||||||
|
resolved.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
notifySkippedSkill(ctx, name, "SKILL.md not found on disk");
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Defer path on before_agent_start: inject one combined message, then clear queue (SPEC §6.5.1).
|
* Defer path on before_agent_start: inject one combined message, then clear queue (SPEC §6.5.1).
|
||||||
* Returns undefined when pendingReinject is empty or manual compaction scheduled a clear (SPEC §16.5).
|
* Returns undefined when pendingReinject is empty or manual compaction scheduled a clear (SPEC §16.5).
|
||||||
@@ -295,7 +338,13 @@ export function tryConsumeDeferredReinject(
|
|||||||
if (state.pendingReinject.length === 0) {
|
if (state.pendingReinject.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const pendingNames = [...state.pendingReinject];
|
const pendingNames = filterPendingReinjectForConsume(
|
||||||
|
state.pendingReinject,
|
||||||
|
state,
|
||||||
|
settings,
|
||||||
|
registeredSkills,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx);
|
const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx);
|
||||||
if (!content) {
|
if (!content) {
|
||||||
state.pendingReinject = [];
|
state.pendingReinject = [];
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs";
|
||||||
|
import { tmpdir } from "os";
|
||||||
|
import { join } from "path";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { filterPendingReinjectForConsume } from "../src/reinject";
|
||||||
|
import { createDefaultSettings } from "../src/settings";
|
||||||
|
import { createInitialState, trackSkill } from "../src/state";
|
||||||
|
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function tempSkillDir(name: string): { filePath: string; baseDir: string } {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "pi-skill-reinject-consume-"));
|
||||||
|
tempDirs.push(root);
|
||||||
|
const baseDir = join(root, name);
|
||||||
|
mkdirSync(baseDir, { recursive: true });
|
||||||
|
const filePath = join(baseDir, "SKILL.md");
|
||||||
|
writeFileSync(filePath, "# skill\n", "utf8");
|
||||||
|
return { filePath, baseDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filterPendingReinjectForConsume", () => {
|
||||||
|
it("keeps registered pending skills", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
trackSkill(state, {
|
||||||
|
name: "alpha",
|
||||||
|
filePath: "/skills/alpha/SKILL.md",
|
||||||
|
baseDir: "/skills/alpha",
|
||||||
|
source: "slash",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterPendingReinjectForConsume(
|
||||||
|
["alpha"],
|
||||||
|
state,
|
||||||
|
createDefaultSettings(),
|
||||||
|
[{ name: "alpha" }],
|
||||||
|
),
|
||||||
|
).toEqual(["alpha"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes unregistered skill from disk when requireRegistered is false", () => {
|
||||||
|
const { filePath, baseDir } = tempSkillDir("loose");
|
||||||
|
const state = createInitialState();
|
||||||
|
trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" });
|
||||||
|
const notify = vi.fn();
|
||||||
|
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterPendingReinjectForConsume(["loose"], state, createDefaultSettings(), [], ctx),
|
||||||
|
).toEqual(["loose"]);
|
||||||
|
expect(notify).toHaveBeenCalledWith('skill-reinject: re-injected "loose" from disk', "info");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips unregistered skill when requireRegistered is true", () => {
|
||||||
|
const { filePath, baseDir } = tempSkillDir("strict");
|
||||||
|
const state = createInitialState();
|
||||||
|
trackSkill(state, { name: "strict", filePath, baseDir, source: "slash" });
|
||||||
|
const notify = vi.fn();
|
||||||
|
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||||
|
const settings = createDefaultSettings();
|
||||||
|
settings.requireRegistered = true;
|
||||||
|
|
||||||
|
expect(filterPendingReinjectForConsume(["strict"], state, settings, [], ctx)).toEqual([]);
|
||||||
|
expect(notify).toHaveBeenCalledWith(
|
||||||
|
'skill-reinject: skipped "strict" — no longer registered',
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips when tracked file is missing on disk", () => {
|
||||||
|
const state = createInitialState();
|
||||||
|
trackSkill(state, {
|
||||||
|
name: "missing",
|
||||||
|
filePath: "/no/such/SKILL.md",
|
||||||
|
baseDir: "/no/such",
|
||||||
|
source: "slash",
|
||||||
|
});
|
||||||
|
expect(existsSync("/no/such/SKILL.md")).toBe(false);
|
||||||
|
const notify = vi.fn();
|
||||||
|
const ctx = { hasUI: true, ui: { notify } } as never;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterPendingReinjectForConsume(["missing"], state, createDefaultSettings(), [], ctx),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(notify).toHaveBeenCalledWith(
|
||||||
|
'skill-reinject: skipped "missing" — SKILL.md not found on disk',
|
||||||
|
"warning",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user