diff --git a/packages/extension/package.json b/packages/extension/package.json index 4e4da0b9..5df645c3 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -219,10 +219,34 @@ "amicode.skillLibraryRoots": { "type": "array", "items": { - "type": "string" + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "path", + "surfaces" + ], + "properties": { + "path": { + "type": "string" + }, + "surfaces": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "additionalProperties": false + } + ] }, "default": [], - "description": "Roots for the central skill library, scanned for skills tagged `surface: product`. Empty = ~/harmoniqs/amico-plugin/skills. Only product-tagged skills stage into Amicode; internal/untagged process skills never leak." + "markdownDescription": "Roots for the central skill library, scanned first-root-wins by directory name. Empty = the defaults: the dev's private plugin checkout (admits `surface: public` **and** `internal`), then the vendored public bundle (admits `public` only). Entries are typed roots `{\"path\": \"...\", \"surfaces\": [\"public\", ...]}` or plain strings. **Back-compat (ADR-0003):** a plain string keeps the pre-typing behavior — it admits `surface: public` only; existing string overrides keep working unchanged. Untagged/malformed skills are dropped from every root with a logged warning." }, "amicode.vaultDir": { "type": "string", diff --git a/packages/extension/skills.lock.json b/packages/extension/skills.lock.json index 50a6d686..2eada0e1 100644 --- a/packages/extension/skills.lock.json +++ b/packages/extension/skills.lock.json @@ -1,7 +1,7 @@ { - "version": "1.6.0", + "version": "1.7.0", "repo": "harmoniqs/amico-plugin", - "tag": "skills-public-v1.6.0", - "asset": "amico-skills-public-1.6.0.tar.gz", - "sha256": "a8400b101eef68df8d5e3acec13bc922f98f45ce0d8da4747d1f46d791ec9eeb" + "tag": "skills-public-v1.7.0", + "asset": "amico-skills-public-1.7.0.tar.gz", + "sha256": "24146ddba3213234f0dc2d84259f94d053f2d8a50ca37afd05d95ad08de6a3a1" } diff --git a/packages/extension/src/extension.ts b/packages/extension/src/extension.ts index 084e0922..fbc3920a 100644 --- a/packages/extension/src/extension.ts +++ b/packages/extension/src/extension.ts @@ -16,6 +16,7 @@ import { buildOpencodeConfigContent, resolveModelPin, } from "./opencode_config"; +import { parseLibraryRootSpecs } from "./scores/package_skills"; import { resolveAmicoRunBinDir, resolveRunsRoot } from "./opencode_paths"; import { mintServerPassword, @@ -415,6 +416,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const v = vscode.workspace.getConfiguration("amicode").get(key, []); return Array.isArray(v) && v.length ? v : undefined; }; + // Typed library roots (ADR-0003): the setting mixes bare strings (public-only, + // pre-ADR behavior) and {path, surfaces} objects; malformed entries drop + warn. + const cfgLibraryRoots = () => { + const raw = vscode.workspace.getConfiguration("amicode").get("skillLibraryRoots", []); + const parsed = parseLibraryRootSpecs(raw); + return parsed.length ? parsed : undefined; + }; const opencodeProject = prepareOpencodeProject({ agentsSrc: path.resolve(ctx.extensionPath, "AGENTS.md"), // MODE-SELECTED vetted template: HP sessions get the Piccolissimo variant @@ -427,7 +435,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), - skillLibraryRoots: cfgArr("skillLibraryRoots"), + skillLibraryRoots: cfgLibraryRoots(), // User-memory substrate (spec-20260705-002847): "" in the setting keeps the // auto-resolve (kind=personal marker scan); a path pins the vault explicitly. vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, @@ -577,7 +585,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.workspace.getConfiguration("amicode").get("juliaProject", ""), ), skillRoots: cfgArr("skillRoots"), - skillLibraryRoots: cfgArr("skillLibraryRoots"), + skillLibraryRoots: cfgLibraryRoots(), vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, }); await serverManager?.stop(); @@ -707,7 +715,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), juliaProject: resolveJuliaProject(vscode.workspace.getConfiguration("amicode").get("juliaProject", "")), skillRoots: cfgArr("skillRoots"), - skillLibraryRoots: cfgArr("skillLibraryRoots"), + skillLibraryRoots: cfgLibraryRoots(), vaultDir: vscode.workspace.getConfiguration("amicode").get("vaultDir", "") || undefined, projectDir: path.join((ctx.storageUri ?? ctx.globalStorageUri).fsPath, "opencode-project"), }); diff --git a/packages/extension/src/opencode_config.ts b/packages/extension/src/opencode_config.ts index 052279c1..b543d3f3 100644 --- a/packages/extension/src/opencode_config.ts +++ b/packages/extension/src/opencode_config.ts @@ -12,6 +12,8 @@ import { buildSkillIndexSection, stageOpencodeSkills, type SkillIndexEntry, + type LibraryRoot, + type LibraryRootSpec, } from "./scores/package_skills"; import { readSolverModeState } from "./solver_mode"; import { buildRoutingSection, readRoutingContext } from "./routing"; @@ -144,16 +146,23 @@ export const DEFAULT_SCORES_ROOT = path.resolve(__dirname, "..", "scores"); * `surface: public` tag from the central amico-plugin library. Overridable * via settings (Task 6). */ export const DEFAULT_SKILL_ROOTS = [path.join(os.homedir(), "harmoniqs", "packages")]; -/** Library roots scanned (first-root-wins) for `surface: public` skills: - * 1. the dev's live amico-plugin checkout — full public set incl. the held - * physics skills (present locally, just excluded from the OSS artifact); +/** Library roots scanned (first-root-wins), TYPED by admitted surface set + * (ADR-0003, amicode#242): + * 1. the dev's live amico-plugin checkout — admits {public, internal}. + * Checkout presence IS the eligibility proof: internal SKILL.md content + * exists only in the private repo, so nobody stages skills they do not + * already possess. This is what gives brainstorming's publish/decompose + * steps (write-an-issue, break-into-subissues — surface:internal) their + * path to Amicode. * 2. the vsix-bundled OSS subset (fetch_skills.mjs -> vendor/skills-public), - * the ONLY root a Marketplace user has. A dev has both; the checkout wins - * per dir name, so the bundle is a pure fallback. Missing roots are - * silently skipped (resolveLibrarySkills). */ -export const DEFAULT_LIBRARY_ROOTS = [ - path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills"), - path.resolve(__dirname, "..", "vendor", "skills-public", "skills"), + * the ONLY root a Marketplace user has — admits {public} ONLY, defense in + * depth on top of the extract pipeline's guarantee; the vendored bundle + * must never ship internal skills. A dev has both; the checkout wins per + * dir name, so the bundle is a pure fallback. Missing roots are silently + * skipped (resolveLibrarySkills). */ +export const DEFAULT_LIBRARY_ROOTS: LibraryRoot[] = [ + { path: path.join(os.homedir(), "harmoniqs", "amico-plugin", "skills"), surfaces: ["public", "internal"] }, + { path: path.resolve(__dirname, "..", "vendor", "skills-public", "skills"), surfaces: ["public"] }, ]; /** The physics/optimization skill subset (formerly `surface: product`, now `public`) — * a documentation/reference anchor, NOT a selection input (selection is purely by @@ -461,9 +470,11 @@ export interface OpencodeConfigOptions { entitlementsDir?: string; /** Roots to search for co-located package skills (spec §3). Default: DEFAULT_SKILL_ROOTS. */ skillRoots?: string[]; - /** Roots for the central library, scanned for `surface: public` skills - * (spec-20260713-003804). Default: DEFAULT_LIBRARY_ROOTS. */ - skillLibraryRoots?: string[]; + /** Roots for the central library, scanned under per-root surface eligibility + * (ADR-0003, amicode#242). Typed `{path, surfaces}` roots; a bare string + * admits public only (pre-ADR settings overrides keep working). + * Default: DEFAULT_LIBRARY_ROOTS. */ + skillLibraryRoots?: LibraryRootSpec[]; /** Personal vault dir for the user-memory substrate (spec-20260705-002847), * three-state (spec-20260707-002846 C1): * undefined → auto-resolve the full Armonia mount stack under diff --git a/packages/extension/src/scores/package_skills.ts b/packages/extension/src/scores/package_skills.ts index a2f668be..c45c5e75 100644 --- a/packages/extension/src/scores/package_skills.ts +++ b/packages/extension/src/scores/package_skills.ts @@ -21,6 +21,52 @@ export interface SkillIndexEntry { path: string; // absolute SKILL.md path } +/** A typed library root (ADR-0003, amicode#242): the directory PLUS the `surface:` + * tags it admits. Two tiers — the dev's private plugin checkout admits + * {public, internal} (checkout presence IS the eligibility proof: internal + * SKILL.md content exists only in the private repo, so nobody stages skills + * they do not already possess); the vendored public bundle admits {public} + * only, as defense in depth on top of the extract pipeline's guarantee. */ +export interface LibraryRoot { + path: string; + surfaces: string[]; // admitted `surface:` tags +} +/** A bare string root keeps the pre-typing behavior: public-only. Settings + * overrides written before ADR-0003 are string arrays — they keep working. */ +export type LibraryRootSpec = string | LibraryRoot; + +function normalizeLibraryRoot(r: LibraryRootSpec): LibraryRoot { + return typeof r === "string" ? { path: r, surfaces: ["public"] } : r; +} + +/** Parse the raw `amicode.skillLibraryRoots` setting value into root specs + * (ADR-0003 back-compat). Bare strings pass through (public-only, the pre-ADR + * behavior); typed objects need a non-empty `path` and a non-empty `surfaces` + * string array. Malformed entries are dropped with a warning — the settings + * surface mirrors the resolver's skip+warn philosophy, never throws. */ +export function parseLibraryRootSpecs(raw: unknown): LibraryRootSpec[] { + if (!Array.isArray(raw)) return []; + const out: LibraryRootSpec[] = []; + for (const entry of raw) { + if (typeof entry === "string") { + out.push(entry); + continue; + } + const e = entry as Partial | null; + const ok = + e !== null && + typeof e === "object" && + typeof e.path === "string" && + e.path.trim() !== "" && + Array.isArray(e.surfaces) && + e.surfaces.length > 0 && + e.surfaces.every((s) => typeof s === "string"); + if (ok) out.push({ path: (e as LibraryRoot).path, surfaces: (e as LibraryRoot).surfaces }); + else console.warn(`amicode: dropping malformed skillLibraryRoots entry: ${JSON.stringify(entry)}`); + } + return out; +} + function expandHome(p: string): string { if (p === "~") return process.env.HOME ?? p; if (p.startsWith("~/")) return path.join(process.env.HOME ?? "", p.slice(2)); @@ -81,31 +127,34 @@ export function resolvePackageSkills(allowlist: string[], roots: string[]): Skil } /** Library skills from the central amico-plugin library, discovered by SURFACE - * TAG (spec-20260713-003804). The library root is SCANNED, but ONLY skills whose - * frontmatter carries `surface: public` are returned — `internal`, untagged, and - * any other value are the leak hazard and are DROPPED. `public` = the OSS-shippable - * surface (the Armonia vault-management layer + physics/opt + generic craft); the - * tag IS the least-privilege guard. Staging (stageOpencodeSkills) copies only THIS - * selected set to the per-session stage dir — `skills.paths` never points at the - * library root itself. First root holding a given `/SKILL.md` wins. + * TAG (spec-20260713-003804) under PER-ROOT eligibility (ADR-0003, amicode#242). + * Each root is scanned, but ONLY skills whose frontmatter `surface:` tag is in + * that root's admitted `surfaces` are returned — the private checkout root + * admits {public, internal}, the vendored bundle root admits {public} only, so + * internal content can stage ONLY from a checkout the user already possesses. + * Untagged and malformed skills are DROPPED from every root. Staging + * (stageOpencodeSkills) copies only THIS selected set to the per-session stage + * dir — `skills.paths` never points at a library root itself. First root + * holding a given `/SKILL.md` wins. * - * The private tier is NOT a library concern: private-package skills live co-located - * in their package repos and are gated by resolvePackageSkills (entitlement-derived - * allowlist ∩ repo presence). There is deliberately no library-level entitlement seam. */ -export function resolveLibrarySkills(roots: string[]): SkillIndexEntry[] { + * The private tier is NOT otherwise a library concern: private-package skills + * live co-located in their package repos and are gated by resolvePackageSkills + * (entitlement-derived allowlist ∩ repo presence). */ +export function resolveLibrarySkills(roots: LibraryRootSpec[]): SkillIndexEntry[] { const out: SkillIndexEntry[] = []; const seen = new Set(); // first-root-wins, keyed by dir name for (const r of roots) { - const root = expandHome(r); + const root = normalizeLibraryRoot(r); + const rootPath = expandHome(root.path); let names: string[] = []; try { - names = fs.readdirSync(root); + names = fs.readdirSync(rootPath); } catch { continue; // missing library root — silently skipped (session proceeds) } for (const name of names.sort()) { if (seen.has(name)) continue; - const skillPath = path.join(root, name, "SKILL.md"); + const skillPath = path.join(rootPath, name, "SKILL.md"); if (!fs.existsSync(skillPath)) continue; let fm: { name: string; description: string; surface?: string }; try { @@ -114,8 +163,12 @@ export function resolveLibrarySkills(roots: string[]): SkillIndexEntry[] { console.warn(`amicode: skipping malformed library skill ${skillPath}: ${e}`); continue; } - if (fm.surface !== "public") continue; // THE GUARD: internal/untagged/product never stage - seen.add(name); // this dir is the authoritative public skill (earlier root wins) + if (fm.surface === undefined) { + console.warn(`amicode: dropping untagged library skill ${skillPath} (no surface: tag — default-deny)`); + continue; + } + if (!root.surfaces.includes(fm.surface)) continue; // THE GUARD, per-root + seen.add(name); // this dir is the authoritative skill of that name (earlier root wins) out.push({ source: "library", name: fm.name, description: fm.description, path: skillPath }); } } diff --git a/packages/extension/test/packaging.test.ts b/packages/extension/test/packaging.test.ts index f6659da8..556b2ccd 100644 --- a/packages/extension/test/packaging.test.ts +++ b/packages/extension/test/packaging.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { execFileSync } from "node:child_process"; -import { existsSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; const VSIX = join(__dirname, "..", "amicode.vsix"); @@ -81,3 +81,33 @@ describe.skipIf(!existsSync(VSIX) && !REQUIRE_VSIX)("packaged VSIX contains runt ).toBe(true); }); }); + +// Two-tier leak guard on the vendored artifact itself (ADR-0003, amicode#242). +// The bundle root admits {public} only at resolve time (the resolver half is in +// package_skills.test.ts); these tests guard the ARTIFACT — a corrupt extract or +// a mis-pinned lock that smuggled an internal skill must red here, not ship. +const SKILLS_BUNDLE = join(__dirname, "..", "vendor", "skills-public"); +const HAVE_BUNDLE = existsSync(join(SKILLS_BUNDLE, "skills")); +describe.skipIf(!HAVE_BUNDLE && !REQUIRE_VSIX)("vendored public skill subset — two-tier leak guard (ADR-0003)", () => { + it("the vendored bundle exists (hard requirement under AMICODE_REQUIRE_VSIX=1)", () => { + expect(HAVE_BUNDLE, "no vendor/skills-public — run: pnpm --filter amicode fetch:skills").toBe(true); + }); + it("every vendored SKILL.md carries surface: public — the bundle never ships internal (AC4)", () => { + const offenders: string[] = []; + for (const name of readdirSync(join(SKILLS_BUNDLE, "skills"))) { + const p = join(SKILLS_BUNDLE, "skills", name, "SKILL.md"); + if (!existsSync(p)) continue; + const m = readFileSync(p, "utf8").match(/^---\n([\s\S]*?)\n---/); + const surface = m?.[1].match(/^surface:\s*(\S+)/m)?.[1]; + if (surface !== "public") offenders.push(`${name} (surface=${surface ?? "MISSING"})`); + } + expect(offenders, `non-public skills in the vendored bundle: ${offenders.join(", ")}`).toEqual([]); + }); + it("the re-tagged dev-workflow skills are absent from the vendored set (AC5)", () => { + const names = readdirSync(join(SKILLS_BUNDLE, "skills")); + // Re-tagged surface:internal by amico-plugin#52 — present in the public bundle + // up to skills-public-v1.6.0, absent from the first post-retag release. + expect(names).not.toContain("implement-issue"); + expect(names).not.toContain("break-into-subissues"); + }); +}); diff --git a/packages/extension/test/scores/package_skills.test.ts b/packages/extension/test/scores/package_skills.test.ts index e0037563..544e11a4 100644 --- a/packages/extension/test/scores/package_skills.test.ts +++ b/packages/extension/test/scores/package_skills.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -7,6 +7,7 @@ import { resolveLibrarySkills, buildSkillIndexSection, stageOpencodeSkills, + parseLibraryRootSpecs, } from "../../src/scores/package_skills"; import { DEFAULT_LIBRARY_ROOTS, DEFAULT_PLATFORM_SKILLS } from "../../src/opencode_config"; @@ -38,7 +39,7 @@ function writeLibSkill(root: string, name: string, surface: "public" | "internal * retag PR and this PR are a coupled merge — post-merge (or on the tiering branch) this returns * the root and the assertions run for real. */ function retaggedLibraryRoot(): string | null { - const root = DEFAULT_LIBRARY_ROOTS[0]; + const root = DEFAULT_LIBRARY_ROOTS[0].path; // the dev checkout (typed root, ADR-0003) if (!fs.existsSync(root)) return null; for (const name of fs.readdirSync(root)) { const p = path.join(root, name, "SKILL.md"); @@ -47,10 +48,11 @@ function retaggedLibraryRoot(): string | null { } return root; } -/** Count on-disk library skills tagged `surface: public`, mirroring the resolver's tolerance - * (well-formed frontmatter carrying name+description; malformed dirs skipped). Derives the - * expected real-root count without a brittle hardcoded golden list. */ -function countPublicSkills(root: string): number { +/** Count on-disk library skills carrying an explicit admitted tag, mirroring the + * resolver's tolerance (well-formed frontmatter carrying name+description; + * malformed dirs skipped). With the checkout root admitting {public, internal} + * (ADR-0003), the expected real-root set is every explicitly-tagged skill. */ +function countTaggedSkills(root: string): number { let n = 0; for (const name of fs.readdirSync(root)) { const p = path.join(root, name, "SKILL.md"); @@ -59,7 +61,7 @@ function countPublicSkills(root: string): number { if (!m) continue; const fm = m[1]; const ok = /^name:\s*\S/m.test(fm) && /^description:\s*\S/m.test(fm); - if (ok && /^surface:\s*public\b/m.test(fm)) n++; + if (ok && /^surface:\s*(public|internal)\b/m.test(fm)) n++; } return n; } @@ -128,22 +130,37 @@ describe("resolveLibrarySkills (spec-20260713-003804 — surface:public discover expect(names).not.toContain("pr"); expect(names).not.toContain("dream"); }); - it("EXCLUDES an untagged skill (no surface frontmatter)", () => { - const root = mkRoot(); - writeLibSkill(root, "atoms", "public"); - writeLibSkill(root, "mystery", null); - expect(resolveLibrarySkills([root]).map((e) => e.name)).toEqual(["atoms"]); + it("EXCLUDES an untagged skill (no surface frontmatter) — dropped WITH a logged warning from every root (AC3)", () => { + for (const spec of ["string-root", { path: "typed-root", surfaces: ["public", "internal"] }]) { + const root = mkRoot(); + writeLibSkill(root, "atoms", "public"); + writeLibSkill(root, "mystery", null); // untagged — default-deny at BOTH tiers + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const roots = typeof spec === "string" ? [root] : [{ ...spec, path: root }]; + expect(resolveLibrarySkills(roots).map((e) => e.name)).toEqual(["atoms"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("mystery")); + } finally { + warn.mockRestore(); + } + } }); it("missing library root → empty, no throw (session proceeds)", () => { expect(resolveLibrarySkills(["/nonexistent-lib"])).toEqual([]); }); - it("malformed frontmatter skips that skill, keeps the public ones", () => { + it("malformed frontmatter skips that skill WITH a logged warning, keeps the public ones (AC3)", () => { const root = mkRoot(); const bad = path.join(root, "broken"); fs.mkdirSync(bad, { recursive: true }); fs.writeFileSync(path.join(bad, "SKILL.md"), "no frontmatter here"); writeLibSkill(root, "atoms", "public"); - expect(resolveLibrarySkills([root]).map((e) => e.name)).toEqual(["atoms"]); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(resolveLibrarySkills([root]).map((e) => e.name)).toEqual(["atoms"]); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("malformed")); + } finally { + warn.mockRestore(); + } }); it("first root containing a public skill wins", () => { const r1 = mkRoot(), @@ -160,20 +177,62 @@ describe("resolveLibrarySkills (spec-20260713-003804 — surface:public discover expect(idx[0].description).toBe("atoms physics"); // r1's copy }); + // Two-tier surfaces (ADR-0003, amicode#242): a typed root carries the surface + // tags it admits. The private plugin checkout root admits {public, internal} — + // checkout presence IS the eligibility proof (the content exists only there). + it("a typed root admitting {public, internal} stages BOTH tiers (the private checkout root)", () => { + const root = mkRoot(); + writeLibSkill(root, "atoms", "public"); + writeLibSkill(root, "implement-issue", "internal"); // dev-workflow skill — the AC1 payload + const idx = resolveLibrarySkills([{ path: root, surfaces: ["public", "internal"] }]); + expect(idx.map((e) => e.name).sort()).toEqual(["atoms", "implement-issue"]); + }); + it("with ONLY the vendored-bundle form (public-only typed root), internal skills resolve to nothing (AC2)", () => { + const root = mkRoot(); + writeLibSkill(root, "atoms", "public"); + writeLibSkill(root, "implement-issue", "internal"); + const idx = resolveLibrarySkills([{ path: root, surfaces: ["public"] }]); + expect(idx.map((e) => e.name)).toEqual(["atoms"]); + }); + it("the checkout wins over the bundle for the same skill name, across tiers", () => { + const checkout = mkRoot(), + bundle = mkRoot(); + writeLibSkill(checkout, "atoms", "public"); + writeLibSkill(bundle, "atoms", "public"); // the bundle copy must lose (first-root-wins) + writeLibSkill(checkout, "implement-issue", "internal"); + const idx = resolveLibrarySkills([ + { path: checkout, surfaces: ["public", "internal"] }, + { path: bundle, surfaces: ["public"] }, + ]); + expect(idx.map((e) => e.name).sort()).toEqual(["atoms", "implement-issue"]); + expect(idx.find((e) => e.name === "atoms")!.path.startsWith(checkout)).toBe(true); + }); + it("DEFAULT_LIBRARY_ROOTS is typed: the checkout admits internal; the vendored bundle admits public only", () => { + expect(DEFAULT_LIBRARY_ROOTS).toHaveLength(2); + const [checkout, bundle] = DEFAULT_LIBRARY_ROOTS; + expect(checkout.path).toMatch(/amico-plugin/); + expect(checkout.surfaces).toEqual(expect.arrayContaining(["public", "internal"])); + expect(bundle.path).toMatch(/skills-public/); + expect(bundle.surfaces).toEqual(["public"]); // defense in depth, never internal + }); + // Real-library-root assertions. Skip on CI (no checkout) and on any pre-retag working tree // (see retaggedLibraryRoot). DEFAULT_PLATFORM_SKILLS is retained as a documentation anchor - // of the physics/opt subset but is NO LONGER the selection input — discovery is purely by tag. - it("discovers a non-empty public set from the real, retagged amico-plugin root", () => { + // of the physics/opt subset but is NO LONGER the selection input — discovery is by tag, + // and the checkout root admits BOTH tiers (ADR-0003): internal dev-workflow skills resolve + // from the checkout because checkout presence is the eligibility proof. + it("discovers every explicitly-tagged skill from the real, retagged amico-plugin checkout (both tiers)", () => { const root = retaggedLibraryRoot(); if (!root) return; - const expected = countPublicSkills(root); // tag-derived, same tolerance as the resolver + const expected = countTaggedSkills(root); // tag-derived, same tolerance as the resolver const names = resolveLibrarySkills(DEFAULT_LIBRARY_ROOTS).map((e) => e.name).sort(); expect(names).toHaveLength(expected); expect(expected).toBeGreaterThan(0); // every physics/opt anchor is public → present in the discovered set (superset check) for (const p of DEFAULT_PLATFORM_SKILLS) expect(names).toContain(p); - // explicit leak-guard on real data: a genuinely internal skill must be absent - expect(names).not.toContain("develop"); + // AC1 on real data: a genuinely internal skill NOW resolves from the checkout + // (the pre-ADR leak guard asserted its absence; the bundle root keeps that guard). + expect(names).toContain("develop"); }); // spec-20260713-003804 §6 tag-required check. The tag-derived count above is near-tautological @@ -195,6 +254,39 @@ describe("resolveLibrarySkills (spec-20260713-003804 — surface:public discover }); }); +describe("parseLibraryRootSpecs (settings back-compat, ADR-0003)", () => { + it("passes bare strings through as public-only roots (pre-ADR overrides keep working)", () => { + expect(parseLibraryRootSpecs(["/a", "~/b"])).toEqual(["/a", "~/b"]); + }); + it("accepts typed {path, surfaces} objects verbatim", () => { + const typed = [{ path: "/x", surfaces: ["public", "internal"] }]; + expect(parseLibraryRootSpecs(typed)).toEqual(typed); + }); + it("drops malformed entries with a logged warning; keeps the valid ones", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + const out = parseLibraryRootSpecs([ + "/ok", + { path: "/no-surfaces" }, + { path: "", surfaces: ["public"] }, + { path: "/bad-surfaces", surfaces: "public" }, + { path: "/empty-surfaces", surfaces: [] }, + 42, + null, + ]); + expect(out).toEqual(["/ok"]); + expect(warn).toHaveBeenCalledTimes(6); + } finally { + warn.mockRestore(); + } + }); + it("non-array / empty → []", () => { + expect(parseLibraryRootSpecs(undefined)).toEqual([]); + expect(parseLibraryRootSpecs("nope")).toEqual([]); + expect(parseLibraryRootSpecs([])).toEqual([]); + }); +}); + describe("buildSkillIndexSection", () => { it("empty index → empty string (no section at all)", () => { expect(buildSkillIndexSection([])).toBe(""); diff --git a/packages/extension/test/scores/prep_integration.test.ts b/packages/extension/test/scores/prep_integration.test.ts index 77f74566..a3a7e33c 100644 --- a/packages/extension/test/scores/prep_integration.test.ts +++ b/packages/extension/test/scores/prep_integration.test.ts @@ -265,6 +265,23 @@ describe("prepareOpencodeProject × skill index (spec §3, Rev 2 — dual-source expect(skills[0].source).toBe("library"); // platform entries first (spec §3) }); + // Two-tier surfaces (ADR-0003, amicode#242 AC1): with the private plugin + // checkout present (a typed root admitting internal), a session's skill index + // includes surface:internal skills AND stages them into the session skill dir. + it("typed checkout root: internal library skills index AND stage into the session skill dir", () => { + const proj = prep({ + entitlementsDir: entitledDir(), + skillRoots: [mkPkgSkillRoot()], + skillLibraryRoots: [{ path: mkLibRoot(), surfaces: ["public", "internal"] }], + }); + const skills = readSkills(); + expect(libNames(skills)).toContain("atoms"); + expect(libNames(skills)).toContain("pr"); // internal, admitted from the checkout root + // ...and staged into the per-session dir opencode actually loads (skills.paths) + expect(fs.existsSync(path.join(proj.skillsStageDir, "pr", "SKILL.md"))).toBe(true); + expect(fs.existsSync(path.join(proj.skillsStageDir, "atoms", "SKILL.md"))).toBe(true); + }); + it("skill index survives a score-compile failure (independent splice, spec §3)", () => { const badRoot = fs.mkdtempSync(path.join(os.tmpdir(), "bad-scores-")); fs.mkdirSync(path.join(badRoot, "pulse-designer"));