From 459b8775f47daaf49d8fc4c3cc4edc9859a9b8a6 Mon Sep 17 00:00:00 2001 From: GRayHook Date: Wed, 17 Jun 2026 17:35:19 +0700 Subject: [PATCH] =?UTF-8?q?Phase=2014:=20reinjectNow=20loose=20fallback=20?= =?UTF-8?q?for=20--skill=20paths=20=E2=80=94=20B-002=20now=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveReinjectSkillNames includes tracked skills on disk when requireRegistered is false so /skill-reinject now works without resourceLoader entry. Co-authored-by: Cursor --- src/reinject.ts | 28 +++++++++++++-- test/reinject-now-loose.test.ts | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 test/reinject-now-loose.test.ts diff --git a/src/reinject.ts b/src/reinject.ts index 33f0c15..1ebba1d 100644 --- a/src/reinject.ts +++ b/src/reinject.ts @@ -252,6 +252,31 @@ export function sendImmediateReinjectAllFollowUp( } } +/** Names eligible for re-inject: registered, or on disk when requireRegistered is false (Phase 14 / B-002). */ +export function resolveReinjectSkillNames( + state: ExtensionState, + settings: SkillReinjectSettings, + registeredSkills: readonly Pick[], + ctx?: ExtensionContext, +): string[] { + const registered = registeredSkillNames(registeredSkills); + const names: string[] = []; + for (const tracked of state.skills) { + if (registered.has(tracked.name)) { + names.push(tracked.name); + continue; + } + if (settings.requireRegistered) { + continue; + } + if (existsSync(tracked.filePath)) { + notifyInfo(ctx, `skill-reinject: re-injected "${tracked.name}" from disk`); + names.push(tracked.name); + } + } + return names; +} + /** Force re-inject all tracked registered skills for /skill-reinject now (SPEC ยง7.1). */ export function reinjectNow( pi: ExtensionAPI, @@ -260,8 +285,7 @@ export function reinjectNow( ctx: ExtensionContext, registeredSkills: readonly Pick[], ): void { - const registered = registeredSkillNames(registeredSkills); - const skillNames = state.skills.map((skill) => skill.name).filter((name) => registered.has(name)); + const skillNames = resolveReinjectSkillNames(state, settings, registeredSkills, ctx); if (skillNames.length === 0) { if (ctx.hasUI) { ctx.ui.notify("skill-reinject: no tracked skills to re-inject", "info"); diff --git a/test/reinject-now-loose.test.ts b/test/reinject-now-loose.test.ts new file mode 100644 index 0000000..3b99994 --- /dev/null +++ b/test/reinject-now-loose.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { reinjectNow, resolveReinjectSkillNames } 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-now-")); + 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("resolveReinjectSkillNames", () => { + it("includes loose tracked skill when file exists and requireRegistered is false", () => { + const { filePath, baseDir } = tempSkillDir("loose"); + const state = createInitialState(); + trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" }); + + expect(resolveReinjectSkillNames(state, createDefaultSettings(), [])).toEqual(["loose"]); + }); + + it("excludes unregistered skills when requireRegistered is true", () => { + const { filePath, baseDir } = tempSkillDir("strict"); + const state = createInitialState(); + trackSkill(state, { name: "strict", filePath, baseDir, source: "slash" }); + const settings = createDefaultSettings(); + settings.requireRegistered = true; + + expect(resolveReinjectSkillNames(state, settings, [])).toEqual([]); + }); +}); + +describe("reinjectNow loose path", () => { + it("sends skill block from disk for unregistered tracked skill", () => { + const { filePath, baseDir } = tempSkillDir("loose"); + const state = createInitialState(); + trackSkill(state, { name: "loose", filePath, baseDir, source: "slash" }); + const sendUserMessage = vi.fn(); + const pi = { sendUserMessage } as never; + const ctx = { hasUI: false, isIdle: () => true } as never; + + reinjectNow(pi, state, createDefaultSettings(), ctx, []); + + expect(sendUserMessage).toHaveBeenCalledTimes(1); + expect(sendUserMessage.mock.calls[0]?.[0]).toContain('