@hegemonart/get-design-done 1.30.6 → 1.31.5

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.
Files changed (175) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +5 -2
  3. package/CHANGELOG.md +105 -0
  4. package/NOTICE +224 -0
  5. package/README.md +22 -1
  6. package/SKILL.md +1 -0
  7. package/agents/design-authority-watcher.md +1 -1
  8. package/agents/perf-analyzer.md +2 -2
  9. package/bin/gdd-mcp +78 -0
  10. package/bin/gdd-sdk +34 -24
  11. package/bin/gdd-state-mcp +78 -0
  12. package/{README.de.md → docs/i18n/README.de.md} +1 -1
  13. package/{README.fr.md → docs/i18n/README.fr.md} +1 -1
  14. package/{README.it.md → docs/i18n/README.it.md} +1 -1
  15. package/{README.ja.md → docs/i18n/README.ja.md} +1 -1
  16. package/{README.ko.md → docs/i18n/README.ko.md} +1 -1
  17. package/{README.zh-CN.md → docs/i18n/README.zh-CN.md} +1 -1
  18. package/hooks/_hook-emit.js +1 -1
  19. package/hooks/budget-enforcer.ts +5 -5
  20. package/hooks/context-exhaustion.ts +2 -2
  21. package/hooks/gdd-precompact-snapshot.js +3 -3
  22. package/hooks/gdd-read-injection-scanner.ts +2 -2
  23. package/hooks/gdd-sessionstart-recap.js +1 -1
  24. package/hooks/gdd-turn-closeout.js +1 -1
  25. package/package.json +24 -10
  26. package/recipes/.gitkeep +0 -0
  27. package/reference/schemas/recipe.schema.json +33 -0
  28. package/scripts/cli/gdd-events.mjs +5 -5
  29. package/scripts/lib/cache/gdd-cache-manager.cjs +1 -1
  30. package/scripts/lib/cli/index.ts +22 -160
  31. package/scripts/lib/connection-probe/index.cjs +1 -1
  32. package/scripts/lib/discuss-parallel-runner/aggregator.ts +1 -1
  33. package/scripts/lib/discuss-parallel-runner/index.ts +1 -1
  34. package/scripts/lib/error-classifier.cjs +24 -227
  35. package/scripts/lib/event-stream/index.ts +25 -193
  36. package/scripts/lib/figma-extract/digest.cjs +430 -0
  37. package/scripts/lib/figma-extract/parse-url.cjs +87 -0
  38. package/scripts/lib/figma-extract/payload-schema.json +108 -0
  39. package/scripts/lib/figma-extract/pull.cjs +394 -0
  40. package/scripts/lib/figma-extract/receiver.cjs +273 -0
  41. package/scripts/lib/figma-extract/render-md.cjs +143 -0
  42. package/scripts/lib/figma-extract/styles-resolver.cjs +147 -0
  43. package/scripts/lib/figma-extract/walk.cjs +100 -0
  44. package/scripts/lib/gdd-errors/index.ts +24 -213
  45. package/scripts/lib/gdd-state/index.ts +23 -161
  46. package/scripts/lib/health-mirror/index.cjs +88 -1
  47. package/scripts/lib/iteration-budget.cjs +23 -199
  48. package/scripts/lib/jittered-backoff.cjs +24 -107
  49. package/scripts/lib/lockfile.cjs +23 -195
  50. package/scripts/lib/logger/index.ts +1 -1
  51. package/scripts/lib/parallelism-engine/concurrency-tuner.cjs +1 -1
  52. package/scripts/lib/perf-analyzer/index.cjs +1 -1
  53. package/scripts/lib/pipeline-runner/index.ts +4 -4
  54. package/scripts/lib/pipeline-runner/state-machine.ts +1 -1
  55. package/scripts/lib/prompt-dedup/index.cjs +1 -1
  56. package/scripts/lib/rate-guard.cjs +2 -2
  57. package/scripts/lib/recipe-loader.cjs +142 -0
  58. package/scripts/lib/session-runner/errors.ts +3 -3
  59. package/scripts/lib/session-runner/index.ts +3 -3
  60. package/scripts/lib/session-runner/transcript.ts +1 -1
  61. package/scripts/lib/tool-scoping/index.ts +1 -1
  62. package/scripts/mcp-servers/gdd-mcp/server.ts +29 -311
  63. package/scripts/mcp-servers/gdd-state/server.ts +28 -282
  64. package/sdk/README.md +45 -0
  65. package/{scripts/lib → sdk}/cli/commands/audit.ts +3 -3
  66. package/{scripts/lib → sdk}/cli/commands/init.ts +3 -3
  67. package/{scripts/lib → sdk}/cli/commands/query.ts +4 -4
  68. package/{scripts/lib → sdk}/cli/commands/run.ts +5 -5
  69. package/{scripts/lib → sdk}/cli/commands/stage.ts +5 -5
  70. package/sdk/cli/index.js +8091 -0
  71. package/sdk/cli/index.ts +172 -0
  72. package/{scripts/lib → sdk}/cli/parse-args.ts +2 -2
  73. package/{scripts/lib/gdd-errors → sdk/errors}/classification.ts +1 -1
  74. package/sdk/errors/index.ts +218 -0
  75. package/{scripts/lib → sdk}/event-stream/emitter.ts +1 -1
  76. package/sdk/event-stream/index.ts +197 -0
  77. package/{scripts/lib → sdk}/event-stream/reader.ts +1 -1
  78. package/{scripts/lib → sdk}/event-stream/types.ts +2 -2
  79. package/{scripts/lib → sdk}/event-stream/writer.ts +1 -1
  80. package/sdk/index.ts +19 -0
  81. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/README.md +3 -3
  82. package/sdk/mcp/gdd-mcp/server.js +1924 -0
  83. package/sdk/mcp/gdd-mcp/server.ts +325 -0
  84. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_cycle_recap.ts +3 -3
  85. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_decisions_list.ts +2 -2
  86. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_events_tail.ts +3 -3
  87. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_health.ts +2 -2
  88. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_intel_get.ts +2 -2
  89. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_learnings_digest.ts +2 -2
  90. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_phase_current.ts +2 -2
  91. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_phases_list.ts +2 -2
  92. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_plans_list.ts +2 -2
  93. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_reflections_latest.ts +2 -2
  94. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_status.ts +3 -3
  95. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/gdd_telemetry_query.ts +3 -3
  96. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/index.ts +2 -2
  97. package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/tools/shared.ts +3 -3
  98. package/sdk/mcp/gdd-state/server.js +2790 -0
  99. package/sdk/mcp/gdd-state/server.ts +294 -0
  100. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/add_blocker.ts +3 -3
  101. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/add_decision.ts +3 -3
  102. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/add_must_have.ts +3 -3
  103. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/checkpoint.ts +2 -2
  104. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/frontmatter_update.ts +2 -2
  105. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/get.ts +3 -3
  106. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/index.ts +1 -1
  107. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/probe_connections.ts +3 -3
  108. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/resolve_blocker.ts +3 -3
  109. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/set_status.ts +2 -2
  110. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/shared.ts +8 -8
  111. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/transition_stage.ts +4 -4
  112. package/{scripts/mcp-servers → sdk/mcp}/gdd-state/tools/update_progress.ts +2 -2
  113. package/sdk/primitives/error-classifier.cjs +232 -0
  114. package/sdk/primitives/iteration-budget.cjs +205 -0
  115. package/sdk/primitives/jittered-backoff.cjs +112 -0
  116. package/sdk/primitives/lockfile.cjs +201 -0
  117. package/{scripts/lib/gdd-state → sdk/state}/gates.ts +1 -1
  118. package/sdk/state/index.ts +167 -0
  119. package/{scripts/lib/gdd-state → sdk/state}/lockfile.ts +1 -1
  120. package/{scripts/lib/gdd-state → sdk/state}/mutator.ts +1 -1
  121. package/{scripts/lib/gdd-state → sdk/state}/parser.ts +1 -1
  122. package/{scripts/lib/gdd-state → sdk/state}/types.ts +4 -4
  123. package/skills/figma-extract/SKILL.md +64 -0
  124. package/skills/health/SKILL.md +10 -0
  125. package/skills/quality-gate/SKILL.md +2 -2
  126. package/scripts/aggregate-agent-metrics.ts +0 -282
  127. package/scripts/bootstrap-manifest.txt +0 -3
  128. package/scripts/bootstrap.sh +0 -80
  129. package/scripts/build-distribution-bundles.cjs +0 -549
  130. package/scripts/build-intel.cjs +0 -486
  131. package/scripts/codegen-schema-types.ts +0 -149
  132. package/scripts/detect-stale-refs.cjs +0 -107
  133. package/scripts/e2e/run-headless.ts +0 -514
  134. package/scripts/extract-changelog-section.cjs +0 -58
  135. package/scripts/gsd-cleanup-incubator.cjs +0 -367
  136. package/scripts/injection-patterns.cjs +0 -58
  137. package/scripts/lint-agentskills-spec.cjs +0 -457
  138. package/scripts/release-smoke-test.cjs +0 -200
  139. package/scripts/rollback-release.sh +0 -42
  140. package/scripts/run-injection-scanner-ci.cjs +0 -83
  141. package/scripts/tests/test-authority-rejected-kinds.sh +0 -58
  142. package/scripts/tests/test-authority-watcher-diff.sh +0 -113
  143. package/scripts/tests/test-motion-provenance.sh +0 -64
  144. package/scripts/validate-frontmatter.ts +0 -409
  145. package/scripts/validate-incubator-scope.cjs +0 -133
  146. package/scripts/validate-schemas.ts +0 -401
  147. package/scripts/validate-skill-length.cjs +0 -283
  148. package/scripts/verify-version-sync.cjs +0 -30
  149. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_cycle_recap.schema.json +0 -0
  150. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_decisions_list.schema.json +0 -0
  151. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_events_tail.schema.json +0 -0
  152. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_health.schema.json +0 -0
  153. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_intel_get.schema.json +0 -0
  154. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_learnings_digest.schema.json +0 -0
  155. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_phase_current.schema.json +0 -0
  156. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_phases_list.schema.json +0 -0
  157. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_plans_list.schema.json +0 -0
  158. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_reflections_latest.schema.json +0 -0
  159. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_status.schema.json +0 -0
  160. /package/{scripts/mcp-servers → sdk/mcp}/gdd-mcp/schemas/gdd_telemetry_query.schema.json +0 -0
  161. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/add_blocker.schema.json +0 -0
  162. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/add_decision.schema.json +0 -0
  163. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/add_must_have.schema.json +0 -0
  164. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/checkpoint.schema.json +0 -0
  165. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/frontmatter_update.schema.json +0 -0
  166. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/get.schema.json +0 -0
  167. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/probe_connections.schema.json +0 -0
  168. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/resolve_blocker.schema.json +0 -0
  169. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/set_status.schema.json +0 -0
  170. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/transition_stage.schema.json +0 -0
  171. /package/{scripts/mcp-servers → sdk/mcp}/gdd-state/schemas/update_progress.schema.json +0 -0
  172. /package/{scripts/lib → sdk/primitives}/error-classifier.d.cts +0 -0
  173. /package/{scripts/lib → sdk/primitives}/iteration-budget.d.cts +0 -0
  174. /package/{scripts/lib → sdk/primitives}/jittered-backoff.d.cts +0 -0
  175. /package/{scripts/lib → sdk/primitives}/lockfile.d.cts +0 -0
@@ -0,0 +1,273 @@
1
+ 'use strict';
2
+ // scripts/lib/figma-extract/receiver.cjs — Plan 31-06 (Wave B.3)
3
+ // Path C receiver; D-06: ephemeral + 127.0.0.1-only.
4
+ //
5
+ // The localhost half of Path C (D-04). The Figma plugin "GDD Sync" (31-05)
6
+ // reads `figma.variables` from inside Figma (works on any plan — sidesteps the
7
+ // spike's Variables-API-403 Enterprise blocker) and POSTs them here. This
8
+ // receiver validates the payload against payload-schema.json and writes it into
9
+ // the raw/ cache as variables.json, where digest.cjs (31-02) consumes it as
10
+ // Path C via the `source:'gdd-plugin'` marker.
11
+ //
12
+ // Security properties are the WHOLE point (D-06):
13
+ // - Binds 127.0.0.1 ONLY (host '127.0.0.1', never 0.0.0.0) — unreachable off
14
+ // the loopback interface.
15
+ // - REFUSES any non-loopback remote with 403 (req.socket.remoteAddress gate),
16
+ // even though the bind already makes that essentially unreachable — defense
17
+ // in depth, and asserted by test via a mocked remote address.
18
+ // - Validates EVERY payload against the schema BEFORE touching disk (400 on
19
+ // invalid; nothing written).
20
+ // - Port is HARDCODED to 5179 — NOT read from env or a CLI flag (acceptance
21
+ // criterion). Changing it requires a code edit. There is intentionally no
22
+ // `process.env.*PORT*` read in this module.
23
+ // - EPHEMERAL: listens only for the duration of one extract run and exits on
24
+ // the FIRST valid receipt OR on a timeout — never a lingering open port.
25
+ //
26
+ // D-10: this module handles design variables ONLY. It NEVER touches the Figma
27
+ // token (that's a REST-path concern, not Path C). There is no secret-handling
28
+ // code here, and the logger seam receives lifecycle events + counts only —
29
+ // never full payloads.
30
+
31
+ const http = require('node:http');
32
+ const fs = require('node:fs/promises');
33
+ const path = require('node:path');
34
+ const Ajv = require('ajv');
35
+
36
+ const payloadSchema = require('./payload-schema.json');
37
+
38
+ // ── constants (D-06 acceptance criterion: hardcoded, no env override) ─────────
39
+ const RECEIVER_HOST = '127.0.0.1'; // loopback ONLY
40
+ const RECEIVER_PORT = 5179; // HARDCODED — intentionally not read from process.env
41
+
42
+ // The marker digest.cjs (31-02) keys on to route variables.json to Path C.
43
+ const PLUGIN_PAYLOAD_MARKER = 'gdd-plugin';
44
+
45
+ // Defensive body cap. Large design systems can ship sizeable variable sets
46
+ // (the risk register notes streaming for the raw pull); 50MB is generous for a
47
+ // variables-only JSON payload while still bounding memory from a hostile body.
48
+ const MAX_BODY_BYTES = 50 * 1024 * 1024;
49
+
50
+ // ── validator (Ajv is a hard repo dependency — package.json "ajv": "^8.18.0") ─
51
+ // Compiled once at module load. Ajv 8 CJS: require('ajv') is the constructor.
52
+ // NOTE: fail-fast (default, NO allErrors). The receiver validates an UNTRUSTED
53
+ // HTTP body from the plugin; `allErrors: true` would walk the entire (possibly
54
+ // hostile, deeply-nested) object collecting every violation — a resource-
55
+ // exhaustion / DoS amplifier (CodeQL js/resources-exhaustion). Fail-fast stops
56
+ // at the first violation, which is all the 400 response needs. The 50MB
57
+ // MAX_BODY_BYTES cap bounds input size; fail-fast bounds traversal cost.
58
+ const AjvCtor = Ajv.default || Ajv;
59
+ const _ajv = new AjvCtor({ strict: false });
60
+ const _validate = _ajv.compile(payloadSchema);
61
+
62
+ /**
63
+ * Validate a parsed body against payload-schema.json.
64
+ * @param {*} body
65
+ * @returns {{ valid: boolean, errors: Array }}
66
+ */
67
+ function validatePayload(body) {
68
+ const valid = _validate(body) === true;
69
+ return { valid, errors: valid ? [] : (_validate.errors || []) };
70
+ }
71
+
72
+ /** Normalize req.socket.remoteAddress to a loopback test (IPv4, IPv6, mapped). */
73
+ function isLoopbackRemote(remoteAddress) {
74
+ return (
75
+ remoteAddress === '127.0.0.1' ||
76
+ remoteAddress === '::1' ||
77
+ remoteAddress === '::ffff:127.0.0.1'
78
+ );
79
+ }
80
+
81
+ /** Read the full request body with a hard size cap. Rejects on overflow. */
82
+ function readBody(req) {
83
+ return new Promise((resolve, reject) => {
84
+ const chunks = [];
85
+ let size = 0;
86
+ req.on('data', (chunk) => {
87
+ size += chunk.length;
88
+ if (size > MAX_BODY_BYTES) {
89
+ reject(new Error('payload too large'));
90
+ req.destroy();
91
+ return;
92
+ }
93
+ chunks.push(chunk);
94
+ });
95
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
96
+ req.on('error', reject);
97
+ });
98
+ }
99
+
100
+ /** No-op logger fallback. The real seam receives lifecycle events + counts only. */
101
+ function emit(logger, event) {
102
+ if (typeof logger === 'function') {
103
+ try {
104
+ logger(event);
105
+ } catch {
106
+ /* a broken logger must never crash the receiver */
107
+ }
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Build the request handler. Exported (`createHandler`) so tests can exercise
113
+ * the loopback gate / routing synchronously by invoking it with a fake req/res
114
+ * — no real remote socket needed (D-06 refusal path is asserted this way).
115
+ *
116
+ * The `onReceipt(parsed, filePath)` callback is invoked exactly once, on the
117
+ * first VALID localhost POST, AFTER the file is written. startReceiver wires it
118
+ * to close the server + resolve. Non-localhost (403), bad route (404), parse
119
+ * error / schema-invalid (400) NEVER call onReceipt — the server keeps waiting.
120
+ *
121
+ * @param {object} opts
122
+ * @param {string} opts.outDir
123
+ * @param {Function} [opts.logger]
124
+ * @param {Function} opts.onReceipt async (parsed, filePath) => void
125
+ * @returns {Function} (req, res) => void
126
+ */
127
+ function createHandler({ outDir, logger, onReceipt }) {
128
+ return function handler(req, res) {
129
+ // (1) Loopback gate FIRST (D-06) — defense in depth on top of the bind.
130
+ const remoteAddress = req.socket && req.socket.remoteAddress;
131
+ if (!isLoopbackRemote(remoteAddress)) {
132
+ emit(logger, { event: 'reject-403', reason: 'non-localhost' });
133
+ res.writeHead(403, { 'content-type': 'text/plain' });
134
+ res.end('forbidden: non-localhost');
135
+ return;
136
+ }
137
+
138
+ // (2) Route — only POST /variables is handled.
139
+ if (req.method !== 'POST' || req.url !== '/variables') {
140
+ emit(logger, { event: 'reject-404', method: req.method, url: req.url });
141
+ res.writeHead(404, { 'content-type': 'text/plain' });
142
+ res.end('not found');
143
+ return;
144
+ }
145
+
146
+ // (3) Read + parse + validate + write. Any thrown error → 500 (no crash).
147
+ readBody(req)
148
+ .then(async (raw) => {
149
+ let parsed;
150
+ try {
151
+ parsed = JSON.parse(raw);
152
+ } catch {
153
+ emit(logger, { event: 'reject-400', reason: 'malformed-json' });
154
+ res.writeHead(400, { 'content-type': 'application/json' });
155
+ res.end(JSON.stringify({ error: 'malformed-json' }));
156
+ return;
157
+ }
158
+
159
+ const { valid, errors } = validatePayload(parsed);
160
+ if (!valid) {
161
+ emit(logger, { event: 'reject-400', reason: 'schema', errorCount: errors.length });
162
+ res.writeHead(400, { 'content-type': 'application/json' });
163
+ res.end(JSON.stringify({ error: 'schema', details: errors }));
164
+ return;
165
+ }
166
+
167
+ // Valid. Stamp the marker if (defensively) absent, then write the file.
168
+ if (parsed.source !== PLUGIN_PAYLOAD_MARKER) parsed.source = PLUGIN_PAYLOAD_MARKER;
169
+ const filePath = path.join(outDir, 'variables.json');
170
+ await fs.mkdir(outDir, { recursive: true });
171
+ await fs.writeFile(filePath, JSON.stringify(parsed));
172
+
173
+ emit(logger, {
174
+ event: 'receipt',
175
+ path: filePath,
176
+ collections: Array.isArray(parsed.collections) ? parsed.collections.length : 0,
177
+ variables: Array.isArray(parsed.variables) ? parsed.variables.length : 0,
178
+ });
179
+
180
+ res.writeHead(200, { 'content-type': 'application/json' });
181
+ res.end(JSON.stringify({ ok: true }));
182
+
183
+ if (typeof onReceipt === 'function') await onReceipt(parsed, filePath);
184
+ })
185
+ .catch((err) => {
186
+ // Body-too-large or unexpected I/O error. Do NOT leak internals; do NOT
187
+ // resolve the receipt. 500 keeps the server waiting for a retry.
188
+ emit(logger, { event: 'error', message: err && err.message });
189
+ if (!res.headersSent) {
190
+ res.writeHead(500, { 'content-type': 'application/json' });
191
+ res.end(JSON.stringify({ error: 'internal' }));
192
+ }
193
+ });
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Start the ephemeral Path C receiver.
199
+ *
200
+ * @param {object} opts
201
+ * @param {string} opts.outDir REQUIRED — writes <outDir>/variables.json on valid receipt
202
+ * @param {number} [opts.timeoutMs=120000] exits if no valid payload arrives in time
203
+ * @param {Function} [opts.logger] structured lifecycle sink (never receives secrets/full payloads)
204
+ * @returns {Promise<{received:true, path:string} | {received:false, reason:'timeout'}>}
205
+ *
206
+ * Resolves with `{received:true, path}` on the FIRST valid POST /variables, or
207
+ * `{received:false, reason:'timeout'}` on timeout. The server is closed on BOTH
208
+ * exit paths (ephemeral — D-06). Non-localhost → 403; schema-invalid → 400;
209
+ * neither resolves the promise (the server keeps waiting until receipt/timeout).
210
+ */
211
+ function startReceiver({ outDir, timeoutMs = 120000, logger } = {}) {
212
+ if (!outDir) {
213
+ return Promise.reject(new TypeError('startReceiver: opts.outDir is required'));
214
+ }
215
+
216
+ return new Promise((resolve, reject) => {
217
+ let settled = false;
218
+ let timer = null;
219
+ let server = null;
220
+
221
+ const finish = (result) => {
222
+ if (settled) return;
223
+ settled = true;
224
+ if (timer) {
225
+ clearTimeout(timer);
226
+ timer = null;
227
+ }
228
+ // Close the server on BOTH exit paths so the port is never left open and
229
+ // the event loop can drain (process can exit). close() is idempotent-safe.
230
+ if (server) server.close(() => resolve(result));
231
+ else resolve(result);
232
+ };
233
+
234
+ const handler = createHandler({
235
+ outDir,
236
+ logger,
237
+ onReceipt: (_parsed, filePath) => finish({ received: true, path: filePath }),
238
+ });
239
+
240
+ server = http.createServer(handler);
241
+
242
+ server.on('error', (err) => {
243
+ // Most likely EADDRINUSE (another receiver already bound 5179) — surface
244
+ // it to the caller rather than hanging. Only meaningful before listen.
245
+ if (!settled) {
246
+ settled = true;
247
+ if (timer) clearTimeout(timer);
248
+ reject(err);
249
+ }
250
+ });
251
+
252
+ server.listen(RECEIVER_PORT, RECEIVER_HOST, () => {
253
+ emit(logger, { event: 'listen', host: RECEIVER_HOST, port: RECEIVER_PORT });
254
+ // Arm the timeout only once we are actually listening.
255
+ timer = setTimeout(() => {
256
+ emit(logger, { event: 'timeout', timeoutMs });
257
+ finish({ received: false, reason: 'timeout' });
258
+ }, timeoutMs);
259
+ // Don't let the timeout itself keep the process alive past its purpose.
260
+ if (typeof timer.unref === 'function') timer.unref();
261
+ });
262
+ });
263
+ }
264
+
265
+ module.exports = {
266
+ startReceiver,
267
+ createHandler,
268
+ validatePayload,
269
+ isLoopbackRemote,
270
+ RECEIVER_PORT,
271
+ RECEIVER_HOST,
272
+ PLUGIN_PAYLOAD_MARKER,
273
+ };
@@ -0,0 +1,143 @@
1
+ 'use strict';
2
+ /**
3
+ * Plan 31-02 — productionized from spike 001 digest.mjs buildDesignMd().
4
+ *
5
+ * Deterministic DESIGN.md renderer with a STABLE section order:
6
+ * header (provenance) →
7
+ * ## Tokens (### Color, ### Typography, ### Other — only when non-empty) →
8
+ * ## Components (Total line; sets first, then ### Singleton components) →
9
+ * ## Widgets / Pages
10
+ *
11
+ * Determinism guarantee: identical {tokens, components, widgets, fileMeta} input
12
+ * produces BYTE-IDENTICAL output. The ONLY nondeterministic value is the
13
+ * provenance line's `fetched_at`, which the caller injects (tests pass a fixed
14
+ * value). This module NEVER calls new Date()/Date.now() — required for 31-10's
15
+ * golden-snapshot baseline.
16
+ *
17
+ * Pure CommonJS, no external deps, no I/O.
18
+ */
19
+
20
+ // Size-bounding slice caps (carried over verbatim from the spike for parity).
21
+ const CAP_COLOR = 200;
22
+ const CAP_TYPOGRAPHY = 100;
23
+ const CAP_OTHER = 100;
24
+ const CAP_VARIANTS = 20;
25
+ const CAP_SINGLETONS = 100;
26
+ const CAP_WIDGETS = 50;
27
+
28
+ /**
29
+ * @param {object} input
30
+ * @param {Array} input.tokens assembled tokens [{name,type,collection?,modes?,value?,description?}]
31
+ * @param {Array} input.components from walk.cjs collectComponents().components
32
+ * @param {Array} input.widgets from walk.cjs collectComponents().widgets
33
+ * @param {object} input.fileMeta { file_key, fetched_at, name } — fetched_at is the only injected nondeterminism
34
+ * @returns {string} DESIGN.md body
35
+ */
36
+ function renderDesignMd({ tokens, components, widgets, fileMeta }) {
37
+ const toks = Array.isArray(tokens) ? tokens : [];
38
+ const comps = Array.isArray(components) ? components : [];
39
+ const wids = Array.isArray(widgets) ? widgets : [];
40
+ const meta = fileMeta || {};
41
+
42
+ const colorTokens = toks.filter((t) => t.type === 'COLOR' || t.type === 'FILL');
43
+ const textTokens = toks.filter((t) => t.type === 'TEXT');
44
+ const otherTokens = toks.filter(
45
+ (t) => !['COLOR', 'FILL', 'TEXT'].includes(t.type)
46
+ );
47
+
48
+ const lines = [];
49
+ lines.push(`# DESIGN.md`);
50
+ lines.push(``);
51
+ lines.push(
52
+ `> Auto-generated from Figma file \`${meta.file_key}\` at ${meta.fetched_at}`
53
+ );
54
+ lines.push(`> Source: ${meta.name || 'Design system'}`);
55
+ lines.push(``);
56
+
57
+ // ── ## Tokens ──────────────────────────────────────────────────────────────
58
+ lines.push(`## Tokens`);
59
+ lines.push(``);
60
+
61
+ if (colorTokens.length) {
62
+ lines.push(`### Color`);
63
+ lines.push(``);
64
+ for (const t of colorTokens.slice(0, CAP_COLOR)) {
65
+ const modes = t.modes
66
+ ? Object.entries(t.modes)
67
+ .map(([m, v]) => `${m}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
68
+ .join(' | ')
69
+ : JSON.stringify(t.value);
70
+ lines.push(`- \`${t.name}\` — ${modes}`);
71
+ }
72
+ lines.push(``);
73
+ }
74
+
75
+ if (textTokens.length) {
76
+ lines.push(`### Typography`);
77
+ lines.push(``);
78
+ for (const t of textTokens.slice(0, CAP_TYPOGRAPHY)) {
79
+ const v = t.value || Object.values(t.modes || {})[0];
80
+ lines.push(`- \`${t.name}\` — ${typeof v === 'object' ? JSON.stringify(v) : v}`);
81
+ }
82
+ lines.push(``);
83
+ }
84
+
85
+ if (otherTokens.length) {
86
+ lines.push(`### Other`);
87
+ lines.push(``);
88
+ for (const t of otherTokens.slice(0, CAP_OTHER)) {
89
+ lines.push(`- \`${t.name}\` (${t.type})`);
90
+ }
91
+ lines.push(``);
92
+ }
93
+
94
+ // ── ## Components ───────────────────────────────────────────────────────────
95
+ lines.push(`## Components`);
96
+ lines.push(``);
97
+ const sets = comps.filter((c) => c.type === 'COMPONENT_SET');
98
+ const singles = comps.filter((c) => c.type === 'COMPONENT');
99
+ lines.push(
100
+ `Total: ${sets.length} component sets + ${singles.length} singleton components`
101
+ );
102
+ lines.push(``);
103
+
104
+ for (const c of sets) {
105
+ lines.push(`### ${c.name}`);
106
+ if (c.description) lines.push(`> ${c.description}`);
107
+ if (c.variants && c.variants.length) {
108
+ lines.push(`Variants (${c.variants.length}):`);
109
+ for (const v of c.variants.slice(0, CAP_VARIANTS)) lines.push(`- ${v}`);
110
+ if (c.variants.length > CAP_VARIANTS) {
111
+ lines.push(`- … +${c.variants.length - CAP_VARIANTS} more`);
112
+ }
113
+ }
114
+ if (c.props && c.props.length) {
115
+ lines.push(`Props:`);
116
+ for (const p of c.props) {
117
+ const opts = p.options ? ` [${p.options.join(', ')}]` : '';
118
+ lines.push(`- \`${p.name}\` (${p.type})${opts} — default: \`${p.default}\``);
119
+ }
120
+ }
121
+ lines.push(``);
122
+ }
123
+
124
+ if (singles.length) {
125
+ lines.push(`### Singleton components`);
126
+ lines.push(``);
127
+ for (const c of singles.slice(0, CAP_SINGLETONS)) {
128
+ lines.push(`- \`${c.name}\``);
129
+ }
130
+ lines.push(``);
131
+ }
132
+
133
+ // ── ## Widgets / Pages ──────────────────────────────────────────────────────
134
+ lines.push(`## Widgets / Pages`);
135
+ lines.push(``);
136
+ for (const w of wids.slice(0, CAP_WIDGETS)) {
137
+ lines.push(`- ${w.name} (\`${w.id}\`)`);
138
+ }
139
+
140
+ return lines.join('\n');
141
+ }
142
+
143
+ module.exports = { renderDesignMd };
@@ -0,0 +1,147 @@
1
+ 'use strict';
2
+ // Plan 31-03 — Path B of D-04 (three-path token extraction).
3
+ //
4
+ // Fixes spike 001's 0-tokens bug. The spike's digest.mjs extractTokensFromStyles
5
+ // (lines 96-132) looked up each /styles entry's `node_id` inside `file.document`
6
+ // and found nothing — because published-style SOURCE nodes are NOT serialized into
7
+ // the main document tree. They live in canvas frames that require a SEPARATE
8
+ // `/files/:key/nodes?ids=...` fetch. This module implements that missing second pass:
9
+ //
10
+ // step 1: read the /styles list (node_id + style_type + name) <- caller supplies
11
+ // step 2: GET /files/:key/nodes?ids=<comma-joined> to read real values <- injected fetcher
12
+ //
13
+ // Resolution priority within D-04: Variables > plugin sync > styles. Styles (this
14
+ // module) is the last-resort fallback for non-Enterprise, legacy-styles DSs.
15
+ //
16
+ // No direct network call lives here except inside the buildStylesResolver-bound
17
+ // fetcher; tests drive resolveStyleTokens fully offline via an injected fetchNodes.
18
+
19
+ // Chunk cap for /nodes?ids= requests. Figma limits URL length, so large style sets
20
+ // are split into batches of this size and the results merged.
21
+ const MAX_IDS_PER_REQUEST = 100;
22
+
23
+ const DEFAULT_API_BASE = 'https://api.figma.com/v1';
24
+
25
+ // rgb(0..1) channels → 2-hex; appends an alpha hex byte only when a < 1.
26
+ // Ported from spike 001 digest.mjs rgbToHex (lines 13-17) — keep value shape identical.
27
+ function rgbToHex({ r, g, b, a }) {
28
+ const to = (v) => Math.round((v || 0) * 255).toString(16).padStart(2, '0');
29
+ const hex = `#${to(r)}${to(g)}${to(b)}`;
30
+ return a !== undefined && a < 1 ? `${hex}${to(a)}` : hex;
31
+ }
32
+
33
+ // Split an array into contiguous chunks of at most `size`.
34
+ function chunk(arr, size) {
35
+ const out = [];
36
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
37
+ return out;
38
+ }
39
+
40
+ // Figma's /nodes response wraps each node under `.document`. Tolerate both the
41
+ // wrapped shape ({ document: <node> }) and a bare node, so the resolver is robust
42
+ // to either the live API or a flattened fixture.
43
+ function unwrapNode(entry) {
44
+ if (!entry) return undefined;
45
+ return entry.document !== undefined ? entry.document : entry;
46
+ }
47
+
48
+ // Resolve a single style's value from its source node, by style_type.
49
+ // Returns undefined when the node lacks the data for that type (style is then skipped).
50
+ function resolveValue(styleType, node) {
51
+ if (!node) return undefined;
52
+ if (styleType === 'FILL') {
53
+ const fill = node.fills && node.fills[0];
54
+ if (fill && fill.color) return rgbToHex({ ...fill.color, a: fill.opacity });
55
+ return undefined;
56
+ }
57
+ if (styleType === 'TEXT') {
58
+ const st = node.style;
59
+ if (!st) return undefined;
60
+ return {
61
+ family: st.fontFamily,
62
+ weight: st.fontWeight,
63
+ size: st.fontSize,
64
+ lineHeight: st.lineHeightPx,
65
+ letterSpacing: st.letterSpacing,
66
+ };
67
+ }
68
+ if (styleType === 'EFFECT') {
69
+ const eff = node.effects && node.effects[0];
70
+ return eff !== undefined ? eff : undefined;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ // Core two-step resolver (Path B). Pure transform over injected data — no network.
76
+ // stylesList: the /styles response body
77
+ // ({ meta: { styles: [{ node_id, style_type, name, description }] } })
78
+ // fetchNodes: async (ids: string[]) => /nodes response body
79
+ // ({ nodes: { <id>: { document: <node> } | <node> } })
80
+ // Returns Array<{ name, type:'FILL'|'TEXT'|'EFFECT', value, description }>.
81
+ // FILL → value = hex string (rgb→hex, alpha-aware)
82
+ // TEXT → value = { family, weight, size, lineHeight, letterSpacing }
83
+ // EFFECT → value = the first effect object
84
+ // Returns [] when stylesList has no styles (fetchNodes is NOT called), or when every
85
+ // node lookup misses. A style whose node_id is absent from /nodes is skipped (graceful).
86
+ async function resolveStyleTokens({ stylesList, fetchNodes }) {
87
+ const styles = (stylesList && stylesList.meta && stylesList.meta.styles) || [];
88
+ if (styles.length === 0) return [];
89
+ if (typeof fetchNodes !== 'function') {
90
+ throw new TypeError('resolveStyleTokens: fetchNodes must be a function');
91
+ }
92
+
93
+ // Step 2: batch the node_ids and fetch their real source nodes, merging into one map.
94
+ const ids = styles.map((s) => s.node_id).filter((id) => id != null);
95
+ const nodeMap = {};
96
+ for (const idChunk of chunk(ids, MAX_IDS_PER_REQUEST)) {
97
+ const body = await fetchNodes(idChunk);
98
+ const nodes = (body && body.nodes) || {};
99
+ for (const id of idChunk) {
100
+ const node = unwrapNode(nodes[id]);
101
+ if (node !== undefined) nodeMap[id] = node;
102
+ }
103
+ }
104
+
105
+ // Map each style onto its resolved value. Skip styles whose node missed or whose
106
+ // node lacked the data for its type.
107
+ const out = [];
108
+ for (const s of styles) {
109
+ const node = nodeMap[s.node_id];
110
+ if (!node) continue;
111
+ const value = resolveValue(s.style_type, node);
112
+ if (value !== undefined) {
113
+ out.push({
114
+ name: s.name,
115
+ type: s.style_type,
116
+ value,
117
+ description: s.description || '',
118
+ });
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+
124
+ // Bind a resolver to a live (fileKey, token, fetchImpl, apiBase) so digest.cjs can
125
+ // inject Path B. Returns an async fn(file, styles) — exactly the `stylesResolver(file, styles)`
126
+ // seam shape digest.cjs (31-02) calls. It ignores `file` (the document tree never holds
127
+ // the source nodes — that is the spike bug) and resolves `styles` via a /nodes fetcher.
128
+ // 31-07's SKILL wires this for live runs. The token is sent ONLY as the X-Figma-Token
129
+ // header and is NEVER logged or persisted (D-10).
130
+ function buildStylesResolver({ fileKey, token, fetchImpl, apiBase } = {}) {
131
+ const base = apiBase || DEFAULT_API_BASE;
132
+ const doFetch = fetchImpl || (typeof fetch !== 'undefined' ? fetch : undefined);
133
+ return async function stylesResolver(_file, styles) {
134
+ const fetchNodes = async (ids) => {
135
+ if (typeof doFetch !== 'function') {
136
+ throw new Error('buildStylesResolver: no fetch implementation available');
137
+ }
138
+ const url = `${base}/files/${fileKey}/nodes?ids=${ids.join(',')}`;
139
+ const res = await doFetch(url, { headers: { 'X-Figma-Token': token } });
140
+ if (!res.ok) throw new Error(`/nodes ${res.status}`);
141
+ return res.json();
142
+ };
143
+ return resolveStyleTokens({ stylesList: styles, fetchNodes });
144
+ };
145
+ }
146
+
147
+ module.exports = { resolveStyleTokens, buildStylesResolver, MAX_IDS_PER_REQUEST, rgbToHex };
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+ /**
3
+ * Plan 31-02 — productionized from spike 001 digest.mjs walk() + summarizeWidgets().
4
+ *
5
+ * Node-tree walker with VARIANT ROLLUP (decision D-02, variant rollup default-on).
6
+ *
7
+ * The spike proved a naive walk inflates the component count ~16× (2,593 vs 167
8
+ * entries) because each COMPONENT_SET's variant children are counted as separate
9
+ * components. The fix — locked here as the non-optional default — is to SKIP the
10
+ * COMPONENT children of a COMPONENT_SET and record their names as a `variants[]`
11
+ * field on the parent set. A COMPONENT_SET with N variant children therefore
12
+ * yields exactly ONE component entry, not N (+1).
13
+ *
14
+ * Pure CommonJS, no external deps, no I/O, no network.
15
+ *
16
+ * Exports:
17
+ * walkDocument(node, ctx, parentIsSet) — low-level recursive helper (unit-testable)
18
+ * collectComponents(documentNode) — top-level entry over file.document
19
+ */
20
+
21
+ /**
22
+ * Recursive tree walker. Mutates `ctx` in place.
23
+ *
24
+ * @param {object|null|undefined} node a Figma node (document/canvas/frame/component/…)
25
+ * @param {{components:Array, widgets:Array, depth:number}} ctx accumulator
26
+ * @param {boolean} [parentIsSet=false] true when the parent node is a COMPONENT_SET
27
+ */
28
+ function walkDocument(node, ctx, parentIsSet = false) {
29
+ if (!node) return;
30
+
31
+ // Rollup core: a COMPONENT is only a standalone component when its parent is
32
+ // NOT a COMPONENT_SET. COMPONENT children of a set are variants — skipped here
33
+ // (they are recorded as variants[] on the parent set below).
34
+ const isStandaloneComponent = node.type === 'COMPONENT' && !parentIsSet;
35
+
36
+ if (node.type === 'COMPONENT_SET' || isStandaloneComponent) {
37
+ ctx.components.push({
38
+ id: node.id,
39
+ name: node.name,
40
+ type: node.type,
41
+ description: node.description || '',
42
+ // Variant names live on the set's children; standalone components have none.
43
+ variants:
44
+ node.type === 'COMPONENT_SET'
45
+ ? (node.children || []).map((c) => c.name)
46
+ : undefined,
47
+ // componentPropertyDefinitions → flattened props. Figma suffixes prop keys
48
+ // with '#<id>' for uniqueness; strip it for the human-facing name.
49
+ props: node.componentPropertyDefinitions
50
+ ? Object.entries(node.componentPropertyDefinitions).map(([k, v]) => ({
51
+ name: k.split('#')[0],
52
+ type: v.type,
53
+ default: v.defaultValue,
54
+ options: v.variantOptions,
55
+ }))
56
+ : undefined,
57
+ });
58
+ }
59
+
60
+ // Top-level FRAMEs (depth 1 — direct children of a page/canvas) are widget /
61
+ // page candidates for downstream classification.
62
+ if (ctx.depth === 1 && node.type === 'FRAME') {
63
+ ctx.widgets.push({ id: node.id, name: node.name });
64
+ }
65
+
66
+ if (node.children) {
67
+ ctx.depth++;
68
+ // Children of a COMPONENT_SET are variants — flag so they are not re-pushed.
69
+ const childParentIsSet = node.type === 'COMPONENT_SET';
70
+ for (const child of node.children) walkDocument(child, ctx, childParentIsSet);
71
+ ctx.depth--;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Collect components (with variant rollup) and top-level frames from a document.
77
+ *
78
+ * @param {object} documentNode file.document — has .children = pages (CANVAS nodes)
79
+ * @returns {{components:Array, widgets:Array}}
80
+ * components: Array<{ id, name, type:'COMPONENT_SET'|'COMPONENT', description,
81
+ * variants?:string[], props?:Array<{name,type,default,options}> }>
82
+ * widgets: Array<{ id, name }> — top-level FRAMEs (depth 1)
83
+ */
84
+ function collectComponents(documentNode) {
85
+ const ctx = { components: [], widgets: [], depth: 0 };
86
+ if (!documentNode || !documentNode.children) {
87
+ return { components: ctx.components, widgets: ctx.widgets };
88
+ }
89
+ // Pages (CANVAS) sit at depth 0; their children are depth 1 — that's where
90
+ // top-level frames become widget candidates. Mirror the spike's depth handling
91
+ // by entering each page's children at depth 1.
92
+ for (const page of documentNode.children) {
93
+ if (!page || !page.children) continue;
94
+ ctx.depth = 1;
95
+ for (const child of page.children) walkDocument(child, ctx, false);
96
+ }
97
+ return { components: ctx.components, widgets: ctx.widgets };
98
+ }
99
+
100
+ module.exports = { walkDocument, collectComponents };