@clay-design-system/kb 0.0.0-stage → 0.14.1

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 (45) hide show
  1. package/dist/contract.d.ts +227 -0
  2. package/dist/contract.d.ts.map +1 -0
  3. package/dist/contract.js +134 -0
  4. package/dist/contract.js.map +1 -0
  5. package/dist/core/memory.d.ts +20 -0
  6. package/dist/core/memory.d.ts.map +1 -0
  7. package/dist/core/memory.js +23 -0
  8. package/dist/core/memory.js.map +1 -0
  9. package/dist/core/model.d.ts +47 -0
  10. package/dist/core/model.d.ts.map +1 -0
  11. package/dist/core/model.js +21 -0
  12. package/dist/core/model.js.map +1 -0
  13. package/dist/core/prose.d.ts +40 -0
  14. package/dist/core/prose.d.ts.map +1 -0
  15. package/dist/core/prose.js +106 -0
  16. package/dist/core/prose.js.map +1 -0
  17. package/dist/core/query.d.ts +87 -0
  18. package/dist/core/query.d.ts.map +1 -0
  19. package/dist/core/query.js +637 -0
  20. package/dist/core/query.js.map +1 -0
  21. package/dist/core/rank.d.ts +123 -0
  22. package/dist/core/rank.d.ts.map +1 -0
  23. package/dist/core/rank.js +429 -0
  24. package/dist/core/rank.js.map +1 -0
  25. package/dist/core/route-email.d.ts +48 -0
  26. package/dist/core/route-email.d.ts.map +1 -0
  27. package/dist/core/route-email.js +172 -0
  28. package/dist/core/route-email.js.map +1 -0
  29. package/dist/core/route.d.ts +431 -0
  30. package/dist/core/route.d.ts.map +1 -0
  31. package/dist/core/route.js +1769 -0
  32. package/dist/core/route.js.map +1 -0
  33. package/dist/core/search.d.ts +64 -0
  34. package/dist/core/search.d.ts.map +1 -0
  35. package/dist/core/search.js +123 -0
  36. package/dist/core/search.js.map +1 -0
  37. package/dist/index.d.ts +3 -0
  38. package/dist/index.d.ts.map +1 -0
  39. package/dist/index.js +2 -0
  40. package/dist/index.js.map +1 -0
  41. package/dist/snapshot-email.json +1 -0
  42. package/dist/snapshot-foundations.json +1 -0
  43. package/dist/snapshot-web.json +1 -0
  44. package/package.json +28 -3
  45. package/README.md +0 -3
@@ -0,0 +1,1769 @@
1
+ import { estimateTokens } from './model.js';
2
+ import { search } from './search.js';
3
+ import { EMAIL_SKILL_DELEGATES, emailBriefCandidates, emailDoBlock, emailGates, inScope, isEmailSkill, } from './route-email.js';
4
+ export const DEFAULT_BUDGET = 2500;
5
+ /** A route with a brief also carries the candidates table and the item shapes. K5 raised this to 3k; K5b deduped the
6
+ * route (the precedence table once, no Mode A inputs in a Mode B route) and brought it back to 2.5k. */
7
+ export const BRIEF_BUDGET = 2500;
8
+ /**
9
+ * Units a skill fetches once at the start of a mode (web-compose SKILL.md "Modes": `pnpm clay get
10
+ * policy.mode-b.precedence policy.mode-b.defaults`). A route names them in one line instead of inlining them again
11
+ * (K5 QA F5: the precedence table was printed twice). k5b.test.ts keeps this list and the skill text in sync.
12
+ */
13
+ export const MODE_START_UNITS = {
14
+ A: [],
15
+ B: ['policy.mode-b.precedence', 'policy.mode-b.defaults'],
16
+ };
17
+ /** A procedure body with "**Mode A …**" / "**Mode B …**" blocks keeps only the requested mode's block (K5b F5). */
18
+ export function modeSlice(body, mode) {
19
+ if (!mode)
20
+ return body;
21
+ const lines = body.split('\n');
22
+ const marks = lines
23
+ .map((l, i) => ({ i, m: /^\*\*Mode ([AB])\b/.exec(l)?.[1] }))
24
+ .filter((x) => x.m);
25
+ if (new Set(marks.map((x) => x.m)).size < 2)
26
+ return body;
27
+ const keep = lines.slice(0, marks[0].i);
28
+ marks.forEach((x, k) => {
29
+ if (x.m !== mode)
30
+ return;
31
+ keep.push(...lines.slice(x.i, k + 1 < marks.length ? marks[k + 1].i : lines.length));
32
+ });
33
+ return keep
34
+ .join('\n')
35
+ .replace(/\n{3,}/g, '\n\n')
36
+ .trim();
37
+ }
38
+ /** K8: Watch lists at most this many one-liners (run D: ~80% of a 35-line Watch list was irrelevant). */
39
+ export const WATCH_MAX = 10;
40
+ /** K11 F1: the Watch score edge a Critical unit has over a non-Critical one of the same rank. */
41
+ export const CRITICAL_EDGE = 0.1;
42
+ /**
43
+ * K11 G1: how far below a Minor unit kept in Watch a Critical unit may score and still take its slot at the cut (see
44
+ * `guardCriticalAtCut`, which makes the Critical guarantee independent of BM25 drift in the edge).
45
+ */
46
+ export const CRITICAL_CUT_MARGIN = 0.15;
47
+ export const MORE_MAX = 12;
48
+ /** K10: the compact route's Watch list (the rest are ids under More). */
49
+ export const WATCH_MAX_COMPACT = 5;
50
+ /** Stages that get the "Your layouts" fact lines (the page is being written or checked). */
51
+ const LAYOUT_FACT_STAGES = new Set(['copy', 'page-file']);
52
+ /* ── brief parsing ─────────────────────────────────────────────────────────────────────────── */
53
+ /**
54
+ * Brief sections: numbered headings — `## N. Title` (the `pnpm capture --brief` format) or `### N. Title` under a
55
+ * `## Sections` heading (the documented Mode B template, `procedure.compose.inputs`) — at whichever level carries the
56
+ * numbered headings; with none, every `##` heading. A section runs to the next heading of the same or a higher level.
57
+ * Item counts come from tables / lists / "·" lists.
58
+ */
59
+ export function parseBrief(text) {
60
+ const lines = text.split('\n');
61
+ const heads = [];
62
+ // "## 3. Title", "### 3) Title" or "## Section 3 — Title"; a "## PMM Brief — …" metadata block is never a section.
63
+ const NUM = /^(?:section\s+)?(\d+)(?:[.)]\s*|\s*[—–:-]\s*|\s+)/i;
64
+ let fence = false;
65
+ lines.forEach((l, i) => {
66
+ if (/^\s*(?:```|~~~)/.test(l))
67
+ fence = !fence;
68
+ if (fence)
69
+ return;
70
+ const m = /^(#{2,3})\s+(.+)$/.exec(l);
71
+ if (!m)
72
+ return;
73
+ const title = m[2].trim();
74
+ heads.push({
75
+ i,
76
+ level: m[1].length,
77
+ title,
78
+ numbered: NUM.test(title) && /^(?:section\s+)?\d+(?:[.)]|\s*[—–:-]|\s)/i.test(title),
79
+ });
80
+ });
81
+ const numberedAt = (lv) => heads.filter((h) => h.level === lv && h.numbered).length;
82
+ // The level with the numbered headings (## wins a tie: a capture brief never nests).
83
+ const level = numberedAt(2) >= numberedAt(3) ? (numberedAt(2) ? 2 : numberedAt(3) ? 3 : 2) : 3;
84
+ const use = heads.some((h) => h.numbered && h.level === level)
85
+ ? heads.filter((h) => h.numbered && h.level === level)
86
+ : heads.filter((h) => h.level === 2 && !/^pmm brief\b/i.test(h.title));
87
+ return use.map((h, k) => {
88
+ const end = Math.min(k + 1 < use.length ? use[k + 1].i : lines.length, heads.find((x) => x.i > h.i && x.level <= h.level)?.i ?? lines.length);
89
+ const body = lines.slice(h.i + 1, end).join('\n');
90
+ const n = Number(NUM.exec(h.title)?.[1] ?? k + 1);
91
+ const title = h.title.replace(NUM, '');
92
+ return { n, title, text: body, items: itemCount(`${title}\n${body}`) };
93
+ });
94
+ }
95
+ const NUMBER_WORDS = {
96
+ two: 2,
97
+ three: 3,
98
+ four: 4,
99
+ five: 5,
100
+ six: 6,
101
+ seven: 7,
102
+ eight: 8,
103
+ nine: 9,
104
+ ten: 10,
105
+ twelve: 12,
106
+ };
107
+ /** The groups of a capture --brief "**Layout (derived):** section · 6 accordion · 2 tabs" line, outermost first. */
108
+ export function derivedLayout(body) {
109
+ const d = /^\s*-\s+\*\*Layout \(derived\):\*\*\s*(.*)$/m.exec(body)?.[1] ?? '';
110
+ const parts = d.split('·').map((x) => x.trim());
111
+ const groups = parts
112
+ .slice(1)
113
+ .map((p) => /^(\d+)\s+([a-z]+)/.exec(p))
114
+ .filter((m) => !!m)
115
+ .map((m) => ({ n: Number(m[1]), kind: m[2] }));
116
+ return { top: parts[0] ?? '', groups };
117
+ }
118
+ /**
119
+ * A `pnpm capture --brief` section (it has a "**Layout (derived):**" line), sized by its OUTER structure (K8): the first
120
+ * derived group that is not a logo row and is not media-only — "6 accordion · 2 tabs" is 6 (the 2-tab carousel inside
121
+ * one panel is content), "4 logos · 3 cards" is 3, "4 cards · 3 cards" of bare images (floating cards) is no item list.
122
+ * Items blocks are never summed (a desktop and a mobile copy of the same 3 cards are 3), nested Items blocks (bullets /
123
+ * tags inside an item) don't count. No usable group: the first top-level Items block whose items carry text; no Items
124
+ * block: repeated same-label CTAs (one per card) give the count.
125
+ */
126
+ /** The top-level "**Items (N, kind):**" blocks of a capture --brief section, with how many of their items carry text. */
127
+ export function itemBlocks(body) {
128
+ const lines = body.split('\n');
129
+ const blocks = [];
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const m = /^-\s+\*\*Items \((\d+), ([a-z]+)\b[^)]*\):\*\*(.*)$/.exec(lines[i]);
132
+ if (!m)
133
+ continue;
134
+ let text = 0;
135
+ // K10: capture --brief writes a group of bare labels or media on one line ("1. **IT** · 2. **HR**").
136
+ const inline = m[3].trim();
137
+ if (inline)
138
+ for (const it of inline.split(/\s·\s(?=\d+\.\s)/))
139
+ if (!/^\d+\.\s+(?:img|video|image|svg|lottie)\b/i.test(it.trim()))
140
+ text++;
141
+ for (let j = i + 1; j < lines.length && !/^-\s/.test(lines[j]) && !/^#/.test(lines[j]); j++) {
142
+ const it = /^\s{2,3}\d+\.\s+(.*)$/.exec(lines[j]);
143
+ if (it && !/^(?:img|video|image|svg|lottie)\b/i.test(it[1].trim()))
144
+ text++;
145
+ }
146
+ blocks.push({ n: Number(m[1]), kind: m[2], text });
147
+ }
148
+ return blocks;
149
+ }
150
+ /**
151
+ * K10 (`policy.mode-b.two-item-groups`): a brief section that holds two different item groups with copy — e.g. "4 tabs"
152
+ * and "3 cards" in one live band. A desktop / mobile copy of one group (same count and kind), media-only groups, logo rows
153
+ * and a 2-slide carousel control inside a panel are not a second group.
154
+ */
155
+ export function twoItemGroups(body) {
156
+ const real = itemBlocks(body).filter((x) => x.kind !== 'logos' && x.n >= 2 && x.text >= 2 && !(x.kind === 'tabs' && x.n === 2));
157
+ for (let i = 0; i + 1 < real.length; i++) {
158
+ const a = real[i];
159
+ const b = real.slice(i + 1).find((x) => x.kind !== a.kind || x.n !== a.n);
160
+ if (b)
161
+ return { a: { n: a.n, kind: a.kind }, b: { n: b.n, kind: b.kind } };
162
+ }
163
+ return null;
164
+ }
165
+ export function captureItemCount(body) {
166
+ const lines = body.split('\n');
167
+ const blocks = itemBlocks(body);
168
+ for (const g of derivedLayout(body).groups) {
169
+ if (g.kind === 'logos' || g.n < 2)
170
+ continue;
171
+ const b = blocks.find((x) => x.kind === g.kind && x.n === g.n) ?? blocks.find((x) => x.kind === g.kind);
172
+ if (b && b.text === 0)
173
+ continue; // media-only: floating cards, tab panels, decorative avatars
174
+ if (!b && !['tabs', 'accordion', 'list'].includes(g.kind))
175
+ continue;
176
+ return g.n;
177
+ }
178
+ const withText = blocks.filter((b) => b.text > 0 && b.kind !== 'logos');
179
+ if (withText.length)
180
+ return withText[0].n >= 2 ? withText[0].n : null;
181
+ if (blocks.length)
182
+ return null;
183
+ const ctas = new Map();
184
+ for (const l of lines) {
185
+ const c = /^-\s+\*\*CTA:\*\*\s*(.+?)\s*→/.exec(l)?.[1];
186
+ if (c)
187
+ ctas.set(c.toLowerCase(), (ctas.get(c.toLowerCase()) ?? 0) + 1);
188
+ }
189
+ const rep = Math.max(0, ...ctas.values());
190
+ return rep >= 2 ? rep : null;
191
+ }
192
+ /** A capture --brief section body (`pnpm capture --brief` output). */
193
+ export const isCaptureSection = (body) => /^\s*-\s+\*\*Layout \(derived\):\*\*/m.test(body);
194
+ function itemCount(body) {
195
+ if (isCaptureSection(body))
196
+ return captureItemCount(body);
197
+ const lines = body.split('\n');
198
+ const rows = lines.filter((l) => /^\s*\|/.test(l) && !/^\s*\|?\s*:?-{3,}/.test(l));
199
+ const tableRows = rows.length > 1 ? rows.length - 1 : 0;
200
+ // Numbered lists: the longest run (a list restarts at 1), never the total — two lists of 3 are 3 items, not 6 (K5b F3).
201
+ let numbered = 0;
202
+ let runN = 0;
203
+ for (const l of lines) {
204
+ const m = /^\s{0,4}(\d+)[.)]\s+\S/.exec(l);
205
+ if (!m)
206
+ continue;
207
+ runN = Number(m[1]) === 1 ? 1 : runN + 1;
208
+ numbered = Math.max(numbered, runN);
209
+ }
210
+ // Bold-led bullets are items ("- **Bring your own agent** — …"); a bold label ending in ":" is a field ("- **H2:** …").
211
+ const boldItems = lines.filter((l) => /^\s*[-*]\s+\*\*([^*]+?)\*\*\s*(?:[—–-]|:)\s*\S/.test(l) &&
212
+ !/^\s*[-*]\s+\*\*[^*]*:\*\*/.test(l)).length;
213
+ let dotted = 0;
214
+ for (const l of lines) {
215
+ if (!/\b(?:tabs?|chips?|logos?|items?|categories|cards?)\b/i.test(l))
216
+ continue;
217
+ const parts = l.split('·').length;
218
+ if (parts > dotted && parts > 2)
219
+ dotted = parts;
220
+ const commas = /:\s*([^.]+)$/.exec(l)?.[1]?.split(',').length ?? 0;
221
+ if (/logos?/i.test(l) && commas > dotted && commas > 2)
222
+ dotted = commas;
223
+ }
224
+ // Nested "Title — description" bullets under a label line ("- **Accordion items:**" / " - Research Agents — …").
225
+ let nested = 0;
226
+ let run = 0;
227
+ for (const l of lines) {
228
+ if (/^\s{2,}[-*]\s+(?:\([^)]{1,20}\)\s*)?[^*:]{2,60}?\s[—–]\s\S/.test(l))
229
+ run++;
230
+ else if (!/^\s{4,}/.test(l))
231
+ run = 0;
232
+ nested = Math.max(nested, run);
233
+ }
234
+ // "- Card A (…)", "- Tab 2:"
235
+ const lettered = lines.filter((l) => /^\s*[-*]\s+(?:\*\*)?(?:Card|Tab|Item|Step|Panel)\s+(?:[A-Z]|\d{1,2})\b/.test(l)).length;
236
+ // "Two feature cards", "6 features in a 3×2 grid"
237
+ const counted = /\b(two|three|four|five|six|seven|eight|nine|ten|twelve|\d{1,2})\s+(?:[a-z-]+\s+){0,2}?(?:cards?|items?|tabs?|steps?|pillars?|columns?|features?|stats?|quotes?|categories)\b/i.exec(body)?.[1];
238
+ const word = counted
239
+ ? /^\d+$/.test(counted)
240
+ ? Number(counted)
241
+ : NUMBER_WORDS[counted.toLowerCase()]
242
+ : 0;
243
+ const n = Math.max(tableRows, numbered, boldItems >= 2 ? boldItems : 0, dotted, nested >= 2 ? nested : 0, lettered >= 2 ? lettered : 0, word);
244
+ return n >= 2 ? n : null;
245
+ }
246
+ const STAT_NAME = /^[$€£]?\d[\d.,]*\s*(?:%|[KMB]\+?|x|\+)?$/i;
247
+ export function itemShape(body) {
248
+ const lines = body.split('\n');
249
+ const top = (l) => /^\s{2,3}\d+\.\s+(.*)$/.exec(l) ?? /^\s?\d+[.)]\s+(.*)$/.exec(l);
250
+ const out = {
251
+ n: 0,
252
+ named: 0,
253
+ bullets: 0,
254
+ tags: 0,
255
+ media: 0,
256
+ rich: 0,
257
+ stats: 0,
258
+ quotes: 0,
259
+ };
260
+ for (let i = 0; i < lines.length; i++) {
261
+ const m = top(lines[i]);
262
+ if (!m)
263
+ continue;
264
+ const nested = [];
265
+ for (let j = i + 1; j < lines.length && !top(lines[j]) && !/^-\s|^#/.test(lines[j]); j++)
266
+ nested.push(lines[j]);
267
+ const line = m[1];
268
+ const name = /^\*\*(.+?)\*\*/.exec(line)?.[1]?.trim() ?? '';
269
+ const nest = nested.join('\n');
270
+ const bullets = /\*\*Items \(\d+, list\b/.test(nest) || /\bbullets?\s*:/i.test(line);
271
+ const tags = /\*\*Items \(\d+, (?:labels|tags)\b/.test(nest) || /\btags?\s*:/i.test(line);
272
+ const media = /\b(?:video|img|image)\b/i.test(line);
273
+ out.n++;
274
+ if (name)
275
+ out.named++;
276
+ if (bullets)
277
+ out.bullets++;
278
+ if (tags)
279
+ out.tags++;
280
+ if (media)
281
+ out.media++;
282
+ if (name && (bullets || tags || media || /\*\*\s+[—–]\s+\S/.test(line)))
283
+ out.rich++;
284
+ if (STAT_NAME.test(name) || /^\s*(?:\*\*)?stat\b/i.test(line))
285
+ out.stats++;
286
+ else if ((name.length >= 30 && /[.!?"”]$/.test(name)) || /^\s*(?:\*\*)?quote\b/i.test(line))
287
+ out.quotes++;
288
+ }
289
+ return out;
290
+ }
291
+ /** Agent cards: named items, most with bullets and media (tags are optional per card) — `RevealCard`'s shape. */
292
+ export const isAgentCards = (sh) => sh.n >= 2 && sh.named >= sh.n * 0.8 && sh.bullets >= sh.n * 0.5 && sh.media >= sh.n * 0.5;
293
+ /**
294
+ * K11 (`policy.mode-b.hero-item-split`): the hero's item group when no HeaderSection slot holds it — items with copy
295
+ * of their own (a description, bullets, tags or per-item media). Bare labels (the `options` row), logos and media-only
296
+ * items are not one.
297
+ */
298
+ export function heroItemGroup(sec) {
299
+ if (sec.items == null || sec.items < 2)
300
+ return null;
301
+ const sh = itemShape(sec.text);
302
+ if (sh.rich < 2 || sh.rich < sh.n * 0.5)
303
+ return null;
304
+ // Hero-level media: a top-level Media / Image / Video line (item media sits on the item lines).
305
+ const heroMedia = /^-\s+\*\*(?:Media|Image|Visual|Video|Hero (?:image|media|video))\b/im.test(sec.text);
306
+ return { n: sec.items, agentCards: isAgentCards(sh), heroMedia };
307
+ }
308
+ /** K11 B2: the top-level items of a section as `{ name, desc }` — the bold name and the copy after the first " — ". */
309
+ export function textItems(body) {
310
+ const out = [];
311
+ for (const l of body.split('\n')) {
312
+ const m = /^\s{2,3}\d+\.\s+(.*)$/.exec(l) ?? /^\s?\d+[.)]\s+(.*)$/.exec(l);
313
+ if (!m)
314
+ continue;
315
+ const name = /^\*\*(.+?)\*\*/.exec(m[1])?.[1]?.trim() ?? '';
316
+ const desc = (/^\*\*.+?\*\*\s+[—–]\s+(.*)$/.exec(m[1])?.[1] ?? '')
317
+ .split(/\s[—–]\s(?=(?:CTA|img|video|image)\b)/)[0]
318
+ .replace(/\s*_\([^)]*\)_\s*$/, '')
319
+ .trim();
320
+ out.push({ name, desc });
321
+ }
322
+ return out;
323
+ }
324
+ /**
325
+ * K11 B2 (`policy.tabs.layout-by-count`, vertical item list): a section whose items stack in one vertical list, each
326
+ * showing its own copy and expanding in place — an accordion, or what a capture labels "tabs" when the tab items carry
327
+ * their description (a horizontal tab row is a group of bare labels, its copy sits in the panels). It needs a visual:
328
+ * one per item (on the item lines, or a media-only group of the same count that swaps per item) → `item`; one shared
329
+ * section visual → `shared`. Not one: nested item groups (an accordion driving a card carousel), no visual, an FAQ (B3:
330
+ * `faqSection`: most item names are questions, or the title says FAQ — even with a section image), a layout line that
331
+ * says the tabs sit in a horizontal row (B3).
332
+ */
333
+ export function verticalItemList(sec) {
334
+ const n = sec.items;
335
+ if (n == null || n < 2)
336
+ return null;
337
+ // B3: an FAQ is an accordion of questions, even with a section image — FaqSection, not a FeaturesSection accordion.
338
+ if (faqSection(sec))
339
+ return null;
340
+ const body = sec.text;
341
+ if (/^\s{2,}-\s+\*\*Items \(/m.test(body))
342
+ return null;
343
+ let labelled;
344
+ let list;
345
+ // B3 (N2): the brief's layout line says the tabs sit in a row → a horizontal tab row, even when every tab carries a
346
+ // description + image in one block (a capture emits a bare-label strip then the panels; a hand-written brief may not).
347
+ const layoutCue = body
348
+ .split('\n')
349
+ .filter((l) => /^-\s+\*\*(?:Layout|Pattern|Interactive|Interaction|Format)\b/i.test(l))
350
+ .join(' ');
351
+ if (/\bhorizontal\b|\btab (?:row|strip|bar)\b|\btabs? (?:in|on) a row\b/i.test(layoutCue))
352
+ return null;
353
+ if (isCaptureSection(body)) {
354
+ const outer = derivedLayout(body).groups.find((g) => g.kind !== 'logos' && g.n >= 2);
355
+ if (!outer || (outer.kind !== 'tabs' && outer.kind !== 'accordion') || outer.n !== n)
356
+ return null;
357
+ labelled = outer.kind;
358
+ // The first Items block of that kind is the visible list; a bare-label first block is a horizontal tab strip.
359
+ const lines = body.split('\n');
360
+ const at = lines.findIndex((l) => new RegExp(`^-\\s+\\*\\*Items \\(${n}, ${outer.kind}\\b`).test(l));
361
+ if (at < 0)
362
+ return null;
363
+ const block = [];
364
+ for (let j = at + 1; j < lines.length && !/^-\s|^#/.test(lines[j]); j++)
365
+ block.push(lines[j]);
366
+ list = block.join('\n');
367
+ }
368
+ else {
369
+ const cue = body
370
+ .split('\n')
371
+ .filter((l) => !/\bmobile\b/i.test(l) &&
372
+ /\b(?:interactive|layout|pattern|interaction|format)\b/i.test(l))
373
+ .join(' ');
374
+ if (!/\baccordion\b|\bvertical (?:tabs?|list)\b|\bexpand(?:s|able)?\b/i.test(cue))
375
+ return null;
376
+ labelled = /\btabs?\b/i.test(cue) ? 'tabs' : 'accordion';
377
+ list = body;
378
+ }
379
+ const items = textItems(list);
380
+ if (items.length < 2 ||
381
+ items.filter((x) => x.name && x.desc.length >= 20).length < items.length * 0.8)
382
+ return null;
383
+ const sh = itemShape(list);
384
+ const mediaGroup = itemBlocks(body).some((b) => b.n === n && b.text === 0);
385
+ const perItem = sh.media >= items.length * 0.5 ||
386
+ mediaGroup ||
387
+ (!isCaptureSection(body) &&
388
+ /\b(?:per|each|every) (?:item|tab|step)\b[^.\n]{0,40}\b(?:visual|image|media|screenshot|video)|\b(?:visual|image|media|screenshot)s? (?:swap|switch|change)s?\b/i.test(body));
389
+ const shared = hasSectionVisual(body);
390
+ if (!perItem && !shared)
391
+ return null;
392
+ return { n, visual: perItem ? 'item' : 'shared', labelled };
393
+ }
394
+ /**
395
+ * K11 B3: an FAQ — most item names are questions (whatever the title says), or the title says FAQ / frequently asked.
396
+ * A features title that mentions "questions" over non-question items is not one.
397
+ */
398
+ export function faqSection(sec) {
399
+ const items = textItems(sec.text);
400
+ if (items.length >= 2 && items.filter((x) => /\?\s*$/.test(x.name)).length > items.length / 2)
401
+ return true;
402
+ return /\bfaqs?\b|\bfrequently asked\b/i.test(sec.title);
403
+ }
404
+ /** A section-level visual line (`- **Image:** …`, `- **Video:** …`): one visual for the whole section. */
405
+ export const hasSectionVisual = (body) => /^-\s+\*\*(?:Media|Image|Visual|Video|Screenshot)\b/im.test(body);
406
+ /**
407
+ * K11 B3 (`policy.features.layout-diversity`): the accordion + media family — accordion rows beside a media panel.
408
+ * The benchmark accepts any of them for "vertical accordion + media panel" (`policy.benchmark.rubric-layout`);
409
+ * side-by-side / side-by-side-lines show the active item's own media, accordion-media-right one shared visual.
410
+ */
411
+ export const ACCORDION_MEDIA_FAMILY = [
412
+ 'layout.FeaturesSection.side-by-side',
413
+ 'layout.FeaturesSection.side-by-side-lines',
414
+ 'layout.FeaturesSection.accordion-media-right',
415
+ ];
416
+ /**
417
+ * K11 B3: two FeaturesSections on one page never share a layout (`policy.features.layout-diversity`). When the route's
418
+ * first pick for a section is an accordion + media layout an earlier section already took first, the later section
419
+ * lists the next one of the family nobody took first: `side-by-side-lines`, then `side-by-side`, then
420
+ * `accordion-media-right` when it also has a shared section visual. A section with only a shared visual takes
421
+ * `accordion-media-right` or nothing (side-by-side / -lines show per-item media). Rows are changed in place; returns a
422
+ * note per changed row index (`[§ of the earlier section, from, to]`), or `[§, from, null]` when no family layout is left.
423
+ */
424
+ export function diversifyAccordionFamily(rows, byId, skip = () => false) {
425
+ const notes = new Map();
426
+ const taken = new Map();
427
+ rows.forEach((r, i) => {
428
+ if (skip(i))
429
+ return;
430
+ const at = r.candidates.findIndex((c) => !c.part);
431
+ const top = r.candidates[at];
432
+ if (!top || !ACCORDION_MEDIA_FAMILY.includes(top.id))
433
+ return;
434
+ const prev = taken.get(top.id);
435
+ if (prev == null) {
436
+ taken.set(top.id, r.section.n);
437
+ return;
438
+ }
439
+ // A shared visual only (no per-item media): accordion-media-right or nothing — side-by-side(-lines) shows per-item media.
440
+ const v = verticalItemList(r.section);
441
+ const sharedOnly = v
442
+ ? v.visual === 'shared'
443
+ : hasSectionVisual(r.section.text) && itemShape(r.section.text).media === 0;
444
+ const order = sharedOnly
445
+ ? ['layout.FeaturesSection.accordion-media-right']
446
+ : [
447
+ 'layout.FeaturesSection.side-by-side-lines',
448
+ 'layout.FeaturesSection.side-by-side',
449
+ ...(hasSectionVisual(r.section.text)
450
+ ? ['layout.FeaturesSection.accordion-media-right']
451
+ : []),
452
+ ];
453
+ const next = order.find((id) => !taken.has(id) && byId.has(id) && fitOf(byId.get(id), r.section.items) !== 'over');
454
+ if (!next) {
455
+ notes.set(i, { prev, from: top.id, to: null });
456
+ return;
457
+ }
458
+ // The taken layout leaves the row (the note names it); the next family layout takes its place.
459
+ const have = r.candidates.findIndex((c) => c.id === next);
460
+ const row = have >= 0 ? r.candidates[have] : candidateRow(byId.get(next), r.section);
461
+ r.candidates = r.candidates.filter((c, k) => k !== at && k !== have);
462
+ r.candidates.splice(at, 0, row);
463
+ taken.set(next, r.section.n);
464
+ notes.set(i, { prev, from: top.id, to: next });
465
+ });
466
+ return notes;
467
+ }
468
+ /**
469
+ * K11 F1 (`policy.layout.accordion-carousel`, C42): an accordion of categories that drives a card carousel — selecting a
470
+ * category swaps a row of cards that carry their own copy. A capture's outer group is the accordion and a later group
471
+ * holds (or nests) cards with a name + description; a hand-written brief's layout line names an accordion and the
472
+ * carousel / cards it drives. `cards`: the cards with copy (a carousel control such as "Go to last slide" is not one).
473
+ */
474
+ export function accordionCarousel(sec) {
475
+ const n = sec.items;
476
+ if (n == null || n < 2 || faqSection(sec))
477
+ return null;
478
+ const lines = sec.text.split('\n');
479
+ let after;
480
+ if (isCaptureSection(sec.text)) {
481
+ const outer = derivedLayout(sec.text).groups.find((g) => g.kind !== 'logos' && g.n >= 2);
482
+ if (!outer || outer.kind !== 'accordion' || outer.n !== n)
483
+ return null;
484
+ const at = lines.findIndex((l) => new RegExp(`^-\\s+\\*\\*Items \\(${n}, accordion\\b`).test(l));
485
+ if (at < 0)
486
+ return null;
487
+ // A desktop / mobile copy of the accordion (same count and kind) is not the carousel: skip those blocks.
488
+ const same = new RegExp(`^-\\s+\\*\\*Items \\(${n}, accordion\\b`);
489
+ after = [];
490
+ let skipping = true;
491
+ for (let j = at; j < lines.length; j++) {
492
+ if (/^-\s|^#/.test(lines[j]))
493
+ skipping = same.test(lines[j]);
494
+ if (!skipping)
495
+ after.push(lines[j]);
496
+ }
497
+ }
498
+ else {
499
+ const cue = lines
500
+ .filter((l) => !/\bmobile\b/i.test(l) &&
501
+ /\b(?:interactive|layout|pattern|interaction|format)\b/i.test(l))
502
+ .join(' ');
503
+ if (!/\baccordion\b/i.test(cue) ||
504
+ !/\b(?:carousel|slider|cards)\b/i.test(cue) ||
505
+ !/\b(?:driv|swap|switch|select|chang|control)\w*/i.test(cue))
506
+ return null;
507
+ after = lines;
508
+ }
509
+ const named = after
510
+ .map((l) => /^\s*\d+[.)]\s+\*\*(.+?)\*\*\s+[—–]\s+(.*)$/.exec(l))
511
+ .filter((m) => !!m && !/^CTA\b/.test(m[2].trim()) && m[2].split(/\s[—–]\s/)[0].trim().length >= 20);
512
+ // A hand-written brief lists the categories and the cards together: the cards are the named items past the categories.
513
+ if (isCaptureSection(sec.text))
514
+ return named.length >= 2 ? { n, cards: named.length } : null;
515
+ return { n, cards: Math.max(0, named.length - n) };
516
+ }
517
+ /** K11 F1: the §Nb card section of an accordion-driven carousel — cards with copy keep it (name, description, CTA, image). */
518
+ export const accordionCarouselCards = (cards) => cards > 5 ? 'layout.CardsSection.horizontal-reveal' : 'layout.CardsSection.side-by-side';
519
+ /**
520
+ * K11 F1 (`policy.layout.accordion-carousel`): the one ordered answer for an accordion that drives a card carousel.
521
+ * 1. `tab-cards-carousel` + `[GAP]` when no other section on the page takes `tab-cards-carousel` first; 2. otherwise the
522
+ * no-repeat fallback `accordion-media-right` (the categories + one shared visual), same `[GAP]`; the cards with copy go to
523
+ * §Nb directly below (`accordionCarouselCards`). Rows change in place; returns per row the § that holds tab-cards-carousel
524
+ * (or null) and the card section.
525
+ */
526
+ /** The ordered answer's two layouts: step 1, then the no-repeat fallback (kb/inventory/shape-layout.json `accordion-driving-card-carousel`). */
527
+ export const ACCORDION_CAROUSEL_LAYOUTS = [
528
+ 'layout.FeaturesSection.tab-cards-carousel',
529
+ 'layout.FeaturesSection.accordion-media-right',
530
+ ];
531
+ export function resolveAccordionCarousels(rows, byId, skip = () => false) {
532
+ const [TCC, AMR] = ACCORDION_CAROUSEL_LAYOUTS;
533
+ const ac = rows.map((r, i) => (skip(i) ? null : accordionCarousel(r.section)));
534
+ const takenBy = rows.find((r, i) => !skip(i) && !ac[i] && r.candidates.find((c) => !c.part)?.id === TCC)
535
+ ?.section.n ?? null;
536
+ const notes = new Map();
537
+ // A tab row already on tab-cards-carousel (or an earlier accordion carousel that took it) sends this one to the fallback.
538
+ let tccBy = takenBy;
539
+ rows.forEach((r, i) => {
540
+ const a = ac[i];
541
+ if (!a)
542
+ return;
543
+ const layout = tccBy == null ? TCC : AMR;
544
+ const by = tccBy;
545
+ if (tccBy == null)
546
+ tccBy = r.section.n;
547
+ const first = r.candidates.find((c) => c.id === layout) ??
548
+ (byId.has(layout) ? candidateRow(byId.get(layout), r.section) : null);
549
+ const rest = r.candidates
550
+ .filter((c) => c.id !== layout && !c.part && !(layout === AMR && c.id === TCC))
551
+ .slice(0, 1);
552
+ const nb = a.cards >= 2 && byId.has(accordionCarouselCards(a.cards))
553
+ ? accordionCarouselCards(a.cards)
554
+ : null;
555
+ const nbRow = nb
556
+ ? { ...candidateRow(byId.get(nb), { ...r.section, items: a.cards }), part: 'b' }
557
+ : null;
558
+ r.candidates = [...(first ? [first] : []), ...rest, ...(nbRow ? [nbRow] : [])];
559
+ notes.set(i, { takenBy: layout === AMR ? by : null, layout, cards: a.cards, nb });
560
+ });
561
+ return notes;
562
+ }
563
+ /**
564
+ * K11 F1 (`policy.layout.statement-stand-in`): a statement with media (heading + body [+ CTA] + a visual, no items) has
565
+ * no section of its own; its stand-in is a 1-item FeaturesSection. Two FeaturesSections on a page never share a layout
566
+ * (`policy.features.layout-diversity`), so each statement takes the next stand-in no other FeaturesSection on the page
567
+ * took first: `stacked-rows`, then `stacked-media-left`. Rows change in place; returns per moved row the § that holds
568
+ * the earlier stand-in, or `to: null` when none is left.
569
+ */
570
+ export const STATEMENT_STANDINS = [
571
+ 'layout.FeaturesSection.stacked-rows',
572
+ 'layout.FeaturesSection.stacked-media-left',
573
+ ];
574
+ export function diversifyStatements(rows, byId, isStatement, skip = () => false) {
575
+ const notes = new Map();
576
+ const taken = new Map();
577
+ rows.forEach((r, i) => {
578
+ const top = r.candidates.find((c) => !c.part)?.id;
579
+ if (!skip(i) &&
580
+ !isStatement(i) &&
581
+ top?.startsWith('layout.FeaturesSection.') &&
582
+ !taken.has(top))
583
+ taken.set(top, r.section.n);
584
+ });
585
+ rows.forEach((r, i) => {
586
+ if (skip(i) || !isStatement(i))
587
+ return;
588
+ const next = STATEMENT_STANDINS.find((id) => !taken.has(id) && byId.has(id));
589
+ if (!next) {
590
+ notes.set(i, { prev: taken.get(STATEMENT_STANDINS[0]) ?? null, to: null });
591
+ return;
592
+ }
593
+ const prev = next === STATEMENT_STANDINS[0] ? null : (taken.get(STATEMENT_STANDINS[0]) ?? null);
594
+ const have = r.candidates.find((c) => c.id === next) ?? candidateRow(byId.get(next), r.section);
595
+ r.candidates = [
596
+ have,
597
+ ...r.candidates.filter((c) => c.id !== next && !STATEMENT_STANDINS.includes(c.id)),
598
+ ].slice(0, 2);
599
+ taken.set(next, r.section.n);
600
+ notes.set(i, { prev, to: next });
601
+ });
602
+ return notes;
603
+ }
604
+ /**
605
+ * K11 F1 (`policy.benchmark.rubric-layout-hero`): the hero's direction. A layout cue in the brief (split / side by side /
606
+ * copy left / media right vs centered / text above / media below / text-only) decides; else a selector hero — a row of
607
+ * bare tab labels plus a visual per tab (a media-only group of the same count; the live Homepage and work-management
608
+ * heroes) — reads as split (copy + tabs left, visual right); else a hero with one visual takes the chooser default
609
+ * `centered-image` (`policy.layout.chooser.HeaderSection`). No hero visual → null.
610
+ */
611
+ export function heroDirection(sec) {
612
+ const cue = sec.text
613
+ .split('\n')
614
+ .filter((l) => !/\bmobile\b/i.test(l) &&
615
+ !/^\s*-\s+\*\*(?:Headline|Body|Subheadline|Description|CTA|Eyebrow|Items \(|Layout \(derived\))/i.test(l) &&
616
+ !/^\s+\d+[.)]\s/.test(l))
617
+ .join(' ')
618
+ .replace(/https?:\/\/\S+|\bCDN2?\/\S+|\S+\.(?:avif|png|jpe?g|webp|mp4|webm|svg|gif)\b/gi, ' ');
619
+ if (/\btext[- ]only\b|\bno (?:hero )?(?:media|visual|image)\b/i.test(cue))
620
+ return { dir: 'text', why: 'cue' };
621
+ if (/\bsplit\b|\bside[- ]by[- ]side\b|\btwo[- ]column\b|\b(?:copy|text|headline)\s+(?:on\s+the\s+)?left\b|\b(?:media|image|visual|video|screenshot|photo)\s+(?:on\s+the\s+)?right\b/i.test(cue))
622
+ return { dir: 'split', why: 'cue' };
623
+ if (/\bcent(?:er|re)d?\b|\b(?:copy|text)\s+above\b|\b(?:media|image|visual|video|screenshot)\s+below\b/i.test(cue))
624
+ return { dir: 'centered', why: 'cue' };
625
+ const blocks = itemBlocks(sec.text);
626
+ const labels = blocks.find((b) => b.n >= 2 && b.text === b.n && itemShape(sec.text).rich === 0);
627
+ if (labels && blocks.some((b) => b !== labels && b.n === labels.n && b.text === 0))
628
+ return { dir: 'split', why: 'selector' };
629
+ if (hasSectionVisual(sec.text) || blocks.some((b) => b.text === 0))
630
+ return { dir: 'centered', why: 'default' };
631
+ return null;
632
+ }
633
+ export const HERO_FAMILIES = {
634
+ split: [
635
+ 'split',
636
+ 'split-full-image',
637
+ 'split-mosaic',
638
+ 'split-strip',
639
+ 'split-search',
640
+ 'floating-left',
641
+ ].map((l) => `layout.HeaderSection.${l}`),
642
+ centered: ['centered-image', 'left-center-image', 'centered-strips'].map((l) => `layout.HeaderSection.${l}`),
643
+ text: ['layout.HeaderSection.text-only'],
644
+ };
645
+ /**
646
+ * K11 B2: plain text cards — every item a name + description, no media, bullets, tags, stats or quotes, in a section
647
+ * with no visual and no tab / accordion structure ("Full control": 6 icon + title + description cards). Their layout
648
+ * is an icon list, not a card carousel. `short`: the descriptions fit `BulletItem` (title ≤ 18c, description ≤ 84c).
649
+ */
650
+ export function textCards(sec) {
651
+ const n = sec.items;
652
+ if (n == null || n < 4 || n > 8)
653
+ return null;
654
+ if (/\b(?:faq|questions?|testimonials?|quotes?|stats?|numbers?|logos?|pricing)\b/i.test(sec.title))
655
+ return null;
656
+ const shape = sectionShape(sec);
657
+ if (shape.tabs || shape.accordion || shape.hasMedia || shape.itemCta)
658
+ return null;
659
+ if (/^\s{2,}-\s+\*\*Items \(/m.test(sec.text))
660
+ return null;
661
+ const items = textItems(sec.text);
662
+ const sh = itemShape(sec.text);
663
+ if (items.length !== n || sh.media || sh.bullets || sh.tags || sh.stats || sh.quotes)
664
+ return null;
665
+ if (items.some((x) => !x.name || x.desc.length < 20))
666
+ return null;
667
+ const short = items.filter((x) => x.name.length <= 18 && x.desc.length <= 84).length >= n * 0.8;
668
+ return { n, short };
669
+ }
670
+ export const SHAPE_PRIORS = [
671
+ {
672
+ name: 'agent cards (name + bullets + tags + media)',
673
+ test: (_s, sh) => isAgentCards(sh),
674
+ layout: 'layout.CardsSection.horizontal-reveal',
675
+ factor: 3,
676
+ unit: 'policy.mode-b.hero-item-split',
677
+ },
678
+ // B2: a vertical list whose items expand in place, a visual per item → side-by-side (accordion + per-item media
679
+ // panel); one shared visual → accordion-media-right. Never tabs-* (the benchmark scores tabs-* −15 here).
680
+ {
681
+ name: 'vertical item list + a visual per item',
682
+ test: (s) => {
683
+ const v = verticalItemList(s);
684
+ return !!v && v.visual === 'item' && v.n <= 5;
685
+ },
686
+ layout: 'layout.FeaturesSection.side-by-side',
687
+ factor: 4,
688
+ unit: 'policy.tabs.layout-by-count',
689
+ },
690
+ {
691
+ name: 'vertical item list + one shared visual (or a visual per item past 5)',
692
+ test: (s) => {
693
+ const v = verticalItemList(s);
694
+ return !!v && (v.visual === 'shared' || v.n > 5);
695
+ },
696
+ layout: 'layout.FeaturesSection.accordion-media-right',
697
+ factor: 4,
698
+ unit: 'policy.tabs.layout-by-count',
699
+ },
700
+ // B3: question items under a title that doesn't say FAQ (TITLE_PRIORS handles those) → FaqSection, even with an image.
701
+ {
702
+ name: 'FAQ by its questions (title not FAQ)',
703
+ test: (s) => faqSection(s) && !/\bfaqs?\b|\bquestions\b/i.test(s.title),
704
+ layout: 'layout.FaqSection.centered',
705
+ factor: 4,
706
+ unit: 'section.FaqSection.usage',
707
+ },
708
+ {
709
+ name: 'FAQ by its questions, side-by-side (title not FAQ)',
710
+ test: (s) => faqSection(s) && !/\bfaqs?\b|\bquestions\b/i.test(s.title),
711
+ layout: 'layout.FaqSection.side-by-side',
712
+ factor: 3,
713
+ unit: 'section.FaqSection.usage',
714
+ },
715
+ // B2: 4–8 icon + title + description cards with no media → the icon list, not a card carousel.
716
+ {
717
+ name: 'text cards (name + description, no media)',
718
+ test: (s) => !!textCards(s),
719
+ layout: 'layout.FeaturesSection.split-icon-list',
720
+ factor: 4,
721
+ unit: 'policy.layout.chooser.FeaturesSection',
722
+ },
723
+ {
724
+ name: 'short text cards (fit BulletItem limits)',
725
+ test: (s) => !!textCards(s)?.short,
726
+ layout: 'layout.BulletsSection.icons',
727
+ factor: 2.5,
728
+ unit: 'section.BulletsSection.usage',
729
+ },
730
+ // B2: quote cards and stat cards mixed → mixed-stat-and-quote (carousel-case-study needs a stat on every card and
731
+ // renders no section CTA).
732
+ {
733
+ name: 'stat + quote cards',
734
+ test: (_s, sh) => sh.stats >= 1 && sh.quotes >= 1,
735
+ layout: 'layout.TestimonialsSection.mixed-stat-and-quote',
736
+ factor: 4,
737
+ unit: 'policy.layout.chooser.TestimonialsSection',
738
+ },
739
+ ];
740
+ /** K11 (`policy.mode-b.precedence`, tint exception 2): layouts whose cards are always grey (grey-300) and take no tint. */
741
+ export const GREY_CARD_LAYOUTS = new Set([
742
+ 'layout.TestimonialsSection.mixed-stat-and-quote',
743
+ 'layout.TestimonialsSection.carousel-with-stat',
744
+ ]);
745
+ /** The live page shows the section on a grey band: capture --brief "bg rgb(…)" grey, or a hand-written grey / muted cue. */
746
+ export function greyBand(sec) {
747
+ const rgb = /\bbg rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(sec.text);
748
+ if (rgb) {
749
+ const c = rgb.slice(1, 4).map(Number);
750
+ return Math.min(...c) >= 225 && Math.max(...c) <= 250 && Math.max(...c) - Math.min(...c) <= 10;
751
+ }
752
+ return /\b(?:grey|gray|muted)\s+(?:band|background|bg|section)\b|\btheme:?\s*`?muted\b/i.test(sec.text);
753
+ }
754
+ export function sectionShape(sec) {
755
+ const t = `${sec.title}\n${sec.text}`;
756
+ const special = /\b(?:logos?|testimonials?|quotes?|faq|questions?|stats?|numbers?|integrations?|badges?|g2|pricing|form)\b/i.test(sec.title);
757
+ const der = derivedLayout(sec.text);
758
+ const outer = der.groups.find((g) => g.kind !== 'logos' && g.n === sec.items)?.kind ?? '';
759
+ // Interaction cues of a hand-written brief ("Interactive: tabs", "7 tabs", "Layout: accordion"), mobile lines aside.
760
+ const cueLines = sec.text
761
+ .split('\n')
762
+ .filter((l) => !/\bmobile\b/i.test(l) &&
763
+ /\b(?:interactive|layout|pattern|interaction|format)\b|\d+\s+(?:tabs|accordion)/i.test(l))
764
+ .join(' ');
765
+ const logoLine = /\*\*Logos \((\d+)\):\*\*\s*(.*)$/m.exec(sec.text);
766
+ const logos = logoLine
767
+ ? Number(logoLine[1])
768
+ : (der.groups.filter((g) => g.kind === 'logos').map((g) => g.n)[0] ?? null);
769
+ const hasBody = /^\s*-\s+\*\*(?:Body|Subheadline|Description)\b/im.test(sec.text);
770
+ const hasCta = /\*\*\s*cta\b|^\s*-\s*cta\b/im.test(t);
771
+ const strip = logos != null &&
772
+ sec.items == null &&
773
+ !hasBody &&
774
+ !hasCta &&
775
+ (der.top === 'logos' || der.top === '' || /\blogos?\b/i.test(sec.title));
776
+ const integ = /\bintegrat|\bconnect (?:all )?your (?:tools|apps)|\bapps?\b|\btools\b/i.test(`${sec.title} ${logoLine?.[2] ?? ''}`);
777
+ const vis = /^\s*-\s+\*\*Visible:\*\*\s*(mobile|desktop) only/im
778
+ .exec(sec.text)?.[1]
779
+ ?.toLowerCase();
780
+ const vertical = !!verticalItemList(sec);
781
+ return {
782
+ statement: sec.items == null && !special && !strip,
783
+ grid: /\bgrid\b|\d\s*[×x]\s*\d|\bcolumns?\b|\bicons?\b|\bicon\s*·/i.test(t
784
+ .split('\n')
785
+ .filter((l) => !/mobile/i.test(l))
786
+ .join('\n')),
787
+ hasCta,
788
+ hasMedia: /\b(?:media|visual|image|video|illustration|screenshot|diagram)\b|\.(?:avif|png|jpe?g|webp|mp4|webm)\b/i.test(t),
789
+ ...perItem(sec),
790
+ // K11 B2: a vertical item list (items show their copy and expand in place) is an accordion, whatever the capture calls it.
791
+ tabs: !vertical && (outer === 'tabs' || (!outer && /\btabs?\b/i.test(cueLines))),
792
+ accordion: vertical || outer === 'accordion' || (!outer && /\baccordion\b/i.test(cueLines)),
793
+ logoStrip: strip ? (integ ? 'integration' : 'customer') : null,
794
+ logos,
795
+ caseStudy: (sec.text.match(/case[- ]stud(?:y|ies)/gi) ?? []).length >= 2,
796
+ visibleAt: vis ?? null,
797
+ variantOf: Number(/responsive variant of §(\d+)/i.exec(sec.text)?.[1] ?? '') || null,
798
+ };
799
+ }
800
+ const sigWords = (s) => new Set(s
801
+ .toLowerCase()
802
+ .replace(/[^a-z0-9 ]/g, ' ')
803
+ .split(/\s+/)
804
+ .filter((w) => w.length > 2 && !['the', 'and', 'for', 'with', 'your', 'our'].includes(w)));
805
+ /**
806
+ * K8 (`policy.mode-b.responsive-variants`): the section a one-viewport section twins — the marked "responsive variant of
807
+ * §N", else a section visible at the other viewport (or both) whose title shares most words, or the same item count
808
+ * and a shared title word.
809
+ */
810
+ export function twinOf(secs, i) {
811
+ const sh = sectionShape(secs[i]);
812
+ if (sh.variantOf)
813
+ return { n: sh.variantOf, marked: true };
814
+ // Only a mobile-only section folds (the desktop section keeps its place); the hero never does.
815
+ if (sh.visibleAt !== 'mobile' || i === 0)
816
+ return null;
817
+ // K11 F1: a mobile-only hero — a desktop-only hero above it, and this section right below it (§2–§4) with a CTA and a
818
+ // section visual but no items — twins the hero even when its headline differs (run G home §3).
819
+ if (i <= 3 &&
820
+ secs[i].items == null &&
821
+ sectionShape(secs[0]).visibleAt === 'desktop' &&
822
+ sh.hasCta &&
823
+ hasSectionVisual(secs[i].text) &&
824
+ !secs.slice(1, i).some((o) => sectionShape(o).visibleAt === 'mobile'))
825
+ return { n: secs[0].n, marked: false, hero: true };
826
+ const w = sigWords(secs[i].title);
827
+ let best = null;
828
+ secs.forEach((o, j) => {
829
+ if (j === i)
830
+ return;
831
+ const os = sectionShape(o);
832
+ if (os.visibleAt === sh.visibleAt || os.variantOf)
833
+ return;
834
+ const ow = sigWords(o.title);
835
+ const shared = [...w].filter((x) => ow.has(x)).length;
836
+ const score = shared / Math.max(1, Math.min(w.size, ow.size)) +
837
+ (o.items != null && o.items === secs[i].items ? 0.4 : 0);
838
+ if (shared && score >= 0.75 && (!best || score > best.score))
839
+ best = { n: o.n, score };
840
+ });
841
+ return best ? { n: best.n, marked: false } : null;
842
+ }
843
+ /** Per-item media / CTA cues: two or more CTA lines, two or more image lines (or items that carry an image). */
844
+ function perItem(sec) {
845
+ const lines = sec.text.split('\n');
846
+ const ctaLines = lines.filter((l) => /^\s*[-*]\s+(?:\*\*)?(?:primary |secondary )?cta\b/i.test(l)).length;
847
+ const mediaLines = lines.filter((l) => /^\s*[-*]\s+(?:\*\*)?(?:image|visual|media|screenshot|video)\b/i.test(l) ||
848
+ /^\s{0,4}\d+[.)]\s.*\b(?:img|image|video)\b/i.test(l)).length;
849
+ const items = sec.items ?? 0;
850
+ return { itemMedia: items >= 2 && mediaLines >= 2, itemCta: items >= 2 && ctaLines >= 2 };
851
+ }
852
+ /* ── helpers ───────────────────────────────────────────────────────────────────────────────── */
853
+ const SEVERITY_RANK = { Critical: 3, Major: 2, Minor: 1 };
854
+ const KIND_RANK = {
855
+ procedure: 2.5,
856
+ policy: 2,
857
+ rule: 2,
858
+ gotcha: 1.8,
859
+ recipe: 1.2,
860
+ section: 1,
861
+ layout: 1,
862
+ component: 0.8,
863
+ limit: 0.6,
864
+ 'item-type': 0.6,
865
+ stat: 0.8,
866
+ 'brand-fact': 0.6,
867
+ };
868
+ const CATALOG_KINDS = new Set(['section', 'layout', 'limit', 'item-type', 'component']);
869
+ /** Skills a skill hands stages to: their units are in scope; units owned only by an unrelated skill are pushed down. */
870
+ export const SKILL_DELEGATES = {
871
+ 'web-compose': [
872
+ 'web-structure',
873
+ 'web-design',
874
+ 'web-assets',
875
+ 'web-qa',
876
+ 'web-static',
877
+ 'knowledge-capture',
878
+ 'web-design-brain',
879
+ ],
880
+ 'web-pmm': ['web-compose', 'web-structure', 'web-design', 'web-assets', 'web-qa', 'web-static'],
881
+ 'web-benchmark': ['web-compose', 'web-qa', 'web-static'],
882
+ 'web-remix': ['web-compose', 'web-static', 'web-qa'],
883
+ 'web-custom-section': ['web-qa'],
884
+ };
885
+ /** Off-skill: the unit names skills, none of them this skill or one it delegates to. */
886
+ function offSkill(u, skill) {
887
+ const skills = u.entities
888
+ .filter((e) => e.startsWith('skill:'))
889
+ .map((e) => e.slice('skill:'.length));
890
+ if (!skills.length)
891
+ return false;
892
+ const ok = new Set([skill, ...(SKILL_DELEGATES[skill] ?? EMAIL_SKILL_DELEGATES[skill] ?? [])]);
893
+ return !skills.some((x) => ok.has(x));
894
+ }
895
+ const skillEntity = (skill) => `skill:${skill}`;
896
+ const primarySkill = (u) => u.entities.find((e) => e.startsWith('skill:'))?.slice('skill:'.length);
897
+ const routable = (u, o) => u.authority !== 'background' &&
898
+ u.status === 'active' &&
899
+ u.stages.includes(o.stage) &&
900
+ (!o.mode || !u.modes || u.modes.includes(o.mode)) &&
901
+ inScope(u, o.skill) &&
902
+ u.kind !== 'token' &&
903
+ u.kind !== 'asset';
904
+ /** One-line layout capability from the derived facts: range, tab label, dropped section props, sub-44px CTAs. */
905
+ export function layoutLine(u) {
906
+ const f = (u.facts ?? {});
907
+ const parts = [];
908
+ const r = f.items;
909
+ if (r && (r.min != null || r.max != null))
910
+ parts.push(r.min === r.max ? `${r.min}` : `${r.min ?? '?'}–${r.max ?? '…'}`);
911
+ if (f.tabLabel?.length)
912
+ parts.push(`tab label ${f.tabLabel[0]}${f.tabListScrollable ? ', scrolls' : ''}`);
913
+ const dropped = (f.propsIgnored ?? []).filter((p) => ['sectionDescription', 'sectionTopic', 'sectionHeading', 'sectionCta', 'tagline'].includes(p));
914
+ if (dropped.length)
915
+ parts.push(`drops ${dropped.join('/')}`);
916
+ const small = (f.buttons ?? []).filter((b) => b.heightPx != null && b.heightPx < 44);
917
+ if (small.length)
918
+ parts.push(`CTA ${small[0].heightPx}px`);
919
+ return `${f.section ?? ''} ${f.layout ?? u.id}${parts.length ? ` (${parts.join('; ')})` : ''}`.trim();
920
+ }
921
+ /** A candidates-table row for layout `u` on brief section `sec`: its capability line (+ "no section CTA" when the brief has one the layout drops) and its item-count fit. */
922
+ function candidateRow(u, sec, shape = sectionShape(sec)) {
923
+ let line = layoutLine(u);
924
+ const section = u.facts?.section ?? u.id.split('.')[1];
925
+ const rendered = (u.facts ?? {}).propsRendered;
926
+ if (shape.hasCta &&
927
+ rendered &&
928
+ !rendered.some((p) => /cta|button|action/i.test(p)) &&
929
+ !['HeaderSection', 'CtaSection'].includes(section))
930
+ line = line.endsWith(')') ? `${line.slice(0, -1)}; no section CTA)` : `${line} (no section CTA)`;
931
+ return { id: u.id, line, fit: fitOf(u, sec.items) };
932
+ }
933
+ function fitOf(u, n) {
934
+ const r = u.facts?.items;
935
+ if (!r || n == null)
936
+ return 'unknown';
937
+ if (r.max != null && n > r.max)
938
+ return 'over';
939
+ if (r.min != null && n < r.min)
940
+ return 'under';
941
+ return 'fits';
942
+ }
943
+ /**
944
+ * Brief section → the best layout candidates. Two signals: which SECTION the brief text reads like (BM25 over the
945
+ * section units: derived props + handbook use cases, hand-written usage) and which LAYOUT (BM25 over the derived
946
+ * layout units). A layout scores by both, then by item-count fit. Page anatomy priors from the stage-1B checks:
947
+ * the first brief section is the hero (HeaderSection), and HeaderSection is a hero only; a closing CTA section is a
948
+ * CtaSection.
949
+ */
950
+ export function briefCandidates(kb, index, sec, byId, pos = { first: false, last: false }, k = 2) {
951
+ const q = briefQuery(sec);
952
+ const sectionOf = (u) => u.facts?.section ?? u.id.split('.')[1];
953
+ const secScore = new Map();
954
+ for (const h of search(kb, index, q, { kind: ['section'], k: 40 })) {
955
+ // Example units are code (every section's example says "Get started"): they say nothing about fit.
956
+ if (h.id.includes('.example.'))
957
+ continue;
958
+ const name = h.id.split('.')[1];
959
+ secScore.set(name, Math.max(secScore.get(name) ?? 0, h.score));
960
+ }
961
+ const maxSec = Math.max(1, ...secScore.values());
962
+ const layScore = new Map();
963
+ for (const h of search(kb, index, q, { kind: ['layout'], authority: ['derived'], k: 40 }))
964
+ layScore.set(h.id, h.score);
965
+ const maxLay = Math.max(1, ...layScore.values());
966
+ const layouts = kb.units.filter((u) => u.kind === 'layout' && u.authority === 'derived');
967
+ const shape = sectionShape(sec);
968
+ const priors = SHAPE_PRIORS.filter((p) => p.test(sec, itemShape(sec.text)));
969
+ const faq = faqSection(sec);
970
+ // The last section with a CTA and no items is the closing CTA, whatever its title says (a capture brief titles it by its H2).
971
+ const isCta = /\b(?:closing|final)\b.*\bcta\b|\bcta\b/i.test(sec.title) ||
972
+ (pos.last && shape.hasCta && sec.items == null);
973
+ const useFor = (u) => /Use for: ([^\n]*)/.exec(u.body)?.[1] ?? '';
974
+ const scored = layouts.map((u) => {
975
+ const name = sectionOf(u);
976
+ let s = (layScore.get(u.id) ?? 0) / maxLay + 0.8 * ((secScore.get(name) ?? 0) / maxSec);
977
+ if (name === 'HeaderSection')
978
+ s *= pos.first ? 2 : 0.2;
979
+ else if (pos.first)
980
+ s *= 0.5;
981
+ if (name === 'CtaSection' && isCta)
982
+ s *= 1.8;
983
+ if (['NavbarMenu', 'FooterSection'].includes(name))
984
+ s *= 0.1;
985
+ // Anatomy priors from the brief section's own title (like the hero prior above): a logo strip is a
986
+ // CustomerLogoSection, an integrations block Integrations, a trust / security panel TrustSection, an FAQ FaqSection,
987
+ // stats NumbersSection, case studies / testimonials TestimonialsSection.
988
+ for (const [re, target, f] of TITLE_PRIORS)
989
+ if (re.test(sec.title) && target.test(u.id))
990
+ s *= f;
991
+ // floating-card is the closing CTA only (policy.final-cta-is-floating-card).
992
+ if (u.id === 'layout.CtaSection.floating-card')
993
+ s *= pos.last ? (isCta ? 3 : 1.5) : 0.3;
994
+ if (!pos.first && !pos.last && !isCta) {
995
+ // A capture brief's "Items (N, cards)" is an extraction label, not the PMM saying "cards": only real copy counts.
996
+ const cueText = `${sec.title} ${sec.text
997
+ .split('\n')
998
+ .filter((l) => !/^\s*-\s+\*\*(?:Items \(|Layout \(derived\))/.test(l))
999
+ .join(' ')}`;
1000
+ if (name === 'CardsSection' && sec.items != null && /\bcards?\b/i.test(cueText))
1001
+ s *= 1.5;
1002
+ // Outer structure (K8): tabs → a tab layout (one panel per tab); an accordion → an accordion layout.
1003
+ if (shape.tabs && /^layout\.FeaturesSection\.(?:tabs-|tab-cards-carousel)/.test(u.id))
1004
+ s *= 2;
1005
+ if (shape.accordion &&
1006
+ /^layout\.FeaturesSection\.(?:side-by-side|numbered-accordion|accordion-media-right)/.test(u.id))
1007
+ s *= 2;
1008
+ if ((shape.tabs || shape.accordion) && /^layout\.CardsSection\./.test(u.id))
1009
+ s *= 0.5;
1010
+ // Two or more case-study links: the case-study testimonial layouts.
1011
+ if (shape.caseStudy &&
1012
+ /^layout\.TestimonialsSection\.(?:carousel-case-study|mixed-stat-and-quote)/.test(u.id))
1013
+ s *= 2;
1014
+ // A grid of icon/text items wants an icon or grid layout, not an accordion or a carousel.
1015
+ if (shape.grid && sec.items != null) {
1016
+ if (/\bgrid\b|\bicon/i.test(useFor(u)))
1017
+ s *= 1.8;
1018
+ if (/accordion|carousel|tabs?\b/i.test(u.id))
1019
+ s *= 0.6;
1020
+ }
1021
+ // A statement (heading + body [+ media] [+ CTA], no items) has no dedicated section (a DS gap): the usual
1022
+ // stand-ins are a one-item sequential FeaturesSection when it has media, a centered CtaSection when it has none.
1023
+ if (shape.statement) {
1024
+ if (shape.hasMedia && /^layout\.FeaturesSection\.(?:side-by-side|stacked-rows)$/.test(u.id))
1025
+ s *= 3;
1026
+ else if (!shape.hasMedia && u.id === 'layout.CtaSection.centered')
1027
+ s *= 3;
1028
+ else if (shape.hasCta && u.id === 'layout.CtaSection.centered')
1029
+ s *= 1.6;
1030
+ if (/^layout\.(?:Integrations|TrustSection|NumbersSection|TestimonialsSection|CustomerLogoSection|FaqSection)\./.test(u.id))
1031
+ s *= 0.3;
1032
+ }
1033
+ }
1034
+ // Per-item image / CTA (K5b F4): a layout whose item type renders no media / no action slot loses that content.
1035
+ const itemRendered = Object.values((u.facts ?? {}).itemFields ?? {}).flatMap((f) => f.rendered);
1036
+ // B3: an FAQ's item images are dropped (FaqSection renders none), not a reason to leave FaqSection.
1037
+ if (itemRendered.length && !(faq && name === 'FaqSection')) {
1038
+ if (shape.itemMedia && !itemRendered.some((f) => /^(?:media|image|visual|video)/i.test(f)))
1039
+ s *= 0.5;
1040
+ if (shape.itemCta && !itemRendered.some((f) => /^(?:actions?|cta\d?|link\w*|href)$/i.test(f)))
1041
+ s *= 0.6;
1042
+ }
1043
+ // A logo row with no copy of its own (K8): CustomerLogoSection, or Integrations when the logos are integrations.
1044
+ if (shape.logoStrip) {
1045
+ const target = shape.logoStrip === 'integration'
1046
+ ? /^layout\.Integrations\./
1047
+ : /^layout\.CustomerLogoSection\./;
1048
+ s *= target.test(u.id)
1049
+ ? 4
1050
+ : /^layout\.(?:CustomerLogoSection|Integrations)\./.test(u.id)
1051
+ ? 1.5
1052
+ : 0.2;
1053
+ }
1054
+ if (!pos.first)
1055
+ for (const p of priors)
1056
+ if (p.layout === u.id)
1057
+ s *= p.factor;
1058
+ const fit = fitOf(u, sec.items);
1059
+ s *= fit === 'fits' ? 1.25 : fit === 'over' ? 0.8 : fit === 'under' ? 0.75 : 1;
1060
+ return { u, fit, s };
1061
+ });
1062
+ scored.sort((a, b) => b.s - a.s || (a.u.id < b.u.id ? -1 : 1));
1063
+ const pick = scored.slice(0, k);
1064
+ // K11 F1: the hero's direction picks the HeaderSection family that leads the hero row (a wrong direction is −20,
1065
+ // `policy.benchmark.rubric-layout-hero`). A guessed direction keeps the best layout of the other family second.
1066
+ const dir = pos.first ? heroDirection(sec) : null;
1067
+ if (dir) {
1068
+ const fam = HERO_FAMILIES[dir.dir];
1069
+ const header = scored.filter((x) => sectionOf(x.u) === 'HeaderSection');
1070
+ const inFam = header.filter((x) => fam.includes(x.u.id));
1071
+ const lead = (dir.why === 'selector'
1072
+ ? inFam.find((x) => x.u.id === 'layout.HeaderSection.split')
1073
+ : undefined) ?? inFam[0];
1074
+ const other = dir.why === 'default'
1075
+ ? header.find((x) => HERO_FAMILIES.split.includes(x.u.id))
1076
+ : inFam.find((x) => x !== lead);
1077
+ if (lead)
1078
+ pick.splice(0, pick.length, lead, ...[other ?? header.find((x) => x !== lead)].filter((x) => !!x && x !== lead));
1079
+ }
1080
+ // A matching item-shape prior always lists its layout (not on the hero row: there it is the §1b candidate below).
1081
+ if (!pos.first)
1082
+ for (const p of priors)
1083
+ if (!pick.some((x) => x.u.id === p.layout)) {
1084
+ const hit = scored.find((x) => x.u.id === p.layout);
1085
+ if (hit)
1086
+ pick.push(hit);
1087
+ }
1088
+ // Mode B picks the layout whose range fits: when no pick fits, add the best-scoring fitting layout of a picked section.
1089
+ if (sec.items != null && !pick.some((p) => p.fit === 'fits')) {
1090
+ const names = new Set(pick.map((p) => sectionOf(p.u)));
1091
+ const alt = scored.slice(k, 12).find((x) => x.fit === 'fits' && names.has(sectionOf(x.u)));
1092
+ if (alt)
1093
+ pick.push(alt);
1094
+ }
1095
+ const rows = pick.map(({ u }) => candidateRow(u, sec, shape));
1096
+ // K11 (`policy.mode-b.hero-item-split`): a hero whose items fit no HeaderSection slot also gets the candidates of its
1097
+ // §1b item section — the same brief section scored as a body section (item-shape priors apply there).
1098
+ if (pos.first && heroItemGroup(sec)) {
1099
+ const body = briefCandidates(kb, index, sec, byId, { first: false, last: false }, 1).filter((c) => !c.id.startsWith('layout.HeaderSection.'));
1100
+ for (const c of body)
1101
+ rows.push({ ...c, part: 'b' });
1102
+ }
1103
+ return rows;
1104
+ }
1105
+ /** [brief section title, layout id, factor]: section-type cues in a brief section's title. */
1106
+ const TITLE_PRIORS = [
1107
+ [
1108
+ /\b(?:customer )?logos?\b(?!.*integrat)|\btrusted by\b(?!.*(?:enterprise|leader))/i,
1109
+ /^layout\.CustomerLogoSection\./,
1110
+ 2.5,
1111
+ ],
1112
+ [/\bintegrations?\b/i, /^layout\.Integrations\./, 2],
1113
+ [/\b(?:trust(?:ed)?|security|compliance|enterprise[- ]grade)\b/i, /^layout\.TrustSection\./, 2],
1114
+ [/\bfaq\b|\bquestions\b/i, /^layout\.FaqSection\./, 2.5],
1115
+ [/\bstats?\b|\bnumbers?\b|\bmetrics?\b/i, /^layout\.NumbersSection\./, 1.8],
1116
+ [
1117
+ /\b(?:case[- ]stud(?:y|ies)|testimonials?|customer (?:results|stories|quotes))\b/i,
1118
+ /^layout\.TestimonialsSection\./,
1119
+ 1.8,
1120
+ ],
1121
+ [/\bcase[- ]stud(?:y|ies)\b/i, /case-study/, 1.4],
1122
+ ];
1123
+ /** A short note for brief sections no layout expresses well (shown in the candidates table). */
1124
+ export function briefHint(sec, pos, twin, candidates = [], page = {}) {
1125
+ const out = [];
1126
+ for (const h of BRIEF_HINTS) {
1127
+ const t = h.hint({ sec, pos, twin, candidates, page });
1128
+ if (!t)
1129
+ continue;
1130
+ out.push(t);
1131
+ if (h.stop)
1132
+ break;
1133
+ }
1134
+ return out.join(' · ');
1135
+ }
1136
+ /**
1137
+ * The candidates-table notes, in order. A rule returns '' when it doesn't apply; a `stop` rule that applies ends the
1138
+ * list (a twin or a logo strip needs no other note). K11 made this a list so a rule-collision note (grey band + grey
1139
+ * cards) can sit next to a layout note; add a row for a new collision the mapping stage must see.
1140
+ */
1141
+ export const BRIEF_HINTS = [
1142
+ {
1143
+ name: 'responsive twin',
1144
+ stop: true,
1145
+ hint: ({ twin, sec }) => !twin
1146
+ ? ''
1147
+ : twin.hero
1148
+ ? `mobile-only hero → likely twin of §${twin.n}: fold into it, add no section; its own copy (headline, form) is a \`[COPY: mobile hero, §${sec.n}]\` flag, not a section (\`policy.mode-b.responsive-variants\`)`
1149
+ : `${twin.marked ? '' : 'likely '}twin of §${twin.n} → fold into it, add no section (\`policy.mode-b.responsive-variants\`)`,
1150
+ },
1151
+ {
1152
+ name: 'logo strip',
1153
+ stop: true,
1154
+ hint: ({ sec }) => {
1155
+ const sh = sectionShape(sec);
1156
+ return sh.logoStrip ? `${sh.logos} logos, not items` : '';
1157
+ },
1158
+ },
1159
+ {
1160
+ name: 'hero direction (K11 F1)',
1161
+ hint: ({ sec, pos }) => {
1162
+ const d = pos.first ? heroDirection(sec) : null;
1163
+ if (!d)
1164
+ return '';
1165
+ const why = d.why === 'cue'
1166
+ ? `brief says ${d.dir === 'split' ? 'split' : d.dir === 'text' ? 'text-only' : 'centered'}`
1167
+ : d.why === 'selector'
1168
+ ? 'tabs + a visual per tab = selector hero: copy + tabs (`options`) left, visual right → `split`'
1169
+ : 'direction not in the brief: copy above the visual → `centered-image`, copy left + visual right → `split*`; check the live hero';
1170
+ return `${why} (wrong direction −20, \`policy.benchmark.rubric-layout-hero\`)`;
1171
+ },
1172
+ },
1173
+ {
1174
+ name: 'hero + items no HeaderSection slot holds (K11)',
1175
+ stop: true,
1176
+ hint: ({ sec, pos }) => {
1177
+ const g = pos.first ? heroItemGroup(sec) : null;
1178
+ if (!g)
1179
+ return '';
1180
+ const b = g.agentCards ? '`CardsSection.horizontal-reveal` (agent cards)' : 'one item section';
1181
+ return `hero + ${g.n} items no HeaderSection slot holds → §${sec.n}a HeaderSection${g.heroMedia ? '' : ' `text-only`'} (copy) + §${sec.n}b ${b} directly below; keep every copy beats never split (\`policy.mode-b.hero-item-split\`)`;
1182
+ },
1183
+ },
1184
+ {
1185
+ name: 'accordion driving a card carousel (K11 F1)',
1186
+ stop: true,
1187
+ hint: ({ sec, page }) => {
1188
+ const c = page?.carousel;
1189
+ if (!c)
1190
+ return '';
1191
+ const lay = c.takenBy == null
1192
+ ? '`tab-cards-carousel`'
1193
+ : `§${c.takenBy} has \`tab-cards-carousel\` → \`accordion-media-right\``;
1194
+ const nb = c.nb
1195
+ ? `; the ${c.cards} cards with copy → §${sec.n}b \`${c.nb.replace(/^layout\./, '')}\` below`
1196
+ : '';
1197
+ return `accordion driving a card carousel: ${lay} + \`[GAP: accordion-driven carousel]\` (−5 once)${nb} (\`policy.layout.accordion-carousel\`)`;
1198
+ },
1199
+ },
1200
+ {
1201
+ name: 'two item groups',
1202
+ stop: true,
1203
+ hint: ({ sec, pos }) => {
1204
+ const two = !pos.first && isCaptureSection(sec.text) ? twoItemGroups(sec.text) : null;
1205
+ return two
1206
+ ? `2 item groups (${two.a.n} ${two.a.kind} + ${two.b.n} ${two.b.kind}) → two adjacent sections §${sec.n}a/§${sec.n}b, nothing dropped (\`policy.mode-b.two-item-groups\`)`
1207
+ : '';
1208
+ },
1209
+ },
1210
+ {
1211
+ name: 'vertical item list labelled tabs (K11 B2)',
1212
+ hint: ({ sec, pos, candidates }) => {
1213
+ const v = pos.first ? null : verticalItemList(sec);
1214
+ if (!v || v.labelled !== 'tabs')
1215
+ return '';
1216
+ // B3: a later section moved along the accordion + media family (layout-diversity) names the layout it got.
1217
+ const top = candidates.find((c) => !c.part)?.id;
1218
+ const to = top && ACCORDION_MEDIA_FAMILY.includes(top)
1219
+ ? `\`${top.replace(/^layout\./, '')}\``
1220
+ : v.visual === 'item' && v.n <= 5
1221
+ ? '`FeaturesSection.side-by-side`'
1222
+ : '`FeaturesSection.accordion-media-right`';
1223
+ return `${v.n} "tabs" that show their own copy = a vertical list (expands in place), not a tab row → ${to}, not tabs-* (\`policy.tabs.layout-by-count\`)`;
1224
+ },
1225
+ },
1226
+ {
1227
+ name: 'live grey band + grey cards (K11)',
1228
+ hint: ({ sec, pos, candidates }) => {
1229
+ if (pos.first || !greyBand(sec))
1230
+ return '';
1231
+ const grey = candidates
1232
+ .filter((c) => GREY_CARD_LAYOUTS.has(c.id))
1233
+ .map((c) => `\`${c.id.replace(/^layout\./, '')}\``);
1234
+ const sh = itemShape(sec.text);
1235
+ if (!grey.length && !(sh.stats >= 1 && sh.quotes >= 1))
1236
+ return '';
1237
+ const which = grey.length
1238
+ ? grey.join(' / ')
1239
+ : 'stat + quote cards: the TestimonialsSection carousels';
1240
+ return `live grey band + grey cards (${which}) → the grey-on-grey Critical beats the band: \`theme="default"\`, log \`[COPY: grey band dropped, grey cards]\` (\`policy.mode-b.precedence\` tint exception 2)`;
1241
+ },
1242
+ },
1243
+ {
1244
+ name: 'statement',
1245
+ hint: ({ sec, pos, page }) => {
1246
+ const shape = sectionShape(sec);
1247
+ if (pos.first || pos.last || !shape.statement)
1248
+ return '';
1249
+ if (!shape.hasMedia)
1250
+ return '[GAP?] statement: no single-statement section; CtaSection centered if it carries a CTA';
1251
+ const st = page?.statement;
1252
+ if (st && !st.to)
1253
+ return `[GAP?] statement + media: both 1-item stand-ins are taken (§${st.prev} has \`stacked-rows\`): keep \`stacked-media-left\`, log \`[GAP: layout-diversity]\` (\`policy.layout.statement-stand-in\`)`;
1254
+ const to = st?.to
1255
+ ? `\`${st.to.replace(/^layout\.FeaturesSection\./, '')}\`${st.prev != null ? ` (§${st.prev} has \`stacked-rows\`)` : ''}`
1256
+ : '`stacked-rows`';
1257
+ return `[GAP?] statement + media → a single-item FeaturesSection ${to}; never \`''\` (\`policy.layout.statement-stand-in\`)`;
1258
+ },
1259
+ },
1260
+ ];
1261
+ /** Compact limit text from a derived limit unit's facts: `16–37c`, `≤3w`, `1–2s`. */
1262
+ export function limitShort(u) {
1263
+ const f = (u?.facts ?? {});
1264
+ const part = (r, unit) => !r || (r.min == null && r.max == null)
1265
+ ? ''
1266
+ : r.min != null && r.max != null
1267
+ ? r.min === r.max
1268
+ ? `${r.max}${unit}`
1269
+ : `${r.min}–${r.max}${unit}`
1270
+ : r.max != null
1271
+ ? `≤${r.max}${unit}`
1272
+ : `≥${r.min}${unit}`;
1273
+ return [part(f.chars, 'c'), part(f.words, 'w'), part(f.sentences, 's')].filter(Boolean).join(' ');
1274
+ }
1275
+ /** One line per item type a layout renders: the rendered fields (● required) with their copy limits. */
1276
+ export function itemShapeLines(u, byId) {
1277
+ const f = (u.facts ?? {});
1278
+ const out = [];
1279
+ for (const [item, { rendered }] of Object.entries(f.itemFields ?? {})) {
1280
+ const iu = byId.get(`item.${item}`);
1281
+ const fields = (iu?.facts ?? {}).fields ?? [];
1282
+ const shown = fields.filter((x) => rendered.includes(x.name));
1283
+ if (!shown.length)
1284
+ continue;
1285
+ const fmt = (x) => {
1286
+ const lim = limitShort(x.limitId ? byId.get(x.limitId) : undefined);
1287
+ const t = /^\{/.test(x.type)
1288
+ ? `{${[...x.type.matchAll(/(\w+)\??:/g)]
1289
+ .map((m) => m[1])
1290
+ .filter((k) => k !== 'onClick')
1291
+ .join(',')}}`
1292
+ : '';
1293
+ return `${x.required ? '●' : ''}${x.name}${t}${lim ? ` ${lim}` : ''}`;
1294
+ };
1295
+ out.push(`- \`clay section ${f.section} --layout ${f.layout}\` · ${item}: ${shown.map(fmt).join(' · ')}`);
1296
+ }
1297
+ return out;
1298
+ }
1299
+ /** The search text for one brief section: the title and any layout / interaction hint lines weigh double. */
1300
+ export function briefQuery(sec) {
1301
+ const clean = sec.text.replace(/https?:\/\/\S+|`[^`]*`/g, ' ');
1302
+ const hints = clean
1303
+ .split('\n')
1304
+ // Mobile behaviour ("an accordion on mobile") says nothing about the desktop layout.
1305
+ .filter((l) => /\b(?:layout|interactive|columns?|grid|carousel|tabs?|accordion|marquee|cards?)\b/i.test(l) &&
1306
+ !/^\s*\|/.test(l) &&
1307
+ !/\bmobile\b/i.test(l))
1308
+ .map((l) => l.slice(0, 200))
1309
+ .join(' ');
1310
+ return `${sec.title} ${sec.title} ${hints} ${hints} ${clean.slice(0, 400)}`;
1311
+ }
1312
+ /**
1313
+ * Email brief blocks (K7): `**Section NN:** <name>` lines (the Lifecycle brief layout, e.g. examples/brief-example.md),
1314
+ * else the items of a "Must-have blocks" list, else the brief's `###` headings. Each block keeps its text until the next.
1315
+ */
1316
+ export function emailBriefRows(brief) {
1317
+ const lines = brief.split('\n');
1318
+ const SEC = /^\s*\*\*Section\s+\d+:?\*\*:?\s*(.+)$/i;
1319
+ const marks = lines
1320
+ .map((l, i) => ({ i, m: SEC.exec(l)?.[1]?.replace(/\*\*/g, '').trim() }))
1321
+ .filter((x) => x.m);
1322
+ if (marks.length)
1323
+ return marks.slice(0, 20).map((x, k) => ({
1324
+ title: x.m,
1325
+ text: lines
1326
+ .slice(x.i + 1, k + 1 < marks.length ? marks[k + 1].i : Math.min(lines.length, x.i + 25))
1327
+ .join(' ')
1328
+ .slice(0, 400),
1329
+ }));
1330
+ const mh = lines.findIndex((l) => /must-have blocks/i.test(l));
1331
+ if (mh >= 0) {
1332
+ const items = [];
1333
+ for (let i = mh + 1; i < lines.length && items.length < 12; i++) {
1334
+ if (/^\s*[-*]\s+/.test(lines[i]))
1335
+ items.push(lines[i].replace(/^\s*[-*]\s+/, '').trim());
1336
+ else if (items.length && lines[i].trim() && !/^\s{2,}/.test(lines[i]))
1337
+ break;
1338
+ }
1339
+ if (items.length)
1340
+ return items.map((t) => ({ title: t, text: '' }));
1341
+ }
1342
+ return parseBrief(brief).map((s) => ({ title: s.title, text: s.text.slice(0, 400) }));
1343
+ }
1344
+ /* ── route ─────────────────────────────────────────────────────────────────────────────────── */
1345
+ /** K10: procedures that only describe the brief format; a compact route with a brief names them instead. */
1346
+ const BRIEF_FORMAT_UNITS = new Set(['procedure.compose.inputs']);
1347
+ /** K10: the navbar + footer code the compact page-file route inlines. */
1348
+ export const CHROME_EXAMPLES = [
1349
+ 'section.NavbarMenu.example.full',
1350
+ 'section.FooterSection.example.default',
1351
+ ];
1352
+ /** A unit body with no blank lines (K10 compact view; same as api.ts `tight`). */
1353
+ const tightBody = (body) => body.replace(/\n[ \t]*\n+/g, '\n').trim();
1354
+ export function route(kb, index, o) {
1355
+ const budget = o.budget ??
1356
+ (o.brief && (o.stage === 'mapping' || o.stage === 'structure') ? BRIEF_BUDGET : DEFAULT_BUDGET);
1357
+ const byId = new Map(kb.units.map((u) => [u.id, u]));
1358
+ const pool = kb.units.filter((u) => routable(u, o));
1359
+ const used = new Set();
1360
+ const out = [];
1361
+ const blocks = [];
1362
+ const compact = !!o.compact;
1363
+ // Same rendering as the final text (blocks joined by a blank line — none in the compact view — and a trailing
1364
+ // newline), so the budget is exact.
1365
+ const render = (bs) => `${bs.join(compact ? '\n' : '\n\n')}\n`;
1366
+ const tokensOf = (extra = '') => estimateTokens(render(extra ? [...blocks, extra] : blocks));
1367
+ const room = (text) => tokensOf(text) <= budget;
1368
+ const sectionsOfBrief = o.brief ? parseBrief(o.brief) : [];
1369
+ const emailRoute = isEmailSkill(o.skill);
1370
+ const header = emailRoute
1371
+ ? `# clay route · ${o.skill} · stage ${o.stage} (scope email)\n` +
1372
+ `${o.brief ? `Brief: ${o.briefLabel ?? '(inline)'} — ${emailBriefRows(o.brief).length} blocks. ` : ''}` +
1373
+ `Units are the source of truth (\`pnpm clay get <id>\` for any id below; \`pnpm clay section email.<id>\` for one email section: slots, brief fields, limits, gotchas).`
1374
+ : compact
1375
+ ? `# clay route · ${o.skill}${o.mode ? ` · mode ${o.mode}` : ''} · stage ${o.stage}${o.brief ? ` · brief: ${sectionsOfBrief.length} sections` : ''} (\`clay get <id>\` for any id)`
1376
+ : `# clay route · ${o.skill}${o.mode ? ` · mode ${o.mode}` : ''} · stage ${o.stage}\n` +
1377
+ `${o.brief ? `Brief: ${o.briefLabel ?? '(inline)'} — ${sectionsOfBrief.length} sections. ` : ''}` +
1378
+ `Units are the source of truth (\`pnpm clay get <id>\` for any id below; \`pnpm clay section <Section> --layout <l>\` for a layout).`;
1379
+ blocks.push(header);
1380
+ /* 1 · Do: the skill's procedures for the stage */
1381
+ const procs = pool
1382
+ .filter((u) => u.kind === 'procedure' && u.entities.includes(skillEntity(o.skill)))
1383
+ // Mode-specific first, then the procedures this skill owns (its skill entity listed first) before the ones it shares.
1384
+ .sort((a, b) => Number(!!b.modes) - Number(!!a.modes) ||
1385
+ Number(primarySkill(b) === o.skill) - Number(primarySkill(a) === o.skill) ||
1386
+ (a.id < b.id ? -1 : 1));
1387
+ const bodyBlock = (u) => `### ${u.id}\n${compact ? tightBody(modeSlice(u.body, o.mode)) : modeSlice(u.body, o.mode)}`;
1388
+ const startUnits = new Set(o.mode ? MODE_START_UNITS[o.mode] : []);
1389
+ const doBlocks = [];
1390
+ const seenBody = (id) => !!o.seen?.bodies.has(id);
1391
+ const seenLine = (id) => seenBody(id) || !!o.seen?.lines.has(id);
1392
+ const earlier = [];
1393
+ const notNeeded = [];
1394
+ for (const u of procs) {
1395
+ if (seenBody(u.id)) {
1396
+ earlier.push(u.id);
1397
+ used.add(u.id);
1398
+ continue;
1399
+ }
1400
+ // K10 compact: with a brief in hand the brief-format template is noise.
1401
+ if (compact && o.brief && BRIEF_FORMAT_UNITS.has(u.id)) {
1402
+ notNeeded.push(u.id);
1403
+ used.add(u.id);
1404
+ continue;
1405
+ }
1406
+ // The first (most specific) procedure always inlines; later ones inline while they fit in 40% of the budget.
1407
+ const b = bodyBlock(u);
1408
+ if (!doBlocks.length || estimateTokens([...doBlocks, b].join('\n')) <= budget * 0.3) {
1409
+ doBlocks.push(b);
1410
+ used.add(u.id);
1411
+ out.push({ id: u.id, tier: 'do', inlined: 'body' });
1412
+ }
1413
+ }
1414
+ if (doBlocks.length)
1415
+ blocks.push(`## Do\n${doBlocks.join(compact ? '\n' : '\n\n')}`);
1416
+ const email = isEmailSkill(o.skill);
1417
+ if (email) {
1418
+ const d = emailDoBlock(o.skill, o.stage);
1419
+ if (d)
1420
+ blocks.push(d);
1421
+ const gates = emailGates(pool).filter((u) => !used.has(u.id));
1422
+ if (gates.length) {
1423
+ blocks.push(`## Gates (Critical email rules of this stage — \`pnpm email:check\` fails on them)\n${gates.map((u) => `- \`${u.id}\` — ${u.summary.replace(/^Email rule \S+ \([^)]*\): /, '')}`).join('\n')}`);
1424
+ for (const u of gates) {
1425
+ used.add(u.id);
1426
+ out.push({ id: u.id, tier: 'decides', inlined: 'summary' });
1427
+ }
1428
+ }
1429
+ }
1430
+ /* 2 · Decides: what the procedures yield to + Critical mode-specific policies */
1431
+ const decide = [];
1432
+ for (const u of procs.filter((p) => used.has(p.id)))
1433
+ for (const id of u.relations?.precedence ?? []) {
1434
+ const t = byId.get(id);
1435
+ if (t && t.authority !== 'background' && !used.has(id) && !decide.includes(t))
1436
+ decide.push(t);
1437
+ }
1438
+ for (const u of pool)
1439
+ if (o.mode &&
1440
+ u.modes?.includes(o.mode) &&
1441
+ u.kind === 'policy' &&
1442
+ u.severity === 'Critical' &&
1443
+ !decide.includes(u) &&
1444
+ !used.has(u.id))
1445
+ decide.push(u);
1446
+ const decideBlocks = [];
1447
+ const fetched = [...startUnits]
1448
+ .map((id) => byId.get(id))
1449
+ .filter((u) => !!u && u.authority !== 'background' && !used.has(u.id) && !seenBody(u.id));
1450
+ for (const id of startUnits)
1451
+ if (seenBody(id) && !used.has(id))
1452
+ used.add(id);
1453
+ if (fetched.length) {
1454
+ decideBlocks.push(`Fetched once at the start of mode ${o.mode} (\`pnpm clay get ${fetched.map((u) => u.id).join(' ')}\`), not repeated here: ${fetched.map((u) => `\`${u.id}\` — ${u.summary}`).join(' · ')}`);
1455
+ for (const u of fetched) {
1456
+ used.add(u.id);
1457
+ out.push({ id: u.id, tier: 'decides', inlined: 'summary' });
1458
+ }
1459
+ }
1460
+ for (const u of decide) {
1461
+ if (startUnits.has(u.id))
1462
+ continue;
1463
+ if (seenBody(u.id)) {
1464
+ earlier.push(u.id);
1465
+ used.add(u.id);
1466
+ continue;
1467
+ }
1468
+ const b = bodyBlock(u);
1469
+ if (estimateTokens([...blocks, ...decideBlocks, b].join('\n')) <= budget * 0.7) {
1470
+ decideBlocks.push(b);
1471
+ used.add(u.id);
1472
+ out.push({ id: u.id, tier: 'decides', inlined: 'body' });
1473
+ }
1474
+ }
1475
+ if (decideBlocks.length)
1476
+ blocks.push(`## Decides collisions\n${decideBlocks.join(compact ? '\n' : '\n\n')}`);
1477
+ if (earlier.length)
1478
+ blocks.push(`Served earlier in this session, not repeated (\`clay get <id> --full\` to re-read): ${earlier.map((id) => `\`${id}\``).join(' · ')}`);
1479
+ if (notNeeded.length)
1480
+ blocks.push(`Brief format not printed (you have the brief): ${notNeeded.map((id) => `\`${id}\``).join(' · ')}`);
1481
+ /* K8 · the build's own layout picks: one fact line each (range, dropped props, dead CTAs), so later stages need no
1482
+ * `clay section` re-call. */
1483
+ if (o.layouts?.length && LAYOUT_FACT_STAGES.has(o.stage)) {
1484
+ const facts = o.layouts
1485
+ .map((k) => byId.get(`layout.${k}`))
1486
+ .filter((u) => !!u)
1487
+ .map((u) => `- ${layoutLine(u)}`);
1488
+ // K10 compact: the session already printed each layout's facts, so one line names them (bench run E / K10: the
1489
+ // per-layout list repeated ~40 lines at page-file).
1490
+ if (facts.length && compact)
1491
+ blocks.push(`Layouts you looked at (facts printed earlier; \`clay section <S> --layout <l>\` re-prints): ${o.layouts.join(' · ')}`);
1492
+ else if (facts.length)
1493
+ blocks.push(`## Your layouts (from \`clay section\` in this session)\n${facts.join('\n')}`);
1494
+ }
1495
+ /* K10 · the site chrome every page file needs (Hard Rule C4), so writing it takes no NavbarMenu / FooterSection lookup. */
1496
+ if (compact && !emailRoute && o.stage === 'page-file') {
1497
+ const chrome = CHROME_EXAMPLES.map((id) => byId.get(id)).filter((u) => !!u && !seenBody(u.id) && !used.has(u.id));
1498
+ if (chrome.length) {
1499
+ blocks.push(`## Chrome (Hard Rule C4: use as is; another brand swaps only the logo key, \`procedure.logo.decision-tree\`)\n${chrome.map((u) => tightBody(u.body)).join('\n')}`);
1500
+ for (const u of chrome) {
1501
+ used.add(u.id);
1502
+ out.push({ id: u.id, tier: 'do', inlined: 'body' });
1503
+ }
1504
+ }
1505
+ }
1506
+ /* 3 · Brief → candidates */
1507
+ const briefRows = [];
1508
+ const candidateEntities = new Set();
1509
+ if (email && o.brief && (o.stage === 'mapping' || o.stage === 'structure')) {
1510
+ const rows = emailBriefRows(o.brief).map((r, i) => {
1511
+ const c = emailBriefCandidates(kb, index, r.title, r.text);
1512
+ for (const id of c)
1513
+ out.push({ id, tier: 'brief', inlined: 'id' });
1514
+ return `| ${i + 1} | ${r.title.slice(0, 48)} | ${c.map((id) => `\`${id.replace(/^(?:section|component)\.email\./, '')}\``).join(' · ') || '— (no match: a [GAP]?)'} |`;
1515
+ });
1516
+ const table = `## Brief → email section candidates (search; alias map first; an explicit brief structure wins — \`clay get section.email.<id>\`)\n| # | brief block | candidates |\n|---|---|---|\n${rows.join('\n')}`;
1517
+ if (rows.length && room(table))
1518
+ blocks.push(table);
1519
+ }
1520
+ if (!email && sectionsOfBrief.length && (o.stage === 'mapping' || o.stage === 'structure')) {
1521
+ const twins = sectionsOfBrief.map((_, i) => twinOf(sectionsOfBrief, i));
1522
+ // The closing section is the last one that is not a mobile twin.
1523
+ const lastReal = twins.reduce((last, t, i) => (t ? last : i), sectionsOfBrief.length - 1);
1524
+ sectionsOfBrief.forEach((sec, i) => {
1525
+ // A responsive twin folds into its section: no candidates of its own (policy.mode-b.responsive-variants).
1526
+ const cands = twins[i]
1527
+ ? []
1528
+ : briefCandidates(kb, index, sec, byId, { first: i === 0, last: i === lastReal });
1529
+ briefRows.push({ section: sec, candidates: cands });
1530
+ for (const c of cands) {
1531
+ const u = byId.get(c.id);
1532
+ for (const e of u.entities)
1533
+ if (e.startsWith('section:') || e.startsWith('layout:') || e.startsWith('item:'))
1534
+ candidateEntities.add(e);
1535
+ }
1536
+ });
1537
+ // K11 B3 (`policy.features.layout-diversity`): a later section whose first pick is an accordion + media layout an
1538
+ // earlier section already took first lists the next layout of that family first.
1539
+ const skipRow = (i) => i === 0 || !!twins[i];
1540
+ // K11 F1 (`policy.layout.accordion-carousel`): an accordion driving a card carousel takes the one canonical answer.
1541
+ const carousels = resolveAccordionCarousels(briefRows, byId, skipRow);
1542
+ const diverse = diversifyAccordionFamily(briefRows, byId, (i) => skipRow(i) || carousels.has(i));
1543
+ // K11 F1 (`policy.layout.statement-stand-in`): statements with media take different 1-item stand-ins.
1544
+ const statements = diversifyStatements(briefRows, byId, (i) => i !== lastReal &&
1545
+ (() => {
1546
+ const sh = sectionShape(briefRows[i].section);
1547
+ return sh.statement && sh.hasMedia;
1548
+ })(), skipRow);
1549
+ // K10 (`policy.layout.no-repeat-fallback`): a section whose best candidate is the previous section's best candidate.
1550
+ let prevTop = null;
1551
+ const repeats = briefRows.map((r) => {
1552
+ const top = r.candidates[0];
1553
+ if (!top)
1554
+ return null;
1555
+ const hit = prevTop && prevTop.id === top.id ? prevTop.n : null;
1556
+ prevTop = { id: top.id, n: r.section.n };
1557
+ return hit;
1558
+ });
1559
+ const rows = briefRows.map((r, i) => {
1560
+ const hint0 = briefHint(r.section, { first: i === 0, last: i === lastReal }, twins[i], r.candidates, { carousel: carousels.get(i), statement: statements.get(i) });
1561
+ const rep = repeats[i] != null
1562
+ ? `⚠ same first pick as §${repeats[i]}: keep §${repeats[i]}, take this section's next closest layout (\`policy.layout.no-repeat-fallback\`)`
1563
+ : '';
1564
+ const d = diverse.get(i);
1565
+ const short = (id) => `\`${id.replace(/^layout\.FeaturesSection\./, '')}\``;
1566
+ const div = !d
1567
+ ? ''
1568
+ : d.to
1569
+ ? `§${d.prev} has ${short(d.from)} → ${short(d.to)} (same accordion + media family; \`policy.features.layout-diversity\`)`
1570
+ : `⚠ §${d.prev} has ${short(d.from)}, no other family layout fits: keep, log \`[GAP: layout-diversity]\` (\`policy.features.layout-diversity\`)`;
1571
+ const hint = [hint0, div, rep].filter(Boolean).join(' · ');
1572
+ return `| ${r.section.n} | ${r.section.title.slice(0, 38)} | ${r.section.items ?? '—'} | ${[...r.candidates.map((c) => `${c.part ? `§${r.section.n}${c.part} ` : ''}\`${c.id.replace(/^layout\./, '')}\` ${c.line.replace(/^\S+ \S+ ?/, '')}${c.fit === 'over' ? ' ⚠ over' : c.fit === 'under' ? ' ⚠ under' : ''}`), ...(hint ? [hint] : [])].join(' · ')} |`;
1573
+ });
1574
+ const table = `## Brief → layout candidates (search + item-count fit; you decide)\n| § | brief section | items | candidates (range; notes) |\n|---|---|---|---|\n${rows.join('\n')}`;
1575
+ if (room(table)) {
1576
+ blocks.push(table);
1577
+ for (const r of briefRows)
1578
+ for (const c of r.candidates)
1579
+ out.push({ id: c.id, tier: 'brief', inlined: 'summary' });
1580
+ // Item shapes of the first candidate per section (the fields that layout renders, with limits), so the mapping
1581
+ // needs no source or doc lookup; each line names the `clay section` call that gives the full view.
1582
+ const seen = new Set();
1583
+ const shapeLines = [];
1584
+ for (const r of briefRows)
1585
+ for (const c of compact ? r.candidates : r.candidates.slice(0, 1)) {
1586
+ if (!c || seen.has(c.id))
1587
+ continue;
1588
+ seen.add(c.id);
1589
+ for (const l of itemShapeLines(byId.get(c.id), byId)) {
1590
+ const key = l.replace(/^- `[^`]*` · /, '');
1591
+ if (!/·.*·/.test(key) || shapeLines.some((x) => x.endsWith(key)))
1592
+ continue; // trivial or same fields as a line above
1593
+ shapeLines.push(l);
1594
+ }
1595
+ }
1596
+ const shapes = `### Item shapes of the ${compact ? '' : 'first '}candidates (● required; c chars · w words · s sentences)\n${[...new Set(shapeLines)].join('\n')}`;
1597
+ if (shapeLines.length && room(shapes))
1598
+ blocks.push(shapes);
1599
+ }
1600
+ }
1601
+ /* 4 · Watch: ranked one-liners */
1602
+ const rel = new Map();
1603
+ if (o.brief) {
1604
+ // Relevance to the brief: BM25 of each brief section against the stage pool.
1605
+ const poolIds = new Set(pool.map((u) => u.id));
1606
+ const qs = sectionsOfBrief.length
1607
+ ? sectionsOfBrief.map((s) => `${s.title} ${s.text.replace(/https?:\/\/\S+|`[^`]*`/g, ' ').slice(0, 400)}`)
1608
+ : [o.brief.slice(0, 2000)];
1609
+ for (const q of qs) {
1610
+ for (const h of search(kb, index, q, {
1611
+ stage: [o.stage],
1612
+ mode: o.mode,
1613
+ k: 25,
1614
+ scope: email ? ['email', 'shared'] : undefined,
1615
+ })) {
1616
+ if (!poolIds.has(h.id))
1617
+ continue;
1618
+ rel.set(h.id, Math.max(rel.get(h.id) ?? 0, h.score));
1619
+ }
1620
+ }
1621
+ }
1622
+ const maxRel = Math.max(1, ...rel.values());
1623
+ // K8: once the build has looked at sections, a unit about other sections only is off-page.
1624
+ const pageSections = new Set(o.sections ?? []);
1625
+ const offPage = (u) => {
1626
+ if (!pageSections.size)
1627
+ return false;
1628
+ const secs = u.entities
1629
+ .filter((e) => e.startsWith('section:') || e.startsWith('layout:'))
1630
+ .map((e) => e.split(':')[1]);
1631
+ return secs.length > 0 && !secs.some((x) => pageSections.has(x));
1632
+ };
1633
+ const watch = pool
1634
+ // Catalog units (derived sections/layouts/limits/item types/components) are reached through the brief rows and `clay section`.
1635
+ .filter((u) => !used.has(u.id) &&
1636
+ !offPage(u) &&
1637
+ (!CATALOG_KINDS.has(u.kind) || u.authority !== 'derived' || (email && u.kind === 'limit')))
1638
+ .map((u) => {
1639
+ let s = (KIND_RANK[u.kind] ?? 0.5) + (SEVERITY_RANK[u.severity ?? ''] ?? 0) * 0.6;
1640
+ // K11 F1: a Critical unit wins a near-tie anywhere in the ranking (BM25 drift from an unrelated unit edit pushed
1641
+ // policy.mode-b.precedence out by 0.05 in run G service). The cut itself is guarded by guardCriticalAtCut (G1).
1642
+ if (u.severity === 'Critical')
1643
+ s += CRITICAL_EDGE;
1644
+ if (u.entities.includes(skillEntity(o.skill)))
1645
+ s += 1;
1646
+ if (offSkill(u, o.skill))
1647
+ s -= 2.5;
1648
+ if (o.mode && u.modes?.includes(o.mode))
1649
+ s += 1.5;
1650
+ s += 3 * ((rel.get(u.id) ?? 0) / maxRel);
1651
+ const touches = u.entities.filter((e) => candidateEntities.has(e));
1652
+ if (touches.some((e) => e.startsWith('layout:')))
1653
+ s += 2.5;
1654
+ else if (touches.length)
1655
+ s += 1.2;
1656
+ return { u, s };
1657
+ })
1658
+ .sort((a, b) => b.s - a.s || (a.u.id < b.u.id ? -1 : 1));
1659
+ const lines = [];
1660
+ const more = [];
1661
+ const footerReserve = 140;
1662
+ // Email routes have no procedure bodies (the steps stay in the skill): inline the top-ranked units' bodies instead.
1663
+ if (email) {
1664
+ const key = [];
1665
+ for (const { u } of watch.slice(0, 6)) {
1666
+ if (key.length >= 3 || u.kind === 'limit')
1667
+ continue;
1668
+ const b = `### ${u.id}\n${u.body}`;
1669
+ if (estimateTokens([...blocks, '## Key units', ...key, b].join('\n')) > budget * 0.65)
1670
+ continue;
1671
+ key.push(b);
1672
+ used.add(u.id);
1673
+ out.push({ id: u.id, tier: 'watch', inlined: 'body' });
1674
+ }
1675
+ if (key.length)
1676
+ blocks.push(`## Key units (top-ranked for this stage, inlined)\n${key.join('\n\n')}`);
1677
+ }
1678
+ // A unit the build already saw keeps its rank slot but is not printed again (K8: no refill, so later stages shrink).
1679
+ const watchMax = compact ? WATCH_MAX_COMPACT : WATCH_MAX;
1680
+ let slots = 0;
1681
+ let seenSkipped = 0;
1682
+ // K11 G1: a Critical unit in a near-tie at the Watch cut is never pushed out by a Minor one.
1683
+ const ranked = guardCriticalAtCut(watch.filter(({ u }) => !used.has(u.id)), watchMax, ({ u }) => seenLine(u.id));
1684
+ for (const { u } of ranked) {
1685
+ if (used.has(u.id))
1686
+ continue;
1687
+ if (seenLine(u.id)) {
1688
+ if (slots < watchMax) {
1689
+ slots++;
1690
+ seenSkipped++;
1691
+ }
1692
+ used.add(u.id);
1693
+ continue;
1694
+ }
1695
+ const line = `- \`${u.id}\`${u.severity ? ` (${u.severity})` : ''} — ${u.summary}`;
1696
+ if (slots < watchMax &&
1697
+ estimateTokens([...blocks, '## Watch', ...lines, line].join('\n')) <= budget - footerReserve) {
1698
+ slots++;
1699
+ lines.push(line);
1700
+ used.add(u.id);
1701
+ out.push({ id: u.id, tier: 'watch', inlined: 'summary' });
1702
+ }
1703
+ else
1704
+ more.push(u.id);
1705
+ }
1706
+ if (lines.length)
1707
+ blocks.push(`## Watch (this stage; ranked by severity, brief and candidates)${seenSkipped ? ` — ${seenSkipped} more listed earlier this session` : ''}\n${lines.join('\n')}`);
1708
+ /* 5 · More */
1709
+ if (more.length) {
1710
+ const ids = [];
1711
+ for (const id of more) {
1712
+ if (ids.length >= (compact ? 8 : MORE_MAX))
1713
+ break;
1714
+ const t = `## More (\`pnpm clay get <id>\`; ${more.length} more units tagged ${o.stage})\n${[...ids, id].join(' · ')}`;
1715
+ if (!room(t))
1716
+ break;
1717
+ ids.push(id);
1718
+ }
1719
+ if (ids.length) {
1720
+ blocks.push(`## More (\`pnpm clay get <id>\`; ${more.length} more units tagged ${o.stage})\n${ids.join(' · ')}`);
1721
+ for (const id of ids)
1722
+ out.push({ id, tier: 'more', inlined: 'id' });
1723
+ }
1724
+ }
1725
+ const text = render(blocks);
1726
+ return { text, tokens: estimateTokens(text), budget, units: out, brief: briefRows };
1727
+ }
1728
+ /**
1729
+ * K11 G1 — the Critical guarantee at the Watch cut: a Minor unit never pushes a competing Critical unit out of Watch.
1730
+ *
1731
+ * `ranked` is the Watch ranking (best first, CRITICAL_EDGE included); its first `n` entries take the Watch slots (an
1732
+ * entry the build already saw takes a slot unprinted). A Critical entry below the cut *competes* for the cut when it
1733
+ * scores within `margin` of the lowest-ranked Minor entry above the cut; the two then swap: the Critical takes the last
1734
+ * kept place, the Minor heads the rest (More). Repeated pairwise (next Critical below, next Minor above) while the pair
1735
+ * is a near-tie. Nothing else moves, and a seen entry neither swaps in (it would print nothing) nor out.
1736
+ *
1737
+ * Why this rule (G1): CRITICAL_EDGE alone is a nudge, so BM25 drift from unrelated unit edits kept flipping
1738
+ * `policy.mode-b.precedence` (Critical) against `gotcha.TrustSection.layout-props` (Minor) in run F/G service; raising
1739
+ * the edge reorders every route. The swap acts only at the cut and only against a Minor — two severity tiers below,
1740
+ * already 1.2 behind on severity weight, so a Minor that still ties a Critical does so on brief relevance alone. A
1741
+ * Major, one tier below, keeps its slot (a Major/Critical near-tie is the ranking working: every no-brief route has
1742
+ * several at the 0.1 score quantum, and swapping them reshuffled ROUTING.md). Units with no severity (procedures,
1743
+ * stats) are not ranked by severity and never swap out. The margin sits in the gap of the run E/F/G route corpus
1744
+ * (Minor/Critical near-ties at ≤ 0.09 and ≥ 0.19) and is 5× the 0.03 deficit precedence had.
1745
+ */
1746
+ export function guardCriticalAtCut(ranked, n, seen = () => false, margin = CRITICAL_CUT_MARGIN) {
1747
+ const r = [...ranked];
1748
+ if (n <= 0 || r.length <= n)
1749
+ return r;
1750
+ let below = n; // next Critical candidate below the cut
1751
+ let above = n - 1; // next Minor candidate above the cut
1752
+ for (;;) {
1753
+ while (below < r.length && (r[below].u.severity !== 'Critical' || seen(r[below])))
1754
+ below++;
1755
+ while (above >= 0 && (r[above].u.severity !== 'Minor' || seen(r[above])))
1756
+ above--;
1757
+ if (below >= r.length || above < 0 || r[above].s - r[below].s > margin)
1758
+ return r;
1759
+ // The Critical takes the last kept place, the Minor heads the rest; everything else keeps its order.
1760
+ const c = r[below];
1761
+ const m = r[above];
1762
+ r.splice(below, 1);
1763
+ r.splice(above, 1);
1764
+ r.splice(n - 1, 0, c, m);
1765
+ below++;
1766
+ above--;
1767
+ }
1768
+ }
1769
+ //# sourceMappingURL=route.js.map