From 4a72984085e6460ccbd121f5b4260412b387f04d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 16 Jun 2026 15:16:32 +0200 Subject: [PATCH 1/5] feat(seo): wire schema-validate hook from AgriciDaniel/claude-seo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the PostToolUse JSON-LD validation hook from the claude-seo open-source plugin into cue's hook format. Three files added: - seo-schema-validate.json — hook definition (PostToolUse: Edit|Write) - seo-schema-validate.sh — bash bridge that reads stdin payload and finds Python at runtime - seo-schema-validate.py — validator: exits 0 (ok), 1 (warnings), or 2 (block) on deprecated types, placeholder text, or broken @context/@type Profile seo/profile.yaml updated to reference the new hook. All 25 npx skills were already wired; cue validate seo shows 21 hooks resolved and schema valid. --- profiles/seo/profile.yaml | 2 + resources/hooks/seo-schema-validate.json | 17 +++ resources/hooks/seo-schema-validate.py | 132 +++++++++++++++++++++++ resources/hooks/seo-schema-validate.sh | 36 +++++++ 4 files changed, 187 insertions(+) create mode 100644 resources/hooks/seo-schema-validate.json create mode 100755 resources/hooks/seo-schema-validate.py create mode 100755 resources/hooks/seo-schema-validate.sh diff --git a/profiles/seo/profile.yaml b/profiles/seo/profile.yaml index 538378f0..d1b0c52f 100644 --- a/profiles/seo/profile.yaml +++ b/profiles/seo/profile.yaml @@ -50,6 +50,8 @@ persona: | need API keys. Wire them per engagement; the core skills work without keys. - Inherits core's defaults (verify before done, minimum-viable change, review the diff) and the integrity protocol. +hooks: + - seo-schema-validate.json skills: npx: - repo: AgriciDaniel/claude-seo diff --git a/resources/hooks/seo-schema-validate.json b/resources/hooks/seo-schema-validate.json new file mode 100644 index 00000000..9940b06f --- /dev/null +++ b/resources/hooks/seo-schema-validate.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "bash ${CLAUDE_CONFIG_DIR}/hooks/seo-schema-validate.sh", + "description": "Validate JSON-LD schema markup after file edits — blocks on deprecated types, placeholder text, or broken @context/@type. From claude-seo.", + "id": "cue:post:write:seo-schema-validate" + } + ] + } + ] + } +} diff --git a/resources/hooks/seo-schema-validate.py b/resources/hooks/seo-schema-validate.py new file mode 100755 index 00000000..1763675b --- /dev/null +++ b/resources/hooks/seo-schema-validate.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Post-edit schema validation hook for Claude Code. + +Validates JSON-LD schema after file edits. Returns exit code 2 to block +if critical validation errors found. Ported from AgriciDaniel/claude-seo. +""" + +import json +import re +import sys +import os +from typing import List + + +def validate_jsonld(content: str) -> List[str]: + """Validate JSON-LD blocks in HTML content.""" + errors = [] + pattern = r'(.*?)' + blocks = re.findall(pattern, content, re.DOTALL | re.IGNORECASE) + + if not blocks: + return [] + + for i, block in enumerate(blocks, 1): + block = block.strip() + try: + data = json.loads(block) + except json.JSONDecodeError as e: + errors.append(f"Block {i}: Invalid JSON; {e}") + continue + + if isinstance(data, list): + for item in data: + errors.extend(_validate_schema_object(item, i)) + elif isinstance(data, dict): + errors.extend(_validate_schema_object(data, i)) + + return errors + + +def _validate_schema_object(obj: dict, block_num: int) -> List[str]: + """Validate a single schema object.""" + errors = [] + prefix = f"Block {block_num}" + + if "@context" not in obj: + errors.append(f"{prefix}: Missing @context") + elif obj["@context"] not in ("https://schema.org", "http://schema.org"): + errors.append(f"{prefix}: @context should be 'https://schema.org'") + + if "@type" not in obj: + errors.append(f"{prefix}: Missing @type") + + placeholders = [ + "[Business Name]", "[City]", "[State]", "[Phone]", "[Address]", + "[Your", "[INSERT", "REPLACE", "[URL]", "[Email]", + ] + text = json.dumps(obj) + for p in placeholders: + if p.lower() in text.lower(): + errors.append(f"{prefix}: Contains placeholder text: {p}") + + schema_type = obj.get("@type", "") + deprecated = { + "HowTo": "deprecated September 2023", + "SpecialAnnouncement": "deprecated July 31, 2025", + "CourseInfo": "retired June 2025", + "EstimatedSalary": "retired June 2025", + "LearningVideo": "retired June 2025", + "ClaimReview": "retired June 2025; fact-check rich results discontinued", + "VehicleListing": "retired June 2025; vehicle listing structured data discontinued", + } + if schema_type in deprecated: + errors.append(f"{prefix}: @type '{schema_type}' is {deprecated[schema_type]}") + + # FAQPage intentionally NOT flagged: Google retired FAQ rich results (May 2026) + # but markup still aids AI Mode / AI Overviews entity resolution. + + return errors + + +def main(): + if len(sys.argv) < 2: + sys.exit(0) + + filepath = sys.argv[1] + + if not os.path.isfile(filepath): + sys.exit(0) + + valid_extensions = (".html", ".htm", ".jsx", ".tsx", ".vue", ".svelte", ".php", ".ejs") + if not filepath.lower().endswith(valid_extensions): + sys.exit(0) + + MAX_FILE_BYTES = 10 * 1024 * 1024 # 10 MiB + try: + if os.path.getsize(filepath) > MAX_FILE_BYTES: + sys.exit(0) + except OSError: + sys.exit(0) + + try: + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + content = f.read() + except (OSError, IOError): + sys.exit(0) + + errors = validate_jsonld(content) + + if not errors: + sys.exit(0) + + critical_keywords = ["placeholder", "deprecated", "retired"] + critical = [e for e in errors if any(kw in e.lower() for kw in critical_keywords)] + warnings = [e for e in errors if e not in critical] + + if warnings: + print("⚠️ Schema validation warnings:") + for w in warnings: + print(f" - {w}") + + if critical: + print("🛑 Schema validation ERRORS (blocking):") + for e in critical: + print(f" - {e}") + sys.exit(2) + + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/resources/hooks/seo-schema-validate.sh b/resources/hooks/seo-schema-validate.sh new file mode 100755 index 00000000..06525f9d --- /dev/null +++ b/resources/hooks/seo-schema-validate.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# PostToolUse:Edit|Write — validate JSON-LD schema after file edits. +# +# Reads the hook payload from stdin, extracts tool_input.file_path, then +# delegates to seo-schema-validate.py. Ported from AgriciDaniel/claude-seo. +# +# Exit 0 = ok, exit 1 = warnings (non-blocking), exit 2 = block. +set -euo pipefail + +payload="$(cat -)" +filepath="$(printf '%s' "$payload" | python3 -c ' +import sys, json +try: + d = json.load(sys.stdin) + print(d.get("tool_input", {}).get("file_path", "")) +except Exception: + pass +' 2>/dev/null)" + +[[ -z "$filepath" ]] && exit 0 + +py_hook="${CLAUDE_CONFIG_DIR}/hooks/seo-schema-validate.py" +[[ -f "$py_hook" ]] || exit 0 + +# Prefer explicit override, then python3, then python. +if [[ -n "${CLAUDE_SEO_PYTHON:-}" ]]; then + python_bin="$CLAUDE_SEO_PYTHON" +elif command -v python3 &>/dev/null; then + python_bin="python3" +elif command -v python &>/dev/null; then + python_bin="python" +else + exit 0 # No Python found; skip validation rather than block. +fi + +exec "$python_bin" "$py_hook" "$filepath" From 5688bef67f5a467cd3c9b665dfc28f9a12dbfc9e Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 16 Jun 2026 15:33:30 +0200 Subject: [PATCH 2/5] feat(studio): show recommends as clickable 'Pair with' chips on profile detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the recommends: [] field from profile.yaml in the Studio Profiles view. After selecting any profile (e.g. seo), its companion suggestions (growth, blog-writer, research) appear as violet pill buttons under the parts row — clicking one jumps the panel to that profile. Backend: ProfileDetail type + handleProfileDetail payload include recommends: string[]. Frontend: api.ts type updated; Profiles.tsx renders .pd-recommends strip; styles.css adds .pd-rec-chip rules. --- src/lib/dashboard-server.ts | 3 +++ web/src/studio/api.ts | 2 ++ web/src/studio/styles.css | 6 +++++- web/src/studio/views/Profiles.tsx | 10 ++++++++++ 4 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/lib/dashboard-server.ts b/src/lib/dashboard-server.ts index 6cba2cbc..d543a7e5 100644 --- a/src/lib/dashboard-server.ts +++ b/src/lib/dashboard-server.ts @@ -422,6 +422,8 @@ interface ProfileDetail { subagents: SubagentRef[]; /** External CLI tools the profile's skills declare (frontmatter Bash refs). */ clis: ProfileCli[]; + /** Companion profiles the active profile recommends pairing with. */ + recommends: string[]; } /** Pull a `uses:` (or `mcps:`) frontmatter list out of a SKILL.md, if present. */ @@ -727,6 +729,7 @@ export async function handleProfileDetail(params: URLSearchParams): Promise {d.parts.map((p) => {partEmoji(p)}{p})} + {d.recommends.length > 0 && ( +
+ Pair with + {d.recommends.map((r) => ( + + ))} +
+ )}
From 27da9857af4a12736a336999203fa32552a0a285 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 17 Jun 2026 01:00:18 +0200 Subject: [PATCH 3/5] feat(studio): extract atomic component library + design-sync to claude.ai/design Add 13 presentational studio components (Dot, LiveDot, Pill, McpBadge, StatTile, Band, Card, GhostButton, SegmentedControl, EmptyState, PageHeader, McpCard, MonoTag) with no API hooks, styled from src/studio/styles.css tokens. Wire up design-sync: config.json (componentSrcMap bypasses .d.ts discovery), lib/index.d.ts for prop extraction, and 11 authored preview TSX files. All 13 components pass Playwright render verification and are synced to the 'cue Studio Design System' claude.ai/design project. --- web/.design-sync/NOTES.md | 38 ++ web/.design-sync/config.json | 25 ++ web/.design-sync/previews/Band.tsx | 43 ++ web/.design-sync/previews/Card.tsx | 43 ++ web/.design-sync/previews/Dot.tsx | 21 + web/.design-sync/previews/EmptyState.tsx | 25 ++ web/.design-sync/previews/GhostButton.tsx | 25 ++ web/.design-sync/previews/LiveDot.tsx | 23 + web/.design-sync/previews/McpBadge.tsx | 24 ++ web/.design-sync/previews/MonoTag.tsx | 26 ++ web/.design-sync/previews/PageHeader.tsx | 43 ++ web/.design-sync/previews/Pill.tsx | 24 ++ web/.design-sync/previews/StatTile.tsx | 33 ++ web/.gitignore | 5 + web/lib/index.d.ts | 181 ++++++++ web/src/studio/components/index.tsx | 499 ++++++++++++++++++++++ 16 files changed, 1078 insertions(+) create mode 100644 web/.design-sync/NOTES.md create mode 100644 web/.design-sync/config.json create mode 100644 web/.design-sync/previews/Band.tsx create mode 100644 web/.design-sync/previews/Card.tsx create mode 100644 web/.design-sync/previews/Dot.tsx create mode 100644 web/.design-sync/previews/EmptyState.tsx create mode 100644 web/.design-sync/previews/GhostButton.tsx create mode 100644 web/.design-sync/previews/LiveDot.tsx create mode 100644 web/.design-sync/previews/McpBadge.tsx create mode 100644 web/.design-sync/previews/MonoTag.tsx create mode 100644 web/.design-sync/previews/PageHeader.tsx create mode 100644 web/.design-sync/previews/Pill.tsx create mode 100644 web/.design-sync/previews/StatTile.tsx create mode 100644 web/lib/index.d.ts create mode 100644 web/src/studio/components/index.tsx diff --git a/web/.design-sync/NOTES.md b/web/.design-sync/NOTES.md new file mode 100644 index 00000000..3cda4a7a --- /dev/null +++ b/web/.design-sync/NOTES.md @@ -0,0 +1,38 @@ +# .design-sync NOTES — cue studio + +## Repo shape + +- This is a Vite **application** repo, not a published component library. +- The design system lives in `src/studio/components/index.tsx` (created for this sync). + The original `src/studio/views/*.tsx` are full-page application views that call live + API hooks (`useStatus()`, `useTimeline()`, etc.) — they CANNOT be synced as-is. +- No library build configured. Converter runs in synth-entry mode, scanning + `src/studio/components/` for PascalCase exports. + +## Fonts + +- IBM Plex Sans and JetBrains Mono are loaded via Google Fonts `` in `index.html`. + They are NOT shipped with the bundle. `runtimeFontPrefixes` suppresses `[FONT_MISSING]`. + If the design project needs previews that render these fonts, the preview HTML must + load them via a `` or the fonts will fall back to system fonts. + +## CSS + +- `src/studio/styles.css` is the canonical token/style file (~1183 lines). + It includes app-global resets (`html,body,#root{height:100%}`) — these don't affect + the DS pane but are present in the uploaded `styles.css`. + +## Build notes + +- No separate `build:components` script. Converter synthesizes entry from source. + If re-sync needs it, run: `node .ds-sync/package-build.mjs --config .design-sync/config.json ...` +- Node modules path: `/home/deadpool/Documents/cue/web/node_modules` + +## Re-sync risks + +- **Component file hand-edited**: `src/studio/components/index.tsx` is hand-authored. + If views in `src/studio/views/` are refactored significantly, component CSS class + names may drift. Re-verify that classes like `.card`, `.band`, `.stat`, `.mc-card` + still exist in `styles.css`. +- **Google Fonts availability**: previews depend on browser font loading. If previews + render with system fonts instead of IBM Plex Sans, add a `` to the preview HTML. diff --git a/web/.design-sync/config.json b/web/.design-sync/config.json new file mode 100644 index 00000000..31acac6e --- /dev/null +++ b/web/.design-sync/config.json @@ -0,0 +1,25 @@ +{ + "pkg": "cue-studio", + "globalName": "CueStudio", + "shape": "package", + "projectId": "f365e97e-550c-4d0a-a1bb-aeb4cb621f52", + "srcDir": "src/studio/components", + "cssEntry": "src/studio/styles.css", + "runtimeFontPrefixes": ["IBM Plex Sans", "JetBrains Mono"], + "tsconfig": "tsconfig.json", + "componentSrcMap": { + "Dot": "src/studio/components/index.tsx", + "LiveDot": "src/studio/components/index.tsx", + "Pill": "src/studio/components/index.tsx", + "McpBadge": "src/studio/components/index.tsx", + "StatTile": "src/studio/components/index.tsx", + "Band": "src/studio/components/index.tsx", + "Card": "src/studio/components/index.tsx", + "GhostButton": "src/studio/components/index.tsx", + "SegmentedControl": "src/studio/components/index.tsx", + "EmptyState": "src/studio/components/index.tsx", + "PageHeader": "src/studio/components/index.tsx", + "McpCard": "src/studio/components/index.tsx", + "MonoTag": "src/studio/components/index.tsx" + } +} diff --git a/web/.design-sync/previews/Band.tsx b/web/.design-sync/previews/Band.tsx new file mode 100644 index 00000000..bd698982 --- /dev/null +++ b/web/.design-sync/previews/Band.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Band, StatTile, Pill, Dot } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +export const Default = () => ( + + +
+ + + +
+
+
+); + +export const WithChildren = () => ( + + +
+
+ + core + +
+
+ + commerce + +
+
+
+
+); + +export const Empty = () => ( + + + +); diff --git a/web/.design-sync/previews/Card.tsx b/web/.design-sync/previews/Card.tsx new file mode 100644 index 00000000..14fd30f2 --- /dev/null +++ b/web/.design-sync/previews/Card.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Card, GhostButton, StatTile, MonoTag } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +export const Default = () => ( + + restart} + > +
+ + +
+
+
+); + +export const Live = () => ( + + +
+ pid 48213 + +247 tok/s +
+
+
+); + +export const Plain = () => ( + + +
+ A bare card with no header — just a container with the studio border and background. +
+
+
+); diff --git a/web/.design-sync/previews/Dot.tsx b/web/.design-sync/previews/Dot.tsx new file mode 100644 index 00000000..2552cb3a --- /dev/null +++ b/web/.design-sync/previews/Dot.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Dot } from 'cue-studio'; + +const Wrap = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const AllVariants = () => ( + + + + + + +); + +export const Ok = () => ; +export const Warn = () => ; +export const Red = () => ; diff --git a/web/.design-sync/previews/EmptyState.tsx b/web/.design-sync/previews/EmptyState.tsx new file mode 100644 index 00000000..66465bd2 --- /dev/null +++ b/web/.design-sync/previews/EmptyState.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { EmptyState, Card } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +export const Default = () => ( + + + + + +); + +export const MessageOnly = () => ( + + + + + +); diff --git a/web/.design-sync/previews/GhostButton.tsx b/web/.design-sync/previews/GhostButton.tsx new file mode 100644 index 00000000..292d89ba --- /dev/null +++ b/web/.design-sync/previews/GhostButton.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { GhostButton } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Default = () => ( + + restart + logs + copy + +); + +export const States = () => ( + + active + default + disabled + delete + +); diff --git a/web/.design-sync/previews/LiveDot.tsx b/web/.design-sync/previews/LiveDot.tsx new file mode 100644 index 00000000..2451f4ef --- /dev/null +++ b/web/.design-sync/previews/LiveDot.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { LiveDot } from 'cue-studio'; + +const Wrap = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Default = () => ( + + + Live agent session + +); + +export const Multiple = () => ( + + + + + +); diff --git a/web/.design-sync/previews/McpBadge.tsx b/web/.design-sync/previews/McpBadge.tsx new file mode 100644 index 00000000..9d0a4518 --- /dev/null +++ b/web/.design-sync/previews/McpBadge.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { McpBadge } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Default = () => ( + + + + + + +); + +export const Variants = () => ( + + + + +); diff --git a/web/.design-sync/previews/MonoTag.tsx b/web/.design-sync/previews/MonoTag.tsx new file mode 100644 index 00000000..f58abb42 --- /dev/null +++ b/web/.design-sync/previews/MonoTag.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { MonoTag } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Default = () => ( + + ~/Documents/cue + pid 48213 + v2.1.0 + +); + +export const Variants = () => ( + + cyan + dim + green + amber + red + +); diff --git a/web/.design-sync/previews/PageHeader.tsx b/web/.design-sync/previews/PageHeader.tsx new file mode 100644 index 00000000..c04837ba --- /dev/null +++ b/web/.design-sync/previews/PageHeader.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { PageHeader, GhostButton, SegmentedControl } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); + +export const Default = () => ( + + new session} + /> + +); + +export const WithFilters = () => ( + + + } + /> + +); + +export const TitleOnly = () => ( + + + +); diff --git a/web/.design-sync/previews/Pill.tsx b/web/.design-sync/previews/Pill.tsx new file mode 100644 index 00000000..a4ca781f --- /dev/null +++ b/web/.design-sync/previews/Pill.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { Pill } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Default = () => ( + + + + + +); + +export const WithCount = () => ( + + + + + +); diff --git a/web/.design-sync/previews/StatTile.tsx b/web/.design-sync/previews/StatTile.tsx new file mode 100644 index 00000000..50de2467 --- /dev/null +++ b/web/.design-sync/previews/StatTile.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { StatTile } from 'cue-studio'; + +const Dark = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +export const Default = () => ( + + + + + +); + +export const Variants = () => ( + + + + + + +); + +export const MediumSize = () => ( + + + + + +); diff --git a/web/.gitignore b/web/.gitignore index e7bdab0b..9eb0f9c1 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -2,3 +2,8 @@ # local secrets .env .env.local + +# design-sync build artifacts (regenerated by the converter) +.ds-sync/ +ds-bundle/ +.design-sync/.cache/ diff --git a/web/lib/index.d.ts b/web/lib/index.d.ts new file mode 100644 index 00000000..7bc03bc1 --- /dev/null +++ b/web/lib/index.d.ts @@ -0,0 +1,181 @@ +/** + * cue studio — atomic presentational components. + * All styling comes from src/studio/styles.css; no API hooks here. + */ +import React from "react"; +export interface DotProps { + /** Visual state of the dot. */ + variant?: "ok" | "violet" | "warn" | "red"; + /** Extra CSS class names. */ + className?: string; +} +/** Small status indicator circle. Wraps `.hdot` CSS class. */ +export declare function Dot({ variant, className }: DotProps): import("react/jsx-runtime").JSX.Element; +/** Live (pulsing green) indicator dot. Wraps `.live-dot`. */ +export declare function LiveDot({ className }: { + className?: string; +}): import("react/jsx-runtime").JSX.Element; +export interface PillProps { + /** Label text. */ + label: string; + /** Optional numeric count shown in a violet badge at the right. */ + count?: number; + className?: string; +} +/** Pill-shaped label with an optional count chip. Wraps `.pill`. */ +export declare function Pill({ label, count, className }: PillProps): import("react/jsx-runtime").JSX.Element; +export interface McpBadgeProps { + label: string; + /** ok = green "installed" style */ + variant?: "default" | "ok"; + className?: string; +} +/** Small tag badge for MCP/skill cards. Wraps `.mc-badge`. */ +export declare function McpBadge({ label, variant, className }: McpBadgeProps): import("react/jsx-runtime").JSX.Element; +export interface StatTileProps { + /** Large number or value to display. */ + value: string | number; + /** Short label below the number. */ + label: string; + /** Optional sub-label (even smaller, dim). */ + sub?: string; + /** Semantic color for the number. */ + variant?: "default" | "violet" | "green" | "red" | "amber"; + /** Size: "lg" = 26px (`.stat-n`), "md" = 23px (`.mt-n`). */ + size?: "lg" | "md"; + className?: string; +} +/** + * Metric tile — a big number with a label. Wraps `.stat` / `.mt-n` CSS. + * Used in dashboard band stat groups. + */ +export declare function StatTile({ value, label, sub, variant, size, className, }: StatTileProps): import("react/jsx-runtime").JSX.Element; +export interface BandProps { + /** Section heading text (rendered uppercase). */ + heading: string; + /** Optional tag shown at the far right of the heading row. */ + tag?: string; + /** ok = green filled tag. */ + tagVariant?: "default" | "ok"; + /** Optional icon element for the heading row. */ + icon?: React.ReactNode; + children?: React.ReactNode; + className?: string; +} +/** + * Rounded card section used in the dashboard three-column grid. + * Wraps `.band`, `.band-top`, `.band-h`, `.band-tag`. + */ +export declare function Band({ heading, tag, tagVariant, icon, children, className, }: BandProps): import("react/jsx-runtime").JSX.Element; +export interface CardProps { + /** Card heading text. */ + title?: string; + /** Subtitle below the title. */ + subtitle?: string; + /** Status dot in the title row. */ + dot?: "ok" | "warn" | "red" | "live"; + /** Slot for actions (buttons, badges) placed at top-right. */ + actions?: React.ReactNode; + children?: React.ReactNode; + className?: string; +} +/** + * Primary container card. Wraps `.card`, `.card-head`, `.card-title`, `.card-sub`. + * Every card has a header with a status dot, a title, and optional actions. + */ +export declare function Card({ title, subtitle, dot, actions, children, className, }: CardProps): import("react/jsx-runtime").JSX.Element; +export interface GhostButtonProps { + children: React.ReactNode; + onClick?: () => void; + /** danger = red text on hover */ + variant?: "default" | "danger"; + active?: boolean; + disabled?: boolean; + className?: string; +} +/** + * Transparent-bg bordered button — the studio's default action style. + * No dedicated CSS class in the sheet; styled inline with variables. + */ +export declare function GhostButton({ children, onClick, variant, active, disabled, className, }: GhostButtonProps): import("react/jsx-runtime").JSX.Element; +export interface SegmentedControlProps { + options: Array<{ + label: string; + value: string; + }>; + value: string; + onChange?: (value: string) => void; + className?: string; +} +/** + * Tab-style filter control. Wraps `.seg` and its `button` children. + * Active option gets violet bg; inactive get bg3 hover. + */ +export declare function SegmentedControl({ options, value, onChange, className, }: SegmentedControlProps): import("react/jsx-runtime").JSX.Element; +export interface EmptyStateProps { + /** One-sentence description of the empty state. */ + message: string; + /** CLI command that would populate it — shown in mono. */ + command?: string; + className?: string; +} +/** + * Centered dim placeholder shown when a card has no data. + * Per DESIGN.md: always include a CLI command that would populate it. + */ +export declare function EmptyState({ message, command, className }: EmptyStateProps): import("react/jsx-runtime").JSX.Element; +export interface PageHeaderProps { + /** Primary heading. */ + title: string; + /** Optional subtitle / description. */ + subtitle?: string; + /** Dot variant beside the title. */ + dot?: "ok" | "warn" | "red" | "live"; + /** Slot for actions placed at the right of the header row. */ + actions?: React.ReactNode; + className?: string; +} +/** + * Page-level heading row. Wraps `.page-head`, `.page-title`, `.page-sub`. + * Used at the top of every studio view. + */ +export declare function PageHeader({ title, subtitle, dot, actions, className, }: PageHeaderProps): import("react/jsx-runtime").JSX.Element; +export interface McpCardProps { + /** Display name of the MCP / skill. */ + name: string; + /** Short description. */ + description?: string; + /** Emoji icon or image URL. */ + icon?: string; + /** Badges shown below the name (e.g. "installed", category). */ + badges?: Array<{ + label: string; + variant?: "default" | "ok"; + }>; + /** Tool names listed in the card footer. */ + tools?: string[]; + /** Install command shown in the card footer. */ + command?: string; + /** Stats: install count, version, etc. */ + stats?: Array<{ + label: string; + value: string; + }>; + className?: string; +} +/** + * MCP / skill catalog card. Wraps the `.mc-card` family of classes. + * Used in the Market, MCPs, and Plugins views. + */ +export declare function McpCard({ name, description, icon, badges, tools, command, stats, className, }: McpCardProps): import("react/jsx-runtime").JSX.Element; +export interface MonoTagProps { + children: React.ReactNode; + /** dim = fg3 color; default = cyan */ + variant?: "default" | "dim" | "green" | "amber" | "red"; + className?: string; +} +/** + * Inline monospace tag — path, PID, version string, CLI command fragment. + * Used inline in tables and card bodies. + */ +export declare function MonoTag({ children, variant, className }: MonoTagProps): import("react/jsx-runtime").JSX.Element; diff --git a/web/src/studio/components/index.tsx b/web/src/studio/components/index.tsx new file mode 100644 index 00000000..a942d3de --- /dev/null +++ b/web/src/studio/components/index.tsx @@ -0,0 +1,499 @@ +/** + * cue studio — atomic presentational components. + * All styling comes from src/studio/styles.css; no API hooks here. + */ +import React from "react"; + +// ── Dot ────────────────────────────────────────────────────────────────────── + +export interface DotProps { + /** Visual state of the dot. */ + variant?: "ok" | "violet" | "warn" | "red"; + /** Extra CSS class names. */ + className?: string; +} + +/** Small status indicator circle. Wraps `.hdot` CSS class. */ +export function Dot({ variant = "ok", className = "" }: DotProps) { + return ( + + ); +} + +/** Live (pulsing green) indicator dot. Wraps `.live-dot`. */ +export function LiveDot({ className = "" }: { className?: string }) { + return ; +} + +// ── Badge / Pill ────────────────────────────────────────────────────────────── + +export interface PillProps { + /** Label text. */ + label: string; + /** Optional numeric count shown in a violet badge at the right. */ + count?: number; + className?: string; +} + +/** Pill-shaped label with an optional count chip. Wraps `.pill`. */ +export function Pill({ label, count, className = "" }: PillProps) { + return ( +
+ {label} + {count !== undefined && {count}} +
+ ); +} + +export interface McpBadgeProps { + label: string; + /** ok = green "installed" style */ + variant?: "default" | "ok"; + className?: string; +} + +/** Small tag badge for MCP/skill cards. Wraps `.mc-badge`. */ +export function McpBadge({ label, variant = "default", className = "" }: McpBadgeProps) { + const cls = `mc-badge${variant === "ok" ? " ok" : ""} ${className}`.trim(); + return {label}; +} + +// ── StatTile ────────────────────────────────────────────────────────────────── + +export interface StatTileProps { + /** Large number or value to display. */ + value: string | number; + /** Short label below the number. */ + label: string; + /** Optional sub-label (even smaller, dim). */ + sub?: string; + /** Semantic color for the number. */ + variant?: "default" | "violet" | "green" | "red" | "amber"; + /** Size: "lg" = 26px (`.stat-n`), "md" = 23px (`.mt-n`). */ + size?: "lg" | "md"; + className?: string; +} + +/** + * Metric tile — a big number with a label. Wraps `.stat` / `.mt-n` CSS. + * Used in dashboard band stat groups. + */ +export function StatTile({ + value, + label, + sub, + variant = "default", + size = "lg", + className = "", +}: StatTileProps) { + const numClass = + size === "lg" + ? `stat-n${variant !== "default" ? ` ${variant}` : ""}` + : `mt-n${variant !== "default" ? ` ${variant}` : ""}`; + const labelClass = size === "lg" ? "stat-l" : "mt-l"; + + return ( +
+ {value} + {label} + {sub && {sub}} +
+ ); +} + +// ── Band ────────────────────────────────────────────────────────────────────── + +export interface BandProps { + /** Section heading text (rendered uppercase). */ + heading: string; + /** Optional tag shown at the far right of the heading row. */ + tag?: string; + /** ok = green filled tag. */ + tagVariant?: "default" | "ok"; + /** Optional icon element for the heading row. */ + icon?: React.ReactNode; + children?: React.ReactNode; + className?: string; +} + +/** + * Rounded card section used in the dashboard three-column grid. + * Wraps `.band`, `.band-top`, `.band-h`, `.band-tag`. + */ +export function Band({ + heading, + tag, + tagVariant = "default", + icon, + children, + className = "", +}: BandProps) { + return ( +
+
+ {icon && {icon}} +

{heading}

+ {tag && ( + + {tag} + + )} +
+ {children} +
+ ); +} + +// ── Card ────────────────────────────────────────────────────────────────────── + +export interface CardProps { + /** Card heading text. */ + title?: string; + /** Subtitle below the title. */ + subtitle?: string; + /** Status dot in the title row. */ + dot?: "ok" | "warn" | "red" | "live"; + /** Slot for actions (buttons, badges) placed at top-right. */ + actions?: React.ReactNode; + children?: React.ReactNode; + className?: string; +} + +/** + * Primary container card. Wraps `.card`, `.card-head`, `.card-title`, `.card-sub`. + * Every card has a header with a status dot, a title, and optional actions. + */ +export function Card({ + title, + subtitle, + dot, + actions, + children, + className = "", +}: CardProps) { + const hasHeader = title || dot || actions; + return ( +
+ {hasHeader && ( +
+
+ {title && ( +
+ {dot === "live" ? : dot && } + {title} +
+ )} + {subtitle && ( +
+ )} +
+ {actions &&
{actions}
} +
+ )} + {children} +
+ ); +} + +// ── GhostButton ─────────────────────────────────────────────────────────────── + +export interface GhostButtonProps { + children: React.ReactNode; + onClick?: () => void; + /** danger = red text on hover */ + variant?: "default" | "danger"; + active?: boolean; + disabled?: boolean; + className?: string; +} + +/** + * Transparent-bg bordered button — the studio's default action style. + * No dedicated CSS class in the sheet; styled inline with variables. + */ +export function GhostButton({ + children, + onClick, + variant = "default", + active = false, + disabled = false, + className = "", +}: GhostButtonProps) { + const style: React.CSSProperties = { + fontFamily: "var(--mono)", + fontSize: "11px", + color: active ? "#fff" : "var(--fg2)", + background: active ? "var(--violet-d)" : "var(--bg3)", + border: `1px solid ${active ? "var(--violet)" : "var(--bd)"}`, + borderRadius: "6px", + padding: "4px 10px", + cursor: disabled ? "not-allowed" : "pointer", + opacity: disabled ? 0.5 : 1, + }; + return ( + + ); +} + +// ── SegmentedControl ────────────────────────────────────────────────────────── + +export interface SegmentedControlProps { + options: Array<{ label: string; value: string }>; + value: string; + onChange?: (value: string) => void; + className?: string; +} + +/** + * Tab-style filter control. Wraps `.seg` and its `button` children. + * Active option gets violet bg; inactive get bg3 hover. + */ +export function SegmentedControl({ + options, + value, + onChange, + className = "", +}: SegmentedControlProps) { + return ( +
+ {options.map((opt) => ( + + ))} +
+ ); +} + +// ── EmptyState ──────────────────────────────────────────────────────────────── + +export interface EmptyStateProps { + /** One-sentence description of the empty state. */ + message: string; + /** CLI command that would populate it — shown in mono. */ + command?: string; + className?: string; +} + +/** + * Centered dim placeholder shown when a card has no data. + * Per DESIGN.md: always include a CLI command that would populate it. + */ +export function EmptyState({ message, command, className = "" }: EmptyStateProps) { + return ( +
+ {message} + {command && ( + + {command} + + )} +
+ ); +} + +// ── PageHeader ──────────────────────────────────────────────────────────────── + +export interface PageHeaderProps { + /** Primary heading. */ + title: string; + /** Optional subtitle / description. */ + subtitle?: string; + /** Dot variant beside the title. */ + dot?: "ok" | "warn" | "red" | "live"; + /** Slot for actions placed at the right of the header row. */ + actions?: React.ReactNode; + className?: string; +} + +/** + * Page-level heading row. Wraps `.page-head`, `.page-title`, `.page-sub`. + * Used at the top of every studio view. + */ +export function PageHeader({ + title, + subtitle, + dot, + actions, + className = "", +}: PageHeaderProps) { + return ( +
+
+
+ {dot === "live" ? : dot && } + {title} +
+ {subtitle &&
{subtitle}
} +
+ {actions &&
{actions}
} +
+ ); +} + +// ── McpCard ─────────────────────────────────────────────────────────────────── + +export interface McpCardProps { + /** Display name of the MCP / skill. */ + name: string; + /** Short description. */ + description?: string; + /** Emoji icon or image URL. */ + icon?: string; + /** Badges shown below the name (e.g. "installed", category). */ + badges?: Array<{ label: string; variant?: "default" | "ok" }>; + /** Tool names listed in the card footer. */ + tools?: string[]; + /** Install command shown in the card footer. */ + command?: string; + /** Stats: install count, version, etc. */ + stats?: Array<{ label: string; value: string }>; + className?: string; +} + +/** + * MCP / skill catalog card. Wraps the `.mc-card` family of classes. + * Used in the Market, MCPs, and Plugins views. + */ +export function McpCard({ + name, + description, + icon, + badges = [], + tools = [], + command, + stats = [], + className = "", +}: McpCardProps) { + const isUrl = icon && (icon.startsWith("http") || icon.startsWith("/")); + return ( +
+
+
+ {isUrl ? ( + {name} + ) : ( + icon ?? "🔧" + )} +
+
+
{name}
+ {badges.length > 0 && ( +
+ {badges.map((b, i) => ( + + ))} +
+ )} +
+
+ + {description &&
{description}
} + + {stats.length > 0 && ( +
+ {stats.map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ )} + + {tools.length > 0 && ( + <> +
TOOLS
+
+ {tools.map((t) => ( + + {t} + () + + ))} +
+ + )} + + {command && ( +
+ $ + {command} + + copy + +
+ )} +
+ ); +} + +// ── MonoTag ─────────────────────────────────────────────────────────────────── + +export interface MonoTagProps { + children: React.ReactNode; + /** dim = fg3 color; default = cyan */ + variant?: "default" | "dim" | "green" | "amber" | "red"; + className?: string; +} + +/** + * Inline monospace tag — path, PID, version string, CLI command fragment. + * Used inline in tables and card bodies. + */ +export function MonoTag({ children, variant = "default", className = "" }: MonoTagProps) { + const colorMap: Record = { + default: "var(--cyan)", + dim: "var(--fg3)", + green: "var(--green)", + amber: "var(--amber)", + red: "var(--red)", + }; + return ( + + {children} + + ); +} From 50ce818c39f4a004db8a1dc84d3416fd23cc45eb Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 17 Jun 2026 10:35:23 +0200 Subject: [PATCH 4/5] add --- .cue.profile | 2 +- AGENTS.md | 12 +++++- profiles/core/profile.yaml | 2 + resources/personas/codegraph-routing.md | 11 ++++++ src/commands/launch.test.ts | 12 ++++++ src/commands/launch.ts | 5 +++ src/lib/pair-suggestions.test.ts | 37 ++++++++++++++++--- src/lib/pair-suggestions.ts | 25 +++++++------ src/lib/picker.test.ts | 49 +++++++++++++------------ src/lib/picker.ts | 9 ++--- src/lib/profile-loader.test.ts | 34 +++++++++++++++-- 11 files changed, 147 insertions(+), 51 deletions(-) create mode 100644 resources/personas/codegraph-routing.md diff --git a/.cue.profile b/.cue.profile index 3454702e..f5bd37c1 100644 --- a/.cue.profile +++ b/.cue.profile @@ -1 +1 @@ -media +core diff --git a/AGENTS.md b/AGENTS.md index 0f7478bb..67306799 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,10 +41,16 @@ Key paths: - Preserve user work. Do not revert, reset, or overwrite unrelated changes. - Prefer small, source-backed changes over broad rewrites. +- For repo architecture, feature-flow, or symbol-impact questions, use + CodeGraph before broad file reads or grep. - For context-heavy files, inspect with `wc`, narrow `rg`, `sed -n`, `head`, or `tail` before reading more. - Do not paste large fixtures, catalogs, generated files, full logs, or full setup manuals into chat. +- Verify with the smallest check that proves the touched surface. For profile + edits, start with `cue validate ` plus targeted tests; run + `cue validate --all` only when requested or when a shared loader/materializer + change creates real cross-profile risk. - For default-profile behavior, source of truth is `src/commands/init.ts` and profile resolution tests under `src/lib/cwd-resolver.test.ts`. @@ -55,10 +61,12 @@ Avoid reading these by default: - `resources/skills/skills/**/test/fixtures/*` - `resources/skills/skills/**/fixtures/*` - `docs/assets/*.svg` -- `dist/`, `node_modules/`, coverage output, package-manager caches +- `dist/`, `node_modules/`, coverage output, generated output, submodule + dependency trees, package-manager caches - `~/.config/cue/analytics.jsonl`, `~/.config/cue/session-log.jsonl` -If one is required, sample it first and cap output. +If one is required, sample it first and cap output. For broad `rg`, use +explicit `--glob` excludes for these traps. ## After Bootstrap diff --git a/profiles/core/profile.yaml b/profiles/core/profile.yaml index 33beb027..b93ee9af 100644 --- a/profiles/core/profile.yaml +++ b/profiles/core/profile.yaml @@ -22,6 +22,7 @@ env: ANTHROPIC_BASE_URL: "http://127.0.0.1:8787" persona_includes: - integrity-protocol-compact # resources/personas/integrity-protocol-compact.md — compact 7-tag system inline; full protocol + examples load on demand (integrity-protocol.md / meta/integrity-tags). Fans out via inheritance. + - codegraph-routing # resources/personas/codegraph-routing.md — compact source-navigation rule: use CodeGraph before broad repo reads/grep so code exploration burns fewer tokens. Fans out via inheritance. - skill-evolution # resources/personas/skill-evolution.md — post-task learning capture via bin/cue-learnings, fans out to every core-inheriting profile - headroom-compression # resources/personas/headroom-compression.md — tells every profile's agent to actively use headroom (proxy wrap is automatic; reach for the MCP on big payloads) to cut tokens. Fans out via inheritance even to profiles with their own persona (persona_includes is additive; inline persona is leaf-wins). - fable-5-prompting-compact # resources/personas/fable-5-prompting-compact.md — Fable 5 / Mythos 5 behavioral deltas (effort, longer turns, grounding, boundaries); full guide on demand at fable-5-prompting.md. Fans out via inheritance. @@ -193,6 +194,7 @@ skills: # github-actions-docs dropped from core 2026-06-01 — CI-docs is not universal # baseline. Add it back per-profile (ops, the deploy-touching ones) if needed. mcps: + - codegraph # Repo code intelligence MCP — use before broad Read/Grep for architecture, feature, bug-context, symbol, caller/callee, and impact questions. Available to Claude and Codex runtimes via the sanitized registry snapshots. - lightpanda # Fast headless browser MCP — fetch/render/scrape URLs without Chromium. Pairs with browser/lightpanda skill. - context7 # Up-to-date, version-specific library docs (Upstash @upstash/context7-mcp). No API key needed; set CONTEXT7_API_KEY for higher limits. Pairs with tools/context7 skill. - headroom # Context-compression MCP (headroom_compress/retrieve/stats) — 60–95% fewer tokens, reversible. Pairs with tools/headroom skill. Inert until `pip install "headroom-ai[mcp]"`. The full all-traffic wrap (ANTHROPIC_BASE_URL → local proxy) IS set in core's env above, but health-gated by the materializer — applied only when the proxy answers, so a down proxy falls back to direct Anthropic instead of bricking Claude. diff --git a/resources/personas/codegraph-routing.md b/resources/personas/codegraph-routing.md new file mode 100644 index 00000000..7d8393e3 --- /dev/null +++ b/resources/personas/codegraph-routing.md @@ -0,0 +1,11 @@ +## Token-efficient repo workflow + +For code questions in an indexed repo, keep exploration narrow: + +- Start with `codegraph_context` for feature, architecture, bug-context, or "how does X work" questions. +- Use `codegraph_files` for structure and `codegraph_search` / `codegraph_explore` for targeted source. Prefer one batched explore over many reads. +- Fall back to `rg` or file reads only when CodeGraph lacks the file, the index is stale, or exact text/fixtures are needed. +- Do not run broad searches through `node_modules`, generated output, build/cache dirs, coverage, `.git`, or submodule dependency trees. Use targeted `rg --glob` excludes and cap output. +- Verify with the smallest proof first: focused test, touched command, or `cue validate ` for profile edits. Do not run `cue validate --all`, full test suites, or broad audits unless requested, required by the touched surface, or a targeted check exposes cross-profile risk. + +This keeps repo exploration out of the main transcript and lowers token burn. diff --git a/src/commands/launch.test.ts b/src/commands/launch.test.ts index c8ea9c95..2f9ed37a 100644 --- a/src/commands/launch.test.ts +++ b/src/commands/launch.test.ts @@ -880,6 +880,18 @@ describe("formatTokenWarning", () => { }); expect(out).toContain(" 💡 Run `cue skills audit` to trim unused skills."); }); + + test("very heavy profiles get explicit launch guidance", () => { + const out = formatTokenWarning({ + alwaysOn: 66000, + maxIfAllActivate: 520000, + totalSkills: 102, + byProfile: [], + heaviestBodies: [], + }); + expect(out.join("\n")).toContain("Very heavy profile:"); + expect(out.join("\n")).toContain('use `--subset ""`'); + }); }); describe("formatDoctorWarnings", () => { diff --git a/src/commands/launch.ts b/src/commands/launch.ts index a9bc6225..8060dd1d 100644 --- a/src/commands/launch.ts +++ b/src/commands/launch.ts @@ -445,6 +445,11 @@ export function formatTokenWarning(b: TokenBreakdown): string[] { lines.push( `${level} Skill overhead: ${c.yellow(`~${alwaysK}`)} always-on (${b.totalSkills} skills)`, ); + if (b.alwaysOn >= 50_000) { + lines.push( + ` ${c.yellow("Very heavy profile:")} prefer \`core\` or a narrow stack; use \`--subset ""\` before launching broad composites.`, + ); + } // `byProfile[0]` is the primary (the profile the user actively picked); // the rest are companions added via the multiselect. We tag whichever part diff --git a/src/lib/pair-suggestions.test.ts b/src/lib/pair-suggestions.test.ts index 75dbcf7c..b1ecfba3 100644 --- a/src/lib/pair-suggestions.test.ts +++ b/src/lib/pair-suggestions.test.ts @@ -219,18 +219,43 @@ describe("buildUniversalSuggestions", () => { ]); }); - test("pinned companions (gstack) close the list when installed, after featured/frequent", () => { - const gstack = UNIVERSAL_COMPANIONS[0]!; // "gstack" + test("no heavy companion is pinned globally by default", () => { + expect(UNIVERSAL_COMPANIONS).toEqual([]); const affinity = computeAffinityMap(lines(...Array(5).fill(row("a")))); const out = buildUniversalSuggestions({ featured: ["f1"], affinity, - known: known("f1", "a", gstack), + known: known("f1", "a", "gstack"), }); expect(out).toEqual([ { name: "f1", origin: "featured" }, { name: "a", origin: "frequent" }, - { name: gstack, origin: "pinned" }, + ]); + }); + + test("a featured heavy profile can still be offered explicitly", () => { + const gstack = "gstack"; + const out = buildUniversalSuggestions({ + featured: [gstack], + affinity: new Map(), + known: known(gstack), + }); + expect(out).toEqual([{ name: gstack, origin: "featured" }]); + }); + + test("pinned companions close the list when configured, after featured/frequent", () => { + const pinned = "lightweight-helper"; + const affinity = computeAffinityMap(lines(...Array(5).fill(row("a")))); + const out = buildUniversalSuggestions({ + featured: ["f1"], + affinity, + known: known("f1", "a", pinned), + pinnedCompanions: [pinned], + }); + expect(out).toEqual([ + { name: "f1", origin: "featured" }, + { name: "a", origin: "frequent" }, + { name: pinned, origin: "pinned" }, ]); }); @@ -239,16 +264,18 @@ describe("buildUniversalSuggestions", () => { featured: ["f1"], affinity: new Map(), known: known("f1"), // gstack not installed + pinnedCompanions: ["lightweight-helper"], }); expect(out).toEqual([{ name: "f1", origin: "featured" }]); }); test("a pinned companion already in featured keeps its featured origin (de-duped)", () => { - const gstack = UNIVERSAL_COMPANIONS[0]!; + const gstack = "gstack"; const out = buildUniversalSuggestions({ featured: [gstack], affinity: new Map(), known: known(gstack), + pinnedCompanions: [gstack], }); expect(out).toEqual([{ name: gstack, origin: "featured" }]); }); diff --git a/src/lib/pair-suggestions.ts b/src/lib/pair-suggestions.ts index 4b5c071f..b995e05a 100644 --- a/src/lib/pair-suggestions.ts +++ b/src/lib/pair-suggestions.ts @@ -172,13 +172,14 @@ export interface UniversalSuggestion { /** * Profiles offered as a combine companion under *every* primary, independent of * the curated featured set, session frequency, the picked profile's - * `recommends:`, or cwd content. gstack is the engineering-team layer (ship / - * QA / deploy / review) that pairs with whatever stack you're building, so the - * picker always offers to stack it on. Emitted as the `pinned` origin by - * `buildUniversalSuggestions` (the single "offered under every primary" path) - * and surfaced unchecked — offered, never forced into the pin. + * `recommends:`, or cwd content. + * + * Keep this empty by default. Broad profiles such as `gstack` can add tens of + * thousands of always-on tokens when accepted casually; they should surface via + * explicit choice, profile `recommends:`, featured suggestions, or real session + * frequency instead. */ -export const UNIVERSAL_COMPANIONS: readonly string[] = ["gstack"]; +export const UNIVERSAL_COMPANIONS: readonly string[] = []; export interface BuildUniversalOptions { /** Curated featured slugs, in display order (from `_featured.yaml`). */ @@ -187,6 +188,8 @@ export interface BuildUniversalOptions { affinity: Map; /** Installed profile names — both sources are filtered to these. */ known: Set; + /** Optional global pins. Defaults to `UNIVERSAL_COMPANIONS`. */ + pinnedCompanions?: readonly string[]; /** Max curated featured suggestions. Default 5. */ maxFeatured?: number; /** Max session-frequency suggestions. Default 2. */ @@ -232,11 +235,11 @@ export function buildUniversalSuggestions(opts: BuildUniversalOptions): Universa out.push({ name, origin: "frequent" }); } - // Pinned companions (gstack) close the list: always offered under every - // primary, after featured/frequent so those keep their slots. De-duped (a - // pinned profile that's also featured keeps the earlier featured origin) and - // known-filtered like the rest, so an uninstalled pin silently drops. - for (const name of UNIVERSAL_COMPANIONS) { + // Pinned companions close the list: always offered under every primary, after + // featured/frequent so those keep their slots. De-duped (a pinned profile + // that's also featured keeps the earlier featured origin) and known-filtered + // like the rest, so an uninstalled pin silently drops. + for (const name of (opts.pinnedCompanions ?? UNIVERSAL_COMPANIONS)) { if (seen.has(name) || !o.known.has(name)) continue; seen.add(name); out.push({ name, origin: "pinned" }); diff --git a/src/lib/picker.test.ts b/src/lib/picker.test.ts index ff0a82f1..61f32dcc 100644 --- a/src/lib/picker.test.ts +++ b/src/lib/picker.test.ts @@ -384,76 +384,77 @@ describe("buildCompanionOptions", () => { expect(solo.overflowOptions).toEqual([]); }); - // gstack is the sole pinned companion: emitted by buildUniversalSuggestions - // as the `pinned` origin (tested in pair-suggestions.test) and rendered here - // through the one universalSuggestions path — no separate picker injection. - const WITH_GSTACK: PickerOption[] = [ + // Pinned companions, when configured, flow through the same + // universalSuggestions path as featured/frequent rows. Production defaults + // to no pinned companions so heavy profiles such as gstack are not offered + // under every primary. + const WITH_PINNED: PickerOption[] = [ ...OPTS, - { value: "gstack", label: "🏭 gstack", hint: "engineering team", conflicts: ["vite"] }, + { value: "lightweight-helper", label: "helper", hint: "small helper", conflicts: ["vite"] }, { value: "vite", label: "vite", hint: "spa" }, ]; - const PINNED: UniversalSuggestion[] = UNIVERSAL_COMPANIONS.map((name) => ({ - name, - origin: "pinned", - })); + const PINNED: UniversalSuggestion[] = [{ name: "lightweight-helper", origin: "pinned" }]; - test("a pinned companion is offered (unchecked) under a primary that never names it", () => { - expect(UNIVERSAL_COMPANIONS).toContain("gstack"); + test("gstack is not pinned as a universal companion by default", () => { + expect(UNIVERSAL_COMPANIONS).not.toContain("gstack"); + }); + + test("a configured pinned companion is offered (unchecked) under a primary that never names it", () => { const { companionOptions, initialValues } = buildCompanionOptions({ primary: "postizz", primaryLabel: "postizz", - options: WITH_GSTACK, + options: WITH_PINNED, recommends: [], pairSuggested: [], companions: [], universalSuggestions: PINNED, autoCheckThreshold: 0.7, }); - expect(companionOptions.map((o) => o.value)).toContain("gstack"); - expect(initialValues).not.toContain("gstack"); // offered, never forced + expect(companionOptions.map((o) => o.value)).toContain("lightweight-helper"); + expect(initialValues).not.toContain("lightweight-helper"); // offered, never forced // Pinned-origin rows get the UNIVERSAL_HINT tag, not the verbose profile // description — consistent with featured/frequent rows. - expect(companionOptions.find((o) => o.value === "gstack")!.hint).toBe(UNIVERSAL_HINT); + expect(companionOptions.find((o) => o.value === "lightweight-helper")!.hint).toBe(UNIVERSAL_HINT); }); test("the pinned companion is dropped when it is the primary or conflicts with it", () => { const asPrimary = buildCompanionOptions({ - primary: "gstack", - primaryLabel: "gstack", - options: WITH_GSTACK, + primary: "lightweight-helper", + primaryLabel: "lightweight-helper", + options: WITH_PINNED, recommends: [], pairSuggested: [], companions: [], universalSuggestions: PINNED, autoCheckThreshold: 0.7, }); - expect(asPrimary.companionOptions.map((o) => o.value)).not.toContain("gstack"); + expect(asPrimary.companionOptions.map((o) => o.value)).not.toContain("lightweight-helper"); const conflicting = buildCompanionOptions({ primary: "vite", primaryLabel: "vite", - options: WITH_GSTACK, + options: WITH_PINNED, recommends: [], pairSuggested: [], companions: [], universalSuggestions: PINNED, autoCheckThreshold: 0.7, }); - expect(conflicting.companionOptions.map((o) => o.value)).not.toContain("gstack"); + expect(conflicting.companionOptions.map((o) => o.value)).not.toContain("lightweight-helper"); }); test("an explicit recommend for a pinned companion is not duplicated", () => { const { companionOptions } = buildCompanionOptions({ primary: "postizz", primaryLabel: "postizz", - options: WITH_GSTACK, - recommends: ["gstack"], + options: WITH_PINNED, + recommends: ["lightweight-helper"], pairSuggested: [], companions: [], universalSuggestions: PINNED, autoCheckThreshold: 0.7, }); - expect(companionOptions.filter((o) => o.value === "gstack")).toHaveLength(1); + expect(companionOptions.filter((o) => o.value === "lightweight-helper")).toHaveLength(1); }); // Featured + frequently-used cross-profile suggestions (buildUniversalSuggestions). diff --git a/src/lib/picker.ts b/src/lib/picker.ts index 95d5ab50..43385112 100644 --- a/src/lib/picker.ts +++ b/src/lib/picker.ts @@ -289,7 +289,7 @@ export const SKIP_COMBINE = "__skip_combine__"; */ export const SHOW_ALL = "__show_all__"; -// Always-on combine companions (gstack) now flow through the single +// Optional always-on combine companions flow through the single // `buildUniversalSuggestions` path as the `pinned` origin — re-exported here so // existing `import { UNIVERSAL_COMPANIONS } from "./picker"` call sites keep // resolving. The canonical definition lives in `./pair-suggestions`. @@ -350,9 +350,8 @@ export interface BuildCompanionArgs { * Assemble the combine multiselect's rows + which start checked. * * Candidates = the primary's `recommends:` ∪ historical pairings ∪ content- - * detected companions ∪ featured/frequently-used profiles ∪ - * `UNIVERSAL_COMPANIONS` (offered under every primary), de-duped by profile - * (that order). A candidate is + * detected companions ∪ featured/frequently-used profiles ∪ optional + * `UNIVERSAL_COMPANIONS` pins, de-duped by profile (that order). A candidate is * dropped when it is the primary itself, a profile that conflicts with the * primary (either side of the declaration), a divider, a composite (`+`) * value, or not a real option. A detected candidate shows its reason as the @@ -405,7 +404,7 @@ export function buildCompanionOptions(args: BuildCompanionArgs): { for (const r of recommends) addCandidate(r, "recommends"); for (const r of pairSuggested) addCandidate(r, "history"); for (const c of companions) addCandidate(c.profile, "detected"); - // Featured + frequent + pinned (gstack) all arrive via the one universal path. + // Featured + frequent + optional pinned companions all arrive via one path. for (const u of universalSuggestions) addCandidate(u.name, u.origin); const companionOptions: AsciiMSOption[] = []; diff --git a/src/lib/profile-loader.test.ts b/src/lib/profile-loader.test.ts index 628d2ebc..e7f57f43 100644 --- a/src/lib/profile-loader.test.ts +++ b/src/lib/profile-loader.test.ts @@ -9,7 +9,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -543,19 +543,47 @@ describe("loadProfile (composite)", () => { describe("core persona_includes fan-out (real profiles)", () => { const REAL_PROFILES = join(REPO_ROOT, "profiles"); - test("core carries the headroom-compression include", async () => { + test("core carries the compact integrity include", async () => { process.env.CUE_PROFILES_DIR = REAL_PROFILES; const core = await loadProfile("core"); + expect(core.personaIncludes).toContain("integrity-protocol-compact"); expect(core.personaIncludes).toContain("headroom-compression"); }); - test("headroom-compression fans out to a child that defines its own persona", async () => { + test("codegraph auto-loads across every built-in profile", async () => { + process.env.CUE_PROFILES_DIR = REAL_PROFILES; + const entries = await readdir(REAL_PROFILES, { withFileTypes: true }); + const names: string[] = []; + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith("_") || entry.name.startsWith(".")) continue; + try { + await access(join(REAL_PROFILES, entry.name, "profile.yaml")); + names.push(entry.name); + } catch { + // Reserved or incomplete profile dirs are ignored by the real loader too. + } + } + + const missingMcp: string[] = []; + const missingRouting: string[] = []; + for (const name of names.sort()) { + const profile = await loadProfile(name); + if (!profile.mcps.some((m) => m.id === "codegraph")) missingMcp.push(name); + if (!profile.personaIncludes.includes("codegraph-routing")) missingRouting.push(name); + } + + expect(missingMcp).toEqual([]); + expect(missingRouting).toEqual([]); + }); + + test("core persona includes fan out to a child that defines its own persona", async () => { // gstack overrides persona (leaf-wins), so this proves persona_includes is // additive — a child keeping its own persona still inherits core's policy // includes. Guards against a core edit silently dropping it everywhere. process.env.CUE_PROFILES_DIR = REAL_PROFILES; const gstack = await loadProfile("gstack"); expect(gstack.persona).toBeTruthy(); // gstack has its own persona block + expect(gstack.personaIncludes).toContain("integrity-protocol-compact"); expect(gstack.personaIncludes).toContain("headroom-compression"); }); }); From 77562e9909b04db7d4b5b757e0df917da7b99c77 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 17 Jun 2026 10:35:27 +0200 Subject: [PATCH 5/5] add --- resources/skills | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/skills b/resources/skills index 3c6b2569..4274beae 160000 --- a/resources/skills +++ b/resources/skills @@ -1 +1 @@ -Subproject commit 3c6b256909d3856a8cc3be78cacf466344cc34e6 +Subproject commit 4274beaece3a38a4a7a85142b2f09f2b23e3ce89