@amityco/social-plus-vise 0.13.0 → 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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to `@amityco/social-plus-vise` are documented in this file.
4
4
 
5
5
  The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## 0.14.1 — 2026-06-03
8
+
9
+ **Theme:** Retract the enumerative DesignBuildBrief from plan output — our own benchmark said so.
10
+
11
+ ### Changed
12
+ - **`vise plan` no longer emits `designContract.brief`** (the 0.14.0 roles / component hints / outcome recipes). The pre-registered ablation we shipped 0.14.0 with came back negative — twice: agents given the enumerative brief produced LESS on-contract UI than agents given the design contract alone (by-name token usage ~75% vs ~89–92%; two runs, n=6 per arm, Cursor/Composer 2.5; full evidence in `benchmarks/brief-ablation/`). The mechanism: enumerative guidance narrows what capable agents build and style, while the contract + `design check` loop drives wholesale token adoption. We measure before we claim — and this one measured against us, so it's out.
13
+ - **Kept:** the non-blocking `primary_action_token` intake question (the brief is still computed internally to detect a missing primary-action token — it asks instead of steering). The brief generator and its 17-scenario test suite remain in-tree for future redesign.
14
+
15
+ ---
16
+
17
+ ## 0.14.0 — 2026-06-03
18
+
19
+ **Theme:** DesignBuildBrief — plan-time, grounded UI-building guidance for coding agents (advisory).
20
+
21
+ ### Added
22
+ - **DesignBuildBrief in `vise plan`** (`designContract.brief`): conservative semantic token roles inferred from token NAMES only (noun-first compounds — `--text-primary` binds `textPrimary`, never `primaryAction`; value-based inference is forbidden), token-derived component hints (card/button/input) with explicit absent variants, and grounded do/avoid lines — every line cites the declared tokens/roles it derives from, and an ungrounded line is structurally impossible. For `add-feed` / `add-chat` outcomes the brief carries a conditional **outcome recipe** whose items only reference roles that were actually inferred. Generated at plan time — never persisted to `sp-vise/`, never part of any digest.
23
+ - **Non-blocking `primary_action_token` intake question** when a design contract exists for a feed/chat outcome but no primary-action token was confidently identified — the agent asks instead of guessing.
24
+
25
+ ### Notes
26
+ - Advisory only; never gates `vise check`. **No design-conformance improvement is claimed for the brief yet** — a pre-registered ablation (`benchmarks/brief-ablation/PROTOCOL.md`: 0.13.0 vs 0.14.0, same contract, n=3, Cursor/Composer 2.5) measures its effect; documentation claims will follow the measurement.
27
+
28
+ ---
29
+
7
30
  ## 0.13.0 — 2026-06-03
8
31
 
9
32
  **Theme:** Deterministic-gate soundness, CLI reliability, and post-rebrand coherence (driven by two repo reviews).
@@ -2323,3 +2323,514 @@ function stableStringify(value) {
2323
2323
  }
2324
2324
  return JSON.stringify(value);
2325
2325
  }
2326
+ /**
2327
+ * Structural grounding helper: builds a BriefLine and throws a TypeError at
2328
+ * construction time if groundedIn is empty. This makes the grounding invariant
2329
+ * structural — an ungrounded line is impossible rather than a runtime surprise.
2330
+ */
2331
+ function line(text, groundedIn, confidence = "high") {
2332
+ if (groundedIn.length === 0) {
2333
+ throw new TypeError(`BriefLine created with empty groundedIn: "${text}"`);
2334
+ }
2335
+ return { text, groundedIn, confidence };
2336
+ }
2337
+ /**
2338
+ * Per-role keyword rules in spec-defined order.
2339
+ * Each entry: [pattern, role, confidence].
2340
+ * Rules are applied with first-match-wins semantics.
2341
+ * Compound rules (muted+text, muted+bg/surface) must precede their plain
2342
+ * counterparts so "--color-text-muted" resolves to textSecondary, not textPrimary.
2343
+ */
2344
+ const ROLE_RULES = [
2345
+ // Compound rules — must precede their plain counterparts
2346
+ {
2347
+ test: (n) => /muted/.test(n) && /\btext\b|foreground|fg/.test(n),
2348
+ role: "textSecondary",
2349
+ confidence: "medium",
2350
+ reason: "name contains 'muted' and 'text'/'foreground'/'fg'",
2351
+ },
2352
+ {
2353
+ test: (n) => /muted/.test(n) && /\bbg\b|surface|background/.test(n),
2354
+ role: "surfaceMuted",
2355
+ confidence: "medium",
2356
+ reason: "name contains 'muted' and 'bg'/'surface'/'background'",
2357
+ },
2358
+ // Noun-first compounds — in real design systems the NOUN keyword (text/surface/
2359
+ // bg/border) sets the role family and primary/secondary act as modifiers within
2360
+ // it: "--text-primary" is the primary BODY-TEXT color, not the action color.
2361
+ // These must precede the plain primary/secondary rules below, or first-match-wins
2362
+ // would misbind some of the most common token names in the wild.
2363
+ {
2364
+ test: (n) => /\btext\b|foreground|\bfg\b/.test(n) && /\bprimary\b/.test(n),
2365
+ role: "textPrimary",
2366
+ confidence: "high",
2367
+ reason: "name contains 'text'/'foreground'/'fg' with 'primary' — the noun keyword sets the role family",
2368
+ },
2369
+ {
2370
+ test: (n) => /\btext\b|foreground|\bfg\b/.test(n) && /\bsecondary\b/.test(n),
2371
+ role: "textSecondary",
2372
+ confidence: "high",
2373
+ reason: "name contains 'text'/'foreground'/'fg' with 'secondary' — the noun keyword sets the role family",
2374
+ },
2375
+ {
2376
+ test: (n) => /surface|background|\bbg\b/.test(n) && /\bprimary\b/.test(n),
2377
+ role: "surface",
2378
+ confidence: "high",
2379
+ reason: "name contains 'surface'/'background'/'bg' with 'primary' — a primary surface, not an action color",
2380
+ },
2381
+ {
2382
+ test: (n) => /surface|background|\bbg\b/.test(n) && /\bsecondary\b/.test(n),
2383
+ role: "surfaceMuted",
2384
+ confidence: "medium",
2385
+ reason: "name contains 'surface'/'background'/'bg' with 'secondary' — a secondary surface",
2386
+ },
2387
+ {
2388
+ test: (n) => /\bborder\b|\boutline\b|\bdivider\b/.test(n) && (/\bprimary\b/.test(n) || /\bsecondary\b/.test(n)),
2389
+ role: "border",
2390
+ confidence: "medium",
2391
+ reason: "name contains a border keyword with a primary/secondary modifier — the noun keyword sets the role family",
2392
+ },
2393
+ // Primary: plain "primary" → high; "brand"/"accent" → medium
2394
+ // EXCLUSION: action roles bind interactive-family names only.
2395
+ // A token whose name contains a text-family noun (text/foreground/fg) MUST
2396
+ // NEVER bind primaryAction or secondaryAction — it represents a text colour,
2397
+ // not an interactive accent (e.g. --text-bright-accent is a text accent, while
2398
+ // --essential-bright-accent is the real interactive accent).
2399
+ {
2400
+ test: (n) => /\bprimary\b/.test(n) && !/brand|accent/.test(n) && !/\btext\b|foreground|\bfg\b/.test(n),
2401
+ role: "primaryAction",
2402
+ confidence: "high",
2403
+ reason: "name contains 'primary' (and is not a text-family name)",
2404
+ },
2405
+ {
2406
+ test: (n) => /\bbrand\b|\baccent\b/.test(n) && !/\btext\b|foreground|\bfg\b/.test(n),
2407
+ role: "primaryAction",
2408
+ confidence: "medium",
2409
+ reason: "name contains 'brand' or 'accent' (and is not a text-family name)",
2410
+ },
2411
+ {
2412
+ test: (n) => /\bsecondary\b/.test(n) && !/\btext\b|foreground|\bfg\b/.test(n),
2413
+ role: "secondaryAction",
2414
+ confidence: "high",
2415
+ reason: "name contains 'secondary' (and is not a text-family name)",
2416
+ },
2417
+ {
2418
+ test: (n) => /\bdanger\b|\berror\b|\bdestructive\b/.test(n),
2419
+ role: "danger",
2420
+ confidence: "high",
2421
+ reason: "name contains 'danger', 'error', or 'destructive'",
2422
+ },
2423
+ {
2424
+ test: (n) => /\bsuccess\b|\bpositive\b/.test(n),
2425
+ role: "success",
2426
+ confidence: "high",
2427
+ reason: "name contains 'success' or 'positive'",
2428
+ },
2429
+ {
2430
+ test: (n) => /surface|background|\bbg\b/.test(n),
2431
+ role: "surface",
2432
+ confidence: "high",
2433
+ reason: "name contains 'surface', 'background', or 'bg'",
2434
+ },
2435
+ {
2436
+ test: (n) => /\btext\b|foreground|\bfg\b/.test(n),
2437
+ role: "textPrimary",
2438
+ confidence: "high",
2439
+ reason: "name contains 'text', 'foreground', or 'fg'",
2440
+ },
2441
+ {
2442
+ test: (n) => /\bborder\b|\boutline\b|\bdivider\b/.test(n),
2443
+ role: "border",
2444
+ confidence: "high",
2445
+ reason: "name contains 'border', 'outline', or 'divider'",
2446
+ },
2447
+ {
2448
+ test: (n) => /\bfocus\b|\bring\b/.test(n),
2449
+ role: "focus",
2450
+ confidence: "high",
2451
+ reason: "name contains 'focus' or 'ring'",
2452
+ },
2453
+ {
2454
+ test: (n) => /\bavatar\b/.test(n),
2455
+ role: "avatarFallback",
2456
+ confidence: "high",
2457
+ reason: "name contains 'avatar'",
2458
+ },
2459
+ ];
2460
+ /** Infer a (role, confidence, reason) from a token name, or null if no rule matches. */
2461
+ function inferRole(tokenName) {
2462
+ const n = tokenName.toLowerCase();
2463
+ for (const rule of ROLE_RULES) {
2464
+ if (rule.test(n)) {
2465
+ return { role: rule.role, confidence: rule.confidence, reason: rule.reason };
2466
+ }
2467
+ }
2468
+ return null;
2469
+ }
2470
+ /**
2471
+ * Among multiple candidates for the same role, prefer:
2472
+ * 1. high confidence over medium
2473
+ * 2. shorter name (e.g. "--color-primary" beats "--color-primary-hover")
2474
+ */
2475
+ function bestCandidate(a, b) {
2476
+ if (a.confidence === "high" && b.confidence !== "high")
2477
+ return a;
2478
+ if (b.confidence === "high" && a.confidence !== "high")
2479
+ return b;
2480
+ return a.token.length <= b.token.length ? a : b;
2481
+ }
2482
+ /**
2483
+ * Build a DesignBuildBrief from a DesignContract.
2484
+ *
2485
+ * - Pure: no I/O, no side effects.
2486
+ * - Never persisted or digested.
2487
+ * - Every BriefLine has non-empty groundedIn citing actual contract tokens/roles.
2488
+ * - Roles inferred from NAME only (never from value).
2489
+ * - Inferred tokens (name: null) are never cited by name; they may be cited only
2490
+ * as a count for do/avoid prose, but only if the contract has declared color
2491
+ * tokens with recognizable names to ground the line instead.
2492
+ */
2493
+ export function buildDesignBrief(contract) {
2494
+ const strength = contract.stats.strength;
2495
+ // ── Role inference ──────────────────────────────────────────────────────────
2496
+ // Walk only declared color tokens (provenance=declared, category=color, name != null).
2497
+ // Inferred tokens always have name: null; value-only matching is forbidden.
2498
+ const colorTokens = contract.tokens.filter((t) => t.category === "color" && t.name !== null);
2499
+ const roleMap = new Map();
2500
+ for (const token of colorTokens) {
2501
+ const inferred = inferRole(token.name);
2502
+ if (!inferred) {
2503
+ continue;
2504
+ }
2505
+ const candidate = {
2506
+ role: inferred.role,
2507
+ token: token.name,
2508
+ value: token.value,
2509
+ confidence: inferred.confidence,
2510
+ reason: inferred.reason,
2511
+ };
2512
+ const existing = roleMap.get(inferred.role);
2513
+ roleMap.set(inferred.role, existing ? bestCandidate(existing, candidate) : candidate);
2514
+ }
2515
+ const roles = [...roleMap.values()];
2516
+ // Helper: is a role name in this brief?
2517
+ const roleNames = new Set(roles.map((r) => r.role));
2518
+ // String-typed version for use in contexts where we compare arbitrary strings against role names.
2519
+ const roleNameStrings = new Set(roles.map((r) => r.role));
2520
+ // ── Component hints ─────────────────────────────────────────────────────────
2521
+ // Reference ONLY tokens that actually exist in the contract.
2522
+ /**
2523
+ * Representative token selector: among a category's declared tokens, prefer
2524
+ * (in order) a name containing "base", then "default", then "md"/"medium",
2525
+ * then the SHORTEST name, then first-in-order.
2526
+ *
2527
+ * Using first-in-contract-order (old behaviour) selected unrepresentative tokens
2528
+ * like --encore-corner-radius-smaller over --encore-corner-radius-base, causing
2529
+ * agents to anchor on non-base variants and miss the authoritative base token.
2530
+ */
2531
+ function representativeToken(cat) {
2532
+ const candidates = contract.tokens.filter((t) => t.category === cat && t.name !== null && t.provenance === "declared");
2533
+ if (candidates.length === 0)
2534
+ return undefined;
2535
+ // Scoring: lower is better. 0 = base, 1 = default, 2 = md/medium, 3 = shortest, 4 = first.
2536
+ const score = (name) => {
2537
+ const n = name.toLowerCase();
2538
+ if (/\bbase\b/.test(n))
2539
+ return 0;
2540
+ if (/\bdefault\b/.test(n))
2541
+ return 1;
2542
+ if (/\bmd\b|\bmedium\b/.test(n))
2543
+ return 2;
2544
+ return 3;
2545
+ };
2546
+ return candidates.reduce((best, t) => {
2547
+ const bs = score(best.name);
2548
+ const ts = score(t.name);
2549
+ if (ts < bs)
2550
+ return t;
2551
+ if (ts > bs)
2552
+ return best;
2553
+ // Same bucket: prefer shorter name, then first-in-order (best wins ties).
2554
+ return t.name.length < best.name.length ? t : best;
2555
+ });
2556
+ }
2557
+ const radiusToken = representativeToken("radius");
2558
+ const spaceToken = representativeToken("space");
2559
+ // Border colour: sourced from the inferred border role (a color token named *border*/*outline*/*divider*).
2560
+ // There is no "border" TokenCategory — border colours live in the "color" category.
2561
+ const borderRoleColor = roleMap.get("border");
2562
+ const shadowToken = representativeToken("shadow");
2563
+ const primaryRole = roleMap.get("primaryAction");
2564
+ // card hint
2565
+ const cardGuidanceLines = [];
2566
+ if (radiusToken) {
2567
+ cardGuidanceLines.push(line(`Use ${radiusToken.name} (${radiusToken.value}) for card corner radius.`, [radiusToken.name]));
2568
+ }
2569
+ if (spaceToken) {
2570
+ cardGuidanceLines.push(line(`Use ${spaceToken.name} (${spaceToken.value}) for card internal padding.`, [spaceToken.name]));
2571
+ }
2572
+ if (borderRoleColor) {
2573
+ cardGuidanceLines.push(line(`Apply ${borderRoleColor.token} for card border colour.`, [borderRoleColor.role]));
2574
+ }
2575
+ else if (shadowToken) {
2576
+ cardGuidanceLines.push(line(`Apply ${shadowToken.name} for card shadow/elevation.`, [shadowToken.name]));
2577
+ }
2578
+ const cardHint = cardGuidanceLines.length > 0
2579
+ ? { kind: "card", guidance: cardGuidanceLines, confidence: radiusToken && spaceToken ? "high" : "medium" }
2580
+ : { kind: "card", absent: true, note: "No card pattern confidently identified — reuse the host app's existing card styles." };
2581
+ // button hint
2582
+ const buttonGuidanceLines = [];
2583
+ if (primaryRole) {
2584
+ buttonGuidanceLines.push(line(`Use ${primaryRole.token} (${primaryRole.value}) as the primary button background.`, [primaryRole.role]));
2585
+ }
2586
+ if (radiusToken) {
2587
+ buttonGuidanceLines.push(line(`Apply ${radiusToken.name} (${radiusToken.value}) for button corner radius.`, [radiusToken.name]));
2588
+ }
2589
+ if (spaceToken) {
2590
+ buttonGuidanceLines.push(line(`Apply ${spaceToken.name} (${spaceToken.value}) for button horizontal padding.`, [spaceToken.name]));
2591
+ }
2592
+ const buttonHint = buttonGuidanceLines.length > 0
2593
+ ? { kind: "button", guidance: buttonGuidanceLines, confidence: primaryRole ? "high" : "medium" }
2594
+ : { kind: "button", absent: true, note: "No button pattern confidently identified — reuse the host app's existing button styles." };
2595
+ // input hint
2596
+ const inputGuidanceLines = [];
2597
+ if (borderRoleColor) {
2598
+ inputGuidanceLines.push(line(`Use ${borderRoleColor.token} for input border colour.`, [borderRoleColor.role]));
2599
+ }
2600
+ if (radiusToken) {
2601
+ inputGuidanceLines.push(line(`Apply ${radiusToken.name} (${radiusToken.value}) for input corner radius.`, [radiusToken.name]));
2602
+ }
2603
+ if (spaceToken) {
2604
+ inputGuidanceLines.push(line(`Apply ${spaceToken.name} (${spaceToken.value}) for input internal padding.`, [spaceToken.name]));
2605
+ }
2606
+ const inputHint = inputGuidanceLines.length > 0
2607
+ ? { kind: "input", guidance: inputGuidanceLines, confidence: borderRoleColor ? "high" : "medium" }
2608
+ : { kind: "input", absent: true, note: "No input pattern confidently identified — reuse the host app's existing input styles." };
2609
+ const componentHints = [cardHint, buttonHint, inputHint];
2610
+ // ── Do/Avoid lines ──────────────────────────────────────────────────────────
2611
+ // Every line MUST be grounded in tokens/roles that actually exist in this brief.
2612
+ const doLines = [];
2613
+ const avoidLines = [];
2614
+ // Only emit do/avoid lines that are grounded in actually-present tokens.
2615
+ const declaredColorTokens = colorTokens.filter((t) => t.provenance === "declared");
2616
+ const declaredSpaceTokens = contract.tokens.filter((t) => t.category === "space" && t.name !== null && t.provenance === "declared");
2617
+ const declaredRadiusTokens = contract.tokens.filter((t) => t.category === "radius" && t.name !== null && t.provenance === "declared");
2618
+ // Do: use declared color tokens
2619
+ if (declaredColorTokens.length > 0) {
2620
+ const tokenNames = declaredColorTokens.slice(0, 3).map((t) => t.name);
2621
+ doLines.push(line(`Reference declared color tokens (e.g. ${tokenNames.join(", ")}) — never introduce new hex literals.`, tokenNames));
2622
+ }
2623
+ // Do: use declared space tokens
2624
+ if (declaredSpaceTokens.length > 0) {
2625
+ const tokenNames = declaredSpaceTokens.slice(0, 3).map((t) => t.name);
2626
+ doLines.push(line(`Reference declared spacing tokens (e.g. ${tokenNames.join(", ")}) for margins, padding, and gaps.`, tokenNames));
2627
+ }
2628
+ // Do: use declared radius tokens
2629
+ if (declaredRadiusTokens.length > 0) {
2630
+ const tokenNames = declaredRadiusTokens.slice(0, 2).map((t) => t.name);
2631
+ doLines.push(line(`Use declared radius tokens (e.g. ${tokenNames.join(", ")}) for corner rounding.`, tokenNames));
2632
+ }
2633
+ // Do: use primary-role token for interactive elements
2634
+ if (primaryRole) {
2635
+ doLines.push(line(`Use the primary colour token (${primaryRole.token}) for primary interactive elements (buttons, CTAs).`, [primaryRole.role]));
2636
+ }
2637
+ // Avoid: hex literals (grounded in declared color tokens)
2638
+ if (declaredColorTokens.length > 0) {
2639
+ const tokenNames = declaredColorTokens.slice(0, 3).map((t) => t.name);
2640
+ avoidLines.push(line(`Do not introduce new hex or colour literals — use the ${declaredColorTokens.length} declared colour token(s) (e.g. ${tokenNames.join(", ")}).`, tokenNames));
2641
+ }
2642
+ // Avoid: raw spacing literals (grounded in declared space tokens)
2643
+ if (declaredSpaceTokens.length > 0) {
2644
+ const tokenNames = declaredSpaceTokens.slice(0, 2).map((t) => t.name);
2645
+ avoidLines.push(line(`Do not hardcode raw spacing values — use declared spacing tokens (e.g. ${tokenNames.join(", ")}).`, tokenNames));
2646
+ }
2647
+ // Avoid: overriding the primary colour token on interactive elements
2648
+ if (primaryRole) {
2649
+ avoidLines.push(line(`Do not override the primary colour token (${primaryRole.token}) with ad-hoc colours on interactive elements.`, [primaryRole.role]));
2650
+ }
2651
+ // ── Breadth instruction (Fix 3 — anti-anchoring) ─────────────────────────────
2652
+ //
2653
+ // The roles and hints above are a starting lens — a minimal named anchor set.
2654
+ // Without an explicit instruction, agents anchor on the named tokens and stop
2655
+ // reading the full token file. This breadth line counters that by directing the
2656
+ // agent to the FULL declared token set, grounded in one representative token
2657
+ // per category so the grounding itself spans the system's breadth.
2658
+ //
2659
+ // Grounding rule: up to one representative declared token per category present,
2660
+ // using the representativeToken() selector (Fix 2). Only emitted when ≥1
2661
+ // declared token exists — weak/empty contracts do NOT get an invented breadth line.
2662
+ const ALL_TOKEN_CATEGORIES = ["color", "space", "radius", "shadow", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing", "borderWidth", "breakpoint", "motion", "opacity", "zIndex"];
2663
+ // Collect one representative declared token per category that has any declared tokens.
2664
+ const breadthGroundingTokens = [];
2665
+ for (const cat of ALL_TOKEN_CATEGORIES) {
2666
+ const rep = representativeToken(cat);
2667
+ if (rep)
2668
+ breadthGroundingTokens.push(rep);
2669
+ }
2670
+ const totalDeclaredTokens = contract.tokens.filter((t) => t.provenance === "declared" && t.name !== null).length;
2671
+ if (breadthGroundingTokens.length > 0) {
2672
+ // Build the grounding set (names of the representative tokens).
2673
+ const breadthGrounding = breadthGroundingTokens.map((t) => t.name);
2674
+ doLines.push(line(`The roles and hints above are a starting lens, not the full design system — reference the FULL declared token set (${totalDeclaredTokens} declared tokens) and prefer an existing token over any new value.`, breadthGrounding));
2675
+ }
2676
+ // ── Review notes ─────────────────────────────────────────────────────────────
2677
+ const reviewNotes = [];
2678
+ if (strength === "weak") {
2679
+ reviewNotes.push("Contract is weak — very few named tokens were found. Guidance above is minimal. Run `vise design extract --from-project` to derive a richer contract from the host project's design system, or provide a prototype.");
2680
+ }
2681
+ if (roles.length === 0) {
2682
+ reviewNotes.push("No colour roles could be inferred from token names. Role-based guidance is unavailable. Ensure tokens use recognisable names (e.g. --color-primary, --color-surface) and run `vise design extract --from-project` again.");
2683
+ }
2684
+ // Suggest missing roles using name examples, not camelCase role identifiers
2685
+ // (camelCase role names must not appear in prose to keep the weak/neutral brief JSON clean).
2686
+ if (!roleNames.has("primaryAction") && contract.stats.declared_tokens > 0) {
2687
+ reviewNotes.push("No primary action colour found — consider naming a token --color-primary (or --color-brand / --color-accent) for primary interactive elements.");
2688
+ }
2689
+ if (!roleNames.has("surface") && contract.stats.declared_tokens > 0) {
2690
+ reviewNotes.push("No surface colour found — consider naming a token --color-surface or --color-background.");
2691
+ }
2692
+ if (!roleNames.has("border") && contract.stats.declared_tokens > 0) {
2693
+ reviewNotes.push("No border colour found — consider naming a token --color-border or --color-outline.");
2694
+ }
2695
+ // Conservative-inference reviewNote (Fix 3):
2696
+ // When roles bind fewer than half the declared COLOR tokens, the role inference
2697
+ // was conservative on this vocabulary (e.g. Encore's essential-/decorative-
2698
+ // prefixed names are correct restraint but result in thin role coverage).
2699
+ // Alert the agent that it MUST read the full token file rather than relying on roles alone.
2700
+ const declaredColorCount = declaredColorTokens.length;
2701
+ const colorTokensBoundToRoles = roles.filter((r) => declaredColorTokens.some((t) => t.name === r.token)).length;
2702
+ if (declaredColorCount > 0 && colorTokensBoundToRoles < declaredColorCount / 2) {
2703
+ reviewNotes.push(`Role inference was conservative on this vocabulary — only ${colorTokensBoundToRoles} of ${declaredColorCount} declared colour token(s) are bound to roles. The full token file must be read to discover the complete colour system.`);
2704
+ }
2705
+ // ── Summary ──────────────────────────────────────────────────────────────────
2706
+ const tokenCount = contract.tokens.filter((t) => t.name !== null).length;
2707
+ // Count how many distinct declared token names are referenced by roles + hints.
2708
+ const briefReferencedTokenNames = new Set();
2709
+ for (const r of roles)
2710
+ briefReferencedTokenNames.add(r.token);
2711
+ for (const h of componentHints) {
2712
+ if (!("absent" in h)) {
2713
+ for (const l of h.guidance) {
2714
+ for (const g of l.groundedIn) {
2715
+ // Only add entries that are actual declared token names (not role names).
2716
+ if (!roleNameStrings.has(g))
2717
+ briefReferencedTokenNames.add(g);
2718
+ }
2719
+ }
2720
+ }
2721
+ }
2722
+ const briefCoveredCount = [...briefReferencedTokenNames].filter((n) => contract.tokens.some((t) => t.name === n && t.provenance === "declared")).length;
2723
+ const summary = roles.length > 0
2724
+ ? `Brief grounded in ${tokenCount} named token(s) and ${roles.length} inferred role(s). Contract strength: ${strength}. roles/hints cover ${briefCoveredCount} of ${totalDeclaredTokens} declared tokens — consult the full set.`
2725
+ : tokenCount > 0
2726
+ ? `Brief grounded in ${tokenCount} named token(s); no colour roles could be inferred from token names. Contract strength: ${strength}. roles/hints cover ${briefCoveredCount} of ${totalDeclaredTokens} declared tokens — consult the full set.`
2727
+ : `Contract has no named tokens — guidance is unavailable. Contract strength: ${strength}. Run \`vise design extract --from-project\` to derive tokens from the host project.`;
2728
+ return {
2729
+ summary,
2730
+ strength,
2731
+ roles,
2732
+ componentHints,
2733
+ do: doLines,
2734
+ avoid: avoidLines,
2735
+ reviewNotes,
2736
+ };
2737
+ }
2738
+ /**
2739
+ * Build outcome-specific design recipe items grounded in an existing brief.
2740
+ *
2741
+ * HARD INVARIANT: every item is grounded ONLY in roles/tokens already in the brief.
2742
+ * Items for absent roles are silently omitted. Returns `undefined` when zero items
2743
+ * can be grounded (e.g. empty brief).
2744
+ *
2745
+ * Pure — no I/O, no side effects. Generated at plan time; never persisted.
2746
+ */
2747
+ export function buildOutcomeDesignRecipe(brief, outcome) {
2748
+ const roleMap = new Map(brief.roles.map((r) => [r.role, r]));
2749
+ // Collect groundedIn entries from a given component hint (absent hints contribute nothing).
2750
+ const hintGrounding = (kind) => {
2751
+ const hint = brief.componentHints.find((h) => h.kind === kind);
2752
+ if (!hint || "absent" in hint)
2753
+ return [];
2754
+ return hint.guidance.flatMap((l) => l.groundedIn);
2755
+ };
2756
+ // Collect radius-specific grounding from the card hint by looking for guidance
2757
+ // lines that mention "corner radius" — avoids mis-citing a space token as radius.
2758
+ const cardRadiusGrounding = () => {
2759
+ const hint = brief.componentHints.find((h) => h.kind === "card");
2760
+ if (!hint || "absent" in hint)
2761
+ return [];
2762
+ return hint.guidance
2763
+ .filter((l) => l.text.includes("corner radius"))
2764
+ .flatMap((l) => l.groundedIn);
2765
+ };
2766
+ const items = [];
2767
+ if (outcome === "add-feed") {
2768
+ // Composer / action button — only when primaryAction exists.
2769
+ const primaryAction = roleMap.get("primaryAction");
2770
+ if (primaryAction) {
2771
+ items.push(line(`The post composer action button uses the primary action colour token (${primaryAction.token}).`, ["primaryAction"]));
2772
+ }
2773
+ // Post cards — only when the card hint has grounding tokens.
2774
+ const cardGrounding = hintGrounding("card");
2775
+ if (cardGrounding.length > 0) {
2776
+ items.push(line("Post cards follow the card component hint: apply the card hint tokens for corner radius, padding, and border/shadow.", cardGrounding));
2777
+ }
2778
+ // Post metadata and timestamps.
2779
+ const textSecondary = roleMap.get("textSecondary");
2780
+ if (textSecondary) {
2781
+ items.push(line(`Post metadata and timestamps use the secondary text colour token (${textSecondary.token}).`, ["textSecondary"]));
2782
+ }
2783
+ // Report / delete affordances.
2784
+ const danger = roleMap.get("danger");
2785
+ if (danger) {
2786
+ items.push(line(`Report and delete affordances use the danger colour token (${danger.token}).`, ["danger"]));
2787
+ }
2788
+ }
2789
+ else {
2790
+ // add-chat
2791
+ // Message bubbles use surface.
2792
+ const surface = roleMap.get("surface");
2793
+ if (surface) {
2794
+ const radiusGrounding = cardRadiusGrounding();
2795
+ if (radiusGrounding.length > 0) {
2796
+ items.push(line(`Message bubbles use the surface colour token (${surface.token}) with the card corner radius token applied.`, ["surface", ...radiusGrounding]));
2797
+ }
2798
+ else {
2799
+ items.push(line(`Message bubbles use the surface colour token (${surface.token}).`, ["surface"]));
2800
+ }
2801
+ }
2802
+ // Own-message vs other-message contrast — ONLY when BOTH primaryAction AND surface exist.
2803
+ const primaryAction = roleMap.get("primaryAction");
2804
+ if (primaryAction && surface) {
2805
+ items.push(line(`Own messages use the primary action colour (${primaryAction.token}) as background; other messages use the surface colour (${surface.token}).`, ["primaryAction", "surface"]));
2806
+ }
2807
+ // Timestamps.
2808
+ const textSecondary = roleMap.get("textSecondary");
2809
+ if (textSecondary) {
2810
+ items.push(line(`Message timestamps use the secondary text colour token (${textSecondary.token}).`, ["textSecondary"]));
2811
+ }
2812
+ // Composer follows the input hint tokens.
2813
+ const inputGrounding = hintGrounding("input");
2814
+ if (inputGrounding.length > 0) {
2815
+ items.push(line("The message composer follows the input component hint: apply the input hint tokens for border colour, corner radius, and padding.", inputGrounding));
2816
+ }
2817
+ // Moderation actions.
2818
+ const danger = roleMap.get("danger");
2819
+ if (danger) {
2820
+ items.push(line(`Moderation actions (report, block, mute) use the danger colour token (${danger.token}).`, ["danger"]));
2821
+ }
2822
+ }
2823
+ // Breadth audit item (Fix 3 — anti-anchoring):
2824
+ // Append a final item instructing the agent to audit against the FULL token set.
2825
+ // Grounded the same way as the brief's breadth do-line: reuse the groundedIn of
2826
+ // the "starting lens" do-line if it exists in the brief (spans the breadth of
2827
+ // the system's categories). Only emitted when the brief has declared tokens
2828
+ // (the breadth do-line only exists when there are declared tokens).
2829
+ const breadthDoLine = brief.do.find((l) => l.text.includes("starting lens"));
2830
+ if (breadthDoLine) {
2831
+ items.push(line("Before finishing, audit the UI against the full declared token set and replace any near-miss values with the matching token.", breadthDoLine.groundedIn));
2832
+ }
2833
+ if (items.length === 0)
2834
+ return undefined;
2835
+ return { outcome, items };
2836
+ }
@@ -4,7 +4,7 @@ import { BROAD_SOCIAL_REGEX, DESIGN_REGEX, classifyOutcome, getOutcomeDefinition
4
4
  import { objectInput, optionalStringField, stringField, textResult } from "../types.js";
5
5
  import { capabilityChecklist } from "../capabilities.js";
6
6
  import { applicableComplianceRuleSummaries } from "./compliance.js";
7
- import { readDesignContract } from "./design.js";
7
+ import { buildDesignBrief, readDesignContract } from "./design.js";
8
8
  import { sdkVersionGuidance } from "./sdkVersion.js";
9
9
  import { detectCommandSensors } from "./harness.js";
10
10
  import { inspectProject } from "./project.js";
@@ -71,8 +71,15 @@ async function buildIntegrationPlan(repoPath, request, surfacePath, answers = {}
71
71
  answers,
72
72
  });
73
73
  const definition = getOutcomeDefinition(outcome);
74
- const intake = intakeFor(ctx, definition.intakeQuestions(ctx));
74
+ // The design brief is computed INTERNALLY only, to drive the missing-primary-action
75
+ // intake fallback. It is deliberately NOT emitted in plan output: a pre-registered
76
+ // ablation (benchmarks/brief-ablation/RESULTS.md, two runs, n=6/arm) showed that
77
+ // enumerative plan-time design guidance NARROWS what capable agents build and style —
78
+ // the contract + design-check loop alone scored higher on by-name token conformance.
79
+ // Retracted in 0.14.1; the generator stays in design.ts for future redesign.
75
80
  const designContract = await readDesignContract(repoRoot);
81
+ const designBrief = designContract ? buildDesignBrief(designContract) : undefined;
82
+ const intake = intakeFor(ctx, definition.intakeQuestions(ctx), outcome, designBrief);
76
83
  // Advisory SDK-version currency guidance (npm registry for TS/RN; version-agnostic
77
84
  // for native). Best-effort — degrades to greenfield "install latest + pin" if the
78
85
  // registry is unreachable. Never gates.
@@ -173,7 +180,7 @@ function intentFor(request, interpretation) {
173
180
  ambiguity: broadSocialRequest || designRequest ? "high" : "medium",
174
181
  };
175
182
  }
176
- function intakeFor(ctx, outcomeQuestions) {
183
+ function intakeFor(ctx, outcomeQuestions, outcome, brief) {
177
184
  const questions = [...outcomeQuestions];
178
185
  if (ctx.mentionsDesign && ctx.designSignals.length === 0 && !hasAnswer(ctx.answers, "design_source")) {
179
186
  questions.push({
@@ -194,6 +201,21 @@ function intakeFor(ctx, outcomeQuestions) {
194
201
  options: ["yes", "use another source"],
195
202
  });
196
203
  }
204
+ // Graceful-degradation fallback: when a design contract exists for a feed or chat
205
+ // outcome but no primary-action token was confidently inferred, ask the developer
206
+ // to name the correct token. Non-blocking so it doesn't stall implementation.
207
+ if (brief &&
208
+ (outcome === "add-feed" || outcome === "add-chat") &&
209
+ !brief.roles.some((r) => r.role === "primaryAction") &&
210
+ !hasAnswer(ctx.answers, "primary_action_token")) {
211
+ questions.push({
212
+ id: "primary_action_token",
213
+ question: "Which design token (or color value) should be used as the primary action color? No primary-action token was confidently identified in the design contract.",
214
+ why: "A primary action colour is needed for interactive elements (composer button, own-message bubble). Without a confident token, the agent must guess or omit it.",
215
+ required: false,
216
+ blocksImplementationWhenMissing: false,
217
+ });
218
+ }
197
219
  const remainingBlocking = questions.filter((question) => question.blocksImplementationWhenMissing).length;
198
220
  return {
199
221
  status: remainingBlocking > 0 ? "needs-clarification" : "ready",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amityco/social-plus-vise",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "description": "Skill-guided deterministic CLI for social.plus SDK integration assistance.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",
@@ -62,9 +62,10 @@
62
62
  "test:sdk-version": "npm run build && node test/run-sdk-version.mjs",
63
63
  "typecheck": "tsc -p tsconfig.json --noEmit",
64
64
  "test:e2e-package": "npm run build && node test/run-e2e-package.mjs",
65
- "validate": "npm run typecheck && npm test && npm run test:mcp && npm run test:cli && npm run test:docs && npm run test:ast && npm run test:design-extract && npm run test:capabilities && npm run test:classify && npm run test:compliance && npm run test:rule-coverage && npm run test:readme-coverage && npm run test:happy-path-clean && npm run test:fixture-symmetry && npm run test:nonui-skip && npm run test:sdk-version && npm run test:native-idioms && npm run test:grader-facts && npm run test:ground-truth && npm run test:improvements && npm run test:debug && npm run test:preflight && npm run test:e2e-package && npm run pack:check",
65
+ "validate": "npm run typecheck && npm test && npm run test:mcp && npm run test:cli && npm run test:docs && npm run test:ast && npm run test:design-extract && npm run test:design-brief && npm run test:capabilities && npm run test:classify && npm run test:compliance && npm run test:rule-coverage && npm run test:readme-coverage && npm run test:happy-path-clean && npm run test:fixture-symmetry && npm run test:nonui-skip && npm run test:sdk-version && npm run test:native-idioms && npm run test:grader-facts && npm run test:ground-truth && npm run test:improvements && npm run test:debug && npm run test:preflight && npm run test:e2e-package && npm run pack:check",
66
66
  "test:ast": "node test/run-ast-helpers.mjs",
67
67
  "test:design-extract": "npm run build && node test/run-design-extract.mjs",
68
+ "test:design-brief": "npm run build && node test/run-design-brief.mjs",
68
69
  "test:capabilities": "npm run build && node test/run-capabilities.mjs",
69
70
  "test:classify": "npm run build && node test/run-classify.mjs",
70
71
  "test:debug": "npm run build && node test/run-debug.mjs",