diff --git a/packages/extension/skills/autoresearch/SKILL.md b/packages/extension/skills/autoresearch/SKILL.md index 301a3781..fa047dcc 100644 --- a/packages/extension/skills/autoresearch/SKILL.md +++ b/packages/extension/skills/autoresearch/SKILL.md @@ -18,8 +18,12 @@ vault_contract: > is the `autoresearch` primary agent card). **Entry points:** the `autoresearch` primary agent (Tab-switch into research mode — -its prompt embeds this spine), direct invocation of this skill, or the standing line in -the user's autoresearch kickoff prompts. All three lead here; this file is the protocol. +its first action invokes the `director-core` skill, the canonical loop protocol), +direct invocation of this skill, or the standing line in the user's autoresearch +kickoff prompts. All three lead here; this skill is the research-mode instantiation — +it carries what director-core deliberately does not (the spec-gate review budget, the +checkout registry, the probe/experiment boundary). Where this file and director-core +appear to conflict, director-core wins; report the conflict, never resolve it by edit. The operating principle: **the context window is a cache; the vault is the database.** Every piece of load-bearing state lives in a vault note; the context window holds only the diff --git a/packages/extension/test/fixtures/internal-skill-registry.json b/packages/extension/test/fixtures/internal-skill-registry.json new file mode 100644 index 00000000..7dee890f --- /dev/null +++ b/packages/extension/test/fixtures/internal-skill-registry.json @@ -0,0 +1,5 @@ +[ + "develop", + "director-core", + "implement-issue" +] diff --git a/packages/extension/test/skill_reference_closure.test.ts b/packages/extension/test/skill_reference_closure.test.ts new file mode 100644 index 00000000..219744fc --- /dev/null +++ b/packages/extension/test/skill_reference_closure.test.ts @@ -0,0 +1,101 @@ +// Skill-reference closure (#537) — every skill-invocation directive in the +// agent cards must resolve against the repo's public skills plus a committed +// registry of internally-homed skills. Born from the 2026-08-23 outage: both +// mode cards' first action invokes `director-core`, but the server's staging +// allowlist omitted it, and runtime died with "skill director-core +// unavailable". CI must catch what staging forgot. +import { describe, it, expect } from "vitest"; +import { readFileSync, existsSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const EXT = path.join(HERE, ".."); +const AGENTS_DIR = path.join(EXT, "agents"); +const SKILLS_DIR = path.join(EXT, "skills"); +const REGISTRY_PATH = path.join(HERE, "fixtures", "internal-skill-registry.json"); + +// The live armonissima team mount — canonical home of the registry's skills; +// absent on machines without the mount (same conditional as mode_cards.test.ts). +const ARMONISSIMA_SKILLS = path.join( + process.env.HOME ?? "", + ".amico", + "vaults", + "armonissima", + "skills", +); + +const registry: string[] = JSON.parse(readFileSync(REGISTRY_PATH, "utf8")) as string[]; + +/** + * Skill-invocation directives in a card: "the `X` skill", "invoke the `X`", + * "invoke the **`X`**". Deliberately narrow — this is an invocation contract, + * not every mention. + */ +function skillDirectives(text: string): string[] { + const names = new Set(); + for (const m of text.matchAll(/`([a-z][a-z0-9-]+)`\s+skill\b/g)) names.add(m[1]!); + for (const m of text.matchAll(/invoke the (?:\*\*)?`([a-z][a-z0-9-]+)`/gi)) + names.add(m[1]!); + return [...names]; +} + +const cards = readdirSync(AGENTS_DIR).filter((f) => f.endsWith(".md")); + +// Repo public skills + the committed internal registry = the resolvable set. +const repoSkills = readdirSync(SKILLS_DIR).filter((f) => + existsSync(path.join(SKILLS_DIR, f, "SKILL.md")), +); +const resolvable = new Set([...repoSkills, ...registry]); + +describe("skill-reference closure — agent card directives resolve", () => { + it("the card set is non-empty (guard against a vacuous suite)", () => { + expect(cards.length).toBeGreaterThan(0); + }); + + it("cards carry at least one skill directive (guard against a vacuous scan)", () => { + const all = cards.flatMap((c) => + skillDirectives(readFileSync(path.join(AGENTS_DIR, c), "utf8")), + ); + expect(all.length).toBeGreaterThan(0); + }); + + for (const card of cards) { + it(`${card}: every skill directive resolves`, () => { + const text = readFileSync(path.join(AGENTS_DIR, card), "utf8"); + const directives = skillDirectives(text); + for (const name of directives) { + expect( + resolvable.has(name), + `${card} invokes "${name}" — not in repo skills nor the internal registry`, + ).toBe(true); + } + }); + } +}); + +describe("skill-reference closure — internal registry", () => { + it("the registry is non-empty and duplicate-free (guard against a vacuous check)", () => { + expect(registry.length).toBeGreaterThan(0); + expect(new Set(registry).size).toBe(registry.length); + }); + + it("registry entries are not shadows of repo skills (registry = internal-only)", () => { + for (const name of registry) { + expect( + repoSkills.includes(name), + `"${name}" exists in repo skills — remove it from the internal registry`, + ).toBe(false); + } + }); + + it("every registry entry exists in the team mount when the mount is present", () => { + if (!existsSync(ARMONISSIMA_SKILLS)) return; // mount absent: registry is the record + for (const name of registry) { + expect( + existsSync(path.join(ARMONISSIMA_SKILLS, name, "SKILL.md")), + `registry entry "${name}" has no SKILL.md in the armonissima mount`, + ).toBe(true); + } + }); +});