@hegemonart/get-design-done 1.30.6 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Get Design Done — 5-stage agent-orchestrated design pipeline with 9 connections, handoff-first workflow, bidirectional Figma write-back, 22+ specialized agents, queryable knowledge layer (intel store, dependency analysis, learnings extraction), and a self-improvement loop (reflector, frontmatter + budget feedback, global-skills layer). v1.20.0 ships the SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream, and resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) for rate-limit + 429 + context-overflow recovery. Full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows) and release automation (auto-tag + GitHub Release + release-time smoke test).",
8
- "version": "1.30.6"
8
+ "version": "1.31.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "get-design-done",
13
13
  "source": "./",
14
14
  "description": "Agent-orchestrated 5-stage design pipeline: Brief → Explore → Plan → Design → Verify. 22+ specialized agents, 9 connections (Figma, Refero, Preview, Storybook, Chromatic, Figma Writer, Graphify, Pinterest, Claude Design), Claude Design handoff, bidirectional Figma write-back, and a queryable intel store (.design/intel/) for dependency and learnings queries. Standalone commands: style, darkmode, compare, figma-write, graphify, handoff, analyze-dependencies, skill-manifest, extract-learnings. Embeds NNG heuristics, WCAG thresholds, typographic systems, motion framework, and anti-pattern catalog. Ships with a full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows) and release automation. Optimization layer (v1.0.4.1, retroactive): gdd-router + gdd-cache-manager skills, PreToolUse budget-enforcer hook, tier-aware agent frontmatter, lazy checker gates, streaming synthesizer, /gdd:warm-cache + /gdd:optimize commands, and cost telemetry at .design/telemetry/costs.jsonl — targeting 50-70% per-task token-cost reduction with no quality-floor regression. v1.20.0 SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream at .design/telemetry/events.jsonl, resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) with rate-limit + 429 + context-overflow recovery, and TypeScript toolchain.",
15
- "version": "1.30.6",
15
+ "version": "1.31.0",
16
16
  "author": {
17
17
  "name": "hegemonart"
18
18
  },
@@ -68,7 +68,10 @@
68
68
  "gemini",
69
69
  "mcp",
70
70
  "parallel-agents",
71
- "agent-sdk"
71
+ "agent-sdk",
72
+ "figma",
73
+ "extractor",
74
+ "design-system-sync"
72
75
  ]
73
76
  }
74
77
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "get-design-done",
3
3
  "short_name": "gdd",
4
- "version": "1.30.6",
4
+ "version": "1.31.0",
5
5
  "description": "Agent-orchestrated 5-stage design pipeline: Brief → Explore → Plan → Design → Verify. 22+ specialized agents, 9 connections (Figma, Refero, Preview, Storybook, Chromatic, Figma Writer, Graphify, Pinterest, Claude Design), handoff-first workflow via Claude Design bundles, bidirectional Figma write-back (annotations, Code Connect), queryable intel store (`.design/intel/`) for O(1) design surface lookups, and self-improvement loop (reflector agent, frontmatter + budget feedback, global-skills layer at `~/.claude/gdd/global-skills/`). Standalone commands: style, darkmode, compare, figma-write, graphify, handoff, analyze-dependencies, skill-manifest, extract-learnings, reflect, apply-reflections. Embeds NNG heuristics, WCAG thresholds, typographic systems, motion framework, and anti-pattern catalog. Ships with a full CI/CD pipeline (Node 22/24 × Linux/macOS/Windows, lint + schema + frontmatter + stale-ref + shellcheck + gitleaks + injection-scan + blocking size-budget) and release automation (auto-tag + GitHub Release + release-time smoke test). Optimization layer (v1.0.4.1, retroactive): gdd-router + gdd-cache-manager skills, PreToolUse budget-enforcer hook, tier-aware agent frontmatter, lazy checker gates, streaming synthesizer, /gdd:warm-cache + /gdd:optimize commands, and cost telemetry at .design/telemetry/costs.jsonl — targeting 50-70% per-task token-cost reduction with no quality-floor regression. v1.20.0 SDK foundation: gdd-state MCP server (11 typed tools), lockfile-safe STATE.md mutations, event stream at .design/telemetry/events.jsonl, resilience primitives (jittered-backoff, rate-guard, error-classifier, iteration-budget) with rate-limit + 429 + context-overflow recovery, and TypeScript toolchain. v1.27.7 ships gdd-mcp (Phase 27.7): 12 read-only MCP tools for sub-3s priming. v1.28.0 (Phase 28): Foundational References Tier 2 — 5 new reference files (color-theory, composition, proportion-systems, i18n, contrast-advanced), 2 verifier i18n probes + 1 explore i18n-readiness probe, 12 additive cross-link insertions across 10 existing references, 2 orthogonal audit-scoring lens-tags (composition_alignment + i18n_readiness).",
6
6
  "author": {
7
7
  "name": "hegemonart",
@@ -62,7 +62,10 @@
62
62
  "agent-sdk",
63
63
  "mcp-server",
64
64
  "context-loading",
65
- "cross-session"
65
+ "cross-session",
66
+ "figma",
67
+ "extractor",
68
+ "design-system-sync"
66
69
  ],
67
70
  "skills": [
68
71
  "./skills/"
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ All notable changes to get-design-done are documented here. Versions follow [sem
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.31.0] - 2026-05-29
8
+
9
+ ### Phase 31 — Figma Off-Context Extractor + Variables Sync Plugin
10
+
11
+ Ships `gdd-figma-extract` — pull a whole Figma design system into a compact, queryable local digest (`DESIGN.md` + `tokens.json` + `components.json`) **without the raw JSON ever entering Claude's context**. One command extracts the file via the Figma REST API to a local raw cache; a separate digest stage reduces it to an LLM-readable spec. Plus a thin **"GDD Sync"** Figma plugin that fills the Variables-API-Enterprise gap by reading `figma.variables` from inside Figma (works on any plan — including Free) and POSTing them to an ephemeral localhost receiver. 10 plans across 4 waves: Wave A (raw puller + digest + styles resolver + SKILL), Wave B (plugin scaffold + export + receiver), Wave C (`--component` slice + `figma_extract` health check), Wave D (closeout).
12
+
13
+ ### Motivation — the spike trail
14
+
15
+ Spike 001 (commit `c3a9cf6`, `.planning/spikes/001-figma-offcontext-extractor/`) validated the economics: **898× compression** (223 MB raw → 254 KB digest), a 15.7K-token DESIGN.md (under the 20K target), 127 component sets + 40 singletons captured with variants/props/defaults, ~33s wall time, and crucially **0 Claude tokens + 0 Figma MCP calls during extraction**. This is the economic alternative to Figma MCP for whole-design-system workflows (Figma MCP remains correct for spot questions on individual components). The spike surfaced two gaps, both closed by this phase:
16
+
17
+ 1. **Variables API → 403 (Enterprise-only)** — closed by **Path C** (the GDD Sync plugin reads variables locally and POSTs to the receiver).
18
+ 2. **Legacy styles → 0 tokens** — closed by **Path B** (two-step `/styles` + `/nodes?ids=` lookup; published-style source nodes live in canvas frames, not the main document tree).
19
+
20
+ ### Added
21
+
22
+ - **`gdd-figma-extract` off-context extractor** — the two-stage pipeline (D-01: extract → digest stay separated, re-digest without re-pulling):
23
+ - `scripts/lib/figma-extract/pull.cjs` — productionized Figma REST puller (retry/backoff, version-based cache invalidation with 1h TTL fallback per D-11, drops `geometry=paths` per D-03). Writes raw JSON to disk only; raw bodies never returned to a logging caller.
24
+ - `scripts/lib/figma-extract/digest.cjs` — reads the raw cache, walks the node tree with **variant rollup** (D-02 — naive walk inflates ~16×), assembles tokens via the 3-path chain, renders `DESIGN.md` + `tokens.json` + `components.json`.
25
+ - `scripts/lib/figma-extract/styles-resolver.cjs` — **Path B** two-step `/styles` + `/nodes?ids=` resolver (fixes the spike's 0-tokens bug).
26
+ - `scripts/lib/figma-extract/render-md.cjs` + `walk.cjs` — deterministic, byte-stable DESIGN.md renderer + the variant-rollup walker.
27
+ - **Three-path token extraction (D-04)** with a fallback chain — Path A (Variables API, Enterprise; 403 skipped silently), Path B (`/styles` + `/nodes`), Path C (plugin sync). Resolution priority on collision: Variables > plugin sync > styles, with a `--prefer-styles` escape hatch.
28
+ - **"GDD Sync" Figma plugin** (`figma-plugin/`, D-05) — TypeScript, single "Export to GDD" button. Reads ALL local variables (D-13 — digest filters later), resolves aliases + modes, and POSTs to the localhost receiver.
29
+ - **Localhost receiver** (`scripts/lib/figma-extract/receiver.cjs`, D-06) — ephemeral, **127.0.0.1-only**, port hardcoded to 5179, schema-validated, refuses non-loopback remotes, exits on first valid receipt or timeout.
30
+ - **`--component <name|glob>` digest slice** (D-08) — a ~500-token per-component slice instead of the full ~16K digest; strictly additive (omitting it reproduces the full digest unchanged).
31
+ - **`figma_extract` health check** (Plan 31-09) — `/gdd:health` now reports one of three exact states: `figma extract: ready (token set)`, `figma extract: token missing`, or `figma extract: plugin sync needed for variables (Free tier detected)`. Presence-only token check (D-10); the Free-tier signal is read from the local raw-pull cache only (no network call).
32
+ - **`skills/figma-extract/SKILL.md`** (`name: gdd-figma-extract`) — off-context orchestration that never instructs reading `raw/*.json` (D-12).
33
+ - **`tests/phase-31-baseline.test.cjs`** + 6 baseline files at `test-fixture/baselines/phase-31/` (design-md, components-json, tokens-json, health-line, manifest-network-scope, token-isolation-static), **`tests/phase-31-end-to-end.test.cjs`** (offline pull+digest against fixtures), and **`tests/figma-extract-token-isolation.test.cjs`** (the D-10 static scanner + meta-tests).
34
+
35
+ ### Security & guarantees
36
+
37
+ - **Off-context guarantee (D-12) — statically enforced.** The SKILL never instructs reading `raw/*.json`; the digest is a pure function of the raw cache. The raw pull consumes 0 Claude tokens.
38
+ - **Token isolation (D-10) — statically enforced.** `FIGMA_TOKEN` (fallback `FIGMA_PERSONAL_ACCESS_TOKEN`) is read from the environment only and is sent ONLY as the `X-Figma-Token` request header — never logged, never written to disk. `tests/figma-extract-token-isolation.test.cjs` scans every file under `scripts/lib/figma-extract/` for any `writeFile`/`appendFile`/`console.*`/`logger.*`/`process.std*.write` call referencing the token and fails on a non-zero count (meta-tested to prove the scanner is not vacuous).
39
+ - **Receiver network scope (D-06).** The plugin manifest's `allowedDomains` is exactly the localhost pair (`http://localhost:5179`, `http://127.0.0.1:5179`) — any widening trips the baseline test. No external host, no wildcard.
40
+
41
+ ### Deferred
42
+
43
+ - **Live Figma validation (D-07).** The phase ships with hermetic offline tests (stubbed fetch against committed fixtures); a one-time end-to-end run against a real Figma file with a live token is a maintainer follow-up, not a v1.31.0 blocker. Plugin Community-directory submission is likewise deferred (dev-install path ships now).
44
+
45
+ ### Decisions (D-01 through D-13)
46
+
47
+ - **D-01**: Two-stage pipeline (extract → digest) stays separated. Re-run digest without re-pulling.
48
+ - **D-02**: Variant rollup default-on — skip COMPONENT children of a COMPONENT_SET; record variants as a field on the parent (naive walk inflates ~16×).
49
+ - **D-03**: Drop the `geometry=paths` query param (~30% smaller raw; geometry is discarded in the digest).
50
+ - **D-04**: Three-path token extraction with a fallback chain (Variables > plugin sync > styles; `--prefer-styles` escape).
51
+ - **D-05**: "GDD Sync" plugin as a separate `figma-plugin/` package (TypeScript, single button).
52
+ - **D-06**: Receiver is ephemeral + 127.0.0.1-only + hardcoded port 5179; refuses non-loopback; closes on receipt or timeout.
53
+ - **D-07**: Plugin distribution is dev-build now, Community submission as a follow-up; live-Figma validation is a maintainer follow-up — neither blocks v1.31.0.
54
+ - **D-08**: `--component <name|glob>` filter on digest for per-component slicing (~500 tokens vs ~16K).
55
+ - **D-09**: Raw cache gitignored (`.figma-extract-cache/`), reproducible from `pull.cjs`; `digest/` artifacts may be committed.
56
+ - **D-10**: Token never logged or persisted — `FIGMA_TOKEN` from env only; CI static-analysis test enforces it library-wide.
57
+ - **D-11**: Cache invalidation is content-based via Figma's `version` field, with a 1h wall-clock TTL fallback.
58
+ - **D-12**: Off-context guarantee enforced statically — the skill never instructs reading `raw/*.json`.
59
+ - **D-13**: Plugin emits ALL local variables (not just published-collection ones); filtering happens at the digest stage.
60
+
61
+ ### Backward compatibility
62
+
63
+ - 4-manifest + 2 Tier-2 manifest lockstep at v1.31.0 (`package.json` + `.claude-plugin/plugin.json` + `.claude-plugin/marketplace.json` (metadata.version + plugins[0].version) + `.cursor-plugin/plugin.json` + `.codex-plugin/plugin.json`). Keywords `figma`, `extractor`, `design-system-sync` added across the manifest keyword arrays.
64
+ - `NOTICE` preserved verbatim — Phase 31 productionizes our own spike (`c3a9cf6`) and uses Figma's official REST + plugin APIs plus `@figma/plugin-typings` (a normal npm devDependency, not a vendored code transplant). No third-party CODE was vendored, so no new attribution is owed.
65
+
66
+ ---
67
+
7
68
  ## [1.30.6] - 2026-05-28
8
69
 
9
70
  ### Phase 30.6 — Graphify Self-Ownership
package/README.md CHANGED
@@ -256,6 +256,27 @@ When K=3 stable clusters surface across M=10 reflection cycles (defaults in `ref
256
256
  **Discipline preserved.** Reflector authors nothing that auto-ships. `/gdd:apply-reflections` remains the single human gate (Phase 11 SC-8). Stage 1 never auto-flips — user opts in.
257
257
 
258
258
 
259
+ ### Figma off-context extraction (v1.31.0+)
260
+
261
+ Pull an entire Figma design system into a compact, LLM-readable local digest — **without the raw JSON ever entering Claude's context**. One command extracts the file via the Figma REST API into a local raw cache; a separate digest stage reduces it to `DESIGN.md` + `tokens.json` + `components.json`.
262
+
263
+ ```bash
264
+ # Stage 1 — raw pull (0 Claude tokens; writes a gitignored cache)
265
+ node scripts/lib/figma-extract/pull.cjs <figma-file-url-or-key>
266
+ # Stage 2 — digest (reads the cache, writes the compact spec)
267
+ node scripts/lib/figma-extract/digest.cjs --raw <cache>/raw/<key> --out .design/figma
268
+ ```
269
+
270
+ **The economics.** A spike measured **898× compression** (223 MB raw → 254 KB digest) with a ~15.7K-token `DESIGN.md`, capturing 127 component sets + 40 singletons (variants, props, defaults) in ~33s — at **0 Claude tokens and 0 Figma MCP calls during extraction**. This is the cost-sane alternative to the Figma MCP for whole-design-system work, which can run tens of minutes and 50–500K+ tokens for the same data. (The Figma MCP remains the right tool for spot questions on a single component.)
271
+
272
+ - **Token-safe by construction.** `FIGMA_TOKEN` is read from the environment only and sent solely as a request header — never logged, never written to disk (a CI static-analysis test enforces this across the whole extract library). The digest never reads raw JSON into context.
273
+ - **Works on any Figma plan.** The optional **"GDD Sync"** Figma plugin (`figma-plugin/`) fills the Variables-API-Enterprise gap: it reads `figma.variables` from inside Figma and POSTs them to an ephemeral, 127.0.0.1-only receiver — so Free-tier users get full token coverage too.
274
+ - **Per-component slices.** `digest --component <name|glob>` renders a ~500-token slice of just the components you ask for, instead of the full digest.
275
+ - **Health-aware.** `/gdd:health` reports a `figma_extract` readiness line (token set / token missing / Free-tier plugin-sync needed).
276
+
277
+ See [`skills/figma-extract/SKILL.md`](skills/figma-extract/SKILL.md) and [`figma-plugin/README.md`](figma-plugin/README.md) for the full flow.
278
+
279
+
259
280
  ## How It Works
260
281
 
261
282
  > **New to an existing codebase?** Run `/gdd:map` first. It dispatches 5 specialist mappers in parallel (tokens, components, visual hierarchy, a11y, motion) and writes structured JSON to `.design/map/` — much higher signal than the grep-based fallback in Explore.
package/SKILL.md CHANGED
@@ -35,6 +35,7 @@ Each stage produces artifacts in `.design/` inside the current project.
35
35
  | `darkmode` | `get-design-done:gdd-darkmode` | Audit dark mode architecture + contrast + anti-patterns → .design/DARKMODE-AUDIT.md |
36
36
  | `compare` | `get-design-done:gdd-compare` | Delta between DESIGN.md baseline and DESIGN-VERIFICATION.md → .design/COMPARE-REPORT.md |
37
37
  | `figma-write <mode>` | `get-design-done:gdd-figma-write` | Write design decisions to Figma (annotate/tokenize/mappings) |
38
+ | `figma-extract <file-url-or-key>` | `get-design-done:gdd-figma-extract` | Off-context Figma design-system extraction → compact local digest (DESIGN.md + tokens.json + components.json), zero raw JSON in context |
38
39
  | `graphify <subcommand>` | `get-design-done:gdd-graphify` | Manage Graphify knowledge graph (build/query/status/diff) |
39
40
  | `discuss [topic] [--all] [--spec] [--cycle <name>]` | `get-design-done:gdd-discuss` | Adaptive design interview — spawns design-discussant; appends D-XX decisions to STATE.md |
40
41
  | `list-assumptions [--area]` | `get-design-done:gdd-list-assumptions` | Surface implicit design assumptions baked into the codebase |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.30.6",
3
+ "version": "1.31.0",
4
4
  "description": "A design-quality pipeline for AI coding agents: brief, plan, implement, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
@@ -80,7 +80,10 @@
80
80
  "gemini",
81
81
  "mcp",
82
82
  "parallel-agents",
83
- "agent-sdk"
83
+ "agent-sdk",
84
+ "figma",
85
+ "extractor",
86
+ "design-system-sync"
84
87
  ],
85
88
  "skills": [
86
89
  "SKILL.md"
@@ -0,0 +1,430 @@
1
+ 'use strict';
2
+ /**
3
+ * Plan 31-02 — productionized from spike 001 digest.mjs (orchestration + token extraction).
4
+ *
5
+ * DIGEST stage of the two-stage pipeline (decision D-01: extract → digest stay
6
+ * separated). This module reads ONLY the raw/ cache that pull.cjs (31-01) wrote;
7
+ * it performs ZERO network calls, so it can re-run against an existing cache
8
+ * without re-pulling (idempotent / off-line).
9
+ *
10
+ * Three-path token assembly (decision D-04):
11
+ * Path A — Variables API body (rawDir/variables.json without the plugin marker)
12
+ * Path B — styles resolver (rawDir/styles.json) — pluggable seam; 31-03 ships
13
+ * the real two-step /styles + /nodes?ids= resolver
14
+ * Path C — plugin sync (rawDir/variables.json WITH the receiver marker, written
15
+ * by 31-06's localhost receiver)
16
+ * Resolution priority on name collision: Variables > plugin sync > styles.
17
+ * The --prefer-styles escape inverts the chain to prefer styles.
18
+ *
19
+ * Pure CommonJS, no external deps, no network.
20
+ *
21
+ * Per-component slicing (decision D-08):
22
+ * digest({..., component}) — when `component` is a name or glob (e.g.
23
+ * 'Sample/Button', 'Sample/*', 'Form/Butt?'), the digest renders ONLY
24
+ * the matching component(s) — a ~500-token slice instead of the full
25
+ * ~16K digest. The filter is strictly ADDITIVE: omitting `component`
26
+ * reproduces 31-02's full-digest behavior unchanged. Glob support is a
27
+ * few lines of in-file string work (no external glob dependency).
28
+ *
29
+ * Exports:
30
+ * digest(opts) — async orchestrator (reads raw/, writes digest/)
31
+ * assembleTokens(opts) — pure three-path merge by priority
32
+ * DEFAULT_TOKEN_PRIORITY — ['variables','plugin','styles'] (D-04)
33
+ * globToRegExp(pattern) — minimal glob→RegExp (D-08 component filter)
34
+ */
35
+
36
+ const fs = require('node:fs/promises');
37
+ const path = require('node:path');
38
+ const { collectComponents } = require('./walk.cjs');
39
+ const { renderDesignMd } = require('./render-md.cjs');
40
+
41
+ // D-04: Variables > plugin sync > styles.
42
+ const DEFAULT_TOKEN_PRIORITY = ['variables', 'plugin', 'styles'];
43
+
44
+ // Receiver-written payload marker (31-06 contract). A rawDir/variables.json that
45
+ // carries this top-level field is the plugin's Path-C payload, NOT the Figma
46
+ // Variables API body.
47
+ const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
48
+
49
+ // ── component filter (D-08) ────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Translate a minimal glob pattern into an anchored, case-sensitive RegExp.
53
+ *
54
+ * Supported wildcards (the only two the acceptance criterion needs):
55
+ * `*` → `.*` (zero or more of any char)
56
+ * `?` → `.` (exactly one char)
57
+ * Every other character — including regex metacharacters like `.`/`/`/`(`/`+`
58
+ * and the Figma `Sample/Path/Name` separators — is treated LITERALLY. We escape
59
+ * the whole pattern first, then re-activate the escaped `\*`/`\?` placeholders,
60
+ * so e.g. the `.` in 'Sample.Icon' is literal and does NOT over-match 'Sample/Icon'.
61
+ *
62
+ * Matching is exact (anchored `^...$`) and case-sensitive — Figma component
63
+ * names are case-significant, and an exact name with no wildcard must match only
64
+ * that one component.
65
+ *
66
+ * @param {string} pattern a component name or glob (e.g. 'Button*', 'Form/*')
67
+ * @returns {RegExp}
68
+ */
69
+ function globToRegExp(pattern) {
70
+ // Escape ALL regex metacharacters (incl. * and ? for now).
71
+ const escaped = String(pattern).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
72
+ // Re-activate the wildcards: escaped '\*' → '.*', escaped '\?' → '.'.
73
+ const body = escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
74
+ return new RegExp(`^${body}$`);
75
+ }
76
+
77
+ /**
78
+ * Collect the set of token names that are RELEVANT to a set of components, so a
79
+ * slice can carry just those tokens instead of the full ~hundreds-of-tokens
80
+ * catalog (which would blow past the ~500-token budget). Relevance = a token
81
+ * whose name is referenced by a component's prop default/options, variant name,
82
+ * or the component name itself. When nothing is determinable we return an empty
83
+ * set and the slice renders component shape only — keeping the slice bounded
84
+ * regardless of catalog size (D-08 size guarantee).
85
+ *
86
+ * @param {Array} matched matched component entries (from collectComponents)
87
+ * @param {Array} tokens the full assembled token list
88
+ * @returns {Array} the subset of `tokens` referenced by the matched components
89
+ */
90
+ function tokensForComponents(matched, tokens) {
91
+ if (!Array.isArray(tokens) || tokens.length === 0) return [];
92
+ // Build a haystack of strings drawn from the matched components.
93
+ const haystack = [];
94
+ for (const c of matched) {
95
+ if (c.name) haystack.push(c.name);
96
+ for (const v of c.variants || []) haystack.push(v);
97
+ for (const p of c.props || []) {
98
+ if (p.name) haystack.push(p.name);
99
+ if (p.default !== undefined) haystack.push(String(p.default));
100
+ for (const o of p.options || []) haystack.push(String(o));
101
+ }
102
+ }
103
+ const blob = haystack.join('\n');
104
+ return tokens.filter((t) => t && t.name !== undefined && blob.includes(t.name));
105
+ }
106
+
107
+ // ── helpers (Path A) ─────────────────────────────────────────────────────────
108
+
109
+ /** Convert a Figma {r,g,b,a?} (0..1 floats) colour to a hex string. */
110
+ function rgbToHex({ r, g, b, a }) {
111
+ const to = (v) => Math.round(v * 255).toString(16).padStart(2, '0');
112
+ const hex = `#${to(r)}${to(g)}${to(b)}`;
113
+ return a !== undefined && a < 1 ? `${hex}${to(a)}` : hex;
114
+ }
115
+
116
+ /**
117
+ * Path A — extract tokens from a Figma Variables API body
118
+ * (`/v1/files/:key/variables/local`). Mirrors the spike's extractTokensFromVariables.
119
+ * @param {object|null} vars the Variables API response (has .meta.{variables,variableCollections})
120
+ * @returns {Array<{name,type,collection?,modes?}>}
121
+ */
122
+ function extractTokensFromVariables(vars) {
123
+ if (!vars || !vars.meta) return [];
124
+ const collections = vars.meta.variableCollections || {};
125
+ const variables = vars.meta.variables || {};
126
+ const tokens = [];
127
+ for (const v of Object.values(variables)) {
128
+ const collection = collections[v.variableCollectionId];
129
+ const modes = collection?.modes || [];
130
+ const valuesByMode = {};
131
+ for (const mode of modes) {
132
+ const raw = v.valuesByMode?.[mode.modeId];
133
+ if (raw && typeof raw === 'object' && 'r' in raw) {
134
+ valuesByMode[mode.name] = rgbToHex(raw);
135
+ } else if (raw && raw.type === 'VARIABLE_ALIAS') {
136
+ valuesByMode[mode.name] = `{${variables[raw.id]?.name || raw.id}}`;
137
+ } else {
138
+ valuesByMode[mode.name] = raw;
139
+ }
140
+ }
141
+ tokens.push({
142
+ name: v.name,
143
+ type: v.resolvedType,
144
+ collection: collection?.name,
145
+ modes: valuesByMode,
146
+ });
147
+ }
148
+ return tokens;
149
+ }
150
+
151
+ /**
152
+ * Path C — normalize a receiver-written plugin payload into the common token
153
+ * shape. The plugin (D-13) emits ALL local variables; we accept either a
154
+ * pre-shaped `tokens[]` array or the raw `variables`/`meta` form and pass it
155
+ * through extractTokensFromVariables when needed.
156
+ * @param {object|null} payload variables.json carrying source:'gdd-plugin'
157
+ * @returns {Array}
158
+ */
159
+ function normalizePluginPayload(payload) {
160
+ if (!payload) return [];
161
+ // Preferred shape: the plugin already emits a flat tokens[] array.
162
+ if (Array.isArray(payload.tokens)) return payload.tokens;
163
+ // Fallback: it carries a Variables-API-like body — reuse Path A extraction.
164
+ if (payload.meta) return extractTokensFromVariables(payload);
165
+ return [];
166
+ }
167
+
168
+ // ── three-path merge (D-04) ──────────────────────────────────────────────────
169
+
170
+ /**
171
+ * Merge the three token sources by priority. On a NAME collision the
172
+ * higher-priority source wins.
173
+ *
174
+ * Implementation note: we iterate the priority chain HIGHEST-first and only set
175
+ * a name the first time we see it (skip-if-present), so the highest-priority
176
+ * source's entry is the one that survives.
177
+ *
178
+ * @param {object} opts
179
+ * @param {Array} [opts.variables] Path A tokens
180
+ * @param {Array} [opts.pluginVariables] Path C tokens
181
+ * @param {Array} [opts.styleTokens] Path B tokens
182
+ * @param {boolean} [opts.preferStyles] D-04 escape — move styles to the front
183
+ * @returns {Array} merged tokens (insertion order follows the priority chain)
184
+ */
185
+ function assembleTokens({ variables, pluginVariables, styleTokens, preferStyles } = {}) {
186
+ const bySource = {
187
+ variables: Array.isArray(variables) ? variables : [],
188
+ plugin: Array.isArray(pluginVariables) ? pluginVariables : [],
189
+ styles: Array.isArray(styleTokens) ? styleTokens : [],
190
+ };
191
+ const priority = preferStyles
192
+ ? ['styles', 'variables', 'plugin']
193
+ : DEFAULT_TOKEN_PRIORITY;
194
+
195
+ const merged = new Map();
196
+ for (const source of priority) {
197
+ for (const tok of bySource[source]) {
198
+ if (!tok || tok.name === undefined) continue;
199
+ if (!merged.has(tok.name)) merged.set(tok.name, tok);
200
+ }
201
+ }
202
+ return [...merged.values()];
203
+ }
204
+
205
+ // ── orchestrator ─────────────────────────────────────────────────────────────
206
+
207
+ /** Read+parse a JSON file from the raw cache; return null if absent/unreadable. */
208
+ async function readJson(rawDir, name) {
209
+ try {
210
+ const body = await fs.readFile(path.join(rawDir, `${name}.json`), 'utf8');
211
+ return JSON.parse(body);
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Run the digest: read raw/ cache → walk (variant rollup) → 3-path token
219
+ * assembly → render DESIGN.md + write tokens.json + components.json.
220
+ *
221
+ * @param {object} opts
222
+ * @param {string} opts.rawDir raw/ cache dir produced by pull.cjs (31-01) — REQUIRED
223
+ * @param {string} opts.outDir dir to write DESIGN.md/tokens.json/components.json — REQUIRED for writes
224
+ * @param {Function} [opts.stylesResolver] fn(file, styles) → styleTokens[] (Path B; 31-03 provides real impl)
225
+ * @param {boolean} [opts.preferStyles] D-04 escape hatch
226
+ * @param {string} [opts.fetchedAtOverride] deterministic provenance header for tests
227
+ * @param {string} [opts.component] D-08 — name or glob; when set, render a
228
+ * per-component SLICE (~500 tokens) of only
229
+ * the matching component(s). Additive: when
230
+ * absent, the full-digest path is unchanged.
231
+ * @returns {Promise<object>}
232
+ * full: { ok:true, counts, bytes, outDir }
233
+ * sliced: { ok:true, sliced:true, matched:[names], counts:{components,tokens}, bytes, outDir, note? }
234
+ * error: { ok:false, error }
235
+ */
236
+ async function digest({ rawDir, outDir, stylesResolver, preferStyles, fetchedAtOverride, component } = {}) {
237
+ if (!rawDir) {
238
+ return { ok: false, error: 'rawDir is required — run pull.cjs first' };
239
+ }
240
+
241
+ // (1) Required input — graceful guard (mirrors spike). NEVER throws.
242
+ const file = await readJson(rawDir, 'file');
243
+ if (!file) {
244
+ return { ok: false, error: 'raw/file.json not found — run pull.cjs first' };
245
+ }
246
+
247
+ // (2) Optional inputs.
248
+ const variablesRaw = await readJson(rawDir, 'variables');
249
+ const styles = await readJson(rawDir, 'styles');
250
+ const meta = await readJson(rawDir, '_meta');
251
+
252
+ // Distinguish Path A (Variables API body) from Path C (receiver plugin payload)
253
+ // by the receiver marker. Only one of the two is populated from variables.json.
254
+ let apiVariables = null;
255
+ let pluginPayload = null;
256
+ if (variablesRaw && variablesRaw.source === PLUGIN_PAYLOAD_MARKER) {
257
+ pluginPayload = variablesRaw; // Path C
258
+ } else {
259
+ apiVariables = variablesRaw; // Path A (may be null)
260
+ }
261
+
262
+ // (3) Components + widgets — variant rollup is default-on (D-02).
263
+ const { components, widgets } = collectComponents(file.document);
264
+
265
+ // (4) Three token paths.
266
+ const pathATokens = extractTokensFromVariables(apiVariables);
267
+ const styleTokens = stylesResolver ? await stylesResolver(file, styles) : [];
268
+ const pluginVariables = normalizePluginPayload(pluginPayload);
269
+
270
+ // (5) Merge by priority (D-04).
271
+ const tokens = assembleTokens({
272
+ variables: pathATokens,
273
+ pluginVariables,
274
+ styleTokens,
275
+ preferStyles,
276
+ });
277
+
278
+ // (6) Provenance — fetched_at is injectable for deterministic output.
279
+ const fileMeta = {
280
+ file_key: meta?.file_key,
281
+ fetched_at: fetchedAtOverride !== undefined ? fetchedAtOverride : meta?.fetched_at,
282
+ name: file.name,
283
+ };
284
+
285
+ // (6b) D-08 — per-component SLICE. When `component` is provided we short-circuit
286
+ // the full digest and render only the matching component(s) + their relevant
287
+ // tokens. This is additive: the block below is skipped entirely when `component`
288
+ // is undefined, so the full-digest path (step 7) stays byte-identical.
289
+ if (component !== undefined && component !== null && component !== '') {
290
+ const rx = globToRegExp(component);
291
+ const matched = components.filter((c) => rx.test(c.name));
292
+ // Only tokens referenced by the matched components — keeps the slice ~500
293
+ // tokens instead of dumping the whole catalog.
294
+ const sliceTokens = tokensForComponents(matched, tokens);
295
+ const sliceMd = renderDesignMd({
296
+ tokens: sliceTokens,
297
+ components: matched,
298
+ widgets: [], // a per-component slice omits page/widget noise
299
+ fileMeta,
300
+ });
301
+ if (outDir) {
302
+ await fs.mkdir(outDir, { recursive: true });
303
+ // Write the slice to DESIGN.md (the SKILL/e2e read whatever digest writes).
304
+ await fs.writeFile(path.join(outDir, 'DESIGN.md'), sliceMd);
305
+ }
306
+ const result = {
307
+ ok: true,
308
+ sliced: true,
309
+ matched: matched.map((c) => c.name),
310
+ counts: { components: matched.length, tokens: sliceTokens.length },
311
+ bytes: { designMd: Buffer.byteLength(sliceMd, 'utf8') },
312
+ outDir,
313
+ };
314
+ if (matched.length === 0) result.note = `no component matched ${component}`;
315
+ return result;
316
+ }
317
+
318
+ // (7) Render + write artifacts (D-09: digest/ is commit-able).
319
+ const designMd = renderDesignMd({ tokens, components, widgets, fileMeta });
320
+ const tokensJson = JSON.stringify(tokens, null, 2);
321
+ const componentsJson = JSON.stringify(components, null, 2);
322
+
323
+ if (outDir) {
324
+ await fs.mkdir(outDir, { recursive: true });
325
+ await fs.writeFile(path.join(outDir, 'DESIGN.md'), designMd);
326
+ await fs.writeFile(path.join(outDir, 'tokens.json'), tokensJson);
327
+ await fs.writeFile(path.join(outDir, 'components.json'), componentsJson);
328
+ }
329
+
330
+ return {
331
+ ok: true,
332
+ counts: {
333
+ tokens: tokens.length,
334
+ components: components.length,
335
+ widgets: widgets.length,
336
+ },
337
+ bytes: {
338
+ designMd: Buffer.byteLength(designMd, 'utf8'),
339
+ tokensJson: Buffer.byteLength(tokensJson, 'utf8'),
340
+ componentsJson: Buffer.byteLength(componentsJson, 'utf8'),
341
+ },
342
+ outDir,
343
+ };
344
+ }
345
+
346
+ module.exports = {
347
+ digest,
348
+ assembleTokens,
349
+ DEFAULT_TOKEN_PRIORITY,
350
+ // exported for unit reuse / downstream (31-08 --component, 31-03 normalization parity)
351
+ extractTokensFromVariables,
352
+ normalizePluginPayload,
353
+ PLUGIN_PAYLOAD_MARKER,
354
+ // D-08 component filter — exported for unit reuse / downstream slicing tools.
355
+ globToRegExp,
356
+ tokensForComponents,
357
+ };
358
+
359
+ // ── CLI entry (31-08) ──────────────────────────────────────────────────────────
360
+ //
361
+ // Thin argv wrapper invoked by the figma-extract SKILL (31-07) and the e2e test
362
+ // (31-10). The callable API (digest/assembleTokens/…) above is the import surface;
363
+ // this block runs ONLY when the file is executed directly. Flag → option map
364
+ // (contract from 31-02 SUMMARY, extended with --component for D-08):
365
+ //
366
+ // --raw <dir> rawDir (required) raw cache dir written by pull.cjs
367
+ // --out <dir> outDir (required) digest artifact output dir
368
+ // --prefer-styles preferStyles:true D-04 escape — styles-first priority
369
+ // --component <name> component D-08 per-component slice (name or glob)
370
+ //
371
+ // D-10: this block NEVER reads, logs, or persists FIGMA_TOKEN — digest is offline
372
+ // and token-free by construction; the CLI only echoes counts/paths.
373
+
374
+ /** Minimal flag parser for the four supported options (no external dep). */
375
+ function parseArgv(argv) {
376
+ const opts = {};
377
+ for (let i = 0; i < argv.length; i++) {
378
+ const a = argv[i];
379
+ if (a === '--raw') opts.rawDir = argv[++i];
380
+ else if (a === '--out') opts.outDir = argv[++i];
381
+ else if (a === '--prefer-styles') opts.preferStyles = true;
382
+ else if (a === '--component') opts.component = argv[++i];
383
+ else if (a === '--help' || a === '-h') opts.help = true;
384
+ }
385
+ return opts;
386
+ }
387
+
388
+ const CLI_USAGE =
389
+ 'Usage: node digest.cjs --raw <dir> --out <dir> [--prefer-styles] [--component <name|glob>]';
390
+
391
+ if (require.main === module) {
392
+ (async () => {
393
+ const opts = parseArgv(process.argv.slice(2));
394
+ if (opts.help) {
395
+ process.stdout.write(`${CLI_USAGE}\n`);
396
+ return;
397
+ }
398
+ if (!opts.rawDir || !opts.outDir) {
399
+ process.stderr.write(`${CLI_USAGE}\n--raw and --out are required.\n`);
400
+ process.exitCode = 2;
401
+ return;
402
+ }
403
+ const res = await digest({
404
+ rawDir: opts.rawDir,
405
+ outDir: opts.outDir,
406
+ preferStyles: opts.preferStyles,
407
+ component: opts.component,
408
+ });
409
+ if (!res.ok) {
410
+ process.stderr.write(`digest failed: ${res.error}\n`);
411
+ process.exitCode = 1;
412
+ return;
413
+ }
414
+ if (res.sliced) {
415
+ const summary =
416
+ res.matched.length > 0
417
+ ? `sliced ${res.counts.components} component(s): ${res.matched.join(', ')} (${res.counts.tokens} tokens) → ${res.outDir}`
418
+ : `${res.note} → ${res.outDir} (empty slice)`;
419
+ process.stdout.write(`${summary}\n`);
420
+ } else {
421
+ process.stdout.write(
422
+ `digest ok: ${res.counts.components} components, ${res.counts.tokens} tokens, ${res.counts.widgets} widgets → ${res.outDir}\n`
423
+ );
424
+ }
425
+ })().catch((err) => {
426
+ // Never leak a token; surface only the message.
427
+ process.stderr.write(`digest error: ${err && err.message ? err.message : err}\n`);
428
+ process.exitCode = 1;
429
+ });
430
+ }