cue-ai 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (310) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +82 -33
  3. package/bin/cue-review-progress +107 -0
  4. package/bin/cue-review-watch +98 -0
  5. package/dist/cue.js +7352 -3744
  6. package/package.json +16 -5
  7. package/profiles/_types.ts +9 -0
  8. package/profiles/backend/profile.yaml +2 -0
  9. package/profiles/blog-writer/profile.yaml +10 -0
  10. package/profiles/browser/profile.yaml +9 -2
  11. package/profiles/builder/profile.yaml +3 -6
  12. package/profiles/career/profile.yaml +13 -2
  13. package/profiles/claude-api/profile.yaml +1 -1
  14. package/profiles/commerce/profile.yaml +27 -3
  15. package/profiles/core/logo.png +0 -0
  16. package/profiles/core/profile.yaml +62 -2
  17. package/profiles/dash-merge-test/profile.yaml +109 -0
  18. package/profiles/designer/profile.yaml +2 -0
  19. package/profiles/designer-medusa-next/profile.yaml +4 -1
  20. package/profiles/designer-medusa-vite/profile.yaml +4 -1
  21. package/profiles/docs-writer/profile.yaml +3 -1
  22. package/profiles/eu-tender-research/README.md +48 -0
  23. package/profiles/eu-tender-research/logo.png +0 -0
  24. package/profiles/eu-tender-research/profile.yaml +108 -0
  25. package/profiles/finance/logo.png +0 -0
  26. package/profiles/finance/profile.yaml +46 -0
  27. package/profiles/frontend/profile.yaml +5 -9
  28. package/profiles/growth/profile.yaml +2 -3
  29. package/profiles/gstack/profile.yaml +15 -0
  30. package/profiles/higgsfield/profile.yaml +3 -0
  31. package/profiles/hyperframes/logo.png +0 -0
  32. package/profiles/hyperframes/profile.yaml +59 -0
  33. package/profiles/improver/profile.yaml +88 -0
  34. package/profiles/marketing/profile.yaml +0 -3
  35. package/profiles/medusa-dev/profile.yaml +2 -0
  36. package/profiles/medusa-next/profile.yaml +2 -3
  37. package/profiles/medusa-vite/profile.yaml +2 -3
  38. package/profiles/n8n/logo.png +0 -0
  39. package/profiles/n8n/profile.yaml +50 -0
  40. package/profiles/nextjs/profile.yaml +2 -3
  41. package/profiles/ops/profile.yaml +2 -0
  42. package/profiles/postizz/profile.yaml +13 -3
  43. package/profiles/python/profile.yaml +3 -0
  44. package/profiles/research/profile.yaml +3 -1
  45. package/profiles/schema.json +10 -0
  46. package/profiles/secops/profile.yaml +2 -0
  47. package/profiles/seo/profile.yaml +56 -0
  48. package/profiles/skill-writer/profile.yaml +8 -0
  49. package/profiles/ssh/profile.yaml +32 -0
  50. package/profiles/strapi/logo.png +0 -0
  51. package/profiles/strapi/profile.yaml +45 -0
  52. package/profiles/stripe/logo.png +0 -0
  53. package/profiles/stripe/profile.yaml +1 -0
  54. package/profiles/supabase/logo.png +0 -0
  55. package/profiles/supabase/profile.yaml +85 -0
  56. package/profiles/vercel/logo.png +0 -0
  57. package/profiles/vercel/profile.yaml +25 -1
  58. package/profiles/vite/profile.yaml +4 -3
  59. package/profiles/web-frontend-base/profile.yaml +5 -4
  60. package/profiles/webshop/profile.yaml +23 -5
  61. package/profiles/x-growth-bot/profile.yaml +44 -0
  62. package/resources/icons/generate-icons.py +128 -2
  63. package/resources/mcps/configs/claude.sanitized.json +42 -0
  64. package/resources/mcps/configs/codex.sanitized.json +7 -0
  65. package/resources/skills/skills/career/resume-version-manager/SKILL.md +351 -0
  66. package/resources/skills/skills/career/salary-negotiation-prep/SKILL.md +378 -0
  67. package/resources/skills/skills/content/pdf/SKILL.md +2 -0
  68. package/resources/skills/skills/content/postiz-cards/SKILL.md +48 -0
  69. package/resources/skills/skills/content/postiz-cards/scripts/analytics.sh +38 -0
  70. package/resources/skills/skills/content/postiz-cards/scripts/card.sh +42 -0
  71. package/resources/skills/skills/content/postiz-cards/scripts/lint.py +38 -0
  72. package/resources/skills/skills/design/headless-gif-demo/SKILL.md +1 -1
  73. package/resources/skills/skills/design/readme-svg-design/SKILL.md +1 -1
  74. package/resources/skills/skills/eu-funding/grant-outreach/SKILL.md +70 -0
  75. package/resources/skills/skills/eu-funding/hu-grant-finder/SKILL.md +114 -0
  76. package/resources/skills/skills/eu-funding/hu-grant-finder/evals.md +26 -0
  77. package/resources/skills/skills/eu-funding/ted-tender-search/SKILL.md +80 -0
  78. package/resources/skills/skills/eu-funding/ted-tender-search/evals.md +26 -0
  79. package/resources/skills/skills/eu-funding/ted-tender-search/scripts/ted-search.sh +46 -0
  80. package/resources/skills/skills/event-design/wedding-invitations/SKILL.md +1 -1
  81. package/resources/skills/skills/github/gx-agents/SKILL.md +96 -0
  82. package/resources/skills/skills/gstack/design-shotgun/SKILL.md +1 -1
  83. package/resources/skills/skills/marketing/ab-test-analyzer/SKILL.md +1 -1
  84. package/resources/skills/skills/marketing/ab-test-setup-and-analysis/SKILL.md +1 -1
  85. package/resources/skills/skills/marketing/account-structure-review/SKILL.md +1 -1
  86. package/resources/skills/skills/marketing/ad-copy-variant-generator/SKILL.md +1 -1
  87. package/resources/skills/skills/marketing/ad-extension-audit/SKILL.md +1 -1
  88. package/resources/skills/skills/marketing/ad-spend-allocator/SKILL.md +1 -1
  89. package/resources/skills/skills/marketing/anomaly-detection/SKILL.md +1 -1
  90. package/resources/skills/skills/marketing/attribution-model-comparison/SKILL.md +1 -1
  91. package/resources/skills/skills/marketing/audience-overlap-analysis/SKILL.md +7 -1
  92. package/resources/skills/skills/marketing/bid-strategy-recommendations/SKILL.md +7 -1
  93. package/resources/skills/skills/marketing/budget-scenario-planner/SKILL.md +6 -1
  94. package/resources/skills/skills/marketing/campaign-naming-convention-builder/SKILL.md +7 -1
  95. package/resources/skills/skills/marketing/channel-mix-optimizer/SKILL.md +7 -1
  96. package/resources/skills/skills/marketing/client-report-narratives/SKILL.md +6 -1
  97. package/resources/skills/skills/marketing/competitor-creative-analysis/SKILL.md +1 -1
  98. package/resources/skills/skills/marketing/competitor-teardown/SKILL.md +1 -1
  99. package/resources/skills/skills/marketing/content-repurposer/SKILL.md +1 -1
  100. package/resources/skills/skills/marketing/conversion-path-analysis/SKILL.md +1 -1
  101. package/resources/skills/skills/marketing/cpa-diagnostics/SKILL.md +1 -1
  102. package/resources/skills/skills/marketing/creative-fatigue-detection/SKILL.md +1 -1
  103. package/resources/skills/skills/marketing/day-hour-performance-breakdown/SKILL.md +1 -1
  104. package/resources/skills/skills/marketing/device-performance-split/SKILL.md +1 -1
  105. package/resources/skills/skills/marketing/e2e-seo-assistant/SKILL.md +1 -1
  106. package/resources/skills/skills/marketing/email-sequence-writer/SKILL.md +1 -1
  107. package/resources/skills/skills/marketing/frequency-cap-recommendations/SKILL.md +1 -1
  108. package/resources/skills/skills/marketing/geo-performance-analysis/SKILL.md +1 -1
  109. package/resources/skills/skills/marketing/google-ads-audit/SKILL.md +1 -1
  110. package/resources/skills/skills/marketing/icp-research-assistant/SKILL.md +1 -1
  111. package/resources/skills/skills/marketing/keyword-cannibalization-check/SKILL.md +1 -1
  112. package/resources/skills/skills/marketing/landing-page-audit/SKILL.md +1 -1
  113. package/resources/skills/skills/marketing/landing-page-audit-quick/SKILL.md +1 -1
  114. package/resources/skills/skills/marketing/linkedin-ads-audit/SKILL.md +1 -1
  115. package/resources/skills/skills/marketing/meta-ads-audit/SKILL.md +1 -1
  116. package/resources/skills/skills/marketing/pacing-monitor/SKILL.md +1 -1
  117. package/resources/skills/skills/marketing/performance-benchmarking/SKILL.md +1 -1
  118. package/resources/skills/skills/marketing/programmatic-seo-builder/SKILL.md +1 -1
  119. package/resources/skills/skills/marketing/quality-score-breakdown/SKILL.md +1 -1
  120. package/resources/skills/skills/marketing/reddit-ads-audit/SKILL.md +1 -1
  121. package/resources/skills/skills/marketing/retargeting-window-analysis/SKILL.md +1 -1
  122. package/resources/skills/skills/marketing/roas-forecasting/SKILL.md +1 -1
  123. package/resources/skills/skills/marketing/search-term-mining/SKILL.md +1 -1
  124. package/resources/skills/skills/marketing/utm-tracking-generator/SKILL.md +1 -1
  125. package/resources/skills/skills/marketing/wasted-spend-finder/SKILL.md +1 -1
  126. package/resources/skills/skills/marketing/weekly-account-summary/SKILL.md +1 -1
  127. package/resources/skills/skills/meta/awesome-list-submit/SKILL.md +4 -4
  128. package/resources/skills/skills/meta/cue-dashboard/SKILL.md +109 -0
  129. package/resources/skills/skills/meta/cue-developer/SKILL.md +161 -0
  130. package/resources/skills/skills/meta/cue-developer/evals/evals.json +57 -0
  131. package/resources/skills/skills/meta/cue-developer/references/architecture.md +65 -0
  132. package/resources/skills/skills/meta/cue-developer/references/build_and_test.md +72 -0
  133. package/resources/skills/skills/meta/cue-developer/references/contributing.md +75 -0
  134. package/resources/skills/skills/meta/cue-developer/references/conventions.md +57 -0
  135. package/resources/skills/skills/meta/cue-developer/references/first_time_setup.md +51 -0
  136. package/resources/skills/skills/meta/cue-developer/references/skill_and_mcp_authoring.md +84 -0
  137. package/resources/skills/skills/meta/cue-developer/references/troubleshooting.md +42 -0
  138. package/resources/skills/skills/meta/delegation-check/SKILL.md +148 -0
  139. package/resources/skills/skills/meta/delegation-check/specs/scan-algorithm.md +125 -0
  140. package/resources/skills/skills/meta/delegation-check/specs/separation-rules.md +190 -0
  141. package/resources/skills/skills/meta/focus/SKILL.md +62 -0
  142. package/resources/skills/skills/meta/help/SKILL.md +1 -1
  143. package/resources/skills/skills/meta/integrity-tags/SKILL.md +2 -0
  144. package/resources/skills/skills/meta/next-steps/SKILL.md +124 -0
  145. package/resources/skills/skills/meta/next-steps/evals/eval-set.json +92 -0
  146. package/resources/skills/skills/meta/profile-from-docs/SKILL.md +141 -0
  147. package/resources/skills/skills/meta/ralph-loop/SKILL.md +83 -0
  148. package/resources/skills/skills/meta/ralph-loop/scripts/loop.sh +73 -0
  149. package/resources/skills/skills/meta/skill-simplify/SKILL.md +136 -0
  150. package/resources/skills/skills/meta/skill-simplify/phases/01-analysis.md +173 -0
  151. package/resources/skills/skills/meta/skill-simplify/phases/02-optimize.md +104 -0
  152. package/resources/skills/skills/meta/skill-simplify/phases/03-check.md +145 -0
  153. package/resources/skills/skills/meta/smart-loader/scripts/smart-lookup.sh +13 -4
  154. package/resources/skills/skills/meta/verify-council/SKILL.md +182 -0
  155. package/resources/skills/skills/meta/verify-council/references/lane-prompts.md +103 -0
  156. package/resources/skills/skills/meta/verify-council/references/workflow.js +217 -0
  157. package/resources/skills/skills/nvidia/aiq-research/SKILL.md +1 -1
  158. package/resources/skills/skills/nvidia/cuopt-developer/SKILL.md +16 -1
  159. package/resources/skills/skills/nvidia/cuopt-developer/resources/contributing.md +2 -2
  160. package/resources/skills/skills/nvidia/cuopt-developer/resources/numerical_debugging.md +128 -0
  161. package/resources/skills/skills/nvidia/cuopt-developer/resources/python_bindings.md +2 -9
  162. package/resources/skills/skills/nvidia/cuopt-developer/resources/vrp_skills.md +166 -0
  163. package/resources/skills/skills/nvidia/cuopt-install/SKILL.md +2 -10
  164. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/SKILL.md +3 -23
  165. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-c/resources/examples.md +40 -20
  166. package/resources/skills/skills/nvidia/cuopt-numerical-optimization-api-python/SKILL.md +5 -1
  167. package/resources/skills/skills/nvidia/skill-evolution/SKILL.md +4 -5
  168. package/resources/skills/skills/research/trendradar/SKILL.md +1 -1
  169. package/resources/skills/skills/ssh/ssh-config/SKILL.md +94 -0
  170. package/resources/skills/skills/ssh/ssh-copy/SKILL.md +92 -0
  171. package/resources/skills/skills/ssh/ssh-harden/SKILL.md +108 -0
  172. package/resources/skills/skills/ssh/ssh-keys/SKILL.md +82 -0
  173. package/resources/skills/skills/ssh/ssh-paste-image/LICENSE +28 -0
  174. package/resources/skills/skills/ssh/ssh-paste-image/SKILL.md +149 -0
  175. package/resources/skills/skills/ssh/ssh-paste-image/scripts/build.sh +29 -0
  176. package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/go.mod +3 -0
  177. package/resources/skills/skills/ssh/ssh-paste-image/scripts/client/main.go +79 -0
  178. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/ccimgd.service +12 -0
  179. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/com.ccimgd.plist +20 -0
  180. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/go.mod +3 -0
  181. package/resources/skills/skills/ssh/ssh-paste-image/scripts/daemon/main.go +98 -0
  182. package/resources/skills/skills/ssh/ssh-tunnel/SKILL.md +96 -0
  183. package/resources/skills/skills/strapi/building-with-strapi/SKILL.md +112 -0
  184. package/resources/skills/skills/strapi/strapi-cli/SKILL.md +93 -0
  185. package/resources/skills/skills/strapi/strapi-content-api/SKILL.md +115 -0
  186. package/resources/skills/skills/strapi/strapi-deploy/SKILL.md +89 -0
  187. package/resources/skills/skills/strapi/strapi-mcp-setup/SKILL.md +101 -0
  188. package/resources/skills/skills/strapi/strapi-plugins/SKILL.md +97 -0
  189. package/resources/skills/skills/tools/context7/SKILL.md +101 -0
  190. package/resources/skills/skills/tools/opensrc/SKILL.md +1 -1
  191. package/resources/skills/skills/tools/portless/SKILL.md +186 -0
  192. package/resources/skills/skills/xbot/operate/SKILL.md +229 -0
  193. package/src/commands/_index.ts +8 -0
  194. package/src/commands/ai-score.e2e.test.ts +11 -4
  195. package/src/commands/ai.ts +3 -4
  196. package/src/commands/auto-detect.ts +1 -1
  197. package/src/commands/cli.test.ts +1 -2
  198. package/src/commands/cli.ts +1 -1
  199. package/src/commands/cloud.ts +1 -1
  200. package/src/commands/current.ts +1 -4
  201. package/src/commands/dash.test.ts +110 -0
  202. package/src/commands/dash.ts +194 -0
  203. package/src/commands/dashboard.ts +26 -0
  204. package/src/commands/diff.ts +1 -1
  205. package/src/commands/discover.test.ts +1 -1
  206. package/src/commands/discover.ts +90 -40
  207. package/src/commands/doctor.test.ts +58 -0
  208. package/src/commands/doctor.ts +79 -3
  209. package/src/commands/eval-behavior.ts +1 -1
  210. package/src/commands/eval.ts +2 -2
  211. package/src/commands/evolve.ts +4 -3
  212. package/src/commands/failures.test.ts +1 -1
  213. package/src/commands/features-batch1.test.ts +6 -1
  214. package/src/commands/icon.ts +1 -5
  215. package/src/commands/import-profile.ts +1 -1
  216. package/src/commands/init.ts +50 -7
  217. package/src/commands/install-sh.e2e.test.ts +65 -0
  218. package/src/commands/launch-handoff.e2e.test.ts +88 -0
  219. package/src/commands/launch.e2e.test.ts +8 -1
  220. package/src/commands/launch.test.ts +29 -0
  221. package/src/commands/launch.ts +185 -131
  222. package/src/commands/lock.ts +0 -1
  223. package/src/commands/marketplace.ts +0 -4
  224. package/src/commands/materialize.ts +1 -1
  225. package/src/commands/mem.ts +341 -0
  226. package/src/commands/optimizer.ts +0 -3
  227. package/src/commands/playground.ts +1 -2
  228. package/src/commands/profile-draft-skill.ts +1 -1
  229. package/src/commands/replay-whatif.ts +1 -6
  230. package/src/commands/score.ts +2 -2
  231. package/src/commands/security.test.ts +88 -0
  232. package/src/commands/security.ts +74 -28
  233. package/src/commands/shell.test.ts +65 -4
  234. package/src/commands/shell.ts +67 -7
  235. package/src/commands/skills-test.ts +0 -1
  236. package/src/commands/skills.ts +28 -2
  237. package/src/commands/sources.ts +1 -2
  238. package/src/commands/status.ts +2 -6
  239. package/src/commands/submit-profile.ts +1 -1
  240. package/src/commands/suggest.ts +35 -10
  241. package/src/commands/trigger-gaps.test.ts +50 -0
  242. package/src/commands/trigger-gaps.ts +63 -29
  243. package/src/commands/update.ts +1 -1
  244. package/src/commands/validate.ts +16 -4
  245. package/src/commands/watch-live.ts +1 -1
  246. package/src/commands/workspace.ts +1 -1
  247. package/src/index.ts +26 -10
  248. package/src/lib/active-sessions.ts +1 -1
  249. package/src/lib/agent-adapters.test.ts +100 -0
  250. package/src/lib/agent-adapters.ts +2 -2
  251. package/src/lib/analytics.test.ts +88 -0
  252. package/src/lib/analytics.ts +82 -1
  253. package/src/lib/auto-detect.test.ts +10 -4
  254. package/src/lib/auto-detect.ts +19 -23
  255. package/src/lib/brand-icons.ts +0 -1
  256. package/src/lib/cache.ts +2 -3
  257. package/src/lib/claude-mem-env.test.ts +148 -0
  258. package/src/lib/claude-mem-env.ts +172 -0
  259. package/src/lib/combo-history.test.ts +53 -0
  260. package/src/lib/combo-history.ts +83 -0
  261. package/src/lib/companion-detect.test.ts +108 -0
  262. package/src/lib/companion-detect.ts +140 -0
  263. package/src/lib/companion-fetch.ts +4 -6
  264. package/src/lib/conditional-skills.test.ts +1 -1
  265. package/src/lib/config-paths.test.ts +53 -0
  266. package/src/lib/config-paths.ts +33 -0
  267. package/src/lib/dashboard-server.test.ts +351 -0
  268. package/src/lib/dashboard-server.ts +1476 -27
  269. package/src/lib/debug-log.test.ts +66 -0
  270. package/src/lib/debug-log.ts +45 -0
  271. package/src/lib/mcp-catalog.test.ts +102 -0
  272. package/src/lib/mcp-catalog.ts +193 -0
  273. package/src/lib/pair-suggestions.test.ts +111 -0
  274. package/src/lib/pair-suggestions.ts +98 -5
  275. package/src/lib/permissions.test.ts +76 -0
  276. package/src/lib/permissions.ts +125 -0
  277. package/src/lib/picker.test.ts +1106 -1
  278. package/src/lib/picker.ts +1230 -142
  279. package/src/lib/plugin-discovery.ts +126 -0
  280. package/src/lib/pr-poster.ts +1 -1
  281. package/src/lib/pr-throttle.ts +2 -6
  282. package/src/lib/profile-linter.test.ts +67 -1
  283. package/src/lib/profile-linter.ts +59 -14
  284. package/src/lib/profile-loader.test.ts +21 -0
  285. package/src/lib/profile-loader.ts +22 -3
  286. package/src/lib/profile-metrics.ts +2 -6
  287. package/src/lib/profile-names.test.ts +58 -0
  288. package/src/lib/repos.test.ts +57 -0
  289. package/src/lib/repos.ts +167 -0
  290. package/src/lib/resolver-npx.ts +10 -1
  291. package/src/lib/runtime-materializer.test.ts +200 -3
  292. package/src/lib/runtime-materializer.ts +129 -20
  293. package/src/lib/shared-profiles.ts +2 -3
  294. package/src/lib/skill-clis.test.ts +113 -0
  295. package/src/lib/skill-clis.ts +232 -0
  296. package/src/lib/skill-dependencies.ts +9 -1
  297. package/src/lib/skill-deps.ts +1 -1
  298. package/src/lib/skill-linter.ts +1 -1
  299. package/src/lib/skill-quality.ts +0 -1
  300. package/src/lib/skill-sandbox.test.ts +1 -1
  301. package/src/lib/skills-lock.test.ts +1 -1
  302. package/src/lib/telemetry-consent.ts +3 -5
  303. package/src/lib/telemetry-report.test.ts +2 -2
  304. package/src/lib/token-budget.ts +111 -0
  305. package/src/lib/trigger-gaps.test.ts +70 -0
  306. package/src/lib/trigger-gaps.ts +48 -6
  307. package/src/lib/tui/data.ts +1 -5
  308. package/src/lib/workflow-store.ts +150 -0
  309. package/src/lib/workspace-secrets.ts +0 -4
  310. package/src/lib/workspaces.ts +1 -1
@@ -0,0 +1,66 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+
3
+ import { debug, debugEnabled } from "./debug-log";
4
+
5
+ const SAVED = process.env.CUE_DEBUG;
6
+
7
+ // Capture stderr writes without spilling to the test runner's output.
8
+ function captureStderr(fn: () => void): string {
9
+ const orig = process.stderr.write.bind(process.stderr);
10
+ let buf = "";
11
+ // @ts-expect-error — narrow override for the test
12
+ process.stderr.write = (chunk: string) => {
13
+ buf += chunk;
14
+ return true;
15
+ };
16
+ try {
17
+ fn();
18
+ } finally {
19
+ process.stderr.write = orig;
20
+ }
21
+ return buf;
22
+ }
23
+
24
+ describe("debug-log", () => {
25
+ beforeEach(() => {
26
+ delete process.env.CUE_DEBUG;
27
+ });
28
+ afterEach(() => {
29
+ if (SAVED === undefined) delete process.env.CUE_DEBUG;
30
+ else process.env.CUE_DEBUG = SAVED;
31
+ });
32
+
33
+ test("debugEnabled is false when CUE_DEBUG unset / falsy", () => {
34
+ expect(debugEnabled()).toBe(false);
35
+ process.env.CUE_DEBUG = "";
36
+ expect(debugEnabled()).toBe(false);
37
+ process.env.CUE_DEBUG = "0";
38
+ expect(debugEnabled()).toBe(false);
39
+ process.env.CUE_DEBUG = "false";
40
+ expect(debugEnabled()).toBe(false);
41
+ });
42
+
43
+ test("debugEnabled is true for a truthy value", () => {
44
+ process.env.CUE_DEBUG = "1";
45
+ expect(debugEnabled()).toBe(true);
46
+ });
47
+
48
+ test("debug() is a silent no-op when disabled", () => {
49
+ const out = captureStderr(() => debug("scope", new Error("boom")));
50
+ expect(out).toBe("");
51
+ });
52
+
53
+ test("debug() writes a namespaced line with the error message when enabled", () => {
54
+ process.env.CUE_DEBUG = "1";
55
+ const out = captureStderr(() => debug("launch:autodetect", new Error("boom")));
56
+ expect(out).toContain("[cue:debug]");
57
+ expect(out).toContain("launch:autodetect");
58
+ expect(out).toContain("boom");
59
+ });
60
+
61
+ test("debug() handles a missing detail and non-Error values", () => {
62
+ process.env.CUE_DEBUG = "1";
63
+ expect(captureStderr(() => debug("scope"))).toContain("scope");
64
+ expect(captureStderr(() => debug("scope", { a: 1 }))).toContain('{"a":1}');
65
+ });
66
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Opt-in debug logging for cue's many best-effort code paths.
3
+ *
4
+ * cue deliberately swallows a lot of non-fatal errors (missing files, malformed
5
+ * JSON, absent optional tools) so one bad input never blocks a launch. That's
6
+ * correct, but it makes failures invisible when something genuinely IS wrong —
7
+ * the user sees a silent degradation with no signal. `debug()` surfaces those
8
+ * swallowed errors on stderr ONLY when `CUE_DEBUG` is set, so normal runs stay
9
+ * quiet and `CUE_DEBUG=1 cue …` turns into a diagnosis tool.
10
+ *
11
+ * try { risky(); } catch (err) { debug("launch:autodetect", err); }
12
+ */
13
+
14
+ /** True when CUE_DEBUG is set to a non-falsy value (`0`/`false`/empty = off). */
15
+ export function debugEnabled(): boolean {
16
+ const v = process.env.CUE_DEBUG;
17
+ return v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
18
+ }
19
+
20
+ /** Render an arbitrary catch value to a single log string. */
21
+ function format(detail: unknown): string {
22
+ if (detail === undefined) return "";
23
+ if (detail instanceof Error) return detail.stack ?? `${detail.name}: ${detail.message}`;
24
+ if (typeof detail === "string") return detail;
25
+ try {
26
+ return JSON.stringify(detail);
27
+ } catch {
28
+ return String(detail);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Emit a namespaced debug line to stderr when CUE_DEBUG is enabled; a no-op
34
+ * otherwise. `scope` is a short "area:step" tag (e.g. "launch:autodetect");
35
+ * `detail` is the swallowed error or a message. Never throws.
36
+ */
37
+ export function debug(scope: string, detail?: unknown): void {
38
+ if (!debugEnabled()) return;
39
+ const rendered = format(detail);
40
+ try {
41
+ process.stderr.write(`[cue:debug] ${scope}${rendered ? ` — ${rendered}` : ""}\n`);
42
+ } catch {
43
+ /* stderr unavailable — debug logging must never itself break a run */
44
+ }
45
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Tests for the MCP catalog + profile-write helpers behind the studio's
3
+ * "Available in cue" section. Catalog reads run against the real on-disk
4
+ * sanitized configs; write tests redirect CUE_PROFILES_DIR to a temp tree so
5
+ * no real `profiles/*.yaml` is ever mutated.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ import { loadMcpCatalog, loadAllMcpIds, addMcpToProfile } from "./mcp-catalog";
14
+
15
+ describe("loadMcpCatalog", () => {
16
+ test("returns the full catalog with inferred transport + install", () => {
17
+ const catalog = loadMcpCatalog();
18
+ expect(catalog.length).toBeGreaterThan(10);
19
+
20
+ // ids are unique + sorted
21
+ const ids = catalog.map((e) => e.id);
22
+ expect(new Set(ids).size).toBe(ids.length);
23
+ expect([...ids].sort((a, b) => a.localeCompare(b))).toEqual(ids);
24
+
25
+ // every entry has a concrete transport
26
+ for (const e of catalog) {
27
+ expect(["stdio", "sse", "http", "unknown"]).toContain(e.transport);
28
+ }
29
+
30
+ // a known stdio server resolves an install command
31
+ const coolify = catalog.find((e) => e.id === "coolify");
32
+ expect(coolify).toBeDefined();
33
+ expect(coolify!.transport).toBe("stdio");
34
+ expect(coolify!.install.length).toBeGreaterThan(0);
35
+ });
36
+
37
+ test("catalog ids match the add-validation registry exactly", () => {
38
+ // The UI must never offer an MCP the add path would reject.
39
+ expect(loadMcpCatalog().map((e) => e.id)).toEqual(loadAllMcpIds());
40
+ });
41
+ });
42
+
43
+ describe("addMcpToProfile", () => {
44
+ const created: string[] = [];
45
+ afterEach(async () => {
46
+ delete process.env.CUE_PROFILES_DIR;
47
+ for (const d of created.splice(0)) await rm(d, { recursive: true, force: true });
48
+ });
49
+
50
+ async function tempProfiles(yaml: string): Promise<string> {
51
+ const root = await mkdtemp(join(tmpdir(), "cue-mcp-test-"));
52
+ created.push(root);
53
+ await mkdir(join(root, "demo"), { recursive: true });
54
+ await writeFile(join(root, "demo", "profile.yaml"), yaml);
55
+ process.env.CUE_PROFILES_DIR = root;
56
+ return root;
57
+ }
58
+
59
+ test("rejects an id not in the catalog", async () => {
60
+ await tempProfiles("name: demo\nmcps:\n - gbrain\n");
61
+ await expect(addMcpToProfile("totally-not-real", "demo")).rejects.toThrow(/unknown-mcp/);
62
+ });
63
+
64
+ test("rejects a path-traversal profile name", async () => {
65
+ await tempProfiles("name: demo\n");
66
+ await expect(addMcpToProfile("coolify", "../../etc")).rejects.toThrow(/invalid-profile/);
67
+ });
68
+
69
+ test("rejects a composite profile with no profile.yaml", async () => {
70
+ await tempProfiles("name: demo\n");
71
+ await expect(addMcpToProfile("coolify", "core+skill-writer")).rejects.toThrow(
72
+ /invalid-profile|not-a-physical-profile/,
73
+ );
74
+ });
75
+
76
+ test("appends a new id into an existing mcps: block", async () => {
77
+ const root = await tempProfiles("name: demo\nmcps:\n - gbrain\nplugins:\n - foo\n");
78
+ const res = await addMcpToProfile("coolify", "demo");
79
+ expect(res.alreadyPresent).toBe(false);
80
+ const written = await readFile(join(root, "demo", "profile.yaml"), "utf8");
81
+ expect(written).toContain("- gbrain");
82
+ expect(written).toContain("- coolify");
83
+ // inserted inside the mcps block, above plugins
84
+ expect(written.indexOf("- coolify")).toBeLessThan(written.indexOf("plugins:"));
85
+ });
86
+
87
+ test("creates an mcps: block when the profile has none", async () => {
88
+ const root = await tempProfiles("name: demo\nskills:\n local: []\n");
89
+ await addMcpToProfile("coolify", "demo");
90
+ const written = await readFile(join(root, "demo", "profile.yaml"), "utf8");
91
+ expect(written).toMatch(/mcps:\n\s+- coolify/);
92
+ });
93
+
94
+ test("is idempotent when the id is already present", async () => {
95
+ const root = await tempProfiles("name: demo\nmcps:\n - coolify\n");
96
+ const res = await addMcpToProfile("coolify", "demo");
97
+ expect(res.alreadyPresent).toBe(true);
98
+ const written = await readFile(join(root, "demo", "profile.yaml"), "utf8");
99
+ // not duplicated
100
+ expect(written.match(/- coolify/g)?.length).toBe(1);
101
+ });
102
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * MCP catalog + profile-write helpers, shared by the dashboard server's
3
+ * `/api/v1/mcps/catalog` and `/api/v1/mcps/add` endpoints.
4
+ *
5
+ * The "catalog" is the union of MCP server ids declared across cue's sanitized
6
+ * config snapshots (`resources/mcps/configs/*.sanitized.json`) — the same set
7
+ * `cue mcps add` validates against, so the studio never offers an MCP the CLI
8
+ * would then reject. Per-entry transport + install command are inferred from
9
+ * the server config; the description is the first prose line of the MCP's
10
+ * `README.md` when one exists (most catalog entries have none — that's fine,
11
+ * the card just shows the id).
12
+ *
13
+ * Paths are resolved per-call from env so tests can point CUE_PROFILES_DIR /
14
+ * CUE_REPO_ROOT at a fixture tree without a real write to `profiles/`.
15
+ */
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { readFile, writeFile, access } from "node:fs/promises";
19
+ import { resolve, dirname, join } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ function repoRoot(): string {
23
+ return (
24
+ process.env.CUE_REPO_ROOT ??
25
+ process.env.SOUL_REPO_ROOT ??
26
+ resolve(dirname(fileURLToPath(import.meta.url)), "..", "..")
27
+ );
28
+ }
29
+ function profilesDir(): string {
30
+ return process.env.CUE_PROFILES_DIR ?? join(repoRoot(), "profiles");
31
+ }
32
+ function configsDir(): string {
33
+ return join(repoRoot(), "resources", "mcps", "configs");
34
+ }
35
+ function docsDir(): string {
36
+ return join(repoRoot(), "resources", "mcps", "mcps");
37
+ }
38
+
39
+ const CONFIG_FILES = [
40
+ "claude.sanitized.json",
41
+ "claude_runtime.sanitized.json",
42
+ "codex.sanitized.json",
43
+ ] as const;
44
+
45
+ export type McpTransport = "stdio" | "sse" | "http" | "unknown";
46
+
47
+ export interface McpCatalogEntry {
48
+ id: string;
49
+ description: string;
50
+ transport: McpTransport;
51
+ install: string;
52
+ }
53
+
54
+ interface ServerConfig {
55
+ command?: string;
56
+ args?: string[];
57
+ url?: string;
58
+ type?: string;
59
+ }
60
+
61
+ /** Union of server ids across all sanitized config snapshots, sorted. */
62
+ export function loadAllMcpIds(): string[] {
63
+ const ids = new Set<string>();
64
+ for (const file of CONFIG_FILES) {
65
+ try {
66
+ const raw = JSON.parse(readFileSync(join(configsDir(), file), "utf8"));
67
+ if (raw.servers) for (const id of Object.keys(raw.servers)) ids.add(id);
68
+ } catch {
69
+ /* file may not exist in every snapshot */
70
+ }
71
+ }
72
+ return [...ids].sort((a, b) => a.localeCompare(b));
73
+ }
74
+
75
+ /** First matching server config for `id` across the snapshots, or null. */
76
+ function loadServerConfig(id: string): ServerConfig | null {
77
+ for (const file of CONFIG_FILES) {
78
+ try {
79
+ const raw = JSON.parse(readFileSync(join(configsDir(), file), "utf8"));
80
+ if (raw.servers?.[id]) return raw.servers[id] as ServerConfig;
81
+ } catch {
82
+ /* skip */
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** First prose line of the MCP's README.md (skipping headings), capped. */
89
+ export function getMcpDescription(id: string): string {
90
+ try {
91
+ const readme = readFileSync(join(docsDir(), id, "README.md"), "utf8");
92
+ const firstLine = readme.split("\n").find((l) => l.trim() && !l.startsWith("#"));
93
+ return firstLine?.trim().slice(0, 140) ?? "";
94
+ } catch {
95
+ return "";
96
+ }
97
+ }
98
+
99
+ function transportOf(cfg: ServerConfig | null): McpTransport {
100
+ if (!cfg) return "unknown";
101
+ if (cfg.type === "stdio" || cfg.type === "sse" || cfg.type === "http") return cfg.type;
102
+ if (cfg.url) return cfg.url.includes("/sse") ? "sse" : "http";
103
+ if (cfg.command) return "stdio";
104
+ return "unknown";
105
+ }
106
+
107
+ function installOf(cfg: ServerConfig | null): string {
108
+ if (!cfg) return "";
109
+ if (cfg.command) {
110
+ const args = cfg.args?.length ? " " + cfg.args.join(" ") : "";
111
+ return (cfg.command + args).trim();
112
+ }
113
+ if (cfg.url) return cfg.url;
114
+ return "";
115
+ }
116
+
117
+ /** Full catalog: every addable MCP with inferred transport + install hint. */
118
+ export function loadMcpCatalog(): McpCatalogEntry[] {
119
+ return loadAllMcpIds().map((id) => {
120
+ const cfg = loadServerConfig(id);
121
+ return {
122
+ id,
123
+ description: getMcpDescription(id),
124
+ transport: transportOf(cfg),
125
+ install: installOf(cfg),
126
+ };
127
+ });
128
+ }
129
+
130
+ export interface AddMcpResult {
131
+ id: string;
132
+ profile: string;
133
+ alreadyPresent: boolean;
134
+ }
135
+
136
+ /**
137
+ * Append `id` to the `mcps:` list of `profileName`'s profile.yaml.
138
+ *
139
+ * `profileName` must be a single, physical profile (a `profiles/<name>/`
140
+ * directory) — composite runtime profiles like `core+skill-writer` have no
141
+ * file to write, so the caller resolves which part-profile to target before
142
+ * calling. Validates the id against the catalog and guards the profile name
143
+ * against path traversal. Idempotent: a no-op (alreadyPresent: true) when the
144
+ * id is already wired.
145
+ */
146
+ export async function addMcpToProfile(id: string, profileName: string): Promise<AddMcpResult> {
147
+ if (!id || !loadAllMcpIds().includes(id)) {
148
+ throw new Error(`unknown-mcp: "${id}" is not in the cue catalog`);
149
+ }
150
+ // Reject anything that could escape profiles/ — names are flat slugs.
151
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(profileName)) {
152
+ throw new Error(`invalid-profile: "${profileName}"`);
153
+ }
154
+ const dir = profilesDir();
155
+ const yamlPath = join(dir, profileName, "profile.yaml");
156
+ if (!resolve(yamlPath).startsWith(resolve(dir))) {
157
+ throw new Error(`invalid-profile: "${profileName}"`);
158
+ }
159
+ try {
160
+ await access(yamlPath);
161
+ } catch {
162
+ throw new Error(
163
+ `not-a-physical-profile: "${profileName}" has no profile.yaml (composite profiles can't be written directly)`,
164
+ );
165
+ }
166
+
167
+ let content = await readFile(yamlPath, "utf8");
168
+
169
+ // Already present? Match a list item exactly so "- gbrain" doesn't match
170
+ // "- gbrain-extra".
171
+ const present = content
172
+ .split("\n")
173
+ .some((l) => new RegExp(`^\\s*-\\s+${escapeRe(id)}\\s*(#.*)?$`).test(l));
174
+ if (present) return { id, profile: profileName, alreadyPresent: true };
175
+
176
+ if (/^mcps:/m.test(content)) {
177
+ const lines = content.split("\n");
178
+ const mcpsIdx = lines.findIndex((l) => /^mcps:/.test(l));
179
+ let insertIdx = mcpsIdx + 1;
180
+ while (insertIdx < lines.length && /^\s+-\s/.test(lines[insertIdx] ?? "")) insertIdx++;
181
+ lines.splice(insertIdx, 0, ` - ${id}`);
182
+ content = lines.join("\n");
183
+ } else {
184
+ content = content.trimEnd() + `\nmcps:\n - ${id}\n`;
185
+ }
186
+
187
+ await writeFile(yamlPath, content);
188
+ return { id, profile: profileName, alreadyPresent: false };
189
+ }
190
+
191
+ function escapeRe(s: string): string {
192
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
193
+ }
@@ -1,10 +1,12 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
3
  import {
4
+ buildUniversalSuggestions,
4
5
  computeAffinityMap,
5
6
  parseComposite,
6
7
  suggestPartnersFor,
7
8
  suggestionsByProfile,
9
+ UNIVERSAL_COMPANIONS,
8
10
  } from "./pair-suggestions";
9
11
 
10
12
  const row = (profile: string, ts = "2026-05-28T00:00:00Z"): string =>
@@ -142,3 +144,112 @@ describe("suggestionsByProfile", () => {
142
144
  expect(all.get("solo")).toBeUndefined();
143
145
  });
144
146
  });
147
+
148
+ describe("buildUniversalSuggestions", () => {
149
+ const known = (...names: string[]) => new Set(names);
150
+
151
+ test("featured come first in declared order, capped, and filtered to installed", () => {
152
+ const out = buildUniversalSuggestions({
153
+ featured: ["improver", "ghost", "secops", "builder", "maker", "studio", "ops"],
154
+ affinity: computeAffinityMap(lines()),
155
+ known: known("improver", "secops", "builder", "maker", "studio", "ops"),
156
+ });
157
+ // `ghost` dropped (not installed); default cap 5 keeps the first five real ones,
158
+ // so `ops` (sixth) is excluded.
159
+ expect(out).toEqual([
160
+ { name: "improver", origin: "featured" },
161
+ { name: "secops", origin: "featured" },
162
+ { name: "builder", origin: "featured" },
163
+ { name: "maker", origin: "featured" },
164
+ { name: "studio", origin: "featured" },
165
+ ]);
166
+ });
167
+
168
+ test("frequency fills the rest, highest picks first, above the floor", () => {
169
+ const affinity = computeAffinityMap(
170
+ lines(
171
+ ...Array(10).fill(row("skill-writer")),
172
+ ...Array(5).fill(row("core")),
173
+ ...Array(2).fill(row("rare")), // below default minFrequentPicks=3
174
+ ),
175
+ );
176
+ const out = buildUniversalSuggestions({
177
+ featured: [],
178
+ affinity,
179
+ known: known("skill-writer", "core", "rare"),
180
+ });
181
+ expect(out).toEqual([
182
+ { name: "skill-writer", origin: "frequent" },
183
+ { name: "core", origin: "frequent" },
184
+ ]);
185
+ });
186
+
187
+ test("a profile that is both featured and frequent appears once, as featured", () => {
188
+ const affinity = computeAffinityMap(lines(...Array(10).fill(row("improver"))));
189
+ const out = buildUniversalSuggestions({
190
+ featured: ["improver"],
191
+ affinity,
192
+ known: known("improver"),
193
+ });
194
+ expect(out).toEqual([{ name: "improver", origin: "featured" }]);
195
+ });
196
+
197
+ test("empty featured + empty history yields nothing", () => {
198
+ expect(
199
+ buildUniversalSuggestions({ featured: [], affinity: new Map(), known: new Set() }),
200
+ ).toEqual([]);
201
+ });
202
+
203
+ test("caps and the frequency floor are configurable", () => {
204
+ const affinity = computeAffinityMap(
205
+ lines(...Array(5).fill(row("a")), ...Array(4).fill(row("b")), ...Array(3).fill(row("c"))),
206
+ );
207
+ const out = buildUniversalSuggestions({
208
+ featured: ["f1", "f2"],
209
+ affinity,
210
+ known: known("f1", "f2", "a", "b", "c"),
211
+ maxFeatured: 1,
212
+ maxFrequent: 1,
213
+ minFrequentPicks: 4,
214
+ });
215
+ // featured cap 1 → f1; frequency cap 1 with floor 4 → a (5 picks, highest).
216
+ expect(out).toEqual([
217
+ { name: "f1", origin: "featured" },
218
+ { name: "a", origin: "frequent" },
219
+ ]);
220
+ });
221
+
222
+ test("pinned companions (gstack) close the list when installed, after featured/frequent", () => {
223
+ const gstack = UNIVERSAL_COMPANIONS[0]!; // "gstack"
224
+ const affinity = computeAffinityMap(lines(...Array(5).fill(row("a"))));
225
+ const out = buildUniversalSuggestions({
226
+ featured: ["f1"],
227
+ affinity,
228
+ known: known("f1", "a", gstack),
229
+ });
230
+ expect(out).toEqual([
231
+ { name: "f1", origin: "featured" },
232
+ { name: "a", origin: "frequent" },
233
+ { name: gstack, origin: "pinned" },
234
+ ]);
235
+ });
236
+
237
+ test("a pinned companion that isn't installed is silently dropped", () => {
238
+ const out = buildUniversalSuggestions({
239
+ featured: ["f1"],
240
+ affinity: new Map(),
241
+ known: known("f1"), // gstack not installed
242
+ });
243
+ expect(out).toEqual([{ name: "f1", origin: "featured" }]);
244
+ });
245
+
246
+ test("a pinned companion already in featured keeps its featured origin (de-duped)", () => {
247
+ const gstack = UNIVERSAL_COMPANIONS[0]!;
248
+ const out = buildUniversalSuggestions({
249
+ featured: [gstack],
250
+ affinity: new Map(),
251
+ known: known(gstack),
252
+ });
253
+ expect(out).toEqual([{ name: gstack, origin: "featured" }]);
254
+ });
255
+ });
@@ -13,6 +13,7 @@
13
13
  import { existsSync, readFileSync } from "node:fs";
14
14
  import { homedir } from "node:os";
15
15
  import { join } from "node:path";
16
+ import { readComboHistoryLines } from "./combo-history";
16
17
 
17
18
  /** Resolved path mirrors `lib/telemetry-consent` to avoid a circular import. */
18
19
  function sessionLogPath(): string {
@@ -159,12 +160,104 @@ export function suggestionsByProfile(
159
160
  return out;
160
161
  }
161
162
 
163
+ /** Where a universal combine suggestion came from — drives the row hint. */
164
+ export type UniversalOrigin = "featured" | "frequent" | "pinned";
165
+
166
+ /** A cross-profile combine suggestion offered under every primary. */
167
+ export interface UniversalSuggestion {
168
+ name: string;
169
+ origin: UniversalOrigin;
170
+ }
171
+
172
+ /**
173
+ * Profiles offered as a combine companion under *every* primary, independent of
174
+ * the curated featured set, session frequency, the picked profile's
175
+ * `recommends:`, or cwd content. gstack is the engineering-team layer (ship /
176
+ * QA / deploy / review) that pairs with whatever stack you're building, so the
177
+ * picker always offers to stack it on. Emitted as the `pinned` origin by
178
+ * `buildUniversalSuggestions` (the single "offered under every primary" path)
179
+ * and surfaced unchecked — offered, never forced into the pin.
180
+ */
181
+ export const UNIVERSAL_COMPANIONS: readonly string[] = ["gstack"];
182
+
183
+ export interface BuildUniversalOptions {
184
+ /** Curated featured slugs, in display order (from `_featured.yaml`). */
185
+ featured: string[];
186
+ /** Global pick-frequency map (from `computeAffinityMap`). */
187
+ affinity: Map<string, ProfileAffinity>;
188
+ /** Installed profile names — both sources are filtered to these. */
189
+ known: Set<string>;
190
+ /** Max curated featured suggestions. Default 5. */
191
+ maxFeatured?: number;
192
+ /** Max session-frequency suggestions. Default 2. */
193
+ maxFrequent?: number;
194
+ /** Min own-picks for a profile to count as "used a lot". Default 3. */
195
+ minFrequentPicks?: number;
196
+ }
197
+
198
+ const UNIVERSAL_DEFAULTS: Required<Omit<BuildUniversalOptions, "featured" | "affinity" | "known">> =
199
+ { maxFeatured: 5, maxFrequent: 2, minFrequentPicks: 3 };
200
+
201
+ /**
202
+ * Cross-profile combine suggestions surfaced under *every* primary: the curated
203
+ * `_featured.yaml` set (where profiles like `improver` live) plus the profiles
204
+ * the user actually picks most often, mined from session history. Featured wins
205
+ * on overlap and ordering; frequency fills the remaining slots. Both are
206
+ * filtered to installed profiles and capped so the combine list stays short.
207
+ *
208
+ * Pure: the picker offers the result *unchecked* (a hint, never an auto-pin),
209
+ * de-duped against recommends/history/detected. Empty featured + empty affinity
210
+ * yields an empty list.
211
+ */
212
+ export function buildUniversalSuggestions(opts: BuildUniversalOptions): UniversalSuggestion[] {
213
+ const o = { ...UNIVERSAL_DEFAULTS, ...opts };
214
+ const out: UniversalSuggestion[] = [];
215
+ const seen = new Set<string>();
216
+
217
+ // Curated featured first, in declared order, capped.
218
+ for (const name of o.featured) {
219
+ if (out.length >= o.maxFeatured) break;
220
+ if (seen.has(name) || !o.known.has(name)) continue;
221
+ seen.add(name);
222
+ out.push({ name, origin: "featured" });
223
+ }
224
+
225
+ // Frequency fills the rest: own-picks above the floor, highest first.
226
+ const frequent = [...o.affinity.entries()]
227
+ .filter(([name, a]) => o.known.has(name) && !seen.has(name) && a.picks >= o.minFrequentPicks)
228
+ .sort((x, y) => y[1].picks - x[1].picks || x[0].localeCompare(y[0]))
229
+ .slice(0, o.maxFrequent);
230
+ for (const [name] of frequent) {
231
+ seen.add(name);
232
+ out.push({ name, origin: "frequent" });
233
+ }
234
+
235
+ // Pinned companions (gstack) close the list: always offered under every
236
+ // primary, after featured/frequent so those keep their slots. De-duped (a
237
+ // pinned profile that's also featured keeps the earlier featured origin) and
238
+ // known-filtered like the rest, so an uninstalled pin silently drops.
239
+ for (const name of UNIVERSAL_COMPANIONS) {
240
+ if (seen.has(name) || !o.known.has(name)) continue;
241
+ seen.add(name);
242
+ out.push({ name, origin: "pinned" });
243
+ }
244
+
245
+ return out;
246
+ }
247
+
162
248
  function defaultReadLines(): string[] {
249
+ const lines: string[] = [];
163
250
  const path = sessionLogPath();
164
- if (!existsSync(path)) return [];
165
- try {
166
- return readFileSync(path, "utf8").split("\n");
167
- } catch {
168
- return [];
251
+ if (existsSync(path)) {
252
+ try {
253
+ lines.push(...readFileSync(path, "utf8").split("\n"));
254
+ } catch {
255
+ /* session log unreadable — fall through to combo history */
256
+ }
169
257
  }
258
+ // Combos picked in the interactive picker (combo-history.jsonl) share the
259
+ // `{profile}` row shape, so they fold straight into the affinity map. This is
260
+ // the telemetry-free source: it exists even when session logging is off.
261
+ lines.push(...readComboHistoryLines());
262
+ return lines;
170
263
  }