@cohortapp/agent-sdk 2.12.0 → 2.14.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.
Files changed (129) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +86 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/lib/telemetry/collect.mjs +357 -5
  25. package/lib/telemetry/collect.test.mjs +285 -0
  26. package/package.json +1 -1
  27. package/plugins/maestro-skills/plugin.json +4 -0
  28. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  42. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  43. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  44. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  45. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  46. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  47. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  50. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  51. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  89. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  90. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  95. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  96. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  97. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  98. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  99. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  100. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  105. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  106. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  113. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  114. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  116. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  117. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  118. package/scripts/ci/check-skill-packs.mjs +388 -0
  119. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  120. package/scripts/ci/check.mjs +3 -0
  121. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  122. package/scripts/daemon/agent-daemon.mjs +108 -0
  123. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  124. package/scripts/daemon/cadence-consumer.mjs +46 -22
  125. package/scripts/daemon/prompt-builder.mjs +19 -3
  126. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  127. package/scripts/vendor/skill-packs.mjs +354 -0
  128. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  129. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -0,0 +1,793 @@
1
+ /**
2
+ * lib/design/design-md.mjs — the brand foundation, rendered as DESIGN.md and
3
+ * PRODUCT.md (WP-M7 mechanic 4).
4
+ *
5
+ * WHAT THIS IS FOR. Every design skill on a seat (vendored or authored) is told
6
+ * to read `$AGENT_ROOT/DESIGN.md` before it picks a colour, a typeface or a
7
+ * radius. That file must therefore be a faithful, mechanical projection of the
8
+ * workspace's SAVED brand foundation — `branding.getFoundation` / the
9
+ * `design_foundation` tool — in the shape the ecosystem already reads: the
10
+ * google-labs-code/design.md spec (YAML frontmatter carrying `colors`,
11
+ * `typography`, `rounded`, `spacing` and `components` written with
12
+ * `{colors.primary}`-style refs, then prose).
13
+ *
14
+ * PURE. Everything that could differ between two runs is injected: there is no
15
+ * clock read, no `process.env`, no filesystem and no network here. Given the
16
+ * same `(foundation, opts)` it returns the same bytes — which is what makes the
17
+ * CLI's "re-running with an unchanged foundation rewrites nothing" claim
18
+ * checkable by comparing rendered bytes, and what makes the golden test honest.
19
+ *
20
+ * TOLERANT BY CONTRACT. The foundation is a stored JSON blob that a newer hq
21
+ * may extend (hq's own `coerceFoundation` is deliberately lenient for the same
22
+ * reason). A key this renderer does not model is NOT dropped: it lands in the
23
+ * "Additional tokens" section verbatim, so a token added upstream is visible to
24
+ * a designer on the seat the day it ships, before anyone teaches this module
25
+ * about it. Silence is the failure mode worth engineering against — a dropped
26
+ * token looks exactly like a token that was never set.
27
+ *
28
+ * Node builtins only (none needed). ESM.
29
+ *
30
+ * @module lib/design/design-md
31
+ */
32
+
33
+ "use strict";
34
+
35
+ /**
36
+ * Top-level foundation keys this renderer EMITS somewhere. Anything else is
37
+ * "additional" — see the module docstring.
38
+ *
39
+ * "Models" means emits, not "recognises". `examples` is a real hq field
40
+ * (`z.any()` in `brandFoundationSchema`) that no renderer here reads, so it is
41
+ * deliberately ABSENT from this list and lands in "Additional tokens" verbatim.
42
+ * Listing a key here that nothing emits is how a token gets dropped in silence
43
+ * — the one failure this module is built to make impossible.
44
+ * @type {string[]}
45
+ */
46
+ export const KNOWN_FOUNDATION_KEYS = [
47
+ "v",
48
+ "colors",
49
+ "fonts",
50
+ "type",
51
+ "space",
52
+ "radii",
53
+ "shadow",
54
+ "border",
55
+ "voice",
56
+ "positioning",
57
+ "taglines",
58
+ "toneTraits",
59
+ "logos",
60
+ "visualLanguage",
61
+ "system",
62
+ ];
63
+
64
+ /**
65
+ * `system.*` sub-keys this renderer emits (DESIGN.md, except `voice` which is
66
+ * PRODUCT.md's). `system` is a known TOP-LEVEL key, so without this list an
67
+ * unmodelled sub-block — `system.imagery` was exactly this — would be invisible
68
+ * to "Additional tokens" as well as to the prose. Keep in step with hq
69
+ * `src/lib/branding/foundation.ts#brandSystemSchema`.
70
+ * @type {string[]}
71
+ */
72
+ export const KNOWN_SYSTEM_KEYS = [
73
+ "typography",
74
+ "color",
75
+ "imagery",
76
+ "motifs",
77
+ "iconography",
78
+ "layoutPrinciples",
79
+ "voice",
80
+ ];
81
+
82
+ /**
83
+ * `visualLanguage.*` sub-keys this renderer emits. Same reasoning as
84
+ * {@link KNOWN_SYSTEM_KEYS}; mirrors hq's `visualLanguageSchema`.
85
+ * @type {string[]}
86
+ */
87
+ export const KNOWN_VISUAL_LANGUAGE_KEYS = [
88
+ "v",
89
+ "source",
90
+ "imagery",
91
+ "iconography",
92
+ "motion",
93
+ "texture",
94
+ "guardrails",
95
+ "promptSeed",
96
+ "seedLocked",
97
+ "negativeSeed",
98
+ "references",
99
+ ];
100
+
101
+ /** Canonical colour ROLES a component ref may name, in preference order. */
102
+ const COLOR_ROLES = [
103
+ "primary",
104
+ "secondary",
105
+ "accent",
106
+ "ink",
107
+ "paper",
108
+ "muted",
109
+ "line",
110
+ "background",
111
+ "foreground",
112
+ ];
113
+
114
+ // ── small pure helpers ──────────────────────────────────────────────────────
115
+
116
+ /** Is this a plain object (not null, not an array)? */
117
+ function isObj(v) {
118
+ return !!v && typeof v === "object" && !Array.isArray(v);
119
+ }
120
+
121
+ /** An array, or []. */
122
+ function arr(v) {
123
+ return Array.isArray(v) ? v : [];
124
+ }
125
+
126
+ /** A trimmed non-empty string, or "". */
127
+ function str(v) {
128
+ return typeof v === "string" && v.trim() ? v.trim() : "";
129
+ }
130
+
131
+ /**
132
+ * A token name → a YAML/ref-safe key. `"Primary · Moss"` → `"primary-moss"`,
133
+ * `"2xl"` → `"2xl"`. Empty input yields `""` so the caller can skip it.
134
+ * @param {unknown} name
135
+ * @returns {string}
136
+ */
137
+ export function slugToken(name) {
138
+ return String(name ?? "")
139
+ .normalize("NFKD")
140
+ .replace(/[\u0300-\u036f]/g, "")
141
+ .toLowerCase()
142
+ .replace(/[^a-z0-9]+/g, "-")
143
+ .replace(/^-+|-+$/g, "");
144
+ }
145
+
146
+ /** Double-quote a scalar for YAML, escaping backslashes and quotes. */
147
+ function q(v) {
148
+ return `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
149
+ }
150
+
151
+ /** A dimension token value: numbers become px, strings pass through. */
152
+ function dim(v) {
153
+ if (typeof v === "number" && Number.isFinite(v)) return `${v}px`;
154
+ return str(v);
155
+ }
156
+
157
+ /** `[key, value]` pairs of an ordered map, as YAML lines at `indent`. */
158
+ function yamlMap(pairs, indent) {
159
+ return pairs.map(([k, v]) => `${indent}${k}: ${q(v)}`);
160
+ }
161
+
162
+ /**
163
+ * Insert into an ordered map only when the key is free and the value real.
164
+ * First writer wins — the foundation's own palette outranks a derived alias.
165
+ */
166
+ function put(map, key, value) {
167
+ const k = slugToken(key);
168
+ const v = str(value);
169
+ if (!k || !v || Object.prototype.hasOwnProperty.call(map, k)) return;
170
+ map[k] = v;
171
+ }
172
+
173
+ // ── token extraction ────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * The `colors` block: every palette entry by slugified name, then the canonical
177
+ * role aliases a component ref can name (`primary`, `accent`, …) resolved from
178
+ * `system.color.roles` and, failing that, from a `"<role> · <shade>"` palette
179
+ * name. Also folds in `system.color.semantic` and `system.color.neutralRamp`,
180
+ * which are real tokens no other block would carry.
181
+ *
182
+ * @param {object} foundation
183
+ * @returns {{light:Record<string,string>, dark:Record<string,string>}}
184
+ */
185
+ export function colorTokens(foundation) {
186
+ const light = {};
187
+ const dark = {};
188
+ for (const c of arr(foundation && foundation.colors)) {
189
+ if (!isObj(c)) continue;
190
+ const key = slugToken(c.name);
191
+ if (!key) continue;
192
+ put(light, key, c.value);
193
+ if (str(c.dark)) put(dark, key, c.dark);
194
+ }
195
+ const roles = isObj(foundation && foundation.system) && isObj(foundation.system.color) ? foundation.system.color.roles : null;
196
+ if (isObj(roles)) {
197
+ for (const role of ["primary", "secondary", "accent"]) {
198
+ const r = roles[role];
199
+ if (!isObj(r)) continue;
200
+ put(light, role, r.value);
201
+ if (str(r.dark)) put(dark, role, r.dark);
202
+ }
203
+ }
204
+ // Alias `primary` → `primary-moss` etc. so `{colors.primary}` always resolves
205
+ // on a palette that names its shades.
206
+ for (const role of COLOR_ROLES) {
207
+ if (light[role]) continue;
208
+ const hit = Object.keys(light).find((k) => k.startsWith(`${role}-`));
209
+ if (hit) {
210
+ light[role] = light[hit];
211
+ if (dark[hit]) dark[role] = dark[hit];
212
+ }
213
+ }
214
+ const colorBlock = isObj(foundation && foundation.system) ? foundation.system.color : null;
215
+ if (isObj(colorBlock)) {
216
+ if (isObj(colorBlock.semantic)) {
217
+ for (const [name, value] of Object.entries(colorBlock.semantic)) {
218
+ const k = slugToken(name);
219
+ if (!k) continue;
220
+ put(light, Object.prototype.hasOwnProperty.call(light, k) ? `semantic-${k}` : k, value);
221
+ }
222
+ }
223
+ for (const step of arr(colorBlock.neutralRamp)) {
224
+ if (!isObj(step)) continue;
225
+ put(light, `neutral-${slugToken(step.step)}`, step.value);
226
+ }
227
+ }
228
+ return { light, dark };
229
+ }
230
+
231
+ /**
232
+ * A `[{name, value}]` token array → an ordered map of slug → dimension string.
233
+ * @param {unknown} list
234
+ * @returns {Record<string,string>}
235
+ */
236
+ export function scaleTokens(list) {
237
+ const out = {};
238
+ for (const t of arr(list)) {
239
+ if (!isObj(t)) continue;
240
+ put(out, t.name, dim(t.value));
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * The `typography` block: the three faces plus the role scale.
247
+ * @param {object} foundation
248
+ * @returns {{faces:Record<string,string>, scale:Array<{key:string, size:string, weight:string, leading:string, tracking:string}>}}
249
+ */
250
+ export function typographyTokens(foundation) {
251
+ const fonts = isObj(foundation && foundation.fonts) ? foundation.fonts : {};
252
+ const faces = {};
253
+ for (const role of ["display", "heading", "body", "mono"]) {
254
+ if (str(fonts[role])) faces[role] = str(fonts[role]);
255
+ }
256
+ const families = isObj(foundation && foundation.system) && isObj(foundation.system.typography)
257
+ ? foundation.system.typography.families
258
+ : null;
259
+ if (isObj(families)) {
260
+ for (const role of ["display", "heading", "body", "mono"]) {
261
+ if (faces[role]) continue;
262
+ const f = families[role];
263
+ if (isObj(f) && str(f.family)) faces[role] = str(f.family);
264
+ }
265
+ }
266
+ // WHERE LEADING AND TRACKING ACTUALLY LIVE. `foundation.type` is hq's
267
+ // `typeRoleSchema` — `{role, size, weight}`, and nothing else, ever. The
268
+ // line-height and letter-spacing a workspace tunes are carried by
269
+ // `system.typography.scale` (`{role, size, weight, leading, tracking}`), a
270
+ // separate array keyed by the same role names. Reading them off `type` was
271
+ // dead code: the fields are never there. So the two are merged by role —
272
+ // `type` wins on size/weight (it is the block the Design surface writes),
273
+ // the system scale supplies leading/tracking and any role `type` omits.
274
+ const systemScale = isObj(foundation && foundation.system) && isObj(foundation.system.typography)
275
+ ? arr(foundation.system.typography.scale)
276
+ : [];
277
+ const bySystemRole = new Map();
278
+ for (const t of systemScale) {
279
+ if (!isObj(t)) continue;
280
+ const key = slugToken(t.role);
281
+ if (!key || bySystemRole.has(key)) continue;
282
+ bySystemRole.set(key, t);
283
+ }
284
+ const num = (v) => (Number.isFinite(v) ? String(v) : "");
285
+ const seen = new Set();
286
+ const scale = [];
287
+ for (const t of arr(foundation && foundation.type)) {
288
+ if (!isObj(t)) continue;
289
+ const key = slugToken(t.role);
290
+ if (!key || seen.has(key)) continue;
291
+ seen.add(key);
292
+ const sys = bySystemRole.get(key) || {};
293
+ scale.push({
294
+ key,
295
+ size: dim(t.size) || dim(sys.size),
296
+ weight: num(t.weight) || num(sys.weight),
297
+ leading: num(sys.leading),
298
+ tracking: num(sys.tracking),
299
+ });
300
+ }
301
+ for (const [key, sys] of bySystemRole) {
302
+ if (seen.has(key)) continue;
303
+ seen.add(key);
304
+ const row = { key, size: dim(sys.size), weight: num(sys.weight), leading: num(sys.leading), tracking: num(sys.tracking) };
305
+ if (row.size || row.weight || row.leading || row.tracking) scale.push(row);
306
+ }
307
+ return { faces, scale };
308
+ }
309
+
310
+ /**
311
+ * Resolve a `{group.key}` ref against a token map, preferring the named keys
312
+ * and falling back to the map's first key. `null` when the group is empty — the
313
+ * caller then omits the field rather than emitting a ref that resolves to
314
+ * nothing, which is the one thing worse than an absent component field.
315
+ * @param {string} group
316
+ * @param {Record<string,string>} map
317
+ * @param {string[]} prefer
318
+ * @returns {string|null}
319
+ */
320
+ export function tokenRef(group, map, prefer) {
321
+ const keys = Object.keys(map || {});
322
+ if (keys.length === 0) return null;
323
+ const hit = (prefer || []).find((p) => keys.includes(p)) || keys[0];
324
+ return `{${group}.${hit}}`;
325
+ }
326
+
327
+ /**
328
+ * The `components` block: the four primitives every design skill reaches for,
329
+ * written entirely as refs so a token change propagates without touching them.
330
+ * A field whose ref cannot resolve is omitted (see {@link tokenRef}).
331
+ * @param {{colors:Record<string,string>, rounded:Record<string,string>, spacing:Record<string,string>, shadow:Record<string,string>, border:Record<string,string>, faces:Record<string,string>}} t
332
+ * @returns {Array<[string, Array<[string,string]>]>}
333
+ */
334
+ export function componentRefs(t) {
335
+ const c = (prefer) => tokenRef("colors", t.colors, prefer);
336
+ const r = (prefer) => tokenRef("rounded", t.rounded, prefer);
337
+ const s = (prefer) => tokenRef("spacing", t.spacing, prefer);
338
+ const sh = (prefer) => tokenRef("shadow", t.shadow, prefer);
339
+ const b = (prefer) => tokenRef("border", t.border, prefer);
340
+ const f = (prefer) => tokenRef("typography", t.faces, prefer);
341
+ const pad = (a, bb) => {
342
+ const one = s(a);
343
+ const two = s(bb);
344
+ return one && two ? `${one} ${two}` : one || two;
345
+ };
346
+ const spec = [
347
+ ["button", [
348
+ ["background", c(["primary"])],
349
+ ["foreground", c(["paper", "background"])],
350
+ ["radius", r(["pill", "chip"])],
351
+ ["padding", pad(["sm", "xs"], ["lg", "md"])],
352
+ ["font", f(["body"])],
353
+ ]],
354
+ ["card", [
355
+ ["background", c(["paper", "background"])],
356
+ ["foreground", c(["ink", "foreground"])],
357
+ ["border", b(["hairline"])],
358
+ ["radius", r(["card"])],
359
+ ["padding", s(["xl", "lg"])],
360
+ ["shadow", sh(["md", "sm"])],
361
+ ]],
362
+ ["input", [
363
+ ["background", c(["paper", "background"])],
364
+ ["border", b(["hairline"])],
365
+ ["radius", r(["field", "chip"])],
366
+ ["padding", pad(["sm", "xs"], ["md", "sm"])],
367
+ ["font", f(["body"])],
368
+ ]],
369
+ ["surface", [
370
+ ["background", c(["paper", "background"])],
371
+ ["foreground", c(["ink", "foreground"])],
372
+ ["muted", c(["muted"])],
373
+ ["radius", r(["modal", "card"])],
374
+ ]],
375
+ ];
376
+ return spec
377
+ .map(([name, fields]) => [name, fields.filter(([, v]) => !!v)])
378
+ .filter(([, fields]) => fields.length > 0);
379
+ }
380
+
381
+ /**
382
+ * Foundation keys this renderer does not emit, in stable key order — the thing
383
+ * the "Additional tokens" section exists to make visible.
384
+ *
385
+ * Nested one level into `system` and `visualLanguage` as well, reported as
386
+ * dotted paths (`"system.imagery"`). Those two are the blocks hq actually
387
+ * grows, and a top-level-only sweep calls the whole of `system` "modelled"
388
+ * while a new sub-block inside it vanishes. One level is deliberate: it covers
389
+ * every sub-block hq defines today without turning the guarantee into a
390
+ * recursive diff of two schemas.
391
+ *
392
+ * @param {object} foundation
393
+ * @returns {Array<[string, unknown]>}
394
+ */
395
+ export function additionalTokens(foundation) {
396
+ if (!isObj(foundation)) return [];
397
+ const out = Object.keys(foundation)
398
+ .filter((k) => !KNOWN_FOUNDATION_KEYS.includes(k))
399
+ .sort()
400
+ .map((k) => [k, foundation[k]]);
401
+ for (const [block, known] of [["system", KNOWN_SYSTEM_KEYS], ["visualLanguage", KNOWN_VISUAL_LANGUAGE_KEYS]]) {
402
+ const v = foundation[block];
403
+ if (!isObj(v)) continue;
404
+ for (const k of Object.keys(v).filter((key) => !known.includes(key)).sort()) {
405
+ out.push([`${block}.${k}`, v[k]]);
406
+ }
407
+ }
408
+ return out;
409
+ }
410
+
411
+ // ── prose helpers ───────────────────────────────────────────────────────────
412
+
413
+ /** A markdown bullet list, or [] when there is nothing real to list. */
414
+ function bullets(list, map = (x) => str(x)) {
415
+ return arr(list).map(map).filter(Boolean).map((line) => `- ${line}`);
416
+ }
417
+
418
+ /** `key: value` lines for a plain object's string-ish fields. */
419
+ function fieldLines(obj, fields) {
420
+ const out = [];
421
+ for (const [key, label] of fields) {
422
+ const v = obj ? obj[key] : undefined;
423
+ if (Array.isArray(v)) {
424
+ const items = v.map((x) => str(x)).filter(Boolean);
425
+ if (items.length) out.push(`- **${label}:** ${items.join(", ")}`);
426
+ } else if (typeof v === "number" && Number.isFinite(v)) {
427
+ out.push(`- **${label}:** ${v}`);
428
+ } else if (typeof v === "boolean") {
429
+ out.push(`- **${label}:** ${v ? "yes" : "no"}`);
430
+ } else if (str(v)) {
431
+ out.push(`- **${label}:** ${str(v)}`);
432
+ }
433
+ }
434
+ return out;
435
+ }
436
+
437
+ /** Drop trailing blank lines and end with exactly one newline. */
438
+ function finish(lines) {
439
+ const out = [...lines];
440
+ while (out.length && out[out.length - 1] === "") out.pop();
441
+ return `${out.join("\n")}\n`;
442
+ }
443
+
444
+ /** The provenance footer both files carry. Everything here is injected. */
445
+ function provenance(opts) {
446
+ const o = isObj(opts) ? opts : {};
447
+ const lines = [];
448
+ if (str(o.versionLabel)) lines.push(`- **Version:** ${str(o.versionLabel)}`);
449
+ if (str(o.versionId)) lines.push(`- **Version id:** ${str(o.versionId)}`);
450
+ if (str(o.generatedAt)) lines.push(`- **Synced:** ${str(o.generatedAt)}`);
451
+ lines.push(`- **Source:** ${str(o.source) || "cohort · branding.getFoundation"}`);
452
+ return lines;
453
+ }
454
+
455
+ // ── the two renderers ───────────────────────────────────────────────────────
456
+
457
+ /**
458
+ * Render DESIGN.md — the token contract.
459
+ *
460
+ * @param {object} foundation the saved brand foundation (`branding.getFoundation` → `result.foundation`)
461
+ * @param {object} [opts]
462
+ * @param {string} [opts.name] workspace/brand name for the heading
463
+ * @param {string} [opts.generatedAt] ISO string; INJECTED (this module never reads a clock)
464
+ * @param {string} [opts.versionId] `headVersionId` from the read
465
+ * @param {string} [opts.versionLabel] human label of that version
466
+ * @param {string} [opts.source] provenance line
467
+ * @returns {string} the file's bytes
468
+ */
469
+ export function renderDesignMd(foundation, opts = {}) {
470
+ const f = isObj(foundation) ? foundation : {};
471
+ const o = isObj(opts) ? opts : {};
472
+ const name = str(o.name) || "Workspace";
473
+ const { light, dark } = colorTokens(f);
474
+ const rounded = scaleTokens(f.radii);
475
+ const spacing = scaleTokens(f.space);
476
+ const shadow = scaleTokens(f.shadow);
477
+ const border = scaleTokens(f.border);
478
+ const { faces, scale } = typographyTokens(f);
479
+
480
+ const fm = ["---", `name: ${q(name)}`];
481
+ if (str(o.versionId)) fm.push(`version: ${q(o.versionId)}`);
482
+ if (str(o.generatedAt)) fm.push(`generatedAt: ${q(o.generatedAt)}`);
483
+ fm.push(`source: ${q(str(o.source) || "cohort · branding.getFoundation")}`);
484
+
485
+ fm.push("colors:");
486
+ const lightPairs = Object.entries(light);
487
+ if (lightPairs.length) fm.push(...yamlMap(lightPairs, " "));
488
+ else fm.push(" {}");
489
+ if (Object.keys(dark).length) {
490
+ fm.push("colorsDark:");
491
+ fm.push(...yamlMap(Object.entries(dark), " "));
492
+ }
493
+
494
+ fm.push("typography:");
495
+ if (Object.keys(faces).length) fm.push(...yamlMap(Object.entries(faces), " "));
496
+ if (scale.length) {
497
+ fm.push(" scale:");
498
+ for (const row of scale) {
499
+ const parts = [];
500
+ if (row.size) parts.push(`size: ${q(row.size)}`);
501
+ if (row.weight) parts.push(`weight: ${row.weight}`);
502
+ if (row.leading) parts.push(`leading: ${row.leading}`);
503
+ if (row.tracking) parts.push(`tracking: ${row.tracking}`);
504
+ fm.push(` ${row.key}: { ${parts.join(", ")} }`);
505
+ }
506
+ }
507
+ if (!Object.keys(faces).length && !scale.length) fm.push(" {}");
508
+
509
+ for (const [label, map] of [["rounded", rounded], ["spacing", spacing], ["shadow", shadow], ["border", border]]) {
510
+ fm.push(`${label}:`);
511
+ const pairs = Object.entries(map);
512
+ if (pairs.length) fm.push(...yamlMap(pairs, " "));
513
+ else fm.push(" {}");
514
+ }
515
+
516
+ const components = componentRefs({ colors: light, rounded, spacing, shadow, border, faces });
517
+ fm.push("components:");
518
+ if (components.length) {
519
+ for (const [comp, fields] of components) {
520
+ fm.push(` ${comp}:`);
521
+ fm.push(...yamlMap(fields, " "));
522
+ }
523
+ } else {
524
+ fm.push(" {}");
525
+ }
526
+ fm.push("---");
527
+
528
+ const body = [
529
+ "",
530
+ `# ${name} — design system`,
531
+ "",
532
+ "The frontmatter above is the token contract: it is generated from the workspace's",
533
+ "saved brand foundation and is the source of truth for colour, type, radius and",
534
+ "spacing. Read it before you pick a value. Component entries are written as",
535
+ "`{group.key}` refs, so changing a token changes every component that names it.",
536
+ "",
537
+ "This file is GENERATED — edits here are overwritten on the next sync. To change",
538
+ "the foundation itself, propose the change (`design_propose_change`); a human",
539
+ "reviews and applies it, and the next sync brings it back down.",
540
+ "",
541
+ ];
542
+
543
+ body.push("## Palette", "");
544
+ if (lightPairs.length) {
545
+ for (const [k, v] of lightPairs) {
546
+ body.push(`- \`{colors.${k}}\` — ${v}${dark[k] ? ` (dark ${dark[k]})` : ""}`);
547
+ }
548
+ } else {
549
+ body.push("_No palette in the foundation._");
550
+ }
551
+ body.push("");
552
+
553
+ body.push("## Typography", "");
554
+ if (Object.keys(faces).length) {
555
+ for (const [role, family] of Object.entries(faces)) body.push(`- **${role}:** ${family}`);
556
+ }
557
+ if (scale.length) {
558
+ body.push("");
559
+ for (const row of scale) {
560
+ const bits = [
561
+ row.size,
562
+ row.weight ? `weight ${row.weight}` : "",
563
+ row.leading ? `leading ${row.leading}` : "",
564
+ row.tracking ? `tracking ${row.tracking}` : "",
565
+ ].filter(Boolean).join(" · ");
566
+ body.push(`- \`{typography.scale.${row.key}}\` — ${bits}`);
567
+ }
568
+ }
569
+ if (!Object.keys(faces).length && !scale.length) body.push("_No typography in the foundation._");
570
+ body.push("");
571
+
572
+ const usage = isObj(f.system) && isObj(f.system.typography) ? f.system.typography : null;
573
+ const typeRules = bullets(usage && usage.usageRules);
574
+ if (str(usage && usage.pairingRationale) || typeRules.length) {
575
+ body.push("### Type rules", "");
576
+ if (str(usage.pairingRationale)) body.push(str(usage.pairingRationale), "");
577
+ if (typeRules.length) body.push(...typeRules, "");
578
+ }
579
+ const colorRules = bullets(isObj(f.system) && isObj(f.system.color) ? f.system.color.usageRules : null);
580
+ if (colorRules.length) body.push("### Colour rules", "", ...colorRules, "");
581
+
582
+ // VISUAL LANGUAGE — assembled first, emitted only if it has content, so a
583
+ // present-but-empty `visualLanguage` does not leave a bare heading behind.
584
+ //
585
+ // TWO SOURCES, ONE SECTION. `visualLanguage.imagery` is the art-direction
586
+ // block the Design surface writes; `system.imagery` is the brand system's own
587
+ // (medium / style / treatment / mood / subjects / donts). They are different
588
+ // hq schemas and a workspace can carry either or both — rendering only the
589
+ // first is how `system.imagery` came to be dropped in silence.
590
+ const vlLines = [];
591
+ const vl = isObj(f.visualLanguage) ? f.visualLanguage : {};
592
+ const vlImagery = isObj(vl.imagery) ? vl.imagery : {};
593
+ const img = fieldLines(vlImagery, [
594
+ ["needsImagery", "Uses imagery"],
595
+ ["medium", "Medium"],
596
+ ["secondaryMedia", "Secondary media"],
597
+ ["colorGrade", "Grade"],
598
+ ["grain", "Grain"],
599
+ ["crop", "Crop"],
600
+ ["aspectRatios", "Aspect ratios"],
601
+ ["subjects", "Subjects"],
602
+ ["mood", "Mood"],
603
+ ["composition", "Composition"],
604
+ ]);
605
+ const duo = isObj(vlImagery.duotone) ? vlImagery.duotone : null;
606
+ if (duo && (str(duo.shadow) || str(duo.highlight))) {
607
+ img.push(`- **Duotone:** ${[str(duo.shadow) && `shadow ${str(duo.shadow)}`, str(duo.highlight) && `highlight ${str(duo.highlight)}`].filter(Boolean).join(" · ")}`);
608
+ }
609
+ if (img.length) vlLines.push("### Imagery", "", ...img, "");
610
+ const sysImagery = fieldLines(isObj(f.system) && isObj(f.system.imagery) ? f.system.imagery : {}, [
611
+ ["medium", "Medium"],
612
+ ["style", "Style"],
613
+ ["treatment", "Treatment"],
614
+ ["mood", "Mood"],
615
+ ["subjects", "Subjects"],
616
+ ["donts", "Avoid"],
617
+ ]);
618
+ if (sysImagery.length) vlLines.push("### Imagery (brand system)", "", ...sysImagery, "");
619
+ const icon = fieldLines(isObj(vl.iconography) ? vl.iconography : {}, [
620
+ ["style", "Style"],
621
+ ["weight", "Weight"],
622
+ ["corner", "Corner"],
623
+ ["grid", "Grid"],
624
+ ["notes", "Notes"],
625
+ ]);
626
+ if (icon.length) vlLines.push("### Iconography", "", ...icon, "");
627
+ const motion = fieldLines(isObj(vl.motion) ? vl.motion : {}, [
628
+ ["character", "Character"],
629
+ ["easing", "Easing"],
630
+ ["durationMs", "Duration (ms)"],
631
+ ["principles", "Principles"],
632
+ ]);
633
+ if (motion.length) vlLines.push("### Motion", "", ...motion, "");
634
+ const texture = fieldLines(isObj(vl.texture) ? vl.texture : {}, [
635
+ ["surfaces", "Surfaces"],
636
+ ["background", "Background"],
637
+ ["notes", "Notes"],
638
+ ]);
639
+ if (texture.length) vlLines.push("### Texture", "", ...texture, "");
640
+ const guards = isObj(vl.guardrails) ? vl.guardrails : {};
641
+ const dos = bullets(guards.dos);
642
+ const donts = bullets(guards.donts);
643
+ if (dos.length || donts.length) {
644
+ vlLines.push("### Guardrails", "");
645
+ if (dos.length) vlLines.push("Do:", "", ...dos, "");
646
+ if (donts.length) vlLines.push("Do not:", "", ...donts, "");
647
+ }
648
+ // The image-generation seeds and the block's own provenance. Modelled here
649
+ // rather than left to "Additional tokens" because they are what an image
650
+ // prompt is actually built from.
651
+ const vlMeta = fieldLines(vl, [
652
+ ["promptSeed", "Prompt seed"],
653
+ ["seedLocked", "Seed locked"],
654
+ ["negativeSeed", "Negative seed"],
655
+ ["references", "References"],
656
+ ["source", "Source"],
657
+ ["v", "Schema version"],
658
+ ]);
659
+ if (vlMeta.length) vlLines.push("### Generation seeds", "", ...vlMeta, "");
660
+ if (vlLines.length) body.push("## Visual language", "", ...vlLines);
661
+
662
+ const layout = bullets(isObj(f.system) ? f.system.layoutPrinciples : null);
663
+ if (layout.length) body.push("## Layout principles", "", ...layout, "");
664
+
665
+ const logos = isObj(f.logos) ? f.logos : null;
666
+ if (logos) {
667
+ const slots = Object.entries(logos)
668
+ .filter(([, v]) => str(v))
669
+ .map(([k]) => `- \`${k}\` — set`);
670
+ if (slots.length) body.push("## Marks", "", ...slots, "", "Fetch the bytes with `design_export_kit`; never inline them here.", "");
671
+ }
672
+
673
+ const extra = additionalTokens(f);
674
+ body.push("## Additional tokens", "");
675
+ if (extra.length) {
676
+ body.push(
677
+ "Keys the renderer does not model yet, carried through verbatim so nothing is",
678
+ "lost between an hq that added a token and an SDK that has not learned it.",
679
+ "",
680
+ "```json",
681
+ JSON.stringify(Object.fromEntries(extra), null, 2),
682
+ "```",
683
+ "",
684
+ );
685
+ } else {
686
+ body.push("_None — every key in the foundation is modelled above._", "");
687
+ }
688
+
689
+ body.push("## Provenance", "", ...provenance(o));
690
+ return finish([...fm, ...body]);
691
+ }
692
+
693
+ /**
694
+ * Render PRODUCT.md — the voice and positioning half of the same foundation.
695
+ * Same purity contract as {@link renderDesignMd}.
696
+ *
697
+ * @param {object} foundation
698
+ * @param {object} [opts] see {@link renderDesignMd}
699
+ * @returns {string}
700
+ */
701
+ export function renderProductMd(foundation, opts = {}) {
702
+ const f = isObj(foundation) ? foundation : {};
703
+ const o = isObj(opts) ? opts : {};
704
+ const name = str(o.name) || "Workspace";
705
+ const sys = isObj(f.system) ? f.system : {};
706
+ const sysVoice = isObj(sys.voice) ? sys.voice : {};
707
+
708
+ const lines = [
709
+ "---",
710
+ `name: ${q(name)}`,
711
+ ];
712
+ if (str(o.versionId)) lines.push(`version: ${q(o.versionId)}`);
713
+ if (str(o.generatedAt)) lines.push(`generatedAt: ${q(o.generatedAt)}`);
714
+ lines.push(`source: ${q(str(o.source) || "cohort · branding.getFoundation")}`, "---", "");
715
+
716
+ lines.push(
717
+ `# ${name} — product voice`,
718
+ "",
719
+ "Generated from the workspace's saved brand foundation. It says how this",
720
+ "product sounds; `DESIGN.md` beside it says how it looks. Read both before",
721
+ "writing anything a customer sees. Edits here are overwritten on the next",
722
+ "sync — propose foundation changes with `design_propose_change`.",
723
+ "",
724
+ );
725
+
726
+ const positioning = str(f.positioning);
727
+ if (positioning) lines.push("## Positioning", "", positioning, "");
728
+
729
+ const taglines = arr(f.taglines).map((t) => str(t)).filter(Boolean);
730
+ if (taglines.length) lines.push("## Taglines", "", ...taglines.map((t) => `- ${t}`), "");
731
+
732
+ const voice = str(f.voice) || str(sysVoice.voice);
733
+ if (voice) lines.push("## Voice", "", voice, "");
734
+
735
+ const traits = [...arr(f.toneTraits), ...arr(sysVoice.toneTraits)]
736
+ .map((t) => str(t))
737
+ .filter(Boolean)
738
+ .filter((t, i, all) => all.indexOf(t) === i);
739
+ if (traits.length) lines.push("## Tone", "", ...traits.map((t) => `- ${t}`), "");
740
+
741
+ const lex = isObj(sysVoice.lexicon) ? sysVoice.lexicon : {};
742
+ const use = bullets(lex.use);
743
+ const avoid = bullets(lex.avoid);
744
+ if (use.length || avoid.length) {
745
+ lines.push("## Lexicon", "");
746
+ if (use.length) lines.push("Say:", "", ...use, "");
747
+ if (avoid.length) lines.push("Avoid:", "", ...avoid, "");
748
+ }
749
+
750
+ const example = str(sysVoice.exampleCopy);
751
+ if (example) lines.push("## In practice", "", example, "");
752
+
753
+ const motifs = bullets(sys.motifs);
754
+ if (motifs.length) lines.push("## Motifs", "", ...motifs, "");
755
+ if (str(sys.iconography)) lines.push("## Iconography", "", str(sys.iconography), "");
756
+
757
+ if (!positioning && !voice && !traits.length && !use.length && !avoid.length) {
758
+ lines.push("## Nothing set yet", "", "The foundation carries no voice or positioning. Ask a human to fill it in from", "the Design surface, or propose it with `design_propose_change`.", "");
759
+ }
760
+
761
+ lines.push("## Provenance", "", ...provenance(o));
762
+ return finish(lines);
763
+ }
764
+
765
+ /**
766
+ * Both files, rendered together — the shape the CLI and the daemon both write.
767
+ * Relative paths, so the caller decides the directory.
768
+ * @param {object} foundation
769
+ * @param {object} [opts] see {@link renderDesignMd}
770
+ * @returns {Array<{rel:string, content:string}>}
771
+ */
772
+ export function renderDesignBundle(foundation, opts = {}) {
773
+ return [
774
+ { rel: "DESIGN.md", content: renderDesignMd(foundation, opts) },
775
+ { rel: "PRODUCT.md", content: renderProductMd(foundation, opts) },
776
+ ];
777
+ }
778
+
779
+ export default {
780
+ renderDesignMd,
781
+ renderProductMd,
782
+ renderDesignBundle,
783
+ colorTokens,
784
+ scaleTokens,
785
+ typographyTokens,
786
+ componentRefs,
787
+ additionalTokens,
788
+ tokenRef,
789
+ slugToken,
790
+ KNOWN_FOUNDATION_KEYS,
791
+ KNOWN_SYSTEM_KEYS,
792
+ KNOWN_VISUAL_LANGUAGE_KEYS,
793
+ };