diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 71d71c4c..7e63ae2a 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -29,6 +29,7 @@ import { MeetingService } from "../services/meeting-service"; import { SprintService } from "../services/sprint-service"; import { ComposioConnectionService } from "../services/composio/composio-connection-service"; import { ConnectorGrantsService } from "../services/composio/connector-grants"; +import { syncSkillsFromDisk } from "../services/skill-discovery"; import { coalesce } from "./coalesce"; import { normalizeExternalBrowserUrl } from "./external-url"; @@ -252,6 +253,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { } }); ipcMain.handle(IPC_CHANNELS.importSkillDirectory, (_event, input) => mutate(() => skills.importDirectory(input))); + ipcMain.handle(IPC_CHANNELS.syncSkillsFromDisk, (_event, input) => mutate(() => { + syncSkillsFromDisk(deps.connection.db, skills, input.orgId); + return null; + })); ipcMain.handle(IPC_CHANNELS.bindSkill, (_event, input) => mutate(() => skills.bind(input))); ipcMain.handle(IPC_CHANNELS.resolveAgentSkills, (_event, input) => mutate(() => skills.resolveRuntimeSnapshot(input))); ipcMain.handle(IPC_CHANNELS.runSkillTests, (_event, input) => mutate(() => skills.runTests(input))); diff --git a/src/main/index.ts b/src/main/index.ts index 40a97167..b4fadd30 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,9 +1,12 @@ import { app } from "electron"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import type { DatabaseSync } from "node:sqlite"; import { DatabaseConnection } from "./database/connection"; import { createMainWindow } from "./app/create-window"; import { registerIpcHandlers } from "./app/ipc-handlers"; +import { syncSkillsFromDisk } from "./services/skill-discovery"; +import { SkillService } from "./services/skill-service"; import { startProviderBackgroundWorker } from "./app/provider-background-worker"; import { recoverInterruptedWork } from "./app/startup-recovery"; import { startTaskQueueWorker } from "./app/task-queue-worker"; @@ -30,6 +33,7 @@ if (!app.requestSingleInstanceLock()) { mainWindow: win, artifactRoot: join(profileDir, "artifacts"), }); + syncAllOrganizationSkills(connection.db, join(profileDir, "artifacts")); const stopProviderBackgroundWorker = startProviderBackgroundWorker({ db: connection.db, mainWindow: win, @@ -55,3 +59,21 @@ if (!app.requestSingleInstanceLock()) { if (process.platform !== "darwin") app.quit(); }); } + +/** + * Loads the on-disk skill library for every organization at startup. One + * organization's failure (a broken workspace path, say) must not block the + * rest — or the app. + */ +function syncAllOrganizationSkills(db: DatabaseSync, artifactRoot: string): void { + const skills = new SkillService(db, artifactRoot); + const orgs = db.prepare("select id from organizations").all() as Array<{ id: string }>; + for (const org of orgs) { + try { + syncSkillsFromDisk(db, skills, org.id); + } catch { + // Broken directories are reported per entry inside the sync; anything + // beyond that is a per-org environment fault that must not stop startup. + } + } +} diff --git a/src/main/services/skill-discovery.ts b/src/main/services/skill-discovery.ts new file mode 100644 index 00000000..01d8abec --- /dev/null +++ b/src/main/services/skill-discovery.ts @@ -0,0 +1,104 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import type { DatabaseSync } from "node:sqlite"; +import { RISK_LEVEL } from "../../shared/domain"; +import { loadSkillManifest, type SkillManifest } from "./skill-manifest"; +import type { SkillService } from "./skill-service"; + +/** + * Skills live on disk the way developers keep them: one directory per skill + * under the user's global `~/.agents/skills` and under each workspace's + * `.agents/skills`. A directory qualifies with either a full + * skill.manifest.json or just a SKILL.md; for the latter the manifest is + * derived (name from frontmatter or the directory name, SKILL.md as the + * entrypoint). Paths are joined per platform — Windows, macOS, and Linux + * all resolve the same way. + */ + +export interface SkillSyncReport { + imported: number; + skipped: number; + failures: string[]; +} + +export function defaultGlobalSkillsRoot(): string { + // Same isolation seam as AGENTCOMPANY_NEW_PROFILE_DIR: tests point this at + // a temporary directory so they never read the developer's real library. + return process.env.AGENTCOMPANY_NEW_SKILLS_ROOT ?? join(homedir(), ".agents", "skills"); +} + +export function discoverSkillDirectories(workspaceRoots: string[], globalRoot: string = defaultGlobalSkillsRoot()): string[] { + const roots = [globalRoot, ...workspaceRoots.map((root) => join(root, ".agents", "skills"))]; + const found: string[] = []; + for (const root of roots) { + if (!existsSync(root)) continue; + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const dir = join(root, entry.name); + if (existsSync(join(dir, "skill.manifest.json")) || existsSync(join(dir, "SKILL.md"))) { + found.push(dir); + } + } + } + return found; +} + +/** Full manifest when present; otherwise derived from SKILL.md. */ +export function manifestForDirectory(directoryPath: string): SkillManifest { + if (existsSync(join(directoryPath, "skill.manifest.json"))) { + return loadSkillManifest(directoryPath); + } + const skillMd = readFileSync(join(directoryPath, "SKILL.md"), "utf8"); + return { + name: frontmatterName(skillMd) ?? basename(directoryPath), + version: "1.0.0", + entrypoint: "SKILL.md", + capabilities: [], + requiredTools: [], + compatibleModels: [], + riskLevel: RISK_LEVEL.LOW, + tests: [], + inputs: {}, + outputs: {}, + }; +} + +/** + * Imports every discovered skill the org does not already carry. Individual + * bad directories are reported and skipped so one broken entry cannot block + * the rest of the library. + */ +export function syncSkillsFromDisk( + db: DatabaseSync, + skills: SkillService, + orgId: string, + globalRoot: string = defaultGlobalSkillsRoot(), +): SkillSyncReport { + const workspaceRoots = (db.prepare("select local_path from workspaces where org_id = ?").all(orgId) as Array<{ local_path: string }>) + .map((row) => row.local_path); + const report: SkillSyncReport = { imported: 0, skipped: 0, failures: [] }; + for (const directory of discoverSkillDirectories(workspaceRoots, globalRoot)) { + try { + const manifest = manifestForDirectory(directory); + if (skills.hasSkill(orgId, manifest.name)) { + report.skipped += 1; + continue; + } + skills.importManifest(orgId, directory, manifest); + report.imported += 1; + } catch (error) { + report.failures.push(`${directory}: ${error instanceof Error ? error.message : String(error)}`); + } + } + return report; +} + +function frontmatterName(markdown: string): string | null { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(markdown); + if (!match) return null; + const nameLine = match[1].split(/\r?\n/).find((line) => /^name\s*:/.test(line.trim())); + if (!nameLine) return null; + const value = nameLine.split(":").slice(1).join(":").trim().replace(/^["']|["']$/g, ""); + return value || null; +} diff --git a/src/main/services/skill-manifest.ts b/src/main/services/skill-manifest.ts index 0d259e81..4a360fa9 100644 --- a/src/main/services/skill-manifest.ts +++ b/src/main/services/skill-manifest.ts @@ -16,12 +16,12 @@ export interface SkillManifest { outputs: unknown; } -export function loadSkillManifest(directoryPath: string, requestedVersion: string): SkillManifest { +export function loadSkillManifest(directoryPath: string, requestedVersion?: string): SkillManifest { const manifestPath = join(directoryPath, "skill.manifest.json"); if (!existsSync(manifestPath)) throw new Error("skill.manifest.json is required."); const raw = JSON.parse(readFileSync(manifestPath, "utf8")) as Record; const manifest = normalizeManifest(raw); - if (manifest.version !== requestedVersion) { + if (requestedVersion !== undefined && manifest.version !== requestedVersion) { throw new Error("Skill manifest version must match the requested version."); } verifyDeclaredFile(directoryPath, manifest.entrypoint); diff --git a/src/main/services/skill-service.ts b/src/main/services/skill-service.ts index db92dbd0..b6532202 100644 --- a/src/main/services/skill-service.ts +++ b/src/main/services/skill-service.ts @@ -25,13 +25,23 @@ export class SkillService { importDirectory(input: ImportSkillDirectoryInput): string { const manifest = loadSkillManifest(input.directoryPath, input.versionSemver); + return this.importManifest(input.orgId, input.directoryPath, manifest); + } + + /** True when the org already carries a skill with this name. */ + hasSkill(orgId: string, name: string): boolean { + const slug = slugify(name); + return Boolean(this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?").get(orgId, slug)); + } + + importManifest(orgId: string, directoryPath: string, manifest: SkillManifest): string { const slug = slugify(manifest.name); if (!slug) throw new Error("Skill name could not be derived."); const now = new Date().toISOString(); const skillId = createId("skl"); const revisionId = createId("skr"); - const checksum = recursiveSkillChecksum(input.directoryPath); - const copiedFiles = copySkillRevision(input.directoryPath, this.artifactRoot, revisionId); + const checksum = recursiveSkillChecksum(directoryPath); + const copiedFiles = copySkillRevision(directoryPath, this.artifactRoot, revisionId); const copiedEntrypoint = copiedFiles.find((file) => file.relativePath === manifest.entrypoint); if (!copiedEntrypoint) throw new Error("Skill manifest entrypoint was not found."); @@ -39,10 +49,10 @@ export class SkillService { insert into skill_definitions(id, org_id, slug, name, owner_type, status, created_at) values (?, ?, ?, ?, ?, ?, ?) on conflict(org_id, slug) do update set name = excluded.name, status = 'active' - `).run(skillId, input.orgId, slug, manifest.name, "organization", "active", now); + `).run(skillId, orgId, slug, manifest.name, "organization", "active", now); const existing = this.db.prepare("select id from skill_definitions where org_id = ? and slug = ?") - .get(input.orgId, slug) as { id: string }; + .get(orgId, slug) as { id: string }; this.db.prepare(` insert into skill_revisions( @@ -54,7 +64,7 @@ export class SkillService { manifest.version, JSON.stringify(manifest), "local_directory", - input.directoryPath, + directoryPath, now, checksum, ); @@ -72,7 +82,7 @@ export class SkillService { } this.audit.record({ - orgId: input.orgId, + orgId, entityType: "skill_revision", entityId: revisionId, actorType: "system", diff --git a/src/preload/index.ts b/src/preload/index.ts index df52f88b..ef51b7ab 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -56,6 +56,7 @@ const api: DesktopApi = { streamProviderResponse: (input) => invoke(IPC_CHANNELS.streamProviderResponse, input), cancelProviderResponse: (input) => invoke(IPC_CHANNELS.cancelProviderResponse, input), importSkillDirectory: (input) => invoke(IPC_CHANNELS.importSkillDirectory, input), + syncSkillsFromDisk: (input) => invoke(IPC_CHANNELS.syncSkillsFromDisk, input), bindSkill: (input) => invoke(IPC_CHANNELS.bindSkill, input), resolveAgentSkills: (input) => invoke(IPC_CHANNELS.resolveAgentSkills, input), runSkillTests: (input) => invoke(IPC_CHANNELS.runSkillTests, input), diff --git a/src/renderer/src/pages/settings/SkillsPanel.tsx b/src/renderer/src/pages/settings/SkillsPanel.tsx index f42246be..9afcd113 100644 --- a/src/renderer/src/pages/settings/SkillsPanel.tsx +++ b/src/renderer/src/pages/settings/SkillsPanel.tsx @@ -1,4 +1,4 @@ -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Button, Field, Icon, Pill } from "../../shared/ui"; import { Dialog } from "../../shared/Dialog"; import type { @@ -23,6 +23,12 @@ interface SkillsPanelProps { export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents, actions }: SkillsPanelProps) { const [importOpen, setImportOpen] = useState(false); + // The on-disk skill library (global ~/.agents/skills and each workspace's + // .agents/skills) syncs in when the panel opens. + useEffect(() => { + void actions.run(() => window.agentCompany.syncSkillsFromDisk({ orgId })); + }, [orgId, actions]); + const importSkill = (event: FormEvent) => { event.preventDefault(); const form = new FormData(event.currentTarget); diff --git a/src/shared/commands/skills.ts b/src/shared/commands/skills.ts index 6891d914..748977b8 100644 --- a/src/shared/commands/skills.ts +++ b/src/shared/commands/skills.ts @@ -4,6 +4,10 @@ export interface ImportSkillDirectoryInput { versionSemver: string; } +export interface SyncSkillsFromDiskInput { + orgId: string; +} + export interface BindSkillInput { orgId: string; agentId: string; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index c9d5bc94..9d35e121 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -61,6 +61,7 @@ import type { RunSdlcReviewInput, RunSkillTestsInput, RunTaskInput, + SyncSkillsFromDiskInput, RevokeProviderCredentialInput, SaveProviderCredentialInput, ComposioToolkitSummaryDto, @@ -152,6 +153,7 @@ export interface DesktopApi { streamProviderResponse(input: StreamProviderResponseInput): Promise>; cancelProviderResponse(input: CancelProviderResponseInput): Promise>; importSkillDirectory(input: ImportSkillDirectoryInput): Promise>; + syncSkillsFromDisk(input: SyncSkillsFromDiskInput): Promise>; bindSkill(input: BindSkillInput): Promise>; resolveAgentSkills(input: ResolveAgentSkillsInput): Promise>; runSkillTests(input: RunSkillTestsInput): Promise>; diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index c35fb109..94559b6e 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -33,6 +33,7 @@ export const IPC_CHANNELS = { streamProviderResponse: "agentcompany:stream-provider-response", cancelProviderResponse: "agentcompany:cancel-provider-response", importSkillDirectory: "agentcompany:import-skill-directory", + syncSkillsFromDisk: "agentcompany:sync-skills-from-disk", bindSkill: "agentcompany:bind-skill", resolveAgentSkills: "agentcompany:resolve-agent-skills", runSkillTests: "agentcompany:run-skill-tests", diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 666b4fdc..ea896a10 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -7,12 +7,18 @@ import { randomUUID } from "node:crypto"; test("onboards, hires, and creates work in the Electron shell", async () => { const profileDir = join(tmpdir(), `agentcompany-new-e2e-${randomUUID()}`); mkdirSync(profileDir, { recursive: true }); + // The app reads skills from the user's global library, so the test gets a + // library of its own — seeded with one on-disk skill the Skills page must + // surface without a manual import. + const skillsRoot = join(tmpdir(), `agentcompany-new-e2e-skills-${randomUUID()}`); + createGlobalDiskSkill(skillsRoot); const app = await electron.launch({ args: [resolve("out/main/index.js")], env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", + AGENTCOMPANY_NEW_SKILLS_ROOT: skillsRoot, }, }); @@ -138,12 +144,14 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await expect(page.getByText("0 / 25").first()).toBeVisible(); await page.getByRole("button", { name: "Skills" }).click(); + // The seeded home-directory skill syncs in on its own when the page opens. + await expect(page.getByText("Disk Runbook").first()).toBeVisible(); await page.getByRole("button", { name: "Import" }).click(); await page.getByLabel("Directory path").fill(createSkillDirectory()); await page.locator(".modal").getByRole("button", { name: "Import" }).click(); await expect(page.getByText("Release Gate").first()).toBeVisible(); await expect(page.getByText("Never run").first()).toBeVisible(); - await page.getByRole("button", { name: "Run tests" }).click(); + await page.locator(".table-row").filter({ hasText: "Release Gate" }).getByRole("button", { name: "Run tests" }).click(); await expect(page.getByText("1/1 passed").first()).toBeVisible(); // Virtual office page @@ -163,6 +171,16 @@ test("onboards, hires, and creates work in the Electron shell", async () => { } }); +function createGlobalDiskSkill(skillsRoot: string): void { + const dir = join(skillsRoot, "disk-runbook"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "SKILL.md"), + "---\nname: Disk Runbook\ndescription: Recorded incident response steps.\n---\n\nFollow the recorded steps.\n", + "utf8", + ); +} + function createSkillDirectory(): string { const root = join(tmpdir(), `agentcompany-skill-${randomUUID()}`); mkdirSync(join(root, "checks"), { recursive: true }); diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index b8517a28..c7559ab5 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -11,7 +11,12 @@ test("the office floor opens agent details on click", async () => { mkdirSync(profileDir, { recursive: true }); const app = await electron.launch({ args: [resolve("out/main/index.js")], - env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" }, + env: { + ...process.env, + AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, + AGENTCOMPANY_NEW_AUTORUN: "0", + AGENTCOMPANY_NEW_SKILLS_ROOT: join(profileDir, "skills-root"), + }, }); try { diff --git a/tests/e2e/screenshots.spec.ts b/tests/e2e/screenshots.spec.ts index ace30509..8215a1ec 100644 --- a/tests/e2e/screenshots.spec.ts +++ b/tests/e2e/screenshots.spec.ts @@ -9,7 +9,12 @@ test("captures every page for visual review", async () => { mkdirSync(profileDir, { recursive: true }); const app = await electron.launch({ args: [resolve("out/main/index.js")], - env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0" }, + env: { + ...process.env, + AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, + AGENTCOMPANY_NEW_AUTORUN: "0", + AGENTCOMPANY_NEW_SKILLS_ROOT: join(profileDir, "skills-root"), + }, }); const outDir = resolve("test-results/screenshots"); mkdirSync(outDir, { recursive: true }); diff --git a/tests/unit/skill-discovery.test.ts b/tests/unit/skill-discovery.test.ts new file mode 100644 index 00000000..536e9aae --- /dev/null +++ b/tests/unit/skill-discovery.test.ts @@ -0,0 +1,108 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { SkillService } from "../../src/main/services/skill-service"; +import { syncSkillsFromDisk } from "../../src/main/services/skill-discovery"; + +interface Harness { + connection: DatabaseConnection; + skills: SkillService; + orgId: string; + globalRoot: string; + workspaceRoot: string; +} + +function harness(): Harness { + const connection = new DatabaseConnection(join(tmpdir(), `ac-skdisc-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + const workspaceRoot = join(tmpdir(), `ac-skdisc-ws-${randomUUID()}`); + mkdirSync(workspaceRoot, { recursive: true }); + let orgId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" }); + organization.createWorkspace({ orgId, name: "HQ", localPath: workspaceRoot, projectId: null }); + }); + const globalRoot = join(tmpdir(), `ac-skdisc-global-${randomUUID()}`); + mkdirSync(globalRoot, { recursive: true }); + const artifactRoot = join(tmpdir(), `ac-skdisc-art-${randomUUID()}`); + return { connection, skills: new SkillService(connection.db, artifactRoot), orgId, globalRoot, workspaceRoot }; +} + +function writeManifestSkill(root: string, dirName: string, name: string): void { + const dir = join(root, dirName); + mkdirSync(join(dir, "checks"), { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `Guide for ${name}.\n`, "utf8"); + writeFileSync(join(dir, "checks", "acceptance.txt"), "accepted\n", "utf8"); + writeFileSync(join(dir, "skill.manifest.json"), JSON.stringify({ + name, + version: "1.0.0", + entrypoint: "SKILL.md", + capabilities: ["review"], + required_tools: ["git"], + compatible_models: ["gpt-5.4"], + risk_level: "low", + tests: ["checks/acceptance.txt"], + inputs: {}, + outputs: {}, + }, null, 2), "utf8"); +} + +function writeMarkdownOnlySkill(root: string, dirName: string, frontmatterName: string | null): void { + const dir = join(root, dirName); + mkdirSync(dir, { recursive: true }); + const frontmatter = frontmatterName ? `---\nname: ${frontmatterName}\ndescription: does things\n---\n\n` : ""; + writeFileSync(join(dir, "SKILL.md"), `${frontmatter}# Steps\nDo the thing.\n`, "utf8"); +} + +function skillNames(h: Harness): string[] { + return (h.connection.db.prepare("select name from skill_definitions where org_id = ? order by name asc").all(h.orgId) as Array<{ name: string }>) + .map((row) => row.name); +} + +describe("skill discovery", () => { + test("imports skills from the global directory and every workspace", () => { + const h = harness(); + writeManifestSkill(h.globalRoot, "release-gate", "Release Gate"); + writeMarkdownOnlySkill(join(h.workspaceRoot, ".agents", "skills"), "code-style", "Code Style"); + + const report = syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot); + expect(report.imported).toBe(2); + expect(report.failures).toEqual([]); + expect(skillNames(h)).toEqual(["Code Style", "Release Gate"]); + }); + + test("a SKILL.md without frontmatter falls back to the directory name", () => { + const h = harness(); + writeMarkdownOnlySkill(h.globalRoot, "incident-runbook", null); + syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot); + expect(skillNames(h)).toEqual(["incident-runbook"]); + }); + + test("re-syncing skips what the org already carries", () => { + const h = harness(); + writeManifestSkill(h.globalRoot, "release-gate", "Release Gate"); + syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot); + const second = syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot); + expect(second.imported).toBe(0); + expect(second.skipped).toBe(1); + expect(skillNames(h)).toEqual(["Release Gate"]); + }); + + test("one broken directory cannot block the rest", () => { + const h = harness(); + writeManifestSkill(h.globalRoot, "good", "Good Skill"); + const broken = join(h.globalRoot, "broken"); + mkdirSync(broken, { recursive: true }); + writeFileSync(join(broken, "skill.manifest.json"), "{ not json", "utf8"); + writeFileSync(join(broken, "SKILL.md"), "x", "utf8"); + + const report = syncSkillsFromDisk(h.connection.db, h.skills, h.orgId, h.globalRoot); + expect(report.imported).toBe(1); + expect(report.failures).toHaveLength(1); + expect(skillNames(h)).toEqual(["Good Skill"]); + }); +});