diff --git a/packages/cli/src/commands/figma/asset.test.ts b/packages/cli/src/commands/figma/asset.test.ts
index 680866673..c3e25a340 100644
--- a/packages/cli/src/commands/figma/asset.test.ts
+++ b/packages/cli/src/commands/figma/asset.test.ts
@@ -101,4 +101,56 @@ describe("runAssetImport", () => {
/node/i,
);
});
+
+ it("records description + entity and regenerates .media/index.md", async () => {
+ const dir = scratch();
+ const out = await runAssetImport(
+ "KEY:1-2",
+ { format: "png", description: "hero illustration", entity: "Acme hero" },
+ deps(dir),
+ );
+ expect(out.record.description).toBe("hero illustration");
+ expect(out.record.entity).toBe("Acme hero");
+ expect(out.snippet.html).toContain('alt="hero illustration"');
+ const index = readFileSync(join(dir, ".media", "index.md"), "utf8");
+ expect(index).toContain("hero illustration");
+ expect(index).toContain(out.record.id);
+ });
+
+ it("applies --description/--entity on a reuse hit instead of dropping them", async () => {
+ const dir = scratch();
+ const first = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir));
+ expect(first.record.description).toBeUndefined();
+ const again = await runAssetImport(
+ "KEY:1-2",
+ { format: "png", description: "Acme logo", entity: "Acme logo" },
+ deps(dir),
+ );
+ expect(again.reused).toBe(true);
+ expect(again.record.description).toBe("Acme logo");
+ expect(again.snippet.html).toContain('alt="Acme logo"');
+ const index = readFileSync(join(dir, ".media", "index.md"), "utf8");
+ expect(index).toContain("Acme logo");
+ expect(index).not.toContain("image_002");
+ });
+
+ it("reuses against ANY matching tuple, not just the oldest row", async () => {
+ const dir = scratch();
+ await runAssetImport("KEY:1-2", { format: "svg" }, deps(dir)); // image_001 (svg)
+ const png = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir)); // image_002
+ expect(png.reused).toBe(false);
+ const pngAgain = await runAssetImport("KEY:1-2", { format: "png" }, deps(dir));
+ expect(pngAgain.reused).toBe(true);
+ expect(pngAgain.record.id).toBe(png.record.id);
+ });
+
+ it("flattens whitespace in descriptions so index.md rows stay single-line", async () => {
+ const dir = scratch();
+ const out = await runAssetImport(
+ "KEY:1-2",
+ { format: "png", description: "line one\nline two" },
+ deps(dir),
+ );
+ expect(out.record.description).toBe("line one line two");
+ });
});
diff --git a/packages/cli/src/commands/figma/asset.ts b/packages/cli/src/commands/figma/asset.ts
index 450868249..79252c030 100644
--- a/packages/cli/src/commands/figma/asset.ts
+++ b/packages/cli/src/commands/figma/asset.ts
@@ -9,12 +9,14 @@ import {
appendRecord,
buildAssetSnippet,
createFigmaClient,
- findByFigmaNode,
+ findAllByFigmaNode,
freezeBytes,
nextId,
parseFigmaRef,
+ regenerateIndex,
sanitizeSvg,
typeDirPath,
+ updateRecord,
type AssetSnippet,
type FigmaAssetFormat,
type FigmaClient,
@@ -28,6 +30,10 @@ import { withFigmaErrors } from "./cliError.js";
export interface AssetImportOptions {
format: FigmaAssetFormat;
scale?: number;
+ /** human description — lands in the manifest + index.md +
*/
+ description?: string;
+ /** media-use interop: entity name for `resolve --entity` cache hits */
+ entity?: string;
}
export interface AssetImportDeps {
@@ -55,21 +61,39 @@ export async function runAssetImport(
);
const { version } = await deps.client.fileVersion(ref.fileKey);
+ const description = normalizeMeta(opts.description);
+ const entity = normalizeMeta(opts.entity);
// Cache key per spec §5: fileKey:nodeId:format:scale:version → reuse.
- // Unspecified scale is canonically 1 on both sides (figma's default), so
- // `--scale 1` and no flag dedupe to the same record. Reuse also requires
- // the frozen file to still exist — a deleted file falls through to
- // re-import instead of returning a snippet that points at nothing.
- const existing = findByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId);
- if (
- existing &&
- existing.provenance.format === opts.format &&
- (existing.provenance.scale ?? 1) === (opts.scale ?? 1) &&
- existing.provenance.version === version &&
- existsSync(join(deps.projectDir, existing.path))
- ) {
- return { record: existing, snippet: buildAssetSnippet(existing), reused: true };
+ // Check EVERY row for the node (a node can legitimately have several
+ // format/scale/version tuples — the oldest-row shortcut minted duplicates
+ // forever once a second tuple existed). Unspecified scale is canonically 1
+ // on both sides (figma's default). Reuse also requires the frozen file to
+ // still exist — a deleted file falls through to re-import.
+ const existing = findAllByFigmaNode(deps.projectDir, ref.fileKey, ref.nodeId).find(
+ (r) =>
+ r.provenance.format === opts.format &&
+ (r.provenance.scale ?? 1) === (opts.scale ?? 1) &&
+ r.provenance.version === version &&
+ existsSync(join(deps.projectDir, r.path)),
+ );
+ if (existing) {
+ // Metadata supplied on a re-import still lands: upsert the row instead
+ // of silently discarding the flags.
+ let record = existing;
+ if (
+ (description !== undefined && description !== existing.description) ||
+ (entity !== undefined && entity !== existing.entity)
+ ) {
+ record = {
+ ...existing,
+ ...(description !== undefined && { description }),
+ ...(entity !== undefined && { entity }),
+ };
+ updateRecord(deps.projectDir, record);
+ }
+ safeRegenerateIndex(deps.projectDir);
+ return { record, snippet: buildAssetSnippet(record), reused: true };
}
const rendered = await deps.client.renderNode(ref, opts);
@@ -92,6 +116,8 @@ export async function runAssetImport(
type: "image",
path: relative(deps.projectDir, destAbs),
source: `figma:${ref.fileKey}/${ref.nodeId}`,
+ ...(description !== undefined && { description }),
+ ...(entity !== undefined && { entity }),
provenance: {
source: "figma",
fileKey: ref.fileKey,
@@ -102,9 +128,29 @@ export async function runAssetImport(
},
};
appendRecord(deps.projectDir, record);
+ safeRegenerateIndex(deps.projectDir);
return { record, snippet: buildAssetSnippet(record), reused: false };
}
+/** index.md is a single table row per record — newlines/tabs in a
+ * description would corrupt the whole table. */
+function normalizeMeta(value: string | undefined): string | undefined {
+ if (value === undefined) return undefined;
+ const cleaned = value.replace(/\s+/g, " ").trim();
+ return cleaned.length > 0 ? cleaned : undefined;
+}
+
+/** Keep the agent-readable inventory in step with the manifest (media-use
+ * regenerates the same file after its writes). Best-effort: the import is
+ * already durable, so an index write failure must not fail the command. */
+function safeRegenerateIndex(projectDir: string): void {
+ try {
+ regenerateIndex(projectDir);
+ } catch (err) {
+ console.warn(`index.md regeneration failed: ${err instanceof Error ? err.message : err}`);
+ }
+}
+
const FORMATS: readonly FigmaAssetFormat[] = ["png", "svg", "jpg", "pdf"];
function parseFormat(raw: string): FigmaAssetFormat {
@@ -122,6 +168,14 @@ export default defineCommand({
},
format: { type: "string", description: "png | svg | jpg | pdf", default: "svg" },
scale: { type: "string", description: "export scale (e.g. 2)" },
+ description: {
+ type: "string",
+ description: "what this asset is (index.md +
); e.g. the layer's purpose",
+ },
+ entity: {
+ type: "string",
+ description: 'entity name for media-use cache lookups (e.g. "Acme logo")',
+ },
dir: { type: "string", description: "project directory", default: "." },
},
async run({ args }) {
@@ -133,6 +187,8 @@ export default defineCommand({
{
format: parseFormat(args.format),
scale: args.scale !== undefined ? Number(args.scale) : undefined,
+ description: args.description,
+ entity: args.entity,
},
{ projectDir: args.dir, client, download: downloadRender },
);
diff --git a/packages/cli/src/commands/figma/component.ts b/packages/cli/src/commands/figma/component.ts
index 5c6f6bf4e..b80a2f9f0 100644
--- a/packages/cli/src/commands/figma/component.ts
+++ b/packages/cli/src/commands/figma/component.ts
@@ -67,12 +67,14 @@ export async function runComponentImport(
// The search key must match the EMITTED (html-escaped) node id, and
// replaceAll covers the same node appearing twice in the tree.
let html = mapped.html;
+ const frozenAssets: string[] = [];
for (const req of mapped.rasterize) {
const asset = await runAssetImport(
`${ref.fileKey}:${req.nodeId}`,
- { format: "svg" },
+ { format: "svg", description: req.name },
{ projectDir: deps.projectDir, client: deps.client, download: deps.download },
);
+ frozenAssets.push(asset.record.path);
// src is a URL — always forward slashes, even when relative() yields
// windows separators.
const srcRel = relative(componentDir, join(deps.projectDir, asset.record.path)).replaceAll(
@@ -99,9 +101,11 @@ export async function runComponentImport(
target: `compositions/components/${name}/${name}.html`,
type: "hyperframes:snippet",
},
- ...mapped.rasterize.map((r) => ({
- path: `${r.slug}.svg`,
- target: `.media/images/${r.slug}.svg`,
+ // The paths the frozen files ACTUALLY landed at (image_NNN.svg), which
+ // is also what the emitted HTML references — not the slug names.
+ ...frozenAssets.map((p) => ({
+ path: p.split("/").pop() ?? p,
+ target: p.replaceAll("\\", "/"),
type: "hyperframes:asset",
})),
],
diff --git a/packages/core/src/figma/index.ts b/packages/core/src/figma/index.ts
index 369687ab2..9e9a0b552 100644
--- a/packages/core/src/figma/index.ts
+++ b/packages/core/src/figma/index.ts
@@ -25,12 +25,15 @@ export {
mediaDir,
manifestPath,
typeDirPath,
+ updateRecord,
isFigmaManifestRecord,
readManifest,
appendRecord,
+ findAllByFigmaNode,
findByFigmaNode,
nextId,
} from "./manifest";
+export { regenerateIndex } from "./mediaIndex";
export { buildAssetSnippet } from "./assetSnippet";
export { sanitizeSvg } from "./sanitizeSvg";
export {
diff --git a/packages/core/src/figma/manifest.ts b/packages/core/src/figma/manifest.ts
index 3bce17158..86f0e5dcd 100644
--- a/packages/core/src/figma/manifest.ts
+++ b/packages/core/src/figma/manifest.ts
@@ -1,4 +1,4 @@
-import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
+import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { readJsonlValues } from "./jsonl";
import type { FigmaManifestRecord } from "./types";
@@ -68,15 +68,41 @@ export function findByFigmaNode(
fileKey: string,
nodeId: string,
): FigmaManifestRecord | null {
- for (const r of readManifest(projectDir)) {
- if (
+ return findAllByFigmaNode(projectDir, fileKey, nodeId)[0] ?? null;
+}
+
+/** EVERY row for a node — reuse gates must check all (format, scale, version)
+ * tuples, not just the oldest, or a second tuple defeats idempotency forever. */
+export function findAllByFigmaNode(
+ projectDir: string,
+ fileKey: string,
+ nodeId: string,
+): FigmaManifestRecord[] {
+ return readManifest(projectDir).filter(
+ (r) =>
r.provenance.source === "figma" &&
r.provenance.fileKey === fileKey &&
- r.provenance.nodeId === nodeId
- )
- return r;
- }
- return null;
+ r.provenance.nodeId === nodeId,
+ );
+}
+
+/** Rewrite one row in place by id, preserving every other line (other
+ * writers' rows included) byte-for-byte. */
+export function updateRecord(projectDir: string, record: FigmaManifestRecord): void {
+ const p = manifestPath(projectDir);
+ const lines = readFileSync(p, "utf8").split(/\r?\n/);
+ const out = lines.map((line) => {
+ const trimmed = line.trim();
+ if (trimmed.length === 0) return line;
+ try {
+ const parsed: unknown = JSON.parse(trimmed);
+ if (isFigmaManifestRecord(parsed) && parsed.id === record.id) return JSON.stringify(record);
+ } catch {
+ // non-JSON line — preserve untouched
+ }
+ return line;
+ });
+ writeFileSync(p, out.join("\n"));
}
export function nextId(projectDir: string, type: FigmaManifestRecord["type"]): string {
diff --git a/packages/core/src/figma/mediaIndex.test.ts b/packages/core/src/figma/mediaIndex.test.ts
new file mode 100644
index 000000000..b2d597f8a
--- /dev/null
+++ b/packages/core/src/figma/mediaIndex.test.ts
@@ -0,0 +1,107 @@
+// @vitest-environment node
+import { describe, expect, it, afterEach } from "vitest";
+import { appendFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { execFileSync } from "node:child_process";
+import { generateIndexContent, indexPath, regenerateIndex } from "./mediaIndex";
+import { manifestPath } from "./manifest";
+
+const dirs: string[] = [];
+function project(): string {
+ const d = mkdtempSync(join(tmpdir(), "hf-media-index-"));
+ dirs.push(d);
+ mkdirSync(join(d, ".media"), { recursive: true });
+ return d;
+}
+afterEach(() => {
+ for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
+});
+
+const MEDIA_USE_ROW = {
+ id: "bgm_001",
+ type: "bgm",
+ path: ".media/audio/bgm/bgm_001.mp3",
+ source: "search",
+ description: "upbeat tech launch",
+ duration: 25,
+ provenance: { provider: "heygen-audio", prompt: "upbeat tech launch" },
+};
+const IMAGE_ROW = {
+ id: "image_001",
+ type: "image",
+ path: ".media/images/image_001.jpg",
+ source: "search",
+ description: "gradient tech background",
+ width: 1920,
+ height: 1080,
+ provenance: { provider: "heygen-asset", prompt: "gradient tech background" },
+};
+const ICON_ROW = {
+ id: "icon_001",
+ type: "icon",
+ path: ".media/images/icon_001.svg",
+ source: "search",
+ description: "rocket",
+ transparent: true,
+ provenance: { provider: "heygen-asset", prompt: "rocket" },
+};
+const FIGMA_ROW = {
+ id: "image_002",
+ type: "image",
+ path: ".media/images/image_002.svg",
+ source: "figma:KEY/1:2",
+ description: "hero illustration",
+ entity: "Acme hero",
+ provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "9", format: "svg" },
+};
+// media-use renders every JSON-parseable row, shape or no shape — selection
+// parity matters as much as format parity.
+const JUNK_ROW = { note: "not a media record" };
+const ALL_ROWS = [MEDIA_USE_ROW, IMAGE_ROW, ICON_ROW, FIGMA_ROW, JUNK_ROW];
+
+describe("regenerateIndex", () => {
+ it("renders every writer's rows (media-use + figma) into one table", () => {
+ const p = project();
+ for (const row of ALL_ROWS) appendFileSync(manifestPath(p), JSON.stringify(row) + "\n");
+ regenerateIndex(p);
+ const index = readFileSync(indexPath(p), "utf8");
+ expect(index).toContain("# .media · 5 assets");
+ expect(index).toContain("25s");
+ expect(index).toContain("1920×1080");
+ expect(index).toContain("hero illustration");
+ });
+
+ it("matches media-use's index-gen output byte-for-byte on the same rows", () => {
+ // Covers duration, width×height, icon+transparent, no-dims, and junk-row
+ // selection — the full set of branches both generators format.
+ const ours = generateIndexContent(ALL_ROWS as Record[]);
+ // Run the actual media-use generator on identical input. Resolve the
+ // script relative to THIS file (cwd varies per test runner) and hand it
+ // over as a file:// URL so the specifier is valid on windows too.
+ const genUrl = pathToFileURL(
+ join(
+ fileURLToPath(new URL(".", import.meta.url)),
+ "..",
+ "..",
+ "..",
+ "..",
+ "skills",
+ "media-use",
+ "scripts",
+ "lib",
+ "index-gen.mjs",
+ ),
+ ).href;
+ const script = `
+ import { generateIndexContent } from ${JSON.stringify(genUrl)};
+ const rows = ${JSON.stringify(ALL_ROWS)};
+ process.stdout.write(generateIndexContent(rows));
+ `;
+ const theirs = execFileSync("node", ["--input-type=module", "-e", script], {
+ encoding: "utf8",
+ });
+ expect(ours).toBe(theirs);
+ });
+});
diff --git a/packages/core/src/figma/mediaIndex.ts b/packages/core/src/figma/mediaIndex.ts
new file mode 100644
index 000000000..8ec865b35
--- /dev/null
+++ b/packages/core/src/figma/mediaIndex.ts
@@ -0,0 +1,89 @@
+/**
+ * Regenerate .media/index.md — the agent-readable inventory table — after a
+ * figma import, exactly the way media-use does after a resolve. Both writers
+ * regenerate the SAME file from the full manifest (all writers' rows), so the
+ * output format AND row selection here must stay byte-identical with
+ * skills/media-use/scripts/lib/index-gen.mjs — including rendering every
+ * JSON-parseable row (no shape filtering), or the file would flip-flop
+ * depending on which writer ran last.
+ */
+
+import { mkdirSync, writeFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { readJsonlValues } from "./jsonl";
+import { manifestPath, mediaDir } from "./manifest";
+
+type IndexRow = Record;
+
+function isRow(value: unknown): value is IndexRow {
+ return typeof value === "object" && value !== null;
+}
+
+export function indexPath(projectDir: string): string {
+ return join(mediaDir(projectDir), "index.md");
+}
+
+function pad(str: unknown, len: number): string {
+ return String(str ?? "").padEnd(len);
+}
+
+function formatDur(r: IndexRow): string {
+ if (r.duration == null) return "—";
+ return `${String(r.duration)}s`;
+}
+
+function formatDims(r: IndexRow): string {
+ if (r.width && r.height) return `${String(r.width)}×${String(r.height)}`;
+ if (r.type === "icon" && r.transparent) return "svg";
+ return "—";
+}
+
+function len(value: unknown): number {
+ return String(value ?? "").length;
+}
+
+export function generateIndexContent(records: IndexRow[]): string {
+ const count = records.length;
+ const header = `# .media · ${count} asset${count === 1 ? "" : "s"}\n`;
+ if (count === 0) return header;
+
+ const cols = { id: 4, type: 5, dur: 4, dims: 5, path: 5 };
+ for (const r of records) {
+ cols.id = Math.max(cols.id, len(r.id));
+ cols.type = Math.max(cols.type, len(r.type));
+ cols.dur = Math.max(cols.dur, formatDur(r).length);
+ cols.dims = Math.max(cols.dims, formatDims(r).length);
+ cols.path = Math.max(cols.path, len(r.path));
+ }
+
+ const heading =
+ pad("id", cols.id + 2) +
+ pad("type", cols.type + 2) +
+ pad("dur", cols.dur + 2) +
+ pad("dims", cols.dims + 2) +
+ pad("path", cols.path + 2) +
+ "description";
+
+ const lines = [header, heading];
+ for (const r of records) {
+ lines.push(
+ pad(r.id, cols.id + 2) +
+ pad(r.type, cols.type + 2) +
+ pad(formatDur(r), cols.dur + 2) +
+ pad(formatDims(r), cols.dims + 2) +
+ pad(r.path, cols.path + 2) +
+ String(r.description ?? ""),
+ );
+ }
+ return lines.join("\n") + "\n";
+}
+
+/** Rebuild index.md from EVERY writer's manifest rows (media-use + figma). */
+export function regenerateIndex(projectDir: string): string {
+ const records = readJsonlValues(manifestPath(projectDir)).filter(isRow);
+ const content = generateIndexContent(records);
+ const p = indexPath(projectDir);
+ mkdirSync(dirname(p), { recursive: true });
+ writeFileSync(p, content);
+ return content;
+}
diff --git a/packages/core/src/figma/types.ts b/packages/core/src/figma/types.ts
index 769106235..4cbc7b173 100644
--- a/packages/core/src/figma/types.ts
+++ b/packages/core/src/figma/types.ts
@@ -20,6 +20,8 @@ export interface FigmaManifestRecord {
path: string;
source: string;
description?: string;
+ /** media-use interop: lets `resolve --entity` find figma-imported assets */
+ entity?: string;
width?: number;
height?: number;
provenance: FigmaProvenance;
diff --git a/skills-manifest.json b/skills-manifest.json
index 7001b1b64..2b1142879 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -10,7 +10,7 @@
"files": 17
},
"figma": {
- "hash": "c462519102acc265",
+ "hash": "0adc2a1e01767db7",
"files": 1
},
"general-video": {
@@ -50,7 +50,7 @@
"files": 10
},
"media-use": {
- "hash": "fba6e0963e431b1e",
+ "hash": "75dda0086dda18ce",
"files": 19
},
"motion-graphics": {
diff --git a/skills/figma/SKILL.md b/skills/figma/SKILL.md
index 6a2f15356..17e0f3985 100644
--- a/skills/figma/SKILL.md
+++ b/skills/figma/SKILL.md
@@ -49,10 +49,10 @@ Parse the user's figma link with `parseFigmaRef` (URL, `fileKey:nodeId`, bare `f
## Assets (Phase 1 — CLI)
```bash
-hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2]
+hyperframes figma asset '' [--format svg|png|jpg|pdf] [--scale 2] [--description "..."] [--entity "..."]
```
-Renders over REST, sanitizes SVG, freezes under `.media/images/`, appends the manifest with provenance, prints an `
` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity.
+Renders over REST, sanitizes SVG, freezes under `.media/images/`, appends the manifest with provenance, regenerates `.media/index.md` (the shared media-use inventory), prints an `
` snippet. Idempotent per `fileKey:nodeId:format:scale:version`. Prefer SVG for vectors/logos (scalable, animatable), PNG `--scale 2` for raster fidelity. **Always pass `--description ""`** (it becomes the index row + `
`); add `--entity ""` for named brand marks so media-use `resolve --entity` finds them later (entity hits match across image/icon).
## Tokens (Phase 2 — CLI)
diff --git a/skills/media-use/scripts/resolve.mjs b/skills/media-use/scripts/resolve.mjs
index 0de752cdd..242b49406 100644
--- a/skills/media-use/scripts/resolve.mjs
+++ b/skills/media-use/scripts/resolve.mjs
@@ -74,10 +74,16 @@ async function run() {
return result(projectHit, "cached");
}
- // 1b. entity match in project
+ // 1b. entity match in project. icon and image are interchangeable for
+ // entity hits — both live in images/, and figma-imported brand marks are
+ // always recorded as type image while agents ask for logos as type icon.
if (entity) {
const entityHit = findByEntity(projectDir, entity);
- if (entityHit && entityHit.type === type && existsSync(join(projectDir, entityHit.path))) {
+ if (
+ entityHit &&
+ typesMatch(entityHit.type, type) &&
+ existsSync(join(projectDir, entityHit.path))
+ ) {
return result(entityHit, "cached");
}
}
@@ -115,7 +121,7 @@ async function run() {
if (entity) {
const entityCacheHit = cacheGetByEntity(entity);
- if (entityCacheHit && entityCacheHit.type === type) {
+ if (entityCacheHit && typesMatch(entityCacheHit.type, type)) {
const id = nextId(projectDir, type);
const ext = extname(entityCacheHit.cached_path);
const localPath = `.media/${typeSubdir(type)}/${id}${ext}`;
@@ -197,6 +203,12 @@ async function run() {
return result(record, searchResult.source || "search");
}
+function typesMatch(a, b) {
+ if (a === b) return true;
+ const visual = new Set(["icon", "image"]);
+ return visual.has(a) && visual.has(b);
+}
+
function result(record, source) {
if (args.json) {
console.log(JSON.stringify({ ok: true, ...record, _source: source }));
diff --git a/skills/media-use/scripts/resolve.test.mjs b/skills/media-use/scripts/resolve.test.mjs
index 90462c0be..60899fd92 100644
--- a/skills/media-use/scripts/resolve.test.mjs
+++ b/skills/media-use/scripts/resolve.test.mjs
@@ -68,6 +68,40 @@ test("project manifest hit skips providers", () => {
cleanup();
});
+test("entity hit matches across icon/image (figma-imported brand marks)", () => {
+ setup();
+ const record = makeRecord({
+ id: "image_001",
+ type: "image",
+ path: ".media/images/image_001.svg",
+ description: "Acme logo",
+ entity: "Acme logo",
+ provenance: { source: "figma", fileKey: "KEY", nodeId: "1:2", version: "1", format: "svg" },
+ });
+ delete record.duration;
+ appendRecord(tmp, record);
+ const filePath = join(tmp, record.path);
+ mkdirSync(join(filePath, ".."), { recursive: true });
+ writeFileSync(filePath, "");
+
+ const out = runResolve([
+ "--type",
+ "icon",
+ "--intent",
+ "acme brand mark",
+ "--entity",
+ "Acme logo",
+ "--project",
+ tmp,
+ "--json",
+ ]);
+ const parsed = JSON.parse(out.trim());
+ assert.equal(parsed.ok, true);
+ assert.equal(parsed.id, "image_001");
+ assert.equal(parsed._source, "cached");
+ cleanup();
+});
+
// --- global cache hit ---
test("global cache hit copies to project and registers", () => {