diff --git a/src/reinject.ts b/src/reinject.ts index 35d42f4..d17eba8 100644 --- a/src/reinject.ts +++ b/src/reinject.ts @@ -26,6 +26,13 @@ function notifyWarning(ctx: ExtensionContext | undefined, message: string): void 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 { notifyWarning(ctx, `skill-reinject: skipped "${skillName}" — ${reason}`); } @@ -278,6 +285,42 @@ export function clearPendingReinjectOnUserPrompt( 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[], + 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). * 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) { return undefined; } - const pendingNames = [...state.pendingReinject]; + const pendingNames = filterPendingReinjectForConsume( + state.pendingReinject, + state, + settings, + registeredSkills, + ctx, + ); const content = buildDeferredReinjectContent(pendingNames, state, settings, registeredSkills, ctx); if (!content) { state.pendingReinject = []; diff --git a/test/reinject-deferred-consume.test.ts b/test/reinject-deferred-consume.test.ts new file mode 100644 index 0000000..4f62949 --- /dev/null +++ b/test/reinject-deferred-consume.test.ts @@ -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", + ); + }); +});